Skip to content

Commit a2fc5b5

Browse files
committed
phase 2, track current file and workspace info
Add XcodeWindowInspector to extract document, workspace, and project URLs from focused Xcode windows using accessibility APIs. Update ContentView to display current file/workspace info and XcodeMonitor to refresh window data.
1 parent f333745 commit a2fc5b5

3 files changed

Lines changed: 266 additions & 0 deletions

File tree

AS2/AS2/ContentView.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,56 @@ struct ContentView: View {
3535
}
3636
.font(.headline)
3737

38+
// Phase 2: File and workspace info
39+
if xcodeMonitor.accessibilityPermissionGranted {
40+
Divider()
41+
42+
VStack(spacing: 10) {
43+
Text("📄 Current File & Workspace")
44+
.font(.title2)
45+
.bold()
46+
47+
Group {
48+
if let docURL = xcodeMonitor.currentDocumentURL {
49+
HStack {
50+
Text("📄 Document:")
51+
Text(docURL.lastPathComponent)
52+
.foregroundColor(.blue)
53+
.font(.monospaced(.caption)())
54+
}
55+
} else {
56+
Text("📄 No document detected")
57+
.foregroundColor(.secondary)
58+
}
59+
60+
if let workspaceURL = xcodeMonitor.currentWorkspaceURL {
61+
HStack {
62+
Text("📦 Workspace:")
63+
Text(workspaceURL.lastPathComponent)
64+
.foregroundColor(.green)
65+
.font(.monospaced(.caption)())
66+
}
67+
} else {
68+
Text("📦 No workspace detected")
69+
.foregroundColor(.secondary)
70+
}
71+
72+
if let projectURL = xcodeMonitor.currentProjectURL {
73+
HStack {
74+
Text("🏗️ Project:")
75+
Text(projectURL.lastPathComponent)
76+
.foregroundColor(.purple)
77+
.font(.monospaced(.caption)())
78+
}
79+
} else {
80+
Text("🏗️ No project detected")
81+
.foregroundColor(.secondary)
82+
}
83+
}
84+
.font(.caption)
85+
}
86+
}
87+
3888
if !xcodeMonitor.xcodeInstances.isEmpty {
3989
Text("All Xcode Instances:")
4090
.font(.subheadline)

AS2/AS2/XcodeMonitor.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ class XcodeMonitor: ObservableObject {
99
@Published var lastActiveXcode: NSRunningApplication?
1010
@Published var accessibilityPermissionGranted: Bool = false
1111

12+
// Phase 2: Window and file monitoring
13+
@Published var focusedWindow: XcodeWindowInspector?
14+
@Published var currentDocumentURL: URL?
15+
@Published var currentWorkspaceURL: URL?
16+
@Published var currentProjectURL: URL?
17+
1218
private let workspace = NSWorkspace.shared
1319

1420
init() {
@@ -46,7 +52,15 @@ class XcodeMonitor: ObservableObject {
4652
activeXcode = shouldBeActiveXcode
4753
if let newActive = shouldBeActiveXcode {
4854
lastActiveXcode = newActive
55+
56+
// Update window monitoring for new active Xcode
57+
if accessibilityPermissionGranted {
58+
monitorXcodeWindows(for: newActive)
59+
}
4960
}
61+
} else if let currentActive = shouldBeActiveXcode, accessibilityPermissionGranted {
62+
// Even if active Xcode didn't change, refresh window info
63+
monitorXcodeWindows(for: currentActive)
5064
}
5165
}
5266

@@ -114,6 +128,62 @@ class XcodeMonitor: ObservableObject {
114128
// Test AX access for first Xcode if we have permission
115129
if accessibilityPermissionGranted && !xcodeInstances.isEmpty {
116130
testAccessibilityAccess()
131+
132+
// Start window monitoring if we have an active Xcode
133+
if let activeXcode = activeXcode {
134+
Task { @MainActor in
135+
monitorXcodeWindows(for: activeXcode)
136+
}
137+
}
138+
}
139+
}
140+
141+
// MARK: - Phase 2: Window Monitoring
142+
143+
@MainActor
144+
private func monitorXcodeWindows(for xcodeApp: NSRunningApplication) {
145+
guard accessibilityPermissionGranted else {
146+
print("❌ Cannot monitor windows - no accessibility permission")
147+
return
148+
}
149+
150+
let axApp = AXUIElementCreateApplication(xcodeApp.processIdentifier)
151+
152+
// Get focused window
153+
var focusedWindowElement: AnyObject?
154+
let result = AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &focusedWindowElement)
155+
156+
guard result == .success, let windowElement = focusedWindowElement else {
157+
print("❌ Could not get focused window from Xcode")
158+
return
159+
}
160+
161+
guard CFGetTypeID(windowElement) == AXUIElementGetTypeID() else {
162+
print("❌ Focused window element is not an AXUIElement")
163+
return
164+
}
165+
166+
let axWindowElement = windowElement as! AXUIElement
167+
168+
print("🪟 Found focused window, creating inspector...")
169+
170+
// Create window inspector
171+
let windowInspector = XcodeWindowInspector(
172+
processIdentifier: xcodeApp.processIdentifier,
173+
windowElement: axWindowElement
174+
)
175+
176+
// Check if it's a workspace window
177+
if windowInspector.isWorkspaceWindow {
178+
print("✅ Found Xcode workspace window")
179+
focusedWindow = windowInspector
180+
181+
// Update current file/workspace info
182+
currentDocumentURL = windowInspector.documentURL
183+
currentWorkspaceURL = windowInspector.workspaceURL
184+
currentProjectURL = windowInspector.projectRootURL
185+
} else {
186+
print("📝 Found other Xcode window (not workspace)")
117187
}
118188
}
119189

AS2/AS2/XcodeWindowInspector.swift

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import ApplicationServices
2+
import Foundation
3+
4+
/// Window inspector for individual Xcode windows - follows CopilotForXcode patterns
5+
class XcodeWindowInspector {
6+
let processIdentifier: pid_t
7+
let windowElement: AXUIElement
8+
9+
@Published var documentURL: URL?
10+
@Published var workspaceURL: URL?
11+
@Published var projectRootURL: URL?
12+
13+
init(processIdentifier: pid_t, windowElement: AXUIElement) {
14+
self.processIdentifier = processIdentifier
15+
self.windowElement = windowElement
16+
17+
// Initial extraction
18+
refresh()
19+
}
20+
21+
/// Refresh all URLs from the window
22+
func refresh() {
23+
documentURL = Self.extractDocumentURL(from: windowElement)
24+
workspaceURL = Self.extractWorkspaceURL(from: windowElement)
25+
projectRootURL = Self.extractProjectURL(workspaceURL: workspaceURL, documentURL: documentURL)
26+
27+
print("📄 Window refresh:")
28+
print(" 📁 Document: \(documentURL?.lastPathComponent ?? "None")")
29+
print(" 📦 Workspace: \(workspaceURL?.lastPathComponent ?? "None")")
30+
print(" 🏗️ Project: \(projectRootURL?.lastPathComponent ?? "None")")
31+
}
32+
33+
// MARK: - URL Extraction (following CopilotForXcode patterns)
34+
35+
/// Extract document URL from window element - mirrors CopilotForXcode's extractDocumentURL
36+
static func extractDocumentURL(from windowElement: AXUIElement) -> URL? {
37+
// Fetch file path of the frontmost window of Xcode through Accessibility API
38+
var documentValue: AnyObject?
39+
let result = AXUIElementCopyAttributeValue(windowElement, kAXDocumentAttribute as CFString, &documentValue)
40+
41+
guard result == .success, let path = documentValue as? String else {
42+
return nil
43+
}
44+
45+
// Remove percent encoding and clean up path
46+
guard let cleanPath = path.removingPercentEncoding else { return nil }
47+
48+
let url = URL(fileURLWithPath: cleanPath.replacingOccurrences(of: "file://", with: ""))
49+
return adjustFileURL(url)
50+
}
51+
52+
/// Extract workspace URL from window children - mirrors CopilotForXcode's extractWorkspaceURL
53+
static func extractWorkspaceURL(from windowElement: AXUIElement) -> URL? {
54+
var children: AnyObject?
55+
let result = AXUIElementCopyAttributeValue(windowElement, kAXChildrenAttribute as CFString, &children)
56+
57+
guard result == .success, let childrenArray = children as? [AXUIElement] else {
58+
return nil
59+
}
60+
61+
// Look through children for path descriptions
62+
for child in childrenArray {
63+
var description: AnyObject?
64+
let descResult = AXUIElementCopyAttributeValue(child, kAXDescriptionAttribute as CFString, &description)
65+
66+
guard descResult == .success, let desc = description as? String else { continue }
67+
68+
// Check if this looks like a path (starts with "/" and is longer than 1 character)
69+
if desc.starts(with: "/"), desc.count > 1 {
70+
let trimmedPath = desc.trimmingCharacters(in: .newlines)
71+
return URL(fileURLWithPath: trimmedPath)
72+
}
73+
}
74+
75+
return nil
76+
}
77+
78+
/// Extract project root URL - mirrors CopilotForXcode's extractProjectURL
79+
static func extractProjectURL(workspaceURL: URL?, documentURL: URL?) -> URL? {
80+
guard var currentURL = workspaceURL ?? documentURL else { return nil }
81+
82+
var firstDirectoryURL: URL?
83+
var lastGitDirectoryURL: URL?
84+
85+
while currentURL.pathComponents.count > 1 {
86+
defer { currentURL.deleteLastPathComponent() }
87+
88+
guard FileManager.default.fileExists(atPath: currentURL.path) else { continue }
89+
90+
var isDirectory: ObjCBool = false
91+
FileManager.default.fileExists(atPath: currentURL.path, isDirectory: &isDirectory)
92+
guard isDirectory.boolValue else { continue }
93+
94+
// Skip Xcode-specific directories
95+
guard currentURL.pathExtension != "xcodeproj",
96+
currentURL.pathExtension != "xcworkspace",
97+
currentURL.pathExtension != "playground" else { continue }
98+
99+
if firstDirectoryURL == nil {
100+
firstDirectoryURL = currentURL
101+
}
102+
103+
// Check for .git directory
104+
let gitURL = currentURL.appendingPathComponent(".git")
105+
var gitIsDirectory: ObjCBool = false
106+
107+
if FileManager.default.fileExists(atPath: gitURL.path, isDirectory: &gitIsDirectory) {
108+
if gitIsDirectory.boolValue {
109+
lastGitDirectoryURL = currentURL
110+
} else if let gitContent = try? String(contentsOf: gitURL) {
111+
// Check for git worktree
112+
if !gitContent.hasPrefix("gitdir: ../") && gitContent.contains("/.git/worktrees/") {
113+
lastGitDirectoryURL = currentURL
114+
}
115+
}
116+
}
117+
}
118+
119+
return lastGitDirectoryURL ?? firstDirectoryURL ?? workspaceURL
120+
}
121+
122+
/// Adjust file URL for special cases - mirrors CopilotForXcode's adjustFileURL
123+
static func adjustFileURL(_ url: URL) -> URL {
124+
if url.pathExtension == "playground",
125+
FileManager.default.fileExists(atPath: url.path) {
126+
var isDirectory: ObjCBool = false
127+
FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
128+
if isDirectory.boolValue {
129+
return url.appendingPathComponent("Contents.swift")
130+
}
131+
}
132+
return url
133+
}
134+
135+
/// Check if this is a workspace window
136+
var isWorkspaceWindow: Bool {
137+
var identifier: AnyObject?
138+
let result = AXUIElementCopyAttributeValue(windowElement, kAXIdentifierAttribute as CFString, &identifier)
139+
140+
if result == .success, let id = identifier as? String {
141+
return id == "Xcode.WorkspaceWindow"
142+
}
143+
144+
return false
145+
}
146+
}

0 commit comments

Comments
 (0)