Skip to content

Commit e9dacf8

Browse files
committed
Add XcodeInspector
1 parent a17bb0d commit e9dacf8

File tree

5 files changed

+186
-148
lines changed

5 files changed

+186
-148
lines changed

Core/Package.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,10 @@ let package = Package(
2424
name: "Client",
2525
targets: [
2626
"SuggestionModel",
27-
"GitHubCopilotService",
2827
"Client",
2928
"XPCShared",
3029
"Preferences",
31-
"LaunchAgentManager",
3230
"Logger",
33-
"UpdateChecker",
3431
]
3532
),
3633
.library(
@@ -232,7 +229,8 @@ let package = Package(
232229
"AXExtension",
233230
"Environment",
234231
"Logger",
235-
"AXNotificationStream"
232+
"AXNotificationStream",
233+
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
236234
]
237235
),
238236

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import AppKit
2+
import Foundation
3+
4+
public extension NSRunningApplication {
5+
var isXcode: Bool { bundleIdentifier == "com.apple.dt.Xcode" }
6+
var isCopilotForXcodeExtensionService: Bool {
7+
bundleIdentifier == Bundle.main.bundleIdentifier
8+
}
9+
}
10+
11+
extension FileManager {
12+
func fileIsDirectory(atPath path: String) -> Bool {
13+
var isDirectory: ObjCBool = false
14+
let exists = fileExists(atPath: path, isDirectory: &isDirectory)
15+
return isDirectory.boolValue && exists
16+
}
17+
}
18+

