@@ -149,6 +149,8 @@ public final class XcodeInspector: ObservableObject {
149149 }
150150}
151151
152+ // MARK: - AppInstanceInspector
153+
152154public class AppInstanceInspector : ObservableObject {
153155 public let appElement : AXUIElement
154156 public let runningApplication : NSRunningApplication
@@ -160,26 +162,16 @@ public class AppInstanceInspector: ObservableObject {
160162 }
161163}
162164
163- public final class XcodeAppInstanceInspector : AppInstanceInspector {
164- public struct WorkspaceInfo {
165- public let tabs : Set < String >
166-
167- public func combined( with info: WorkspaceInfo ) -> WorkspaceInfo {
168- return . init( tabs: info. tabs. union ( tabs) )
169- }
170- }
171-
172- public enum WorkspaceIdentifier : Hashable {
173- case url( URL )
174- case unknown
175- }
165+ // MARK: - XcodeAppInstanceInspector
176166
167+ public final class XcodeAppInstanceInspector : AppInstanceInspector {
177168 @Published public var focusedWindow : XcodeWindowInspector ?
178169 @Published public var documentURL : URL = . init( fileURLWithPath: " / " )
179170 @Published public var projectURL : URL = . init( fileURLWithPath: " / " )
180- @Published public var workspaces = [ WorkspaceIdentifier: WorkspaceInfo ] ( )
171+ @Published public var workspaces = [ WorkspaceIdentifier: Workspace ] ( )
181172 public var realtimeWorkspaces : [ WorkspaceIdentifier : WorkspaceInfo ] {
182- Self . fetchWorkspaceInfo ( runningApplication)
173+ updateWorkspaceInfo ( )
174+ return workspaces. mapValues ( \. info)
183175 }
184176
185177 @Published public private( set) var completionPanel : AXUIElement ?
@@ -284,7 +276,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector {
284276
285277 longRunningTasks. insert ( focusedWindowChanged)
286278
287- workspaces = Self . fetchWorkspaceInfo ( runningApplication )
279+ updateWorkspaceInfo ( )
288280 let updateTabsTask = Task { @MainActor in
289281 let notification = AXNotificationStream (
290282 app: runningApplication,
@@ -294,12 +286,12 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector {
294286 if #available( macOS 13 . 0 , * ) {
295287 for await _ in notification. debounce ( for: . seconds( 2 ) ) {
296288 try Task . checkCancellation ( )
297- workspaces = Self . fetchWorkspaceInfo ( runningApplication )
289+ updateWorkspaceInfo ( )
298290 }
299291 } else {
300292 for await _ in notification {
301293 try Task . checkCancellation ( )
302- workspaces = Self . fetchWorkspaceInfo ( runningApplication )
294+ updateWorkspaceInfo ( )
303295 }
304296 }
305297 }
@@ -313,6 +305,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector {
313305 )
314306
315307 for await event in stream {
308+ // We can only observe the creation and closing of the parent
309+ // of the completion panel.
316310 let isCompletionPanel = {
317311 event. element. firstChild { element in
318312 element. identifier == " _XC_COMPLETION_TABLE_ "
@@ -336,32 +330,75 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector {
336330
337331 longRunningTasks. insert ( completionPanelTask)
338332 }
333+ }
334+
335+ // MARK: - Workspace Info
336+
337+ extension XcodeAppInstanceInspector {
338+ public enum WorkspaceIdentifier : Hashable {
339+ case url( URL )
340+ case unknown
341+ }
342+
343+ public class Workspace {
344+ public let element : AXUIElement
345+ public var info : WorkspaceInfo
346+
347+ /// When a window is closed, all it's properties will be set to nil.
348+ /// Since we can't get notification for window closing,
349+ /// we will use it to check if the window is closed.
350+ var isValid : Bool {
351+ element. parent != nil
352+ }
353+
354+ init ( element: AXUIElement ) {
355+ self . element = element
356+ info = . init( tabs: [ ] )
357+ }
358+ }
339359
340- static func fetchWorkspaceInfo(
360+ public struct WorkspaceInfo {
361+ public let tabs : Set < String >
362+
363+ public func combined( with info: WorkspaceInfo ) -> WorkspaceInfo {
364+ return . init( tabs: tabs. union ( info. tabs) )
365+ }
366+ }
367+
368+ func updateWorkspaceInfo( ) {
369+ let workspaceInfoInVisibleSpace = Self . fetchVisibleWorkspaces ( runningApplication)
370+ workspaces = Self . updateWorkspace ( workspaces, with: workspaceInfoInVisibleSpace)
371+ }
372+
373+ /// Use the project path as the workspace identifier.
374+ static func workspaceIdentifier( _ window: AXUIElement ) -> WorkspaceIdentifier {
375+ for child in window. children {
376+ if child. description. starts ( with: " / " ) , child. description. count > 1 {
377+ let path = child. description
378+ let trimmedNewLine = path. trimmingCharacters ( in: . newlines)
379+ var url = URL ( fileURLWithPath: trimmedNewLine)
380+ while !FileManager. default. fileIsDirectory ( atPath: url. path) ||
381+ !url. pathExtension. isEmpty
382+ {
383+ url = url. deletingLastPathComponent ( )
384+ }
385+ return WorkspaceIdentifier . url ( url)
386+ }
387+ }
388+ return WorkspaceIdentifier . unknown
389+ }
390+
391+ /// With Accessibility API, we can ONLY get the information of visible windows.
392+ static func fetchVisibleWorkspaces(
341393 _ app: NSRunningApplication
342- ) -> [ WorkspaceIdentifier : WorkspaceInfo ] {
394+ ) -> [ WorkspaceIdentifier : Workspace ] {
343395 let app = AXUIElementCreateApplication ( app. processIdentifier)
344396 let windows = app. windows. filter { $0. identifier == " Xcode.WorkspaceWindow " }
345397
346- var dict = [ WorkspaceIdentifier: WorkspaceInfo ] ( )
398+ var dict = [ WorkspaceIdentifier: Workspace ] ( )
347399
348400 for window in windows {
349- let workspaceIdentifier = {
350- for child in window. children {
351- if child. description. starts ( with: " / " ) , child. description. count > 1 {
352- let path = child. description
353- let trimmedNewLine = path. trimmingCharacters ( in: . newlines)
354- var url = URL ( fileURLWithPath: trimmedNewLine)
355- while !FileManager. default. fileIsDirectory ( atPath: url. path) ||
356- !url. pathExtension. isEmpty
357- {
358- url = url. deletingLastPathComponent ( )
359- }
360- return WorkspaceIdentifier . url ( url)
361- }
362- }
363- return WorkspaceIdentifier . unknown
364- } ( )
401+ let workspaceIdentifier = workspaceIdentifier ( window)
365402
366403 let tabs = {
367404 guard let editArea = window. firstChild ( where: { $0. description == " editor area " } )
@@ -377,10 +414,26 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector {
377414 return allTabs
378415 } ( )
379416
380- dict [ workspaceIdentifier] = . init( tabs: tabs)
417+ let workspace = Workspace ( element: window)
418+ workspace. info = . init( tabs: tabs)
419+ dict [ workspaceIdentifier] = workspace
381420 }
382-
383421 return dict
384422 }
423+
424+ static func updateWorkspace(
425+ _ old: [ WorkspaceIdentifier : Workspace ] ,
426+ with new: [ WorkspaceIdentifier : Workspace ]
427+ ) -> [ WorkspaceIdentifier : Workspace ] {
428+ var updated = old. filter { $0. value. isValid } // remove closed windows.
429+ for (identifier, workspace) in new {
430+ if let existed = updated [ identifier] {
431+ existed. info = workspace. info
432+ } else {
433+ updated [ identifier] = workspace
434+ }
435+ }
436+ return updated
437+ }
385438}
386439
0 commit comments