diff --git a/Packages/Package.resolved b/Packages/Package.resolved index 557968cf..65e1bf43 100644 --- a/Packages/Package.resolved +++ b/Packages/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3127591c474948afb96f0c81df8be151fe447da11fef447bbb933b0712661346", + "originHash" : "b101d76c29aab052484573baab03434736d3a84ba21abcc8daa6c7fb36cf1473", "pins" : [ { "identity" : "viewinspector", diff --git a/Packages/Package.swift b/Packages/Package.swift index 8c8633b7..4c960bcc 100644 --- a/Packages/Package.swift +++ b/Packages/Package.swift @@ -9,6 +9,7 @@ let package = Package( .library(name: "MessageList", targets: ["MessageList"]), .library(name: "RxCodeCore", targets: ["RxCodeCore"]), .library(name: "RxCodeChatKit", targets: ["RxCodeChatKit"]), + .library(name: "RxCodeEditor", targets: ["RxCodeEditor"]), .library(name: "RxCodeMarkdown", targets: ["RxCodeMarkdown"]), .library(name: "RxCodeSync", targets: ["RxCodeSync"]), .library(name: "DiffView", targets: ["DiffView"]), @@ -30,6 +31,7 @@ let package = Package( ), .target( name: "RxCodeMarkdown", + dependencies: ["RxCodeCore"], path: "Sources/RxCodeMarkdown" ), .target( @@ -53,6 +55,11 @@ let package = Package( dependencies: ["RxCodeCore"], path: "Sources/RxCodeSync" ), + .target( + name: "RxCodeEditor", + dependencies: ["RxCodeCore"], + path: "Sources/RxCodeEditor" + ), .target( name: "DiffView", dependencies: ["RxCodeCore"], @@ -88,6 +95,11 @@ let package = Package( ], path: "Tests/RxCodeChatKitTests" ), + .testTarget( + name: "RxCodeEditorTests", + dependencies: ["RxCodeEditor"], + path: "Tests/RxCodeEditorTests" + ), .testTarget( name: "RxCodeMarkdownTests", dependencies: ["RxCodeMarkdown"], diff --git a/Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift b/Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift index ab9f6002..66c441e9 100644 --- a/Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift +++ b/Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift @@ -7,8 +7,9 @@ import SwiftData /// surface; mobile fetches the same items over the relay and renders them /// identically, so no separate sync channel is needed. /// -/// `surface` selects where the item appears (project / thread / briefing-card menu), -/// `projectId == nil` means "all projects", and `actionKind` + the action fields +/// `surface` selects where the item appears (any combination of project / thread / +/// briefing-card menus, stored comma-separated), `projectId == nil` means +/// "all projects", and `actionKind` + the action fields /// describe the work the desktop performs when the item is tapped. Template fields /// (`urlString`, `bodyTemplate`, `messageTemplate`, header values) may contain /// context placeholders such as `{{projectName}}`, `{{projectPath}}`, @@ -36,7 +37,8 @@ public final class CustomMenuItemRecord { public var systemImage: String? /// `nil` => available in every project; otherwise scoped to this project. public var projectId: UUID? - /// Raw `Surface` value. + /// Comma-separated raw `Surface` values (e.g. `"project,thread"`). A single + /// legacy value (`"project"`) parses identically, so old records keep working. public var surface: String /// Raw `ActionKind` value. public var actionKind: String @@ -63,7 +65,7 @@ public final class CustomMenuItemRecord { title: String, systemImage: String? = nil, projectId: UUID? = nil, - surface: Surface, + surfaces: [Surface], actionKind: ActionKind, httpMethod: String? = nil, urlString: String? = nil, @@ -80,7 +82,7 @@ public final class CustomMenuItemRecord { self.title = title self.systemImage = systemImage self.projectId = projectId - self.surface = surface.rawValue + self.surface = CustomMenuItemRecord.encodeSurfaces(surfaces) self.actionKind = actionKind.rawValue self.httpMethod = httpMethod self.urlString = urlString @@ -94,9 +96,26 @@ public final class CustomMenuItemRecord { self.updatedAt = updatedAt } - public var surfaceValue: Surface { Surface(rawValue: surface) ?? .project } + /// All surfaces this item attaches to, in the stored order. Falls back to + /// `[.project]` for an empty/unparseable value so the item never disappears. + public var surfaces: [Surface] { + let parsed = surface + .split(separator: ",") + .compactMap { Surface(rawValue: $0.trimmingCharacters(in: .whitespaces)) } + return parsed.isEmpty ? [.project] : parsed + } + + /// First surface — kept for callers that only need a single representative value. + public var surfaceValue: Surface { surfaces.first ?? .project } public var actionKindValue: ActionKind { ActionKind(rawValue: actionKind) ?? .createThread } + /// Encode a surface list to the stored comma-separated representation. Falls + /// back to `project` when empty so a record always has a home. + public static func encodeSurfaces(_ surfaces: [Surface]) -> String { + let list = surfaces.isEmpty ? [Surface.project] : surfaces + return list.map(\.rawValue).joined(separator: ",") + } + /// Decoded header map (empty when unset or malformed). public var headers: [String: String] { guard let headersJSON, let data = headersJSON.data(using: .utf8) else { return [:] } diff --git a/Packages/Sources/RxCodeCore/Utilities/SyntaxHighlighter.swift b/Packages/Sources/RxCodeCore/Utilities/SyntaxHighlighter.swift index 29eba89d..0287ebdd 100644 --- a/Packages/Sources/RxCodeCore/Utilities/SyntaxHighlighter.swift +++ b/Packages/Sources/RxCodeCore/Utilities/SyntaxHighlighter.swift @@ -8,6 +8,17 @@ import UIKit // MARK: - Syntax Highlighter public enum SyntaxHighlighter { + /// Async version of `highlightNS` that runs highlighting on a background thread. + public static func highlightNSAsync(_ code: String, language: String, fontSize: CGFloat = 12) async -> NSAttributedString { + struct SendableWrapper: @unchecked Sendable { + let value: NSAttributedString + } + let wrapped = await Task.detached { + SendableWrapper(value: highlightNS(code, language: language, fontSize: fontSize)) + }.value + return wrapped.value + } + public static func highlightNS(_ code: String, language: String, fontSize: CGFloat = 12) -> NSAttributedString { let normalized = normalizeLanguage(language) let tokens = tokenize(code, language: normalized) diff --git a/Packages/Sources/RxCodeEditor/CodeEditorAutocomplete.swift b/Packages/Sources/RxCodeEditor/CodeEditorAutocomplete.swift new file mode 100644 index 00000000..d82a03c2 --- /dev/null +++ b/Packages/Sources/RxCodeEditor/CodeEditorAutocomplete.swift @@ -0,0 +1,156 @@ +import Foundation + +public struct CodeEditorAutocompleteItem: Hashable, Identifiable, Sendable { + public let id: String + public let title: String + public let insertionText: String + public let detail: String? + + public init( + id: String? = nil, + title: String, + insertionText: String, + detail: String? = nil + ) { + self.id = id ?? insertionText + self.title = title + self.insertionText = insertionText + self.detail = detail + } +} + +public struct CodeEditorAutocompleteContext: Hashable, Sendable { + public let text: String + public let selectedRange: NSRange + public let replacementRange: NSRange + public let query: String + + public init( + text: String, + selectedRange: NSRange, + replacementRange: NSRange, + query: String + ) { + self.text = text + self.selectedRange = selectedRange + self.replacementRange = replacementRange + self.query = query + } +} + +public protocol CodeEditorAutocompleteProvider: Sendable { + /// Return a completion context when the caret is in a provider-owned trigger + /// region. LSP-backed providers can use this boundary to map the buffer and + /// caret into a protocol request before returning cached or fetched items. + func context(in text: String, selectedRange: NSRange) -> CodeEditorAutocompleteContext? + func completions(for context: CodeEditorAutocompleteContext) -> [CodeEditorAutocompleteItem] +} + +public struct PredefinedAutocompleteProvider: CodeEditorAutocompleteProvider { + public let trigger: String + public let closingDelimiter: String? + public let items: [CodeEditorAutocompleteItem] + + public init( + trigger: String, + closingDelimiter: String? = nil, + items: [CodeEditorAutocompleteItem] + ) { + self.trigger = trigger + self.closingDelimiter = closingDelimiter + self.items = items + } + + public func context(in text: String, selectedRange: NSRange) -> CodeEditorAutocompleteContext? { + guard selectedRange.length == 0, + !trigger.isEmpty, + selectedRange.location <= (text as NSString).length + else { return nil } + + let nsText = text as NSString + let prefixRange = NSRange(location: 0, length: selectedRange.location) + let triggerRange = nsText.range(of: trigger, options: [.backwards], range: prefixRange) + guard triggerRange.location != NSNotFound else { return nil } + + let queryStart = triggerRange.location + triggerRange.length + let queryLength = selectedRange.location - queryStart + guard queryLength >= 0 else { return nil } + + let queryRange = NSRange(location: queryStart, length: queryLength) + let query = nsText.substring(with: queryRange) + guard isValidQuery(query) else { return nil } + + if let closingDelimiter, + !closingDelimiter.isEmpty, + query.contains(closingDelimiter) { + return nil + } + + return CodeEditorAutocompleteContext( + text: text, + selectedRange: selectedRange, + replacementRange: NSRange( + location: triggerRange.location, + length: selectedRange.location - triggerRange.location + ), + query: query + ) + } + + public func completions(for context: CodeEditorAutocompleteContext) -> [CodeEditorAutocompleteItem] { + let query = context.query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return items } + + return items.filter { item in + item.title.localizedCaseInsensitiveContains(query) + || item.insertionText.localizedCaseInsensitiveContains(query) + || (item.detail?.localizedCaseInsensitiveContains(query) ?? false) + } + } + + private func isValidQuery(_ query: String) -> Bool { + for scalar in query.unicodeScalars { + if CharacterSet.whitespacesAndNewlines.contains(scalar) { return false } + switch scalar { + case "{", "}", "\"", "'", "`", ":", ",", "[", "]", "(", ")": + return false + default: + continue + } + } + return true + } +} + +public extension PredefinedAutocompleteProvider { + static func keywords( + _ keywords: [String], + trigger: String, + closingDelimiter: String? = nil + ) -> PredefinedAutocompleteProvider { + PredefinedAutocompleteProvider( + trigger: trigger, + closingDelimiter: closingDelimiter, + items: keywords.map { keyword in + CodeEditorAutocompleteItem( + title: keyword, + insertionText: keyword + ) + } + ) + } + + static func placeholders(_ placeholders: [String]) -> PredefinedAutocompleteProvider { + PredefinedAutocompleteProvider( + trigger: "{{", + closingDelimiter: "}}", + items: placeholders.map { placeholder in + CodeEditorAutocompleteItem( + title: placeholder, + insertionText: "{{\(placeholder)}}", + detail: "Context placeholder" + ) + } + ) + } +} diff --git a/Packages/Sources/RxCodeEditor/CodeEditorView.swift b/Packages/Sources/RxCodeEditor/CodeEditorView.swift new file mode 100644 index 00000000..adc4dd46 --- /dev/null +++ b/Packages/Sources/RxCodeEditor/CodeEditorView.swift @@ -0,0 +1,178 @@ +#if os(macOS) +import AppKit +import RxCodeCore +import SwiftUI + +public struct CodeEditorView: NSViewRepresentable { + @Binding private var text: String + private let language: String + private let fontSize: CGFloat + private let autocompleteProvider: (any CodeEditorAutocompleteProvider)? + + public init( + text: Binding, + language: String, + fontSize: CGFloat = 12, + autocompleteProvider: (any CodeEditorAutocompleteProvider)? = nil + ) { + _text = text + self.language = language + self.fontSize = fontSize + self.autocompleteProvider = autocompleteProvider + } + + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + public func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + + let textStorage = NSTextStorage() + let layoutManager = NSLayoutManager() + textStorage.addLayoutManager(layoutManager) + + let containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude) + let textContainer = NSTextContainer(containerSize: containerSize) + textContainer.widthTracksTextView = true + textContainer.heightTracksTextView = false + layoutManager.addTextContainer(textContainer) + + let textView = CompletionTextView(frame: .zero, textContainer: textContainer) + textView.delegate = context.coordinator + textView.completionRangeProvider = context.coordinator + textView.isRichText = false + textView.allowsUndo = true + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.textContainerInset = NSSize(width: 6, height: 8) + textView.font = .monospacedSystemFont(ofSize: fontSize, weight: .regular) + textView.backgroundColor = NSColor.textBackgroundColor + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isAutomaticDataDetectionEnabled = false + textView.smartInsertDeleteEnabled = false + + scrollView.documentView = textView + context.coordinator.textView = textView + context.coordinator.apply(text: text) + return scrollView + } + + public func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.parent = self + guard let textView = context.coordinator.textView else { return } + if textView.string != text { + context.coordinator.apply(text: text) + } + } + + @MainActor public final class Coordinator: NSObject, NSTextViewDelegate, CompletionRangeProviding { + var parent: CodeEditorView + weak var textView: CompletionTextView? + private var activeCompletionContext: CodeEditorAutocompleteContext? + + init(_ parent: CodeEditorView) { + self.parent = parent + } + + public func textDidChange(_ notification: Notification) { + guard let textView else { return } + parent.text = textView.string + if !textView.hasMarkedText() { + highlight(textView) + triggerAutocompleteIfNeeded(textView) + } + } + + func apply(text: String) { + guard let textView else { return } + if textView.string != text { + textView.string = text + } + highlight(textView) + } + + func completionRange(for textView: NSTextView) -> NSRange? { + activeCompletionContext?.replacementRange + } + + public func textView( + _ textView: NSTextView, + completions words: [String], + forPartialWordRange charRange: NSRange, + indexOfSelectedItem index: UnsafeMutablePointer? + ) -> [String] { + guard let provider = parent.autocompleteProvider, + let context = activeCompletionContext + else { return [] } + + index?.pointee = 0 + return provider.completions(for: context).map(\.insertionText) + } + + private func triggerAutocompleteIfNeeded(_ textView: CompletionTextView) { + guard let provider = parent.autocompleteProvider else { + activeCompletionContext = nil + textView.completionRangeOverride = nil + return + } + + let selectedRange = textView.selectedRange() + guard let context = provider.context(in: textView.string, selectedRange: selectedRange), + !provider.completions(for: context).isEmpty + else { + activeCompletionContext = nil + textView.completionRangeOverride = nil + return + } + + activeCompletionContext = context + textView.completionRangeOverride = context.replacementRange + textView.complete(nil) + } + + private func highlight(_ textView: NSTextView) { + guard let storage = textView.textStorage else { return } + let selected = textView.selectedRanges + let highlighted = SyntaxHighlighter.highlightNS( + textView.string, + language: parent.language, + fontSize: parent.fontSize + ) + storage.beginEditing() + storage.setAttributedString(highlighted) + storage.endEditing() + textView.selectedRanges = selected + } + } +} + +@MainActor protocol CompletionRangeProviding: AnyObject { + func completionRange(for textView: NSTextView) -> NSRange? +} + +@MainActor final class CompletionTextView: NSTextView { + weak var completionRangeProvider: CompletionRangeProviding? + var completionRangeOverride: NSRange? + + override var rangeForUserCompletion: NSRange { + completionRangeOverride + ?? completionRangeProvider?.completionRange(for: self) + ?? super.rangeForUserCompletion + } +} +#endif diff --git a/Packages/Sources/RxCodeMarkdown/MarkdownView.swift b/Packages/Sources/RxCodeMarkdown/MarkdownView.swift index b771fbf6..babe2a8f 100644 --- a/Packages/Sources/RxCodeMarkdown/MarkdownView.swift +++ b/Packages/Sources/RxCodeMarkdown/MarkdownView.swift @@ -1,4 +1,5 @@ import SwiftUI +import RxCodeCore #if canImport(AppKit) import AppKit #elseif canImport(UIKit) @@ -587,9 +588,7 @@ private struct MarkdownCodeBlockView: View { .frame(height: 0.5) ScrollView(.horizontal, showsIndicators: false) { - Text(content) - .font(.system(size: style.bodyFontSize * 0.88, design: .monospaced)) - .foregroundStyle(style.codeTextColor) + codeText .lineSpacing(style.lineSpacing) .fixedSize() .padding(12) @@ -603,6 +602,18 @@ private struct MarkdownCodeBlockView: View { .strokeBorder(style.borderColor, lineWidth: 0.5) ) } + + /// Syntax-highlighted code when a language is specified, + /// otherwise plain monospaced text in the theme's code color. + private var codeText: Text { + let fontSize = style.bodyFontSize * 0.88 + if let language, !language.isEmpty { + return Text(SyntaxHighlighter.highlight(content, language: language, fontSize: fontSize)) + } + return Text(content) + .font(.system(size: fontSize, design: .monospaced)) + .foregroundColor(style.codeTextColor) + } } private struct MarkdownTableView: View { diff --git a/Packages/Tests/RxCodeEditorTests/PredefinedAutocompleteProviderTests.swift b/Packages/Tests/RxCodeEditorTests/PredefinedAutocompleteProviderTests.swift new file mode 100644 index 00000000..5751eb47 --- /dev/null +++ b/Packages/Tests/RxCodeEditorTests/PredefinedAutocompleteProviderTests.swift @@ -0,0 +1,72 @@ +import Foundation +import RxCodeEditor +import Testing + +@Suite("Predefined autocomplete provider") +struct PredefinedAutocompleteProviderTests { + @Test("Returns all placeholders at an empty trigger") + func emptyTriggerReturnsAllPlaceholders() throws { + let provider = PredefinedAutocompleteProvider.placeholders(["projectName", "branch"]) + let text = #"{"body":"{{"}"# + let location = (text as NSString).length - 2 + + let context = try #require(provider.context( + in: text, + selectedRange: NSRange(location: location, length: 0) + )) + + #expect(context.query.isEmpty) + #expect(context.replacementRange == NSRange(location: 9, length: 2)) + #expect(provider.completions(for: context).map(\.insertionText) == [ + "{{projectName}}", + "{{branch}}", + ]) + } + + @Test("Filters placeholders by typed query") + func filtersByQuery() throws { + let provider = PredefinedAutocompleteProvider.placeholders([ + "projectName", + "projectPath", + "gitHubRepo", + "branch", + ]) + let text = #"{"body":"{{proj"}"# + let location = (text as NSString).length - 2 + + let context = try #require(provider.context( + in: text, + selectedRange: NSRange(location: location, length: 0) + )) + + #expect(context.query == "proj") + #expect(provider.completions(for: context).map(\.insertionText) == [ + "{{projectName}}", + "{{projectPath}}", + ]) + } + + @Test("Does not offer completions after a closed placeholder") + func ignoresClosedPlaceholder() { + let provider = PredefinedAutocompleteProvider.placeholders(["projectName"]) + let text = #"{"body":"{{projectName}}"}"# + let location = (text as NSString).length - 2 + + #expect(provider.context( + in: text, + selectedRange: NSRange(location: location, length: 0) + ) == nil) + } + + @Test("Does not offer completions for whitespace queries") + func ignoresWhitespaceQuery() { + let provider = PredefinedAutocompleteProvider.placeholders(["projectName"]) + let text = #"{"body":"{{project name"}"# + let location = (text as NSString).length - 2 + + #expect(provider.context( + in: text, + selectedRange: NSRange(location: location, length: 0) + ) == nil) + } +} diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 345aa39b..c22bd56e 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -58,6 +58,7 @@ E6C001022F9B000100000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E6C001012F9B000100000001 /* Sparkle */; }; E6D001032FA0000100000001 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001012FA0000100000001 /* RxCodeCore */; }; E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001022FA0000100000001 /* RxCodeChatKit */; }; + E6D001052FA0000100000001 /* RxCodeEditor in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001082FA0000100000001 /* RxCodeEditor */; }; E6D001072FA0000100000001 /* RxCodeSync in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001062FA0000100000001 /* RxCodeSync */; }; FB0000030000000000000001 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000010000000000000001 /* FirebaseAnalytics */; }; FB0000040000000000000001 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000020000000000000001 /* FirebaseCrashlytics */; }; @@ -315,10 +316,11 @@ DF46526A2FCD34B3002D9562 /* JSONSchemaValidator in Frameworks */, DFAA00012FCD34B3002D9562 /* JSONSchema in Frameworks */, DF462A622FC6EDCE002D9562 /* RxAuthSwiftUI in Frameworks */, - DF4652682FCD34B3002D9562 /* JSONSchemaForm in Frameworks */, - E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */, - DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */, - E6D001072FA0000100000001 /* RxCodeSync in Frameworks */, + DF4652682FCD34B3002D9562 /* JSONSchemaForm in Frameworks */, + E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */, + E6D001052FA0000100000001 /* RxCodeEditor in Frameworks */, + DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */, + E6D001072FA0000100000001 /* RxCodeSync in Frameworks */, DF462A602FC6EDCE002D9562 /* RxAuthSwift in Frameworks */, DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */, FB0000030000000000000001 /* FirebaseAnalytics in Frameworks */, @@ -616,10 +618,11 @@ packageProductDependencies = ( E6A001012F8A000100000001 /* SwiftTerm */, E6C001012F9B000100000001 /* Sparkle */, - E6D001012FA0000100000001 /* RxCodeCore */, - E6D001022FA0000100000001 /* RxCodeChatKit */, - E6D001062FA0000100000001 /* RxCodeSync */, - DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */, + E6D001012FA0000100000001 /* RxCodeCore */, + E6D001022FA0000100000001 /* RxCodeChatKit */, + E6D001082FA0000100000001 /* RxCodeEditor */, + E6D001062FA0000100000001 /* RxCodeSync */, + DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */, FB0000010000000000000001 /* FirebaseAnalytics */, FB0000020000000000000001 /* FirebaseCrashlytics */, DF4628912FC611E6002D9562 /* RxAuthSwift */, @@ -1740,6 +1743,11 @@ package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; productName = RxCodeSync; }; + E6D001082FA0000100000001 /* RxCodeEditor */ = { + isa = XCSwiftPackageProductDependency; + package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; + productName = RxCodeEditor; + }; FB0000010000000000000001 /* FirebaseAnalytics */ = { isa = XCSwiftPackageProductDependency; package = FB0000000000000000000001 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cfa3af20..485ec6d8 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,240 +1,240 @@ { - "originHash" : "4e57c3e72b3783f86f3921d55aadf023d63038ca5bc9265e34900dc7724ab193", - "pins" : [ + "originHash": "4e57c3e72b3783f86f3921d55aadf023d63038ca5bc9265e34900dc7724ab193", + "pins": [ { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" + "identity": "abseil-cpp-binary", + "kind": "remoteSourceControl", + "location": "https://github.com/google/abseil-cpp-binary.git", + "state": { + "revision": "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version": "1.2024072200.0" } }, { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", - "version" : "11.2.0" + "identity": "app-check", + "kind": "remoteSourceControl", + "location": "https://github.com/google/app-check.git", + "state": { + "revision": "61b85103a1aeed8218f17c794687781505fbbef5", + "version": "11.2.0" } }, { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5", - "version" : "11.15.0" + "identity": "firebase-ios-sdk", + "kind": "remoteSourceControl", + "location": "https://github.com/firebase/firebase-ios-sdk", + "state": { + "revision": "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5", + "version": "11.15.0" } }, { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed", - "version" : "2.3.0" + "identity": "google-ads-on-device-conversion-ios-sdk", + "kind": "remoteSourceControl", + "location": "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state": { + "revision": "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed", + "version": "2.3.0" } }, { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "45ce435e9406d3c674dd249a042b932bee006f60", - "version" : "11.15.0" + "identity": "googleappmeasurement", + "kind": "remoteSourceControl", + "location": "https://github.com/google/GoogleAppMeasurement.git", + "state": { + "revision": "45ce435e9406d3c674dd249a042b932bee006f60", + "version": "11.15.0" } }, { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" + "identity": "googledatatransport", + "kind": "remoteSourceControl", + "location": "https://github.com/google/GoogleDataTransport.git", + "state": { + "revision": "617af071af9aa1d6a091d59a202910ac482128f9", + "version": "10.1.0" } }, { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", - "version" : "8.1.0" + "identity": "googleutilities", + "kind": "remoteSourceControl", + "location": "https://github.com/google/GoogleUtilities.git", + "state": { + "revision": "60da361632d0de02786f709bdc0c4df340f7613e", + "version": "8.1.0" } }, { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", - "version" : "1.69.1" + "identity": "grpc-binary", + "kind": "remoteSourceControl", + "location": "https://github.com/google/grpc-binary.git", + "state": { + "revision": "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version": "1.69.1" } }, { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "c756a29784521063b6a1202907e2cc47f41b667c", - "version" : "4.5.0" + "identity": "gtm-session-fetcher", + "kind": "remoteSourceControl", + "location": "https://github.com/google/gtm-session-fetcher.git", + "state": { + "revision": "c756a29784521063b6a1202907e2cc47f41b667c", + "version": "4.5.0" } }, { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" + "identity": "interop-ios-for-google-sdks", + "kind": "remoteSourceControl", + "location": "https://github.com/google/interop-ios-for-google-sdks.git", + "state": { + "revision": "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version": "101.0.0" } }, { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" + "identity": "leveldb", + "kind": "remoteSourceControl", + "location": "https://github.com/firebase/leveldb.git", + "state": { + "revision": "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version": "1.22.5" } }, { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version" : "2.30910.0" + "identity": "nanopb", + "kind": "remoteSourceControl", + "location": "https://github.com/firebase/nanopb.git", + "state": { + "revision": "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version": "2.30910.0" } }, { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version" : "2.4.0" + "identity": "promises", + "kind": "remoteSourceControl", + "location": "https://github.com/google/promises.git", + "state": { + "revision": "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version": "2.4.0" } }, { - "identity" : "rxauthswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/rxtech-lab/RxAuthSwift", - "state" : { - "revision" : "f1bc8a1004c58f7eae628eaf8ae705e4f8c21c51", - "version" : "1.1.1" + "identity": "rxauthswift", + "kind": "remoteSourceControl", + "location": "https://github.com/rxtech-lab/RxAuthSwift", + "state": { + "revision": "f1bc8a1004c58f7eae628eaf8ae705e4f8c21c51", + "version": "1.1.1" } }, { - "identity" : "sdwebimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SDWebImage/SDWebImage.git", - "state" : { - "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", - "version" : "5.21.7" + "identity": "sdwebimage", + "kind": "remoteSourceControl", + "location": "https://github.com/SDWebImage/SDWebImage.git", + "state": { + "revision": "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version": "5.21.7" } }, { - "identity" : "sdwebimagesvgcoder", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SDWebImage/SDWebImageSVGCoder", - "state" : { - "revision" : "85b5d58ad02c207c496fa34426dc6560d6ae32f0", - "version" : "1.8.0" + "identity": "sdwebimagesvgcoder", + "kind": "remoteSourceControl", + "location": "https://github.com/SDWebImage/SDWebImageSVGCoder", + "state": { + "revision": "85b5d58ad02c207c496fa34426dc6560d6ae32f0", + "version": "1.8.0" } }, { - "identity" : "sparkle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sparkle-project/Sparkle.git", - "state" : { - "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", - "version" : "2.9.1" + "identity": "sparkle", + "kind": "remoteSourceControl", + "location": "https://github.com/sparkle-project/Sparkle.git", + "state": { + "revision": "066e75a8b3e99962685d6a90cdd5293ebffd9261", + "version": "2.9.1" } }, { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", - "state" : { - "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", - "version" : "1.7.1" + "identity": "swift-argument-parser", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-argument-parser", + "state": { + "revision": "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version": "1.7.1" } }, { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", - "version" : "1.5.1" + "identity": "swift-collections", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-collections", + "state": { + "revision": "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version": "1.5.1" } }, { - "identity" : "swift-json-schema", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sirily11/swift-json-schema", - "state" : { - "revision" : "663afab8c131151950fd7fb114871c02a529a4b4", - "version" : "1.0.2" + "identity": "swift-json-schema", + "kind": "remoteSourceControl", + "location": "https://github.com/sirily11/swift-json-schema", + "state": { + "revision": "663afab8c131151950fd7fb114871c02a529a4b4", + "version": "1.0.2" } }, { - "identity" : "swift-jsonschema-form", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sirily11/swift-jsonschema-form", - "state" : { - "branch" : "main", - "revision" : "a4feb400a0bca57bc39b8ea95544c71aa768fbfd" + "identity": "swift-jsonschema-form", + "kind": "remoteSourceControl", + "location": "https://github.com/sirily11/swift-jsonschema-form", + "state": { + "branch": "main", + "revision": "a4feb400a0bca57bc39b8ea95544c71aa768fbfd" } }, { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "7dc6101ae4dbe95cd3bc9cebad3b7cf8e49a7a63", - "version" : "1.13.0" + "identity": "swift-log", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-log.git", + "state": { + "revision": "7dc6101ae4dbe95cd3bc9cebad3b7cf8e49a7a63", + "version": "1.13.0" } }, { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "f6506eaa86ed2e01cb0ae14a75035b7fdbf0918f", - "version" : "1.38.0" + "identity": "swift-protobuf", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-protobuf.git", + "state": { + "revision": "f6506eaa86ed2e01cb0ae14a75035b7fdbf0918f", + "version": "1.38.0" } }, { - "identity" : "swiftterm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/migueldeicaza/SwiftTerm.git", - "state" : { - "revision" : "8e7a1e154f470e19c709a00a8768df348ba5fc43", - "version" : "1.13.0" + "identity": "swiftterm", + "kind": "remoteSourceControl", + "location": "https://github.com/migueldeicaza/SwiftTerm.git", + "state": { + "revision": "8e7a1e154f470e19c709a00a8768df348ba5fc43", + "version": "1.13.0" } }, { - "identity" : "viewinspector", - "kind" : "remoteSourceControl", - "location" : "https://github.com/nalexn/ViewInspector", - "state" : { - "revision" : "e9a06346499a3a889165647e3f23f8a7b2609a1c", - "version" : "0.10.3" + "identity": "viewinspector", + "kind": "remoteSourceControl", + "location": "https://github.com/nalexn/ViewInspector", + "state": { + "revision": "e9a06346499a3a889165647e3f23f8a7b2609a1c", + "version": "0.10.3" } }, { - "identity" : "waterfallgrid", - "kind" : "remoteSourceControl", - "location" : "https://github.com/paololeonardi/WaterfallGrid", - "state" : { - "revision" : "c7c08652c3540adf8e48409c351879b4caea7e89", - "version" : "1.1.0" + "identity": "waterfallgrid", + "kind": "remoteSourceControl", + "location": "https://github.com/paololeonardi/WaterfallGrid", + "state": { + "revision": "c7c08652c3540adf8e48409c351879b4caea7e89", + "version": "1.1.0" } } ], - "version" : 3 + "version": 3 } diff --git a/RxCode/App/AppState+CIStatus.swift b/RxCode/App/AppState+CIStatus.swift index 7f91277f..0847be51 100644 --- a/RxCode/App/AppState+CIStatus.swift +++ b/RxCode/App/AppState+CIStatus.swift @@ -181,7 +181,8 @@ extension AppState { projectId: project.id, threadId: nil, prompt: prompt, - waitForResponse: false + waitForResponse: false, + skipHooks: true ) logger.info("Started auto CI-fix thread for project \(project.name, privacy: .public)") } catch { @@ -208,7 +209,8 @@ extension AppState { projectId: project.id, threadId: nil, prompt: prompt, - waitForResponse: false + waitForResponse: false, + skipHooks: true ) if let error = result.error { throw CodeReviewError.sendFailed(error) } return result.threadId diff --git a/RxCode/App/AppState+Commit.swift b/RxCode/App/AppState+Commit.swift index 45501c08..3d56c976 100644 --- a/RxCode/App/AppState+Commit.swift +++ b/RxCode/App/AppState+Commit.swift @@ -109,6 +109,7 @@ extension AppState { waitForResponse: false, timeoutSeconds: 600, threadLabel: Self.manualCommitLabel, + skipHooks: true, setupKind: HookSetupKind.commitPush ) if let error = result.error { throw CommitFilesError.sendFailed(error) } diff --git a/RxCode/App/AppState+Hooks.swift b/RxCode/App/AppState+Hooks.swift index 12418454..5891f47c 100644 --- a/RxCode/App/AppState+Hooks.swift +++ b/RxCode/App/AppState+Hooks.swift @@ -48,10 +48,14 @@ extension AppState { /// the desktop's own locale (used by native menus). Mobile relay requests pass /// the phone's locale so titles come back translated. func projectContextMenuItems(for project: Project, branch: String? = nil, locale: String? = nil) -> [MenuItem] { - hookManager.projectContextMenuItems(ProjectContextMenuPayload(project: project, branch: branch, locale: locale)) + // Touch the revision so SwiftUI re-runs this fetch (and thus picks up + // newly created/edited custom items) when called from a view body. + _ = customMenuItemsRevision + return hookManager.projectContextMenuItems(ProjectContextMenuPayload(project: project, branch: branch, locale: locale)) } func threadContextMenuItems(for session: ChatSession.Summary, locale: String? = nil) -> [MenuItem] { + _ = customMenuItemsRevision guard let project = projects.first(where: { $0.id == session.projectId }) else { return [] } return hookManager.threadContextMenuItems(ThreadContextMenuPayload(project: project, session: session, locale: locale)) } diff --git a/RxCode/App/AppState+MenuDispatch.swift b/RxCode/App/AppState+MenuDispatch.swift index 23d280b3..a367e2bc 100644 --- a/RxCode/App/AppState+MenuDispatch.swift +++ b/RxCode/App/AppState+MenuDispatch.swift @@ -108,11 +108,16 @@ extension AppState { case .createThread: let project = try requireProject(command.projectId) + // A menu-spawned thread is a utility/automation run (commit, review, + // a user's custom action) — it shouldn't itself trigger lifecycle + // hooks like code review or auto-continue. Mirrors the built-in + // commit / code-review / CI-fix spawners. let result = try await sendCrossProject( projectId: project.id, threadId: nil, prompt: config.message ?? "", - waitForResponse: false + waitForResponse: false, + skipHooks: true ) if let error = result.error { throw MenuDispatchError.customActionFailed(error) } return MenuCommandResult(threadId: result.threadId) diff --git a/RxCode/App/AppState+PullRequest.swift b/RxCode/App/AppState+PullRequest.swift index c40f3301..cb39b70d 100644 --- a/RxCode/App/AppState+PullRequest.swift +++ b/RxCode/App/AppState+PullRequest.swift @@ -162,6 +162,22 @@ extension AppState { "test", "build", "ci", "chore", "revert" ] + /// Maximum number of whitespace-separated words allowed in a generated PR + /// title (including the Conventional-Commit `:` prefix). Keeps titles + /// short and scannable even when the model ignores the prompt's length hint. + static let maxPullRequestTitleWords = 20 + + /// Truncate `title` to at most ``maxPullRequestTitleWords`` whitespace- + /// separated words. Returns the title unchanged when already within the + /// limit; otherwise keeps the leading words and strips any trailing + /// punctuation left dangling by the cut. + static func truncatePullRequestTitleWords(_ title: String) -> String { + let words = title.split(whereSeparator: { $0.isWhitespace }) + guard words.count > maxPullRequestTitleWords else { return title } + let kept = words.prefix(maxPullRequestTitleWords).joined(separator: " ") + return stripTrailingPullRequestTitlePunctuation(kept) + } + /// True when `title` matches `(): ` and /// `` is one of ``conventionalCommitTypes``. Used to gate generated PR /// titles so a non-conforming title triggers a model retry. @@ -217,6 +233,7 @@ extension AppState { .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) .trimmingCharacters(in: .whitespaces) title = normalizePullRequestTitle(title, fallbackTitle: fallbackTitle) + title = truncatePullRequestTitleWords(title) if title.isEmpty { title = fallbackTitle } let body = lines[(firstIdx + 1)...] diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index d041b03a..5fbf355e 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -239,6 +239,13 @@ final class AppState { /// changes without an accompanying observable property mutation. var todoSnapshotsRevision: Int = 0 + /// Bumped each time a custom context-menu item is created, edited, toggled, + /// or removed (`CustomMenuItemRecord` in SwiftData). The sidebar's project / + /// thread context menus read this when building `hookMenuItems` so SwiftUI + /// re-runs the `customMenuItems(...)` fetch and the new item appears without + /// an app restart. + var customMenuItemsRevision: Int = 0 + /// Pending permission/question prompts keyed by hook id. This mirrors the /// per-window queues so mobile thread rows can show the same attention state. var mobilePendingRequests: [String: PermissionRequest] = [:] diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 279d3a13..e13dbf4b 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -1627,7 +1627,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "向项目、线程和简报卡片菜单中添加你自己的项。它们会自动同步到移动设备。" + "value" : "向项目、聊天和简报卡片菜单中添加你自己的项。它们会自动同步到移动设备。" } } } @@ -1698,7 +1698,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "会话完成后,更改会发送到一个不运行任何 Hook 的关联 [Code Review] 线程。如果审查要求修改,其备注会发送回此线程,以便智能体持续修复并重新接受审查(最多 3 次)。" + "value" : "会话完成后,更改会发送到一个不运行任何 Hook 的关联 [Code Review] 聊天。如果审查要求修改,其备注会发送回此聊天,以便智能体持续修复并重新接受审查(最多 3 次)。" } } } @@ -1714,7 +1714,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "会话完成后,你的消息会发送给助手——作为此线程中的后续消息,或发送到一个不运行任何 Hook 的新关联线程。设置条件后,模型会先判断是/否。每个会话触发一次,发送新消息时重置。" + "value" : "会话完成后,你的消息会发送给助手——作为此聊天中的后续消息,或发送到一个不运行任何 Hook 的新关联聊天。设置条件后,模型会先判断是/否。每个会话触发一次,发送新消息时重置。" } } } @@ -2006,6 +2006,7 @@ } }, "An SF Symbol name shown beside the title, e.g. \"bolt\"." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -2020,6 +2021,9 @@ } } } + }, + "An SF Symbol shown beside the title, e.g. \"bolt\"." : { + }, "API Key" : { "localizations" : { @@ -2776,6 +2780,7 @@ } }, "Body" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -2791,6 +2796,38 @@ } } }, + "Body (JSON)" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "본문 (JSON)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正文 (JSON)" + } + } + } + }, + "Body must be valid JSON before it can be formatted." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON 본문이 유효해야 형식을 지정할 수 있습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON 正文必须有效才能格式化。" + } + } + } + }, "Branch" : { "localizations" : { "ko" : { @@ -2962,7 +2999,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在“设置 → 上下文菜单”中构建你自己的项目、线程和简报卡片菜单项。它们会自动同步到你的手机。" + "value" : "在“设置 → 上下文菜单”中构建你自己的项目、聊天和简报卡片菜单项。它们会自动同步到你的手机。" } } } @@ -3581,7 +3618,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "审查此线程" + "value" : "审查此聊天" } } } @@ -4174,6 +4211,7 @@ } }, "Context Menus" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -4216,7 +4254,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "继续现有线程" + "value" : "继续现有聊天" } } } @@ -4232,7 +4270,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "继续线程" + "value" : "继续聊天" } } } @@ -4427,7 +4465,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用模板消息创建新线程或继续现有线程。" + "value" : "使用模板消息创建新聊天或继续现有聊天。" } } } @@ -4508,7 +4546,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "创建新线程" + "value" : "创建新聊天" } } } @@ -6554,7 +6592,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未通过的审查会发送回原始线程,以便智能体修复并重新接受审查。" + "value" : "未通过的审查会发送回原始聊天,以便智能体修复并重新接受审查。" } } } @@ -6842,6 +6880,22 @@ } } }, + "Format the JSON body." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON 본문의 형식을 지정합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "格式化 JSON 正文。" + } + } + } + }, "Force off" : { "localizations" : { "ko" : { @@ -7514,6 +7568,9 @@ } } } + }, + "Icon" : { + }, "Images" : { "localizations" : { @@ -9393,7 +9450,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "新建线程" + "value" : "新建聊天" } } } @@ -9409,7 +9466,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "新线程标签" + "value" : "新聊天标签" } } } @@ -9834,6 +9891,9 @@ } } } + }, + "No matches." : { + }, "No matching memories" : { "localizations" : { @@ -11351,6 +11411,9 @@ } } } + }, + "Pick one or more menus this item appears on." : { + }, "Pick which model performs the review — defaults to the same model as the thread." : { "localizations" : { @@ -11363,7 +11426,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "选择执行审查的模型——默认与线程使用相同的模型。" + "value" : "选择执行审查的模型——默认与聊天使用相同的模型。" } } } @@ -11492,6 +11555,22 @@ } } }, + "Prettify" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서식 지정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "格式化" + } + } + } + }, "Press Command-K or use the toolbar search button to find past work across projects." : { "localizations" : { "ko" : { @@ -12991,7 +13070,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在会话结束后运行,并在关联线程中审查已修改的文件。" + "value" : "在会话结束后运行,并在关联聊天中审查已修改的文件。" } } } @@ -13204,7 +13283,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "与线程相同" + "value" : "与聊天相同" } } } @@ -13220,7 +13299,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "同一线程" + "value" : "同一聊天" } } } @@ -13665,6 +13744,9 @@ } } } + }, + "Search symbols" : { + }, "Search Threads (⌘K)" : { "extractionState" : "stale", @@ -14350,6 +14432,7 @@ } }, "SF Symbol" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -15358,7 +15441,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "目标线程 ID" + "value" : "目标聊天 ID" } } } @@ -15502,7 +15585,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "要继续的线程的会话 ID。保留 {{sessionId}} 占位符可指向所点按的线程。" + "value" : "要继续的聊天的会话 ID。保留 {{sessionId}} 占位符可指向所点按的聊天。" } } } @@ -15754,7 +15837,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "线程菜单" + "value" : "聊天菜单" } } } @@ -15770,7 +15853,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "线程模型" + "value" : "聊天模型" } } } diff --git a/RxCode/Services/Hooks/hooks/CustomMenuHook.swift b/RxCode/Services/Hooks/hooks/CustomMenuHook.swift index 0bb29f55..e8320ae4 100644 --- a/RxCode/Services/Hooks/hooks/CustomMenuHook.swift +++ b/RxCode/Services/Hooks/hooks/CustomMenuHook.swift @@ -25,7 +25,7 @@ final class CustomMenuHook: Hook { let surface: CustomMenuItemRecord.Surface = payload.branch != nil ? .briefing : .project let context = PlaceholderContext(project: payload.project, branch: payload.branch, sessionId: nil) return controller.customMenuItems(projectId: payload.project.id, surface: surface) - .map { item($0, context: context, projectId: payload.project.id) } + .map { item($0, surface: surface, context: context, projectId: payload.project.id) } } // MARK: - Thread menu @@ -33,13 +33,14 @@ final class CustomMenuHook: Hook { func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { let context = PlaceholderContext(project: payload.project, branch: nil, sessionId: payload.session.id) return controller.customMenuItems(projectId: payload.project.id, surface: .thread) - .map { item($0, context: context, projectId: payload.project.id, sessionId: payload.session.id) } + .map { item($0, surface: .thread, context: context, projectId: payload.project.id, sessionId: payload.session.id) } } // MARK: - Build one item private func item( _ record: CustomMenuItemRecord, + surface: CustomMenuItemRecord.Surface, context: PlaceholderContext, projectId: UUID, sessionId: String? = nil @@ -47,7 +48,7 @@ final class CustomMenuHook: Hook { let config = resolvedConfig(record, context: context, sessionId: sessionId) let isAPI = record.actionKindValue == .callAPI return MenuItem( - id: "\(hookID).\(record.surface).\(record.id)", + id: "\(hookID).\(surface.rawValue).\(record.id)", title: record.title, systemImage: record.systemImage, action: .command(MenuActionCommand( diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index f7d53e9f..c4866368 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -271,7 +271,7 @@ actor OpenAISummarizationService { Write a GitHub pull request title and description that summarize the work on a branch, using the branch briefing below. Format rules (MUST follow exactly): - - The FIRST line is the PR title in Conventional Commits format: `(): ` — under 72 characters, lowercase imperative mood, no trailing period. + - The FIRST line is the PR title in Conventional Commits format: `(): ` — at most 20 words and under 72 characters, lowercase imperative mood, no trailing period. - `` MUST be EXACTLY one of these tokens, spelled verbatim: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. - Use the short token only. Do NOT expand or substitute it (e.g. write `feat`, never `feature`; `perf`, never `performance`). A title whose type is not on the list above is invalid. - Then exactly ONE blank line. diff --git a/RxCode/Services/ThreadStore+CustomMenus.swift b/RxCode/Services/ThreadStore+CustomMenus.swift index 357ff23f..95286aac 100644 --- a/RxCode/Services/ThreadStore+CustomMenus.swift +++ b/RxCode/Services/ThreadStore+CustomMenus.swift @@ -20,16 +20,19 @@ extension ThreadStore { /// Enabled items that should appear on `surface` for `projectId` — i.e. items /// scoped to that project plus the "all projects" (nil) items. func customMenuItems(projectId: UUID?, surface: CustomMenuItemRecord.Surface) -> [CustomMenuItemRecord] { - let raw = surface.rawValue + // Surface membership lives in the computed `surfaces` list, so it can't be + // expressed in a #Predicate — fetch the enabled rows and filter in Swift. let descriptor = FetchDescriptor( - predicate: #Predicate { $0.isEnabled && $0.surface == raw }, + predicate: #Predicate { $0.isEnabled }, sortBy: [ SortDescriptor(\.sortOrder, order: .forward), SortDescriptor(\.createdAt, order: .forward) ] ) let rows = (try? context.fetch(descriptor)) ?? [] - return rows.filter { $0.projectId == nil || $0.projectId == projectId } + return rows.filter { + ($0.projectId == nil || $0.projectId == projectId) && $0.surfaces.contains(surface) + } } func fetchCustomMenuItem(id: String) -> CustomMenuItemRecord? { diff --git a/RxCode/Views/Settings/CustomMenuEditorSheet.swift b/RxCode/Views/Settings/CustomMenuEditorSheet.swift index a39a545a..8f0ef796 100644 --- a/RxCode/Views/Settings/CustomMenuEditorSheet.swift +++ b/RxCode/Views/Settings/CustomMenuEditorSheet.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI import RxCodeCore @@ -9,7 +10,7 @@ struct CustomMenuDraft: Identifiable { var title: String var systemImage: String var projectId: UUID? // nil = all projects - var surface: CustomMenuItemRecord.Surface + var surfaces: Set var actionKind: CustomMenuItemRecord.ActionKind // callAPI @@ -38,7 +39,7 @@ struct CustomMenuDraft: Identifiable { title = "" systemImage = "bolt" projectId = nil - surface = .project + surfaces = [.project] actionKind = .callAPI httpMethod = "POST" urlString = "" @@ -57,7 +58,7 @@ struct CustomMenuDraft: Identifiable { title = record.title systemImage = record.systemImage ?? "bolt" projectId = record.projectId - surface = record.surfaceValue + surfaces = Set(record.surfaces) actionKind = record.actionKindValue httpMethod = record.httpMethod ?? "POST" urlString = record.urlString ?? "" @@ -72,15 +73,16 @@ struct CustomMenuDraft: Identifiable { var trimmedTitle: String { title.trimmingCharacters(in: .whitespacesAndNewlines) } var isValid: Bool { - guard !trimmedTitle.isEmpty else { return false } + guard !trimmedTitle.isEmpty, !surfaces.isEmpty else { return false } switch actionKind { case .callAPI: return !urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty case .createThread: return !messageTemplate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty case .continueThread: guard !messageTemplate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } - // A thread-menu item continues the tapped thread, so no explicit target - // is needed; project/briefing items must name the thread to continue. - if surface == .thread { return true } + // A thread-only menu item continues the tapped thread, so no explicit + // target is needed; if it also appears on a project/briefing surface it + // must name the thread to continue. + if surfaces == [.thread] { return true } return !targetSessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } @@ -96,7 +98,7 @@ struct CustomMenuDraft: Identifiable { title: trimmedTitle, systemImage: systemImage.isEmpty ? nil : systemImage, projectId: projectId, - surface: surface, + surfaces: CustomMenuItemRecord.Surface.allCases.filter { surfaces.contains($0) }, actionKind: actionKind, httpMethod: actionKind == .callAPI ? httpMethod : nil, urlString: actionKind == .callAPI ? urlString.trimmingCharacters(in: .whitespacesAndNewlines) : nil, @@ -115,6 +117,7 @@ struct CustomMenuEditorSheet: View { @Environment(\.dismiss) private var dismiss @State var draft: CustomMenuDraft + @State private var bodyJSONFormatError: String? let projects: [Project] var onSave: (CustomMenuDraft) -> Void @@ -141,7 +144,17 @@ struct CustomMenuEditorSheet: View { footer } - .frame(width: 540, height: 600) + .frame(width: 540, height: 700) + } + + /// Toggles membership of `surface` in the draft's surface set. + private func surfaceBinding(_ surface: CustomMenuItemRecord.Surface) -> Binding { + Binding( + get: { draft.surfaces.contains(surface) }, + set: { isOn in + if isOn { draft.surfaces.insert(surface) } else { draft.surfaces.remove(surface) } + } + ) } // MARK: - Sections @@ -153,11 +166,15 @@ struct CustomMenuEditorSheet: View { SFSymbolPicker(symbol: $draft.systemImage) } .help("An SF Symbol shown beside the title, e.g. \"bolt\".") - Picker("Show in", selection: $draft.surface) { - Text("Project menu").tag(CustomMenuItemRecord.Surface.project) - Text("Thread menu").tag(CustomMenuItemRecord.Surface.thread) - Text("Briefing card menu").tag(CustomMenuItemRecord.Surface.briefing) + LabeledContent("Show in") { + VStack(alignment: .leading, spacing: 4) { + Toggle("Project menu", isOn: surfaceBinding(.project)) + Toggle("Thread menu", isOn: surfaceBinding(.thread)) + Toggle("Briefing card menu", isOn: surfaceBinding(.briefing)) + } + .toggleStyle(.checkbox) } + .help("Pick one or more menus this item appears on.") Picker("Scope", selection: $draft.projectId) { Text("All projects").tag(UUID?.none) ForEach(projects) { project in @@ -190,13 +207,33 @@ struct CustomMenuEditorSheet: View { headersEditor VStack(alignment: .leading, spacing: 4) { - Text("Body") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - TextEditor(text: $draft.bodyTemplate) - .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) - .frame(minHeight: 90) + HStack(alignment: .firstTextBaseline) { + Text("Body (JSON)") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + Spacer() + Button { + prettifyBodyJSON() + } label: { + Label("Prettify", systemImage: "wand.and.stars") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(draft.bodyTemplate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .help("Format the JSON body.") + } + JSONCodeEditor(text: $draft.bodyTemplate, fontSize: ClaudeTheme.size(12), minHeight: 200) + .frame(minHeight: 200) + .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color(NSColor.separatorColor))) + .onChange(of: draft.bodyTemplate) { _, _ in + bodyJSONFormatError = nil + } + if let bodyJSONFormatError { + Text(bodyJSONFormatError) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.statusError) + } } } } @@ -268,4 +305,22 @@ struct CustomMenuEditorSheet: View { .padding(.horizontal, 20) .padding(.vertical, 14) } + + private func prettifyBodyJSON() { + let trimmed = draft.bodyTemplate.trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), + let formatted = try? JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .withoutEscapingSlashes, .fragmentsAllowed] + ), + let string = String(data: formatted, encoding: .utf8) + else { + bodyJSONFormatError = String(localized: "Body must be valid JSON before it can be formatted.") + return + } + + draft.bodyTemplate = string + bodyJSONFormatError = nil + } } diff --git a/RxCode/Views/Settings/CustomMenusSettingsTab.swift b/RxCode/Views/Settings/CustomMenusSettingsTab.swift index 9042698c..ec2db191 100644 --- a/RxCode/Views/Settings/CustomMenusSettingsTab.swift +++ b/RxCode/Views/Settings/CustomMenusSettingsTab.swift @@ -24,6 +24,7 @@ struct CustomMenusSettingsSection: View { .sheet(item: $editing) { draft in CustomMenuEditorSheet(draft: draft, projects: appState.projects) { result in appState.threadStore.upsertCustomMenuItem(result.toRecord()) + appState.customMenuItemsRevision += 1 reload() } } @@ -33,6 +34,7 @@ struct CustomMenusSettingsSection: View { let id = record.id pendingRemoval = nil appState.threadStore.deleteCustomMenuItem(id: id) + appState.customMenuItemsRevision += 1 reload() } } message: { record in @@ -105,6 +107,7 @@ struct CustomMenusSettingsSection: View { onToggle: { enabled in record.isEnabled = enabled appState.threadStore.upsertCustomMenuItem(record) + appState.customMenuItemsRevision += 1 reload() }, onRemove: { pendingRemoval = record } @@ -144,7 +147,7 @@ private struct CustomMenuRow: View { Text(verbatim: record.title) .font(.system(size: ClaudeTheme.size(13), weight: .medium)) HStack(spacing: 6) { - badge(surfaceLabel) + ForEach(record.surfaces, id: \.self) { badge(surfaceLabel($0)) } badge(actionLabel) Text(verbatim: projectName) .font(.system(size: ClaudeTheme.size(10))) @@ -189,8 +192,8 @@ private struct CustomMenuRow: View { .clipShape(RoundedRectangle(cornerRadius: 4)) } - private var surfaceLabel: String { - switch record.surfaceValue { + private func surfaceLabel(_ surface: CustomMenuItemRecord.Surface) -> String { + switch surface { case .project: return "Project" case .thread: return "Thread" case .briefing: return "Briefing" diff --git a/RxCode/Views/Settings/JSONCodeEditor.swift b/RxCode/Views/Settings/JSONCodeEditor.swift new file mode 100644 index 00000000..92c74fe8 --- /dev/null +++ b/RxCode/Views/Settings/JSONCodeEditor.swift @@ -0,0 +1,30 @@ +#if os(macOS) +import SwiftUI +import RxCodeEditor + +/// A lightweight JSON editor with live syntax highlighting, used by the custom +/// menu editor for the request body. +struct JSONCodeEditor: View { + @Binding var text: String + var fontSize: CGFloat = 12 + var minHeight: CGFloat = 200 + + private let placeholderProvider = PredefinedAutocompleteProvider.placeholders([ + "projectName", + "projectPath", + "gitHubRepo", + "branch", + "sessionId", + ]) + + var body: some View { + CodeEditorView( + text: $text, + language: "json", + fontSize: fontSize, + autocompleteProvider: placeholderProvider + ) + .frame(minHeight: minHeight) + } +} +#endif diff --git a/RxCode/Views/Settings/SFSymbolPicker.swift b/RxCode/Views/Settings/SFSymbolPicker.swift index fa3a77b8..cd8da6eb 100644 --- a/RxCode/Views/Settings/SFSymbolPicker.swift +++ b/RxCode/Views/Settings/SFSymbolPicker.swift @@ -2,8 +2,7 @@ import RxCodeCore import SwiftUI /// A dropdown control for choosing an SF Symbol. Shows a live preview of the -/// current selection and opens a searchable grid of common symbols. Users can -/// also type any symbol name directly for symbols not in the curated list. +/// current selection and opens a searchable grid of common symbols. struct SFSymbolPicker: View { @Binding var symbol: String @@ -75,10 +74,6 @@ struct SFSymbolPicker: View { private var pickerBody: some View { VStack(spacing: 10) { - TextField("Symbol name", text: $symbol, prompt: Text(verbatim: "e.g. bolt")) - .textFieldStyle(.roundedBorder) - .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) - HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: ClaudeTheme.size(11))) @@ -103,7 +98,7 @@ struct SFSymbolPicker: View { .frame(height: 220) if filtered.isEmpty { - Text("No matches. Type a name above to use it anyway.") + Text("No matches.") .font(.system(size: ClaudeTheme.size(10))) .foregroundStyle(.secondary) } diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index 0ebf2e11..dcd28626 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -66,6 +66,12 @@ struct BriefingView: View { _ = appState.branchBriefingRevision _ = appState.threadSummaryRevision + // Position of each project in the sidebar order, so briefing cards can be + // arranged to follow the project list rather than pure recency. + let projectOrder: [UUID: Int] = Dictionary( + uniqueKeysWithValues: appState.projects.enumerated().map { ($0.element.id, $0.offset) } + ) + let knownIds = knownProjectIds let briefings = appState.threadStore.allBranchBriefingItems() .filter { knownIds.contains($0.projectId) } @@ -105,7 +111,17 @@ struct BriefingView: View { updatedAt: $0.updated ) } - .sorted { $0.updatedAt > $1.updatedAt } + .sorted { lhs, rhs in + // Primary: follow the sidebar project order. Unknown projects + // (no position) sort after known ones. Within the same project, + // fall back to most-recently-updated first. + let lhsOrder = projectOrder[lhs.projectId] ?? Int.max + let rhsOrder = projectOrder[rhs.projectId] ?? Int.max + if lhsOrder != rhsOrder { + return lhsOrder < rhsOrder + } + return lhs.updatedAt > rhs.updatedAt + } let projectFiltered: [BriefingGroup] if selectedProjectIds.isEmpty { diff --git a/RxCode/Views/Sidebar/FileInspectorView.swift b/RxCode/Views/Sidebar/FileInspectorView.swift index 6a79db0e..97cd9211 100644 --- a/RxCode/Views/Sidebar/FileInspectorView.swift +++ b/RxCode/Views/Sidebar/FileInspectorView.swift @@ -248,10 +248,7 @@ struct FileInspectorView: View { }.value content = textToSave let ext = fileExtension - let highlighted = await Task.detached { - SyntaxHighlighter.highlightNS(textToSave, language: ext) - }.value - highlightedContent = highlighted + highlightedContent = await SyntaxHighlighter.highlightNSAsync(textToSave, language: ext) isEditing = false } catch { saveError = "Save failed: \(error.localizedDescription)" @@ -280,11 +277,8 @@ struct FileInspectorView: View { if let text = String(data: data, encoding: .utf8) { let ext = fileExtension - let highlighted = await Task.detached { - SyntaxHighlighter.highlightNS(text, language: ext) - }.value content = text - highlightedContent = highlighted + highlightedContent = await SyntaxHighlighter.highlightNSAsync(text, language: ext) lineCount = text.components(separatedBy: "\n").count } else { errorMessage = "Binary file — preview not available" diff --git a/RxCodeAndroid/app/build.gradle.kts b/RxCodeAndroid/app/build.gradle.kts index 05afc458..0813d75b 100644 --- a/RxCodeAndroid/app/build.gradle.kts +++ b/RxCodeAndroid/app/build.gradle.kts @@ -65,6 +65,8 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + // Required by the android-tree-sitter AARs. + isCoreLibraryDesugaringEnabled = true } kotlinOptions { jvmTarget = "17" @@ -126,6 +128,17 @@ dependencies { implementation(libs.coil.compose) implementation(libs.coil.svg) implementation(libs.compose.markdown) + + // Tree-sitter syntax highlighting (core + bundled grammars). + coreLibraryDesugaring(libs.desugar.jdk.libs) + implementation(libs.treesitter.core) + implementation(libs.treesitter.java) + implementation(libs.treesitter.kotlin) + implementation(libs.treesitter.python) + implementation(libs.treesitter.json) + implementation(libs.treesitter.c) + implementation(libs.treesitter.cpp) + implementation(libs.treesitter.xml) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) implementation(libs.firebase.analytics) diff --git a/RxCodeAndroid/app/src/main/assets/queries/c/highlights.scm b/RxCodeAndroid/app/src/main/assets/queries/c/highlights.scm new file mode 100644 index 00000000..8ee11890 --- /dev/null +++ b/RxCodeAndroid/app/src/main/assets/queries/c/highlights.scm @@ -0,0 +1,81 @@ +(identifier) @variable + +((identifier) @constant + (#match? @constant "^[A-Z][A-Z\\d_]*$")) + +"break" @keyword +"case" @keyword +"const" @keyword +"continue" @keyword +"default" @keyword +"do" @keyword +"else" @keyword +"enum" @keyword +"extern" @keyword +"for" @keyword +"if" @keyword +"inline" @keyword +"return" @keyword +"sizeof" @keyword +"static" @keyword +"struct" @keyword +"switch" @keyword +"typedef" @keyword +"union" @keyword +"volatile" @keyword +"while" @keyword + +"#define" @keyword +"#elif" @keyword +"#else" @keyword +"#endif" @keyword +"#if" @keyword +"#ifdef" @keyword +"#ifndef" @keyword +"#include" @keyword +(preproc_directive) @keyword + +"--" @operator +"-" @operator +"-=" @operator +"->" @operator +"=" @operator +"!=" @operator +"*" @operator +"&" @operator +"&&" @operator +"+" @operator +"++" @operator +"+=" @operator +"<" @operator +"==" @operator +">" @operator +"||" @operator + +"." @delimiter +";" @delimiter + +(string_literal) @string +(system_lib_string) @string + +(null) @constant +(number_literal) @number +(char_literal) @number + +(field_identifier) @property +(statement_identifier) @label +(type_identifier) @type +(primitive_type) @type +(sized_type_specifier) @type + +(call_expression + function: (identifier) @function) +(call_expression + function: (field_expression + field: (field_identifier) @function)) +(function_declarator + declarator: (identifier) @function) +(preproc_function_def + name: (identifier) @function.special) + +(comment) @comment diff --git a/RxCodeAndroid/app/src/main/assets/queries/cpp/highlights.scm b/RxCodeAndroid/app/src/main/assets/queries/cpp/highlights.scm new file mode 100644 index 00000000..394d4f9e --- /dev/null +++ b/RxCodeAndroid/app/src/main/assets/queries/cpp/highlights.scm @@ -0,0 +1,70 @@ +; Functions + +(call_expression + function: (qualified_identifier + name: (identifier) @function)) + +(template_function + name: (identifier) @function) + +(template_method + name: (field_identifier) @function) + +(template_function + name: (identifier) @function) + +(function_declarator + declarator: (qualified_identifier + name: (identifier) @function)) + +(function_declarator + declarator: (field_identifier) @function) + +; Types + +((namespace_identifier) @type + (#match? @type "^[A-Z]")) + +(auto) @type + +; Constants + +(this) @variable.builtin +(null "nullptr" @constant) + +; Keywords + +[ + "catch" + "class" + "co_await" + "co_return" + "co_yield" + "constexpr" + "constinit" + "consteval" + "delete" + "explicit" + "final" + "friend" + "mutable" + "namespace" + "noexcept" + "new" + "override" + "private" + "protected" + "public" + "template" + "throw" + "try" + "typename" + "using" + "concept" + "requires" + "virtual" +] @keyword + +; Strings + +(raw_string_literal) @string diff --git a/RxCodeAndroid/app/src/main/assets/queries/java/highlights.scm b/RxCodeAndroid/app/src/main/assets/queries/java/highlights.scm new file mode 100644 index 00000000..b13b4f46 --- /dev/null +++ b/RxCodeAndroid/app/src/main/assets/queries/java/highlights.scm @@ -0,0 +1,149 @@ +; Variables + +(identifier) @variable + +; Methods + +(method_declaration + name: (identifier) @function.method) +(method_invocation + name: (identifier) @function.method) +(super) @function.builtin + +; Annotations + +(annotation + name: (identifier) @attribute) +(marker_annotation + name: (identifier) @attribute) + +"@" @operator + +; Types + +(type_identifier) @type + +(interface_declaration + name: (identifier) @type) +(class_declaration + name: (identifier) @type) +(enum_declaration + name: (identifier) @type) + +((field_access + object: (identifier) @type) + (#match? @type "^[A-Z]")) +((scoped_identifier + scope: (identifier) @type) + (#match? @type "^[A-Z]")) +((method_invocation + object: (identifier) @type) + (#match? @type "^[A-Z]")) +((method_reference + . (identifier) @type) + (#match? @type "^[A-Z]")) + +(constructor_declaration + name: (identifier) @type) + +[ + (boolean_type) + (integral_type) + (floating_point_type) + (floating_point_type) + (void_type) +] @type.builtin + +; Constants + +((identifier) @constant + (#match? @constant "^_*[A-Z][A-Z\\d_]+$")) + +; Builtins + +(this) @variable.builtin + +; Literals + +[ + (hex_integer_literal) + (decimal_integer_literal) + (octal_integer_literal) + (decimal_floating_point_literal) + (hex_floating_point_literal) +] @number + +[ + (character_literal) + (string_literal) +] @string +(escape_sequence) @string.escape + +[ + (true) + (false) + (null_literal) +] @constant.builtin + +[ + (line_comment) + (block_comment) +] @comment + +; Keywords + +[ + "abstract" + "assert" + "break" + "case" + "catch" + "class" + "continue" + "default" + "do" + "else" + "enum" + "exports" + "extends" + "final" + "finally" + "for" + "if" + "implements" + "import" + "instanceof" + "interface" + "module" + "native" + "new" + "non-sealed" + "open" + "opens" + "package" + "permits" + "private" + "protected" + "provides" + "public" + "requires" + "record" + "return" + "sealed" + "static" + "strictfp" + "switch" + "synchronized" + "throw" + "throws" + "to" + "transient" + "transitive" + "try" + "uses" + "volatile" + "when" + "while" + "with" + "yield" +] @keyword diff --git a/RxCodeAndroid/app/src/main/assets/queries/json/highlights.scm b/RxCodeAndroid/app/src/main/assets/queries/json/highlights.scm new file mode 100644 index 00000000..ece8392f --- /dev/null +++ b/RxCodeAndroid/app/src/main/assets/queries/json/highlights.scm @@ -0,0 +1,16 @@ +(pair + key: (_) @string.special.key) + +(string) @string + +(number) @number + +[ + (null) + (true) + (false) +] @constant.builtin + +(escape_sequence) @escape + +(comment) @comment diff --git a/RxCodeAndroid/app/src/main/assets/queries/kotlin/highlights.scm b/RxCodeAndroid/app/src/main/assets/queries/kotlin/highlights.scm new file mode 100644 index 00000000..d2e15a68 --- /dev/null +++ b/RxCodeAndroid/app/src/main/assets/queries/kotlin/highlights.scm @@ -0,0 +1,380 @@ +;; Based on the nvim-treesitter highlighting, which is under the Apache license. +;; See https://github.com/nvim-treesitter/nvim-treesitter/blob/f8ab59861eed4a1c168505e3433462ed800f2bae/queries/kotlin/highlights.scm +;; +;; The only difference in this file is that queries using #lua-match? +;; have been removed. + +;;; Identifiers + +(simple_identifier) @variable + +; `it` keyword inside lambdas +; FIXME: This will highlight the keyword outside of lambdas since tree-sitter +; does not allow us to check for arbitrary nestation +((simple_identifier) @variable.builtin +(#eq? @variable.builtin "it")) + +; `field` keyword inside property getter/setter +; FIXME: This will highlight the keyword outside of getters and setters +; since tree-sitter does not allow us to check for arbitrary nestation +((simple_identifier) @variable.builtin +(#eq? @variable.builtin "field")) + +; `this` this keyword inside classes +(this_expression) @variable.builtin + +; `super` keyword inside classes +(super_expression) @variable.builtin + +(class_parameter + (simple_identifier) @property) + +(class_body + (property_declaration + (variable_declaration + (simple_identifier) @property))) + +; id_1.id_2.id_3: `id_2` and `id_3` are assumed as object properties +(_ + (navigation_suffix + (simple_identifier) @property)) + +(enum_entry + (simple_identifier) @constant) + +(type_identifier) @type + +((type_identifier) @type.builtin + (#any-of? @type.builtin + "Byte" + "Short" + "Int" + "Long" + "UByte" + "UShort" + "UInt" + "ULong" + "Float" + "Double" + "Boolean" + "Char" + "String" + "Array" + "ByteArray" + "ShortArray" + "IntArray" + "LongArray" + "UByteArray" + "UShortArray" + "UIntArray" + "ULongArray" + "FloatArray" + "DoubleArray" + "BooleanArray" + "CharArray" + "Map" + "Set" + "List" + "EmptyMap" + "EmptySet" + "EmptyList" + "MutableMap" + "MutableSet" + "MutableList" +)) + +(package_header + . (identifier)) @namespace + +(import_header + "import" @include) + + +; TODO: Seperate labeled returns/breaks/continue/super/this +; Must be implemented in the parser first +(label) @label + +;;; Function definitions + +(function_declaration + . (simple_identifier) @function) + +(getter + ("get") @function.builtin) +(setter + ("set") @function.builtin) + +(primary_constructor) @constructor +(secondary_constructor + ("constructor") @constructor) + +(constructor_invocation + (user_type + (type_identifier) @constructor)) + +(anonymous_initializer + ("init") @constructor) + +(parameter + (simple_identifier) @parameter) + +(parameter_with_optional_type + (simple_identifier) @parameter) + +; lambda parameters +(lambda_literal + (lambda_parameters + (variable_declaration + (simple_identifier) @parameter))) + +;;; Function calls + +; function() +(call_expression + . (simple_identifier) @function) + +; object.function() or object.property.function() +(call_expression + (navigation_expression + (navigation_suffix + (simple_identifier) @function) . )) + +(call_expression + . (simple_identifier) @function.builtin + (#any-of? @function.builtin + "arrayOf" + "arrayOfNulls" + "byteArrayOf" + "shortArrayOf" + "intArrayOf" + "longArrayOf" + "ubyteArrayOf" + "ushortArrayOf" + "uintArrayOf" + "ulongArrayOf" + "floatArrayOf" + "doubleArrayOf" + "booleanArrayOf" + "charArrayOf" + "emptyArray" + "mapOf" + "setOf" + "listOf" + "emptyMap" + "emptySet" + "emptyList" + "mutableMapOf" + "mutableSetOf" + "mutableListOf" + "print" + "println" + "error" + "TODO" + "run" + "runCatching" + "repeat" + "lazy" + "lazyOf" + "enumValues" + "enumValueOf" + "assert" + "check" + "checkNotNull" + "require" + "requireNotNull" + "with" + "suspend" + "synchronized" +)) + +;;; Literals + +[ + (line_comment) + (multiline_comment) + (shebang_line) +] @comment + +(real_literal) @float +[ + (integer_literal) + (long_literal) + (hex_literal) + (bin_literal) + (unsigned_literal) +] @number + +[ + "null" ; should be highlighted the same as booleans + (boolean_literal) +] @boolean + +(character_literal) @character + +(string_literal) @string + +(character_escape_seq) @string.escape + +; There are 3 ways to define a regex +; - "[abc]?".toRegex() +(call_expression + (navigation_expression + ((string_literal) @string.regex) + (navigation_suffix + ((simple_identifier) @_function + (#eq? @_function "toRegex"))))) + +; - Regex("[abc]?") +(call_expression + ((simple_identifier) @_function + (#eq? @_function "Regex")) + (call_suffix + (value_arguments + (value_argument + (string_literal) @string.regex)))) + +; - Regex.fromLiteral("[abc]?") +(call_expression + (navigation_expression + ((simple_identifier) @_class + (#eq? @_class "Regex")) + (navigation_suffix + ((simple_identifier) @_function + (#eq? @_function "fromLiteral")))) + (call_suffix + (value_arguments + (value_argument + (string_literal) @string.regex)))) + +;;; Keywords + +(type_alias "typealias" @keyword) +[ + (class_modifier) + (member_modifier) + (function_modifier) + (property_modifier) + (platform_modifier) + (variance_modifier) + (parameter_modifier) + (visibility_modifier) + (reification_modifier) + (inheritance_modifier) +]@keyword + +[ + "val" + "var" + "enum" + "class" + "object" + "interface" +; "typeof" ; NOTE: It is reserved for future use +] @keyword + +("fun") @keyword.function + +(jump_expression) @keyword.return + +[ + "if" + "else" + "when" +] @conditional + +[ + "for" + "do" + "while" +] @repeat + +[ + "try" + "catch" + "throw" + "finally" +] @exception + + +(annotation + "@" @attribute (use_site_target)? @attribute) +(annotation + (user_type + (type_identifier) @attribute)) +(annotation + (constructor_invocation + (user_type + (type_identifier) @attribute))) + +(file_annotation + "@" @attribute "file" @attribute ":" @attribute) +(file_annotation + (user_type + (type_identifier) @attribute)) +(file_annotation + (constructor_invocation + (user_type + (type_identifier) @attribute))) + +;;; Operators & Punctuation + +[ + "!" + "!=" + "!==" + "=" + "==" + "===" + ">" + ">=" + "<" + "<=" + "||" + "&&" + "+" + "++" + "+=" + "-" + "--" + "-=" + "*" + "*=" + "/" + "/=" + "%" + "%=" + "?." + "?:" + "!!" + "is" + "!is" + "in" + "!in" + "as" + "as?" + ".." + "->" +] @operator + +[ + "(" ")" + "[" "]" + "{" "}" +] @punctuation.bracket + +[ + "." + "," + ";" + ":" + "::" +] @punctuation.delimiter + +; NOTE: `interpolated_identifier`s can be highlighted in any way +(string_literal + "$" @punctuation.special + (interpolated_identifier) @none) +(string_literal + "${" @punctuation.special + (interpolated_expression) @none + "}" @punctuation.special) diff --git a/RxCodeAndroid/app/src/main/assets/queries/python/highlights.scm b/RxCodeAndroid/app/src/main/assets/queries/python/highlights.scm new file mode 100644 index 00000000..af744484 --- /dev/null +++ b/RxCodeAndroid/app/src/main/assets/queries/python/highlights.scm @@ -0,0 +1,137 @@ +; Identifier naming conventions + +(identifier) @variable + +((identifier) @constructor + (#match? @constructor "^[A-Z]")) + +((identifier) @constant + (#match? @constant "^[A-Z][A-Z_]*$")) + +; Function calls + +(decorator) @function +(decorator + (identifier) @function) + +(call + function: (attribute attribute: (identifier) @function.method)) +(call + function: (identifier) @function) + +; Builtin functions + +((call + function: (identifier) @function.builtin) + (#match? + @function.builtin + "^(abs|all|any|ascii|bin|bool|breakpoint|bytearray|bytes|callable|chr|classmethod|compile|complex|delattr|dict|dir|divmod|enumerate|eval|exec|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|isinstance|issubclass|iter|len|list|locals|map|max|memoryview|min|next|object|oct|open|ord|pow|print|property|range|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|vars|zip|__import__)$")) + +; Function definitions + +(function_definition + name: (identifier) @function) + +(attribute attribute: (identifier) @property) +(type (identifier) @type) + +; Literals + +[ + (none) + (true) + (false) +] @constant.builtin + +[ + (integer) + (float) +] @number + +(comment) @comment +(string) @string +(escape_sequence) @escape + +(interpolation + "{" @punctuation.special + "}" @punctuation.special) @embedded + +[ + "-" + "-=" + "!=" + "*" + "**" + "**=" + "*=" + "/" + "//" + "//=" + "/=" + "&" + "&=" + "%" + "%=" + "^" + "^=" + "+" + "->" + "+=" + "<" + "<<" + "<<=" + "<=" + "<>" + "=" + ":=" + "==" + ">" + ">=" + ">>" + ">>=" + "|" + "|=" + "~" + "@=" + "and" + "in" + "is" + "not" + "or" + "is not" + "not in" +] @operator + +[ + "as" + "assert" + "async" + "await" + "break" + "class" + "continue" + "def" + "del" + "elif" + "else" + "except" + "exec" + "finally" + "for" + "from" + "global" + "if" + "import" + "lambda" + "nonlocal" + "pass" + "print" + "raise" + "return" + "try" + "while" + "with" + "yield" + "match" + "case" +] @keyword diff --git a/RxCodeAndroid/app/src/main/assets/queries/xml/highlights.scm b/RxCodeAndroid/app/src/main/assets/queries/xml/highlights.scm new file mode 100644 index 00000000..9861eea1 --- /dev/null +++ b/RxCodeAndroid/app/src/main/assets/queries/xml/highlights.scm @@ -0,0 +1,168 @@ +;; XML declaration + +"xml" @keyword + +[ "version" "encoding" "standalone" ] @property + +(EncName) @string.special + +(VersionNum) @number + +[ "yes" "no" ] @boolean + +;; Processing instructions + +(PI) @embedded + +(PI (PITarget) @keyword) + +;; Element declaration + +(elementdecl + "ELEMENT" @keyword + (Name) @tag) + +(contentspec + (_ (Name) @property)) + +"#PCDATA" @type.builtin + +[ "EMPTY" "ANY" ] @string.special.symbol + +[ "*" "?" "+" ] @operator + +;; Entity declaration + +(GEDecl + "ENTITY" @keyword + (Name) @constant) + +(GEDecl (EntityValue) @string) + +(NDataDecl + "NDATA" @keyword + (Name) @label) + +;; Parsed entity declaration + +(PEDecl + "ENTITY" @keyword + "%" @operator + (Name) @constant) + +(PEDecl (EntityValue) @string) + +;; Notation declaration + +(NotationDecl + "NOTATION" @keyword + (Name) @constant) + +(NotationDecl + (ExternalID + (SystemLiteral (URI) @string.special))) + +;; Attlist declaration + +(AttlistDecl + "ATTLIST" @keyword + (Name) @tag) + +(AttDef (Name) @property) + +(AttDef (Enumeration (Nmtoken) @string)) + +(DefaultDecl (AttValue) @string) + +[ + (StringType) + (TokenizedType) +] @type.builtin + +(NotationType "NOTATION" @type.builtin) + +[ + "#REQUIRED" + "#IMPLIED" + "#FIXED" +] @attribute + +;; Entities + +(EntityRef) @constant + +((EntityRef) @constant.builtin + (#any-of? @constant.builtin + "&" "<" ">" """ "'")) + +(CharRef) @constant + +(PEReference) @constant + +;; External references + +[ "PUBLIC" "SYSTEM" ] @keyword + +(PubidLiteral) @string.special + +(SystemLiteral (URI) @markup.link) + +;; Processing instructions + +(XmlModelPI "xml-model" @keyword) + +(StyleSheetPI "xml-stylesheet" @keyword) + +(PseudoAtt (Name) @property) + +(PseudoAtt (PseudoAttValue) @string) + +;; Doctype declaration + +(doctypedecl "DOCTYPE" @keyword) + +(doctypedecl (Name) @type) + +;; Tags + +(STag (Name) @tag) + +(ETag (Name) @tag) + +(EmptyElemTag (Name) @tag) + +;; Attributes + +(Attribute (Name) @property) + +(Attribute (AttValue) @string) + +;; Delimiters & punctuation + +[ + "" + "" + "<" ">" + "" +] @punctuation.delimiter + +[ "(" ")" "[" "]" ] @punctuation.bracket + +[ "\"" "'" ] @punctuation.delimiter + +[ "," "|" "=" ] @operator + +;; Text + +(CharData) @markup + +(CDSect + (CDStart) @markup.heading + (CData) @markup.raw + "]]>" @markup.heading) + +;; Misc + +(Comment) @comment + +(ERROR) @error diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt index 29e49630..beb48537 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt @@ -5,6 +5,7 @@ import android.util.Log import coil.ImageLoader import coil.ImageLoaderFactory import coil.decode.SvgDecoder +import app.rxlab.rxcode.ui.util.CodeHighlighter import com.google.firebase.FirebaseApp import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.hilt.android.HiltAndroidApp @@ -14,6 +15,9 @@ class RxCodeApplication : Application(), ImageLoaderFactory { override fun onCreate() { super.onCreate() Log.w(TAG, "RxCodeApplication launched") + // Load the tree-sitter native library once. If it fails (unexpected ABI, + // missing .so), syntax highlighting silently falls back to plain text. + CodeHighlighter.initialize(this) // FirebaseApp.initializeApp is a no-op when google-services.json is absent // (e.g. local dev without secrets); both calls return null gracefully then. if (FirebaseApp.initializeApp(this) != null) { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt index 97092fe2..410e0c0a 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt @@ -56,6 +56,7 @@ import app.rxlab.rxcode.state.MobileState import app.rxlab.rxcode.ui.autopilot.ProjectActionsMenu import app.rxlab.rxcode.ui.sheets.NewThreadSheet import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.MarkdownWithCode import app.rxlab.rxcode.ui.util.RxMarkdownText import app.rxlab.rxcode.ui.util.rememberHaptics import app.rxlab.rxcode.ui.util.relativeTime @@ -346,7 +347,7 @@ private fun SummaryCard(text: String) { ) } if (text.isNotEmpty()) { - RxMarkdownText( + MarkdownWithCode( markdown = text, style = MaterialTheme.typography.bodyMedium, ) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingGrouping.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingGrouping.kt index 7372564f..eb60ab69 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingGrouping.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingGrouping.kt @@ -68,3 +68,19 @@ fun groupBriefings( return buckets.values.sortedByDescending { it.updatedAt } } + +/** + * Reorder briefing groups to follow the sidebar project order (the order of + * [projectIds]). Groups whose project isn't listed sort last; within the same + * project the most recently updated comes first. Mirrors the desktop + * `BriefingView` sort and Swift `sortedByProjectOrder`. + */ +fun List.sortedByProjectOrder( + projectIds: List, +): List { + val order = projectIds.withIndex().associate { (index, id) -> id to index } + return sortedWith( + compareBy { order[it.projectId] ?: Int.MAX_VALUE } + .thenByDescending { it.updatedAt } + ) +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt index 5246be93..fc4669a8 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt @@ -100,8 +100,11 @@ fun BriefingScreen( .eachCount() } } - val allGroups by remember(state.branchBriefings, state.threadSummaries) { - derivedStateOf { groupBriefings(state.branchBriefings, state.threadSummaries) } + val allGroups by remember(state.branchBriefings, state.threadSummaries, state.projects) { + derivedStateOf { + groupBriefings(state.branchBriefings, state.threadSummaries) + .sortedByProjectOrder(state.projects.map { it.id }) + } } val visibleGroups by remember(allGroups, selectedProjectIds, showAllBranches, state.projectBranches) { derivedStateOf { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt index 4e111bbc..cee6a15c 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt @@ -114,6 +114,7 @@ import app.rxlab.rxcode.proto.TodoExtractor import app.rxlab.rxcode.proto.TodoItem import app.rxlab.rxcode.ui.sheets.newBashRunProfile import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.MarkdownWithCode import app.rxlab.rxcode.ui.util.RxMarkdownText import app.rxlab.rxcode.ui.util.rememberHaptics import kotlinx.coroutines.flow.distinctUntilChanged @@ -620,7 +621,7 @@ private fun MessageBubble( assistantRenderBlocks(msg).forEach { block -> when (block) { is AssistantRenderBlock.Text -> { - RxMarkdownText( + MarkdownWithCode( markdown = block.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/util/CodeBlock.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/util/CodeBlock.kt new file mode 100644 index 00000000..daf94eb3 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/util/CodeBlock.kt @@ -0,0 +1,201 @@ +package app.rxlab.rxcode.ui.util + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay + +/** A piece of assistant markdown: either prose or a fenced code block. */ +sealed interface MarkdownSegment { + data class Prose(val text: String) : MarkdownSegment + data class Code(val language: String, val content: String) : MarkdownSegment +} + +private val fencedCodeRegex = + Regex("```[ \\t]*([A-Za-z0-9_+#.-]*)[ \\t]*\\r?\\n([\\s\\S]*?)```", RegexOption.MULTILINE) + +/** + * Split markdown into prose and fenced-code segments so code can be rendered with + * syntax highlighting while prose keeps going through the markdown renderer. + * Inline code spans (single backticks) are left inside the prose. + */ +fun splitMarkdownCodeBlocks(markdown: String): List { + val segments = mutableListOf() + var last = 0 + for (match in fencedCodeRegex.findAll(markdown)) { + if (match.range.first > last) { + val prose = markdown.substring(last, match.range.first) + if (prose.isNotBlank()) segments.add(MarkdownSegment.Prose(prose)) + } + val language = match.groupValues[1].trim() + val content = match.groupValues[2].removeSuffix("\n") + segments.add(MarkdownSegment.Code(language, content)) + last = match.range.last + 1 + } + if (last < markdown.length) { + val tail = markdown.substring(last) + if (tail.isNotBlank()) segments.add(MarkdownSegment.Prose(tail)) + } + if (segments.isEmpty()) segments.add(MarkdownSegment.Prose(markdown)) + return segments +} + +/** + * Renders assistant markdown, routing fenced code blocks through the tree-sitter + * [HighlightedCodeBlock] and everything else through [RxMarkdownText]. + */ +@Composable +fun MarkdownWithCode( + markdown: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurface, + style: TextStyle = MaterialTheme.typography.bodyMedium, + linkColor: Color = MaterialTheme.colorScheme.primary, +) { + val segments = remember(markdown) { splitMarkdownCodeBlocks(markdown) } + // Fast path: no fenced code — render exactly as before. + if (segments.size == 1 && segments[0] is MarkdownSegment.Prose) { + RxMarkdownText(markdown = markdown, modifier = modifier, color = color, style = style, linkColor = linkColor) + return + } + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (segment in segments) { + when (segment) { + is MarkdownSegment.Prose -> RxMarkdownText( + markdown = segment.text.trim(), + color = color, + style = style, + linkColor = linkColor, + ) + is MarkdownSegment.Code -> HighlightedCodeBlock( + code = segment.content, + language = segment.language, + ) + } + } + } +} + +/** + * A fenced code block with a language label, copy button, and tree-sitter syntax + * highlighting. Highlighting is computed off the main thread; until it resolves + * (or when the language is unsupported) plain monospaced text is shown. + */ +@Composable +fun HighlightedCodeBlock( + code: String, + language: String, + modifier: Modifier = Modifier, +) { + val dark = isSystemInDarkTheme() + val clipboard = LocalClipboardManager.current + val scheme = MaterialTheme.colorScheme + + var highlighted by remember(code, language, dark) { mutableStateOf(null) } + LaunchedEffect(code, language, dark) { + highlighted = if (CodeHighlighter.supports(language)) { + CodeHighlighter.highlight(code, language, dark) + } else { + null + } + } + + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + + Column( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .border(0.5.dp, scheme.outlineVariant, RoundedCornerShape(8.dp)), + ) { + Row( + Modifier + .fillMaxWidth() + .background(scheme.surfaceVariant) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (language.isNotBlank()) { + Text( + text = language, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = scheme.onSurfaceVariant, + ) + } + Row( + Modifier + .weight(1f) + .padding(start = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (copied) Icons.Outlined.Check else Icons.Outlined.ContentCopy, + contentDescription = "Copy code", + tint = if (copied) Color(0xFF4CAF50) else scheme.onSurfaceVariant, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { + clipboard.setText(AnnotatedString(code)) + copied = true + } + .padding(2.dp), + ) + } + } + + val horizontalScroll = rememberScrollState() + Text( + text = highlighted ?: AnnotatedString(code), + modifier = Modifier + .fillMaxWidth() + .background(scheme.surface) + .horizontalScroll(horizontalScroll) + .padding(12.dp), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = scheme.onSurface, + softWrap = false, + textAlign = TextAlign.Start, + ) + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/util/CodeHighlighter.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/util/CodeHighlighter.kt new file mode 100644 index 00000000..593338db --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/util/CodeHighlighter.kt @@ -0,0 +1,233 @@ +package app.rxlab.rxcode.ui.util + +import android.content.Context +import android.util.Log +import android.util.LruCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import com.itsaky.androidide.treesitter.TSLanguage +import com.itsaky.androidide.treesitter.TSParser +import com.itsaky.androidide.treesitter.TSQuery +import com.itsaky.androidide.treesitter.TSQueryCursor +import com.itsaky.androidide.treesitter.TreeSitter +import com.itsaky.androidide.treesitter.c.TSLanguageC +import com.itsaky.androidide.treesitter.cpp.TSLanguageCpp +import com.itsaky.androidide.treesitter.java.TSLanguageJava +import com.itsaky.androidide.treesitter.json.TSLanguageJson +import com.itsaky.androidide.treesitter.kotlin.TSLanguageKotlin +import com.itsaky.androidide.treesitter.python.TSLanguagePython +import com.itsaky.androidide.treesitter.xml.TSLanguageXml +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap + +/** + * Tree-sitter backed syntax highlighter for Android. + * + * Parses a code string with the matching grammar and applies its + * `highlights.scm` capture colors to produce a Compose [AnnotatedString]. + * Grammars ship as native AARs (android-tree-sitter); the highlight queries are + * shipped as app assets under `assets/queries//highlights.scm`. + * + * Only the languages with a bundled grammar are highlighted — Kotlin, Java, + * Python, C, C++, JSON, and XML. Everything else (and any failure) degrades to + * plain text. All native work runs off the main thread and behind a lock, and + * results are cached. + */ +object CodeHighlighter { + private const val TAG = "CodeHighlighter" + + @Volatile private var available = false + private lateinit var appContext: Context + + private class Grammar(val makeLanguage: () -> TSLanguage, val queryAsset: String) + + private val grammars: Map = mapOf( + "kotlin" to Grammar({ TSLanguageKotlin.getInstance() }, "queries/kotlin/highlights.scm"), + "java" to Grammar({ TSLanguageJava.getInstance() }, "queries/java/highlights.scm"), + "python" to Grammar({ TSLanguagePython.getInstance() }, "queries/python/highlights.scm"), + "json" to Grammar({ TSLanguageJson.getInstance() }, "queries/json/highlights.scm"), + "c" to Grammar({ TSLanguageC.getInstance() }, "queries/c/highlights.scm"), + "cpp" to Grammar({ TSLanguageCpp.getInstance() }, "queries/cpp/highlights.scm"), + "xml" to Grammar({ TSLanguageXml.getInstance() }, "queries/xml/highlights.scm"), + ) + + // Native objects are not safe to touch concurrently; serialize all parsing. + private val nativeLock = Any() + private val compiledQueries = ConcurrentHashMap() + private val failedLanguages = ConcurrentHashMap.newKeySet() + private val resultCache = object : LruCache(128) {} + + /** Loads the tree-sitter native library. Safe to call once at startup. */ + fun initialize(context: Context) { + appContext = context.applicationContext + available = try { + TreeSitter.loadLibrary() + true + } catch (t: Throwable) { + Log.w(TAG, "tree-sitter native library failed to load; highlighting disabled", t) + false + } + } + + /** Canonical grammar key for a markdown/file language identifier. */ + fun normalize(language: String): String = when (language.lowercase().trim()) { + "kt", "kts", "kotlin" -> "kotlin" + "java" -> "java" + "py", "python", "py3" -> "python" + "json", "jsonc", "json5", "geojson" -> "json" + "c", "h" -> "c" + "cpp", "cc", "cxx", "c++", "hpp", "hh", "hxx" -> "cpp" + "xml", "xsd", "xsl", "svg", "plist" -> "xml" + else -> language.lowercase().trim() + } + + /** True when a grammar is available to highlight [language]. */ + fun supports(language: String): Boolean = + available && grammars.containsKey(normalize(language)) + + /** + * Highlight [code] for [language], producing a colored [AnnotatedString]. + * Returns plain text when the language is unsupported or parsing fails. + */ + suspend fun highlight(code: String, language: String, dark: Boolean): AnnotatedString = + withContext(Dispatchers.Default) { + val key = normalize(language) + val grammar = grammars[key] + if (!available || grammar == null || code.isEmpty()) { + return@withContext AnnotatedString(code) + } + val cacheKey = "$key|$dark|$code" + resultCache.get(cacheKey)?.let { return@withContext it } + + val result = try { + buildHighlighted(code, key, grammar, dark) + } catch (t: Throwable) { + Log.w(TAG, "highlighting failed for $key; falling back to plain text", t) + AnnotatedString(code) + } + resultCache.put(cacheKey, result) + result + } + + private fun buildHighlighted(code: String, key: String, grammar: Grammar, dark: Boolean): AnnotatedString { + val spans = ArrayList() + synchronized(nativeLock) { + val query = compiledQuery(key, grammar) ?: return AnnotatedString(code) + val parser = TSParser.create() + try { + parser.setLanguage(grammar.makeLanguage()) + val tree = parser.parseString(code) ?: return AnnotatedString(code) + try { + val cursor = TSQueryCursor.create() + try { + cursor.exec(query, tree.rootNode) + var match = cursor.nextMatch() + while (match != null) { + for (capture in match.captures) { + val name = query.getCaptureNameForId(capture.index) + val node = capture.node + spans.add(Span(node.startByte, node.endByte, name)) + } + match = cursor.nextMatch() + } + } finally { + cursor.close() + } + } finally { + tree.close() + } + } finally { + parser.close() + } + } + + val length = code.length + return buildAnnotatedString { + append(code) + for (span in spans) { + // android-tree-sitter offsets are UTF-16 byte offsets; the Kotlin + // String is UTF-16, so divide by two to get char indices. + val start = span.startByte / 2 + val end = span.endByte / 2 + if (start in 0..length && end in start..length && start < end) { + styleFor(span.capture, dark)?.let { addStyle(it, start, end) } + } + } + } + } + + private fun compiledQuery(key: String, grammar: Grammar): TSQuery? { + compiledQueries[key]?.let { return it } + if (failedLanguages.contains(key)) return null + return try { + val scm = appContext.assets.open(grammar.queryAsset).bufferedReader().use { it.readText() } + val query = TSQuery.create(grammar.makeLanguage(), scm) + compiledQueries[key] = query + query + } catch (t: Throwable) { + // A query authored for a newer grammar can reference unknown node + // types; remember the failure and fall back to plain text. + Log.w(TAG, "failed to compile highlights query for $key", t) + failedLanguages.add(key) + null + } + } + + // MARK: - Capture → style mapping + + private class Span(val startByte: Int, val endByte: Int, val capture: String) + + private fun styleFor(capture: String, dark: Boolean): SpanStyle? { + var key = capture + while (true) { + palette[key]?.let { entry -> + return SpanStyle( + color = if (dark) entry.dark else entry.light, + fontWeight = if (entry.bold) FontWeight.Medium else null, + ) + } + val dot = key.lastIndexOf('.') + if (dot < 0) return null + key = key.substring(0, dot) + } + } + + private class Style(val light: Color, val dark: Color, val bold: Boolean = false) + + private val palette: Map = mapOf( + "keyword" to Style(Color(0xFFAF3A93), Color(0xFFFF7AB2), bold = true), + "conditional" to Style(Color(0xFFAF3A93), Color(0xFFFF7AB2), bold = true), + "repeat" to Style(Color(0xFFAF3A93), Color(0xFFFF7AB2), bold = true), + "include" to Style(Color(0xFFAF3A93), Color(0xFFFF7AB2), bold = true), + "exception" to Style(Color(0xFFAF3A93), Color(0xFFFF7AB2), bold = true), + "boolean" to Style(Color(0xFFAF3A93), Color(0xFFFF7AB2)), + "operator" to Style(Color(0xFF3C3929), Color(0xFFCCC9C0)), + "string" to Style(Color(0xFFC4442D), Color(0xFFFF8170)), + "character" to Style(Color(0xFFC4442D), Color(0xFFFF8170)), + "comment" to Style(Color(0xFF72962A), Color(0xFF7EC856)), + "number" to Style(Color(0xFF1C00CF), Color(0xFFD0BF69)), + "float" to Style(Color(0xFF1C00CF), Color(0xFFD0BF69)), + "constant" to Style(Color(0xFF1C00CF), Color(0xFFD0BF69)), + "type" to Style(Color(0xFF5B2699), Color(0xFFDABAFF), bold = true), + "constructor" to Style(Color(0xFF5B2699), Color(0xFFDABAFF), bold = true), + "namespace" to Style(Color(0xFF5B2699), Color(0xFFDABAFF)), + "module" to Style(Color(0xFF5B2699), Color(0xFFDABAFF)), + "function" to Style(Color(0xFF326D74), Color(0xFF78C2B3)), + "method" to Style(Color(0xFF326D74), Color(0xFF78C2B3)), + "property" to Style(Color(0xFF3E6D74), Color(0xFF78C2B3)), + "field" to Style(Color(0xFF3E6D74), Color(0xFF78C2B3)), + "attribute" to Style(Color(0xFF947100), Color(0xFFFFA14F)), + "annotation" to Style(Color(0xFF947100), Color(0xFFFFA14F)), + "decorator" to Style(Color(0xFF947100), Color(0xFFFFA14F)), + "label" to Style(Color(0xFF947100), Color(0xFFFFA14F)), + "escape" to Style(Color(0xFF947100), Color(0xFFFFA14F)), + "tag" to Style(Color(0xFFAF3A93), Color(0xFFFF7AB2), bold = true), + "variable" to Style(Color(0xFF3C3929), Color(0xFFCCC9C0)), + "parameter" to Style(Color(0xFF3C3929), Color(0xFFCCC9C0)), + "punctuation" to Style(Color(0xFF3C3929), Color(0xFFCCC9C0)), + ) +} diff --git a/RxCodeAndroid/gradle/libs.versions.toml b/RxCodeAndroid/gradle/libs.versions.toml index ac7a5bb2..385eef86 100644 --- a/RxCodeAndroid/gradle/libs.versions.toml +++ b/RxCodeAndroid/gradle/libs.versions.toml @@ -22,6 +22,8 @@ mlkitBarcode = "17.3.0" accompanistPermissions = "0.34.0" coil = "2.7.0" composeMarkdown = "0.5.4" +treesitter = "4.3.2" +desugarJdkLibs = "2.1.4" androidxWebkit = "1.12.1" firebaseBom = "33.5.1" googleServices = "4.4.2" @@ -79,6 +81,19 @@ accompanist-permissions = { group = "com.google.accompanist", name = "accompanis coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "composeMarkdown" } + +# Tree-sitter syntax highlighting. android-tree-sitter ships a real Android AAR +# (all four ABIs); highlights.scm queries are shipped as app assets since the +# grammar artifacts do not bundle them. +treesitter-core = { group = "com.itsaky.androidide.treesitter", name = "android-tree-sitter", version.ref = "treesitter" } +treesitter-java = { group = "com.itsaky.androidide.treesitter", name = "tree-sitter-java", version.ref = "treesitter" } +treesitter-kotlin = { group = "com.itsaky.androidide.treesitter", name = "tree-sitter-kotlin", version.ref = "treesitter" } +treesitter-python = { group = "com.itsaky.androidide.treesitter", name = "tree-sitter-python", version.ref = "treesitter" } +treesitter-json = { group = "com.itsaky.androidide.treesitter", name = "tree-sitter-json", version.ref = "treesitter" } +treesitter-c = { group = "com.itsaky.androidide.treesitter", name = "tree-sitter-c", version.ref = "treesitter" } +treesitter-cpp = { group = "com.itsaky.androidide.treesitter", name = "tree-sitter-cpp", version.ref = "treesitter" } +treesitter-xml = { group = "com.itsaky.androidide.treesitter", name = "tree-sitter-xml", version.ref = "treesitter" } +desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarJdkLibs" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index a1109259..cf3a087d 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -147,9 +147,12 @@ struct MobileBriefingView: View { return counts } - /// Every briefing group before the project/branch filters are applied. + /// Every briefing group before the project/branch filters are applied, + /// ordered to follow the sidebar project order (then most-recent first + /// within a project) so cards line up with the project list. private var allGroups: [GroupedBriefing] { groupBriefings(briefings: state.branchBriefings, threads: state.threadSummaries) + .sortedByProjectOrder(state.projects) } /// Briefing groups after applying the active project and branch filters. @@ -355,6 +358,7 @@ struct BriefingListView: View { private var allGroups: [GroupedBriefing] { groupBriefings(briefings: state.branchBriefings, threads: state.threadSummaries) + .sortedByProjectOrder(state.projects) } private var groups: [GroupedBriefing] { @@ -822,3 +826,22 @@ func groupBriefings( return buckets.values.sorted { $0.updatedAt > $1.updatedAt } } + +extension Array where Element == GroupedBriefing { + /// Reorder briefing groups to follow the given project order. Groups whose + /// project isn't in the list sort last; within the same project the most + /// recently updated comes first. Mirrors the desktop `BriefingView` sort. + func sortedByProjectOrder(_ projects: [Project]) -> [GroupedBriefing] { + let order: [UUID: Int] = Dictionary( + uniqueKeysWithValues: projects.enumerated().map { ($0.element.id, $0.offset) } + ) + return sorted { lhs, rhs in + let lhsOrder = order[lhs.projectId] ?? Int.max + let rhsOrder = order[rhs.projectId] ?? Int.max + if lhsOrder != rhsOrder { + return lhsOrder < rhsOrder + } + return lhs.updatedAt > rhs.updatedAt + } + } +} diff --git a/RxCodeTests/AppStateTests.swift b/RxCodeTests/AppStateTests.swift index 67847c5f..2dfe3caa 100644 --- a/RxCodeTests/AppStateTests.swift +++ b/RxCodeTests/AppStateTests.swift @@ -564,6 +564,53 @@ final class AppStateTests: XCTestCase { XCTAssertFalse(AppState.isConventionalCommitTitle("")) } + func testParsePullRequestContentTruncatesTitleToTwentyWords() { + let raw = """ + feat: add one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty + + Body text. + """ + + let result = AppState.parsePullRequestContent(raw, branch: "context-menu") + + let wordCount = result.title.split(whereSeparator: { $0.isWhitespace }).count + XCTAssertEqual(wordCount, AppState.maxPullRequestTitleWords) + XCTAssertEqual( + result.title, + "feat: add one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen" + ) + XCTAssertEqual(result.body, "Body text.") + } + + func testParsePullRequestContentKeepsShortTitleUnchanged() { + let raw = """ + fix: correct the crash on launch + + Body text. + """ + + let result = AppState.parsePullRequestContent(raw, branch: "context-menu") + + XCTAssertEqual(result.title, "fix: correct the crash on launch") + } + + func testTruncatePullRequestTitleWordsStripsDanglingPunctuation() { + // 21 words where the 20th word ends in a period; truncation must drop the + // trailing punctuation left dangling at the cut. + let title = "feat: add one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen. nineteen" + + let truncated = AppState.truncatePullRequestTitleWords(title) + + XCTAssertEqual( + truncated, + "feat: add one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen" + ) + XCTAssertEqual( + truncated.split(whereSeparator: { $0.isWhitespace }).count, + AppState.maxPullRequestTitleWords + ) + } + // MARK: - Helpers private func makeProject(_ name: String) -> Project {