Skip to content

Commit 25f1681

Browse files
authored
Merge branch 'CodeEditApp:main' into hide-interface
2 parents 3f5cd70 + f2850b0 commit 25f1681

28 files changed

Lines changed: 1025 additions & 198 deletions

CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,6 @@ final class CodeFileDocument: NSDocument, ObservableObject {
5050
/// See ``CodeEditSourceEditor/CombineCoordinator``.
5151
@Published var contentCoordinator: CombineCoordinator = CombineCoordinator()
5252

53-
/// Set by ``LanguageServer`` when initialized.
54-
@Published var lspCoordinator: LSPContentCoordinator?
55-
5653
/// Used to override detected languages.
5754
@Published var language: CodeLanguage?
5855

@@ -65,6 +62,9 @@ final class CodeFileDocument: NSDocument, ObservableObject {
6562
/// Document-specific overridden line wrap preference.
6663
@Published var wrapLines: Bool?
6764

65+
/// Set up by ``LanguageServer``, conforms this type to ``LanguageServerDocument``.
66+
@Published var languageServerObjects: LanguageServerDocumentObjects<CodeFileDocument> = .init()
67+
6868
/// The type of data this file document contains.
6969
///
7070
/// If its text content is not nil, a `text` UTType is returned.
@@ -83,9 +83,6 @@ final class CodeFileDocument: NSDocument, ObservableObject {
8383
return type
8484
}
8585

86-
/// A stable string to use when identifying documents with language servers.
87-
var languageServerURI: String? { fileURL?.absolutePath }
88-
8986
/// Specify options for opening the file such as the initial cursor positions.
9087
/// Nulled by ``CodeFileView`` on first load.
9188
var openOptions: OpenOptions?
@@ -208,6 +205,10 @@ final class CodeFileDocument: NSDocument, ObservableObject {
208205
}
209206
}
210207

208+
/// Determines the code language of the document.
209+
/// Use ``CodeFileDocument/language`` for the default value before using this. That property is used to override
210+
/// the file's language.
211+
/// - Returns: The detected code language.
211212
func getLanguage() -> CodeLanguage {
212213
guard let url = fileURL else {
213214
return .default
@@ -223,3 +224,13 @@ final class CodeFileDocument: NSDocument, ObservableObject {
223224
fileURL?.findWorkspace()
224225
}
225226
}
227+
228+
// MARK: LanguageServerDocument
229+
230+
extension CodeFileDocument: LanguageServerDocument {
231+
/// A stable string to use when identifying documents with language servers.
232+
/// Needs to be a valid URI, so always returns with the `file://` prefix to indicate it's a file URI.
233+
var languageServerURI: String? {
234+
fileURL?.lspURI
235+
}
236+
}

CodeEdit/Features/Editor/Views/CodeFileView.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,13 @@ struct CodeFileView: View {
1919
/// The current cursor positions in the view
2020
@State private var cursorPositions: [CursorPosition] = []
2121

22+
@State private var treeSitterClient: TreeSitterClient = TreeSitterClient()
23+
2224
/// Any coordinators passed to the view.
2325
private var textViewCoordinators: [TextViewCoordinator]
2426

27+
@State private var highlightProviders: [any HighlightProviding] = []
28+
2529
@AppSettings(\.textEditing.defaultTabWidth)
2630
var defaultTabWidth
2731
@AppSettings(\.textEditing.indentOption)
@@ -62,16 +66,19 @@ struct CodeFileView: View {
6266

6367
init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) {
6468
self._codeFile = .init(wrappedValue: codeFile)
69+
6570
self.textViewCoordinators = textViewCoordinators
6671
+ [codeFile.contentCoordinator]
67-
+ [codeFile.lspCoordinator].compactMap({ $0 })
72+
+ [codeFile.languageServerObjects.textCoordinator].compactMap({ $0 })
6873
self.isEditable = isEditable
6974

7075
if let openOptions = codeFile.openOptions {
7176
codeFile.openOptions = nil
7277
self.cursorPositions = openOptions.cursorPositions
7378
}
7479

80+
updateHighlightProviders()
81+
7582
codeFile
7683
.contentCoordinator
7784
.textUpdatePublisher
@@ -119,7 +126,7 @@ struct CodeFileView: View {
119126
editorOverscroll: overscroll.overscrollPercentage,
120127
cursorPositions: $cursorPositions,
121128
useThemeBackground: useThemeBackground,
122-
highlightProviders: [treeSitter],
129+
highlightProviders: highlightProviders,
123130
contentInsets: edgeInsets.nsEdgeInsets,
124131
additionalTextInsets: NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0),
125132
isEditable: isEditable,
@@ -144,6 +151,10 @@ struct CodeFileView: View {
144151
.onChange(of: settingsFont) { newFontSetting in
145152
font = newFontSetting.current
146153
}
154+
.onReceive(codeFile.$languageServerObjects) { languageServerObjects in
155+
// This will not be called in single-file views (for now) but is safe to listen to either way
156+
updateHighlightProviders(lspHighlightProvider: languageServerObjects.highlightProvider)
157+
}
147158
}
148159

149160
/// Determines the style of bracket emphasis based on the `bracketEmphasis` setting and the current theme.
@@ -166,6 +177,12 @@ struct CodeFileView: View {
166177
return .underline(color: color)
167178
}
168179
}
180+
181+
/// Updates the highlight providers array.
182+
/// - Parameter lspHighlightProvider: The language server provider, if available.
183+
private func updateHighlightProviders(lspHighlightProvider: HighlightProviding? = nil) {
184+
highlightProviders = [lspHighlightProvider].compactMap({ $0 }) + [treeSitterClient]
185+
}
169186
}
170187

171188
// This extension is kept here because it should not be used elsewhere in the app and may cause confusion

CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,8 @@ struct HistoryPopoverView: View {
1818
var body: some View {
1919
VStack {
2020
CommitDetailsHeaderView(commit: commit)
21-
.padding(.horizontal)
2221

2322
Divider()
24-
.padding(.horizontal)
2523

2624
VStack(alignment: .leading, spacing: 0) {
2725
// TODO: Implementation Needed
@@ -71,6 +69,8 @@ struct HistoryPopoverView: View {
7169
}, icon: {
7270
Image(systemName: image)
7371
.frame(width: 16, alignment: .center)
72+
.padding(.leading, -2.5)
73+
.padding(.trailing, 2.5)
7474
})
7575
.frame(maxWidth: .infinity, alignment: .leading)
7676
.foregroundColor(isHovering && isEnabled ? .white : .primary)

CodeEdit/Features/LSP/Editor/LSPContentCoordinator.swift renamed to CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import LanguageServerProtocol
1919
/// Language servers expect edits to be sent in chunks (and it helps reduce processing overhead). To do this, this class
2020
/// keeps an async stream around for the duration of its lifetime. The stream is sent edit notifications, which are then
2121
/// chunked into 250ms timed groups before being sent to the ``LanguageServer``.
22-
class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate {
22+
class LSPContentCoordinator<DocumentType: LanguageServerDocument>: TextViewCoordinator, TextViewDelegate {
2323
// Required to avoid a large_tuple lint error
2424
private struct SequenceElement: Sendable {
2525
let uri: String
@@ -28,25 +28,27 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate {
2828
}
2929

3030
private var editedRange: LSPRange?
31-
private var stream: AsyncStream<SequenceElement>?
3231
private var sequenceContinuation: AsyncStream<SequenceElement>.Continuation?
3332
private var task: Task<Void, Never>?
3433

35-
weak var languageServer: LanguageServer?
34+
weak var languageServer: LanguageServer<DocumentType>?
3635
var documentURI: String
3736

3837
/// Initializes a content coordinator, and begins an async stream of updates
39-
init(documentURI: String, languageServer: LanguageServer) {
38+
init(documentURI: String, languageServer: LanguageServer<DocumentType>) {
4039
self.documentURI = documentURI
4140
self.languageServer = languageServer
42-
self.stream = AsyncStream { continuation in
43-
self.sequenceContinuation = continuation
44-
}
41+
42+
setUpUpdatesTask()
4543
}
4644

4745
func setUpUpdatesTask() {
4846
task?.cancel()
49-
guard let stream else { return }
47+
// Create this stream here so it's always set up when the text view is set up, rather than only once on init.
48+
let stream = AsyncStream { continuation in
49+
self.sequenceContinuation = continuation
50+
}
51+
5052
task = Task.detached { [weak self] in
5153
// Send edit events every 250ms
5254
for await events in stream.chunked(by: .repeating(every: .milliseconds(250), clock: .continuous)) {
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
//
2+
// SemanticTokenHighlightProvider.swift
3+
// CodeEdit
4+
//
5+
// Created by Khan Winter on 12/26/24.
6+
//
7+
8+
import Foundation
9+
import LanguageServerProtocol
10+
import CodeEditSourceEditor
11+
import CodeEditTextView
12+
import CodeEditLanguages
13+
14+
/// Provides semantic token information from a language server for a source editor view.
15+
///
16+
/// This class works in tangent with the ``LanguageServer`` class to ensure we don't unnecessarily request new tokens
17+
/// if the document isn't updated. The ``LanguageServer`` will call the
18+
/// ``SemanticTokenHighlightProvider/documentDidChange`` method, which in turn refreshes the semantic token storage.
19+
///
20+
/// That behavior may not be intuitive due to the
21+
/// ``SemanticTokenHighlightProvider/applyEdit(textView:range:delta:completion:)`` method. One might expect this class
22+
/// to respond to that method immediately, but it does not. It instead stores the completion passed in that method until
23+
/// it can respond to the edit with invalidated indices.
24+
final class SemanticTokenHighlightProvider<
25+
Storage: GenericSemanticTokenStorage,
26+
DocumentType: LanguageServerDocument
27+
>: HighlightProviding {
28+
enum HighlightError: Error {
29+
case lspRangeFailure
30+
}
31+
32+
typealias EditCallback = @MainActor (Result<IndexSet, any Error>) -> Void
33+
typealias HighlightCallback = @MainActor (Result<[HighlightRange], any Error>) -> Void
34+
35+
private let tokenMap: SemanticTokenMap
36+
private let documentURI: String
37+
private weak var languageServer: LanguageServer<DocumentType>?
38+
private weak var textView: TextView?
39+
40+
private var lastEditCallback: EditCallback?
41+
private var pendingHighlightCallbacks: [HighlightCallback] = []
42+
private var storage: Storage
43+
44+
var documentRange: NSRange {
45+
textView?.documentRange ?? .zero
46+
}
47+
48+
init(tokenMap: SemanticTokenMap, languageServer: LanguageServer<DocumentType>, documentURI: String) {
49+
self.tokenMap = tokenMap
50+
self.languageServer = languageServer
51+
self.documentURI = documentURI
52+
self.storage = Storage()
53+
}
54+
55+
// MARK: - Language Server Content Lifecycle
56+
57+
/// Called when the language server finishes sending a document update.
58+
///
59+
/// This method first checks if this object has any semantic tokens. If not, requests new tokens and responds to the
60+
/// `pendingHighlightCallbacks` queue with cancellation errors, causing the highlighter to re-query those indices.
61+
///
62+
/// If this object already has some tokens, it determines whether or not we can request a token delta and
63+
/// performs the request.
64+
func documentDidChange() async throws {
65+
guard let languageServer, let textView else {
66+
return
67+
}
68+
69+
guard storage.hasReceivedData else {
70+
// We have no semantic token info, request it!
71+
try await requestTokens(languageServer: languageServer, textView: textView)
72+
await MainActor.run {
73+
for callback in pendingHighlightCallbacks {
74+
callback(.failure(HighlightProvidingError.operationCancelled))
75+
}
76+
pendingHighlightCallbacks.removeAll()
77+
}
78+
return
79+
}
80+
81+
// The document was updated. Update our token cache and send the invalidated ranges for the editor to handle.
82+
if let lastResultId = storage.lastResultId {
83+
try await requestDeltaTokens(languageServer: languageServer, textView: textView, lastResultId: lastResultId)
84+
return
85+
}
86+
87+
try await requestTokens(languageServer: languageServer, textView: textView)
88+
}
89+
90+
// MARK: - LSP Token Requests
91+
92+
/// Requests and applies a token delta. Requires a previous response identifier.
93+
private func requestDeltaTokens(
94+
languageServer: LanguageServer<DocumentType>,
95+
textView: TextView,
96+
lastResultId: String
97+
) async throws {
98+
guard let response = try await languageServer.requestSemanticTokens(
99+
for: documentURI,
100+
previousResultId: lastResultId
101+
) else {
102+
return
103+
}
104+
switch response {
105+
case let .optionA(tokenData):
106+
await applyEntireResponse(tokenData, callback: lastEditCallback)
107+
case let .optionB(deltaData):
108+
await applyDeltaResponse(deltaData, callback: lastEditCallback, textView: textView)
109+
}
110+
}
111+
112+
/// Requests and applies tokens for an entire document. This does not require a previous response id, and should be
113+
/// used in place of `requestDeltaTokens` when that's the case.
114+
private func requestTokens(languageServer: LanguageServer<DocumentType>, textView: TextView) async throws {
115+
guard let response = try await languageServer.requestSemanticTokens(for: documentURI) else {
116+
return
117+
}
118+
await applyEntireResponse(response, callback: lastEditCallback)
119+
}
120+
121+
// MARK: - Apply LSP Response
122+
123+
/// Applies a delta response from the LSP to our storage.
124+
private func applyDeltaResponse(_ data: SemanticTokensDelta, callback: EditCallback?, textView: TextView?) async {
125+
let lspRanges = storage.applyDelta(data)
126+
lastEditCallback = nil // Don't use this callback again.
127+
await MainActor.run {
128+
let ranges = lspRanges.compactMap { textView?.nsRangeFrom($0) }
129+
callback?(.success(IndexSet(ranges: ranges)))
130+
}
131+
}
132+
133+
private func applyEntireResponse(_ data: SemanticTokens, callback: EditCallback?) async {
134+
storage.setData(data)
135+
lastEditCallback = nil // Don't use this callback again.
136+
await callback?(.success(IndexSet(integersIn: documentRange)))
137+
}
138+
139+
// MARK: - Highlight Provider Conformance
140+
141+
func setUp(textView: TextView, codeLanguage: CodeLanguage) {
142+
// Send off a request to get the initial token data
143+
self.textView = textView
144+
Task {
145+
try await self.documentDidChange()
146+
}
147+
}
148+
149+
func applyEdit(textView: TextView, range: NSRange, delta: Int, completion: @escaping EditCallback) {
150+
if let lastEditCallback {
151+
lastEditCallback(.success(IndexSet())) // Don't throw a cancellation error
152+
}
153+
lastEditCallback = completion
154+
}
155+
156+
func queryHighlightsFor(textView: TextView, range: NSRange, completion: @escaping HighlightCallback) {
157+
guard storage.hasReceivedData else {
158+
pendingHighlightCallbacks.append(completion)
159+
return
160+
}
161+
162+
guard let lspRange = textView.lspRangeFrom(nsRange: range) else {
163+
completion(.failure(HighlightError.lspRangeFailure))
164+
return
165+
}
166+
let rawTokens = storage.getTokensFor(range: lspRange)
167+
let highlights = tokenMap
168+
.decode(tokens: rawTokens, using: textView)
169+
.filter({ $0.capture != nil || !$0.modifiers.isEmpty })
170+
completion(.success(highlights))
171+
}
172+
}

CodeEdit/Features/LSP/Editor/SemanticTokenMap.swift renamed to CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMap.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,31 @@ struct SemanticTokenMap: Sendable { // swiftlint:enable line_length
4545
/// Decodes the compressed semantic token data into a `HighlightRange` type for use in an editor.
4646
/// This is marked main actor to prevent runtime errors, due to the use of the actor-isolated `rangeProvider`.
4747
/// - Parameters:
48-
/// - tokens: Semantic tokens from a language server.
48+
/// - tokens: Encoded semantic tokens type from a language server.
4949
/// - rangeProvider: The provider to use to translate token ranges to text view ranges.
5050
/// - Returns: An array of decoded highlight ranges.
5151
@MainActor
5252
func decode(tokens: SemanticTokens, using rangeProvider: SemanticTokenMapRangeProvider) -> [HighlightRange] {
53-
tokens.decode().compactMap { token in
53+
return decode(tokens: tokens.decode(), using: rangeProvider)
54+
}
55+
56+
/// Decodes the compressed semantic token data into a `HighlightRange` type for use in an editor.
57+
/// This is marked main actor to prevent runtime errors, due to the use of the actor-isolated `rangeProvider`.
58+
/// - Parameters:
59+
/// - tokens: Decoded semantic tokens from a language server.
60+
/// - rangeProvider: The provider to use to translate token ranges to text view ranges.
61+
/// - Returns: An array of decoded highlight ranges.
62+
@MainActor
63+
func decode(tokens: [SemanticToken], using rangeProvider: SemanticTokenMapRangeProvider) -> [HighlightRange] {
64+
tokens.compactMap { token in
5465
guard let range = rangeProvider.nsRangeFrom(line: token.line, char: token.char, length: token.length) else {
5566
return nil
5667
}
5768

69+
// Only modifiers are bit packed, capture types are given as a simple index into the ``tokenTypeMap``
5870
let modifiers = decodeModifier(token.modifiers)
5971

60-
// Capture types are indicated by the index of the set bit.
61-
let type = token.type > 0 ? Int(token.type.trailingZeroBitCount) : -1 // Don't try to decode 0
72+
let type = Int(token.type)
6273
let capture = tokenTypeMap.indices.contains(type) ? tokenTypeMap[type] : nil
6374

6475
return HighlightRange(

CodeEdit/Features/LSP/Editor/SemanticTokenMapRangeProvider.swift renamed to CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMapRangeProvider.swift

File renamed without changes.

0 commit comments

Comments
 (0)