Core/Sources/XcodeInspector/SourceEditor.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ import SuggestionModel
77
public class SourceEditor {
88
public struct Content {
99
/// The content of the source editor.
10-
var content: String
10+
public var content: String
1111
/// The content of the source editor in lines.
12-
var lines: [String]
12+
public var lines: [String]
1313
/// The selection ranges of the source editor.
14-
var selections: [CursorRange]
14+
public var selections: [CursorRange]
1515
/// The cursor position of the source editor.
16-
var cursorPosition: CursorPosition
16+
public var cursorPosition: CursorPosition
1717
/// Line annotations of the source editor.
18-
var lineAnnotations: [String]
18+
public var lineAnnotations: [String]
1919
}
2020

2121
let runningApplication: NSRunningApplication
@@ -25,8 +25,8 @@ public class SourceEditor {
2525
public var content: Content {
2626
let content = element.value
2727
let split = Self.breakLines(content)
28-
let lineAnnotationElements = element.children { $0.identifier == "Line Annotation" }
29-
let lineAnnotations = lineAnnotationElements.map(\.label)
28+
let lineAnnotationElements = element.children.filter { $0.identifier == "Line Annotation" }
29+
let lineAnnotations = lineAnnotationElements.map(\.description)
3030

3131
if let selectionRange = element.selectedTextRange {
3232
let range = Self.convertRangeToCursorRange(selectionRange, in: content)
Lines changed: 45 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import AppKit
2+
import AsyncAlgorithms
23
import AXExtension
34
import AXNotificationStream
45
import Combine
@@ -14,8 +15,8 @@ public final class XcodeInspector: ObservableObject {
1415
@Published public internal(set) var activeXcode: XcodeAppInstanceInspector?
1516
@Published public internal(set) var latestActiveXcode: XcodeAppInstanceInspector?
1617
@Published public internal(set) var xcodes: [XcodeAppInstanceInspector] = []
17-
@Published public internal(set) var activeProjectPath = ""
18-
@Published public internal(set) var activeDocumentPath = ""
18+
@Published public internal(set) var activeProjectURL = URL(fileURLWithPath: "/")
19+
@Published public internal(set) var activeDocumentURL = URL(fileURLWithPath: "/")
1920
@Published public internal(set) var focusedWindow: XcodeWindowInspector?
2021
@Published public internal(set) var focusedEditor: SourceEditor?
2122
@Published public internal(set) var focusedElement: AXUIElement?
@@ -90,7 +91,8 @@ public final class XcodeInspector: ObservableObject {
9091
}
9192

9293
func observeXcode(_ xcode: XcodeAppInstanceInspector) {
93-
xcode.$document.filter { _ in xcode.isActive }.assign(to: &$activeDocumentPath)
94+
xcode.$documentURL.filter { _ in xcode.isActive }.assign(to: &$activeDocumentURL)
95+
xcode.$projectURL.filter { _ in xcode.isActive }.assign(to: &$activeProjectURL)
9496
xcode.$focusedWindow.filter { _ in xcode.isActive }.assign(to: &$focusedWindow)
9597
}
9698

@@ -100,7 +102,7 @@ public final class XcodeInspector: ObservableObject {
100102

101103
activeXcode = xcode
102104
latestActiveXcode = xcode
103-
activeDocumentPath = xcode.document
105+
activeDocumentURL = xcode.documentURL
104106
focusedWindow = xcode.focusedWindow
105107

106108
let focusedElementChanged = Task { @MainActor in
@@ -137,7 +139,10 @@ public class AppInstanceInspector: ObservableObject {
137139

138140
public final class XcodeAppInstanceInspector: AppInstanceInspector {
139141
@Published var focusedWindow: XcodeWindowInspector?
140-
var longRunningTasks = Set<Task<Void, Error>>()
142+
@Published var documentURL: URL = .init(fileURLWithPath: "/")
143+
@Published var projectURL: URL = .init(fileURLWithPath: "/")
144+
@Published var tabs: Set<String> = []
145+
private var longRunningTasks = Set<Task<Void, Error>>()
141146

142147
deinit {
143148
for task in longRunningTasks { task.cancel() }
@@ -146,87 +151,58 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector {
146151
override init(runningApplication: NSRunningApplication) {
147152
super.init(runningApplication: runningApplication)
148153

154+
observeFocusedWindow()
149155
let focusedWindowChanged = Task {
150156
let notification = AXNotificationStream(
151157
app: runningApplication,
152158
notificationNames: kAXFocusedWindowChangedNotification
153159
)
154160
for await _ in notification {
155161
try Task.checkCancellation()
156-
if let window = appElement.focusedWindow {
157-
focusedWindow = XcodeWindowInspector(uiElement: window)
158-
} else {
159-
focusedWindow = nil
160-
}
162+
observeFocusedWindow()
161163
}
162164
}
163165

164166
longRunningTasks.insert(focusedWindowChanged)
165-
}
166-
}
167167

168-
public class XcodeWindowInspector: ObservableObject {
169-
let uiElement: AXUIElement
170-
171-
init(uiElement: AXUIElement) {
172-
self.uiElement = uiElement
173-
}
174-
}
175-
176-
public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector {
177-
let app: NSRunningApplication
178-
@Published var documentURL: URL = .init(fileURLWithPath: "/")
179-
@Published var projectURL: URL = .init(fileURLWithPath: "/")
180-
@Published var tabs: Set<String> = []
181-
private var updateTabsTask: Task<Void, Error>?
182-
private var focusedElementChangedTask: Task<Void, Error>?
183-
184-
deinit {
185-
updateTabsTask?.cancel()
186-
focusedElementChangedTask?.cancel()
187-
}
188-
189-
init(app: NSRunningApplication, uiElement: AXUIElement) {
190-
self.app = app
191-
super.init(uiElement: uiElement)
192-
193-
updateTabsTask = Task { @MainActor in
194-
while true {
195-
try Task.checkCancellation()
196-
if let updatedTabs = Self.findAvailableOpenedTabs(app) {
197-
tabs = updatedTabs
198-
}
199-
try await Task.sleep(nanoseconds: 60 * 1_000_000_000)
200-
}
168+
if let updatedTabs = Self.findAvailableOpenedTabs(runningApplication) {
169+
tabs = updatedTabs
201170
}
202-
203-
focusedElementChangedTask = Task { @MainActor in
204-
let update = {
205-
let documentURL = Self.extractDocumentURL(app, windowElement: uiElement)
206-
if let documentURL {
207-
self.documentURL = documentURL
171+
let updateTabsTask = Task { @MainActor in
172+
let notification = AXNotificationStream(
173+
app: runningApplication,
174+
notificationNames: kAXFocusedUIElementChangedNotification
175+
)
176+
if #available(macOS 13.0, *) {
177+
for await _ in notification.debounce(for: .seconds(5)) {
178+
try Task.checkCancellation()
179+
if let updatedTabs = Self.findAvailableOpenedTabs(runningApplication) {
180+
tabs = updatedTabs
181+
}
208182
}
209-
let projectURL = Self.extractProjectURL(
210-
app,
211-
windowElement: uiElement,
212-
fileURL: documentURL
213-
)
214-
if let projectURL {
215-
self.projectURL = projectURL
183+
} else {
184+
for await _ in notification {
185+
try Task.checkCancellation()
186+
if let updatedTabs = Self.findAvailableOpenedTabs(runningApplication) {
187+
tabs = updatedTabs
188+
}
216189
}
217190
}
218-
219-
update()
220-
let notifications = AXNotificationStream(
221-
app: app,
222-
element: uiElement,
223-
notificationNames: kAXFocusedUIElementChangedNotification
224-
)
225-
226-
for await _ in notifications {
227-
try Task.checkCancellation()
228-
update()
191+
}
192+
193+
longRunningTasks.insert(updateTabsTask)
194+
}
195+
196+
func observeFocusedWindow() {
197+
if let window = appElement.focusedWindow {
198+
let window = XcodeWindowInspector(uiElement: window)
199+
focusedWindow = window
200+
if let workspaceWindow = window as? WorkspaceXcodeWindowInspector {
201+
workspaceWindow.$documentURL.assign(to: &$documentURL)
202+
workspaceWindow.$projectURL.assign(to: &$projectURL)
229203
}
204+
} else {
205+
focusedWindow = nil
230206
}
231207
}
232208

@@ -249,73 +225,5 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector {
249225
}
250226
return allTabs
251227
}
252-
253-
static func extractDocumentURL(
254-
_ app: NSRunningApplication,
255-
windowElement: AXUIElement
256-
) -> URL? {
257-
// fetch file path of the frontmost window of Xcode through Accessability API.
258-
let application = AXUIElementCreateApplication(app.processIdentifier)
259-
var path = windowElement.document
260-
if let path = path?.removingPercentEncoding {
261-
let url = URL(
262-
fileURLWithPath: path
263-
.replacingOccurrences(of: "file://", with: "")
264-
)
265-
return url
266-
}
267-
return nil
268-
}
269-
270-
static func extractProjectURL(
271-
_ app: NSRunningApplication,
272-
windowElement: AXUIElement,
273-
fileURL: URL?
274-
) -> URL? {
275-
let application = AXUIElementCreateApplication(app.processIdentifier)
276-
let focusedWindow = application.focusedWindow
277-
for child in focusedWindow?.children ?? [] {
278-
if child.description.starts(with: "/"), child.description.count > 1 {
279-
let path = child.description
280-
let trimmedNewLine = path.trimmingCharacters(in: .newlines)
281-
var url = URL(fileURLWithPath: trimmedNewLine)
282-
while !FileManager.default.fileIsDirectory(atPath: url.path) ||
283-
!url.pathExtension.isEmpty
284-
{
285-
url = url.deletingLastPathComponent()
286-
}
287-
return url
288-
}
289-
}
290-
291-
guard var currentURL = fileURL else { return nil }
292-
var firstDirectoryURL: URL?
293-
while currentURL.pathComponents.count > 1 {
294-
defer { currentURL.deleteLastPathComponent() }
295-
guard FileManager.default.fileIsDirectory(atPath: currentURL.path) else { continue }
296-
if firstDirectoryURL == nil { firstDirectoryURL = currentURL }
297-
let gitURL = currentURL.appendingPathComponent(".git")
298-
if FileManager.default.fileIsDirectory(atPath: gitURL.path) {
299-
return currentURL
300-
}
301-
}
302-
303-
return firstDirectoryURL ?? fileURL
304-
}
305-
}
306-
307-
public extension NSRunningApplication {
308-
var isXcode: Bool { bundleIdentifier == "com.apple.dt.Xcode" }
309-
var isCopilotForXcodeExtensionService: Bool {
310-
bundleIdentifier == Bundle.main.bundleIdentifier
311-
}
312-
}
313-
314-
extension FileManager {
315-
func fileIsDirectory(atPath path: String) -> Bool {
316-
var isDirectory: ObjCBool = false
317-
let exists = fileExists(atPath: path, isDirectory: &isDirectory)
318-
return isDirectory.boolValue && exists
319-
}
320228
}
321229

0 commit comments

Comments
 (0)