@@ -144,6 +144,129 @@ extension DirectoryDetector {
144144 return nil
145145 }
146146
147+ // MARK: - Electron IDE Storage Fallback
148+
149+ /// Get the Application Support directory name for an Electron IDE
150+ static func getElectronIDEAppSupportName( _ bundleId: String ) -> String ? {
151+ if bundleId == " com.microsoft.VSCode " { return " Code " }
152+ if bundleId == " com.microsoft.VSCodeInsiders " { return " Code - Insiders " }
153+ if bundleId == " com.vscodium.VSCodium " { return " VSCodium " }
154+ if isCursor ( bundleId) { return " Cursor " }
155+ if isWindsurf ( bundleId) { return " Windsurf " }
156+ if isKiro ( bundleId) { return " Kiro " }
157+ return nil
158+ }
159+
160+ /// Extract workspace name candidates from Electron IDE window title
161+ /// VSCode titles: "file.ext — FolderName", "FolderName", "● file.ext — FolderName"
162+ static func extractWorkspaceNamesFromElectronIDETitle( _ title: String ) -> [ String ] {
163+ var cleanTitle = title
164+ // Remove leading indicators (● for unsaved changes)
165+ cleanTitle = cleanTitle. replacingOccurrences ( of: " ^[●◎] \\ s* " , with: " " , options: . regularExpression)
166+ // Remove trailing brackets like [SSH: remote], [Extension Development Host]
167+ cleanTitle = cleanTitle. replacingOccurrences ( of: " \\ s* \\ [.* \\ ] \\ s*$ " , with: " " , options: . regularExpression)
168+
169+ var candidates : [ String ] = [ ]
170+
171+ // Split by " — " (em dash with spaces) - common VSCode separator
172+ let emDashParts = cleanTitle. components ( separatedBy: " \u{2014} " )
173+ if emDashParts. count >= 2 {
174+ for part in emDashParts {
175+ let trimmed = part. trimmingCharacters ( in: . whitespaces)
176+ if !trimmed. isEmpty {
177+ candidates. append ( trimmed)
178+ }
179+ }
180+ }
181+
182+ // Also try splitting by " - " (regular dash) - some IDE variants
183+ let dashParts = cleanTitle. components ( separatedBy: " - " )
184+ if dashParts. count >= 2 {
185+ for part in dashParts {
186+ let trimmed = part. trimmingCharacters ( in: . whitespaces)
187+ if !trimmed. isEmpty && !candidates. contains ( trimmed) {
188+ candidates. append ( trimmed)
189+ }
190+ }
191+ }
192+
193+ // If no separator found, use the whole title
194+ if candidates. isEmpty {
195+ let trimmed = cleanTitle. trimmingCharacters ( in: . whitespaces)
196+ if !trimmed. isEmpty {
197+ candidates. append ( trimmed)
198+ }
199+ }
200+
201+ // Filter out known app name suffixes (only IDEs that use state.vscdb storage)
202+ let appNames = [ " visual studio code " , " vs code " , " cursor " , " windsurf " , " kiro " ]
203+ candidates = candidates. filter { candidate in
204+ !appNames. contains ( candidate. lowercased ( ) )
205+ }
206+
207+ return candidates
208+ }
209+
210+ /// Get project directory from Electron IDE's storage database (state.vscdb)
211+ /// Reads recently opened workspaces and matches against workspace name
212+ static func getDirectoryFromElectronIDEStorage( workspaceNames: [ String ] , bundleId: String ) -> String ? {
213+ guard let appSupportName = getElectronIDEAppSupportName ( bundleId) else {
214+ return nil
215+ }
216+
217+ let homeDir = FileManager . default. homeDirectoryForCurrentUser. path
218+ let dbPath = " \( homeDir) /Library/Application Support/ \( appSupportName) /User/globalStorage/state.vscdb "
219+
220+ // Query using sqlite3 command (available on macOS by default)
221+ // .timeout 500 prevents blocking if the database is locked by the IDE
222+ let process = Process ( )
223+ process. executableURL = URL ( fileURLWithPath: " /usr/bin/sqlite3 " )
224+ process. arguments = [ " -cmd " , " .timeout 500 " , dbPath, " SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList' " ]
225+
226+ let pipe = Pipe ( )
227+ process. standardOutput = pipe
228+ process. standardError = FileHandle . nullDevice
229+
230+ do {
231+ try process. run ( )
232+ // Read pipe BEFORE waitUntilExit to prevent deadlock when output exceeds pipe buffer (64KB)
233+ let data = pipe. fileHandleForReading. readDataToEndOfFile ( )
234+ process. waitUntilExit ( )
235+
236+ guard process. terminationStatus == 0 else {
237+ return nil
238+ }
239+
240+ guard let output = String ( data: data, encoding: . utf8) ? . trimmingCharacters ( in: . whitespacesAndNewlines) ,
241+ !output. isEmpty,
242+ let jsonData = output. data ( using: . utf8) ,
243+ let json = try ? JSONSerialization . jsonObject ( with: jsonData) as? [ String : Any ] ,
244+ let entries = json [ " entries " ] as? [ [ String : Any ] ] else {
245+ return nil
246+ }
247+
248+ // Find matching workspace by folder name
249+ let lowercaseNames = Set ( workspaceNames. map { $0. lowercased ( ) } )
250+ for entry in entries {
251+ if let folderUri = entry [ " folderUri " ] as? String ,
252+ folderUri. hasPrefix ( " file:// " ) {
253+ let path = String ( folderUri. dropFirst ( 7 ) ) // Remove "file://"
254+ let decoded = path. removingPercentEncoding ?? path
255+ let basename = ( decoded as NSString ) . lastPathComponent
256+ if lowercaseNames. contains ( basename. lowercased ( ) ) {
257+ if FileManager . default. fileExists ( atPath: decoded) {
258+ return decoded
259+ }
260+ }
261+ }
262+ }
263+
264+ return nil
265+ } catch {
266+ return nil
267+ }
268+ }
269+
147270 /// Get project directory from IDE window title using Accessibility API (legacy function for compatibility)
148271 /// Most IDEs show the project path or name in the window title
149272 /// Uses kAXFocusedWindowAttribute to get the currently focused window (not just the first window)
0 commit comments