forked from intitni/CopilotForXcode
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRealtimeSuggestionController.swift
More file actions
181 lines (157 loc) · 6.82 KB
/
Copy pathRealtimeSuggestionController.swift
File metadata and controls
181 lines (157 loc) · 6.82 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
import ActiveApplicationMonitor
import AppKit
import AsyncAlgorithms
import AXExtension
import Combine
import DebounceFunction
import Foundation
import Logger
import Preferences
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?
private let throttledEventTrigger = ThrottleRunner(duration: 0.2)
private let throttledCleaner = ThrottleRunner(duration: 0.1)
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)
}
for await _ in valueChange {
guard let self else { return }
if Task.isCancelled { return }
await self.throttledEventTrigger.throttle {
await handler()
}
}
}
group.addTask {
let handler = {
guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL
else { return }
await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded(
fileURL: fileURL,
sourceEditor: sourceEditor
)
}
for await _ in selectedTextChanged {
guard let self else { return }
if Task.isCancelled { return }
await self.throttledCleaner.throttle {
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 {
Logger.service.info("Generate cache for file.")
// avoid the command get called twice
filespace.codeMetadata.uti = ""
do {
try await XcodeInspector.shared.safe.latestActiveXcode?
.triggerCopilotCommand(name: "Prepare for Real-time Suggestions")
} catch {
if filespace.codeMetadata.uti?.isEmpty ?? true {
filespace.codeMetadata.uti = nil
}
}
}
}
}
}
extension RealtimeSuggestionController {
func triggerPrefetchDebounced(force: Bool = false) {
inflightPrefetchTask?.cancel()
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()
let workspaces = await Service.shared.workspacePool.workspaces
// cancel in-flight tasks
await withTaskGroup(of: Void.self) { group in
for (_, workspace) in workspaces {
group.addTask {
await workspace.cancelInFlightRealtimeSuggestionRequests()
}
}
}
}
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)
}
}