Skip to content

Commit 5416c8a

Browse files
authored
Merge pull request #240 from nkmr-jp/fix/electron-ide-directory-detection-without-terminal
fix(directory-detector): add storage fallback for Electron IDE directory detection without terminal
2 parents 9fc771e + 44ed5f5 commit 5416c8a

File tree

2 files changed

+139
-0
lines changed

2 files changed

+139
-0
lines changed

native/directory-detector/DirectoryDetector.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,22 @@ class DirectoryDetector {
9090

9191
return result
9292
}
93+
94+
// Fallback: Use window title + storage database (state.vscdb)
95+
// This works even when no terminal tab is open in the IDE
96+
if let windowTitle = getWindowTitle(pid: appPid, bundleId: bundleId) {
97+
let candidates = extractWorkspaceNamesFromElectronIDETitle(windowTitle)
98+
if let storageDir = getDirectoryFromElectronIDEStorage(workspaceNames: candidates, bundleId: bundleId) {
99+
return [
100+
"success": true,
101+
"directory": storageDir,
102+
"appName": appName,
103+
"bundleId": bundleId,
104+
"idePid": appPid,
105+
"method": "electron-ide-storage"
106+
]
107+
}
108+
}
93109
} else if isJetBrainsIDE(bundleId) {
94110
// JetBrains IDEs: First try to get project name from focused window title
95111
// Then use it as a hint to find the correct shell process

native/directory-detector/IDEDetector.swift

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)