forked from github/CopilotForXcode
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRealtimeSuggestionController.swift
More file actions
196 lines (169 loc) · 7.54 KB
/
RealtimeSuggestionController.swift
File metadata and controls
196 lines (169 loc) · 7.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
import ActiveApplicationMonitor
import AppKit
import AsyncAlgorithms
import AXExtension
import Combine
import Foundation
import Logger
import Preferences
import Status
import QuartzCore
import Workspace
import XcodeInspector
public actor RealtimeSuggestionController {
private var cancellable: Set<AnyCancellable> = []
private var inflightPrefetchTask: Task<Void, Error>?
private var editorObservationTask: Task<Void, Error>?
private var sourceEditor: SourceEditor?
init() {}
deinit {
cancellable.forEach { $0.cancel() }
inflightPrefetchTask?.cancel()
editorObservationTask?.cancel()
}
nonisolated
func start() {
Task { await observeXcodeChange() }
}
private func observeXcodeChange() {
cancellable.forEach { $0.cancel() }
XcodeInspector.shared.$focusedEditor
.sink { [weak self] editor in
guard let self else { return }
Task {
guard let editor else { return }
await self.handleFocusElementChange(editor)
}
}.store(in: &cancellable)
}
private func handleFocusElementChange(_ sourceEditor: SourceEditor) {
self.sourceEditor = sourceEditor
let notificationsFromEditor = sourceEditor.axNotifications
editorObservationTask?.cancel()
editorObservationTask = nil
editorObservationTask = Task { [weak self] in
if let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL {
await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded(
fileURL: fileURL,
sourceEditor: sourceEditor
)
}
let valueChange = await notificationsFromEditor.notifications()
.filter { $0.kind == .valueChanged }
let selectedTextChanged = await notificationsFromEditor.notifications()
.filter { $0.kind == .selectedTextChanged }
await withTaskGroup(of: Void.self) { [weak self] group in
group.addTask { [weak self] in
let handler = { [weak self] in
guard let self else { return }
await cancelInFlightTasks()
await self.triggerPrefetchDebounced()
await self.notifyEditingFileChange(editor: sourceEditor.element)
}
if #available(macOS 13.0, *) {
for await _ in valueChange._throttle(for: .milliseconds(200)) {
if Task.isCancelled { return }
await handler()
}
} else {
for await _ in valueChange {
if Task.isCancelled { return }
await handler()
}
}
}
group.addTask {
let handler = {
guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL
else { return }
await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded(
fileURL: fileURL,
sourceEditor: sourceEditor
)
}
if #available(macOS 13.0, *) {
for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) {
if Task.isCancelled { return }
await handler()
}
} else {
for await _ in selectedTextChanged {
if Task.isCancelled { return }
await handler()
}
}
}
await group.waitForAll()
}
}
Task { @WorkspaceActor in // Get cache ready for real-time suggestions.
guard UserDefaults.shared.value(for: \.preCacheOnFileOpen) else { return }
guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL
else { return }
let (_, filespace) = try await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
if filespace.codeMetadata.uti == nil {
// avoid the command get called twice
filespace.codeMetadata.uti = ""
do {
try await XcodeInspector.shared.safe.latestActiveXcode?
.triggerCopilotCommand(name: "Sync Text Settings")
await Status.shared.updateExtensionStatus(.succeeded)
} catch {
if filespace.codeMetadata.uti?.isEmpty ?? true {
filespace.codeMetadata.uti = nil
}
await Status.shared.updateExtensionStatus(.failed)
}
}
}
}
func triggerPrefetchDebounced(force: Bool = false) {
inflightPrefetchTask = Task(priority: .utility) { @WorkspaceActor in
try? await Task.sleep(nanoseconds: UInt64(
max(UserDefaults.shared.value(for: \.realtimeSuggestionDebounce), 0.15)
* 1_000_000_000
))
if Task.isCancelled { return }
guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle)
else { return }
if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally),
let fileURL = await XcodeInspector.shared.safe.activeDocumentURL,
let (workspace, _) = try? await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
{
let isEnabled = workspace.isSuggestionFeatureEnabled
if !isEnabled { return }
}
if Task.isCancelled { return }
// So the editor won't be blocked (after information are cached)!
await PseudoCommandHandler().generateRealtimeSuggestions(sourceEditor: sourceEditor)
}
}
func cancelInFlightTasks(excluding: Task<Void, Never>? = nil) async {
inflightPrefetchTask?.cancel()
// cancel in-flight tasks
await withTaskGroup(of: Void.self) { group in
for (_, workspace) in Service.shared.workspacePool.workspaces {
group.addTask {
await workspace.cancelInFlightRealtimeSuggestionRequests()
}
}
}
}
/// This method will still return true if the completion panel is hidden by esc.
/// Looks like the Xcode will keep the panel around until content is changed,
/// not sure how to observe that it's hidden.
func isCompletionPanelPresenting() -> Bool {
guard let activeXcode = ActiveApplicationMonitor.shared.activeXcode else { return false }
let application = AXUIElementCreateApplication(activeXcode.processIdentifier)
return application.focusedWindow?.child(identifier: "_XC_COMPLETION_TABLE_") != nil
}
func notifyEditingFileChange(editor: AXUIElement) async {
guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL,
let (workspace, _) = try? await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
else { return }
await workspace.didUpdateFilespace(fileURL: fileURL, content: editor.value)
}
}