Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Packages/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions Packages/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand All @@ -30,6 +31,7 @@ let package = Package(
),
.target(
name: "RxCodeMarkdown",
dependencies: ["RxCodeCore"],
path: "Sources/RxCodeMarkdown"
),
.target(
Expand All @@ -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"],
Expand Down Expand Up @@ -88,6 +95,11 @@ let package = Package(
],
path: "Tests/RxCodeChatKitTests"
),
.testTarget(
name: "RxCodeEditorTests",
dependencies: ["RxCodeEditor"],
path: "Tests/RxCodeEditorTests"
),
.testTarget(
name: "RxCodeMarkdownTests",
dependencies: ["RxCodeMarkdown"],
Expand Down
31 changes: 25 additions & 6 deletions Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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}}`,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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 [:] }
Expand Down
11 changes: 11 additions & 0 deletions Packages/Sources/RxCodeCore/Utilities/SyntaxHighlighter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
156 changes: 156 additions & 0 deletions Packages/Sources/RxCodeEditor/CodeEditorAutocomplete.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
)
}
}
Loading
Loading