const { shell, clipboard, dialog } = require('electron'); const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); function ensureExists(targetPath) { try { fs.accessSync(targetPath); return { ok: true }; } catch { return { ok: false, error: `Path not found:\n${targetPath}` }; } } function ensureWindows() { if (process.platform !== 'win32') { return { ok: false, error: 'Windows only.' }; } return { ok: true }; } async function openInExplorer(targetPath) { const exists = ensureExists(targetPath); if (!exists.ok) return exists; const result = await shell.openPath(targetPath); if (result) return { ok: false, error: result }; return { ok: true }; } // Reasonable cap for the alias used as a wt --title argument and as a // search pattern. Strips control + reserved chars that would break wt's // command parser (it splits on `;`). function sanitizeTitle(s) { if (!s) return ''; return String(s).replace(/[-;]/g, ' ').trim().slice(0, 80); } function isHexColor(s) { return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s); } let claudeAvailableCache = null; // One-shot check for the `claude` CLI on PATH. Cached after first call so we // don't spawn `where` per-bookmark-row. Restart the app if you install Claude // after launch. function isClaudeAvailable() { if (claudeAvailableCache !== null) return claudeAvailableCache; if (process.platform !== 'win32') { claudeAvailableCache = false; return false; } try { const { spawnSync } = require('child_process'); const result = spawnSync('where', ['claude'], { stdio: 'ignore' }); claudeAvailableCache = result.status === 0; } catch { claudeAvailableCache = false; } return claudeAvailableCache; } // Calls find-tab.ps1 to locate and focus a Windows Terminal tab whose title // matches `title`. Resolves to { found: boolean, ... } from the script JSON. // Returns { found: false } on any spawn/parse failure so callers can fall // through to launching a fresh tab. function findAndFocusTab(title) { return new Promise((resolve) => { const t = sanitizeTitle(title); if (!t) { resolve({ found: false, reason: 'no-title' }); return; } const scriptPath = path.join(__dirname, 'find-tab.ps1'); const child = spawn('powershell.exe', [ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, '-Title', t, ], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => { stdout += d.toString(); }); child.stderr.on('data', (d) => { stderr += d.toString(); }); child.on('error', () => resolve({ found: false, reason: 'spawn-error' })); child.on('close', (code) => { if (code !== 0) { resolve({ found: false, reason: `exit-${code}`, stderr: stderr.trim() || null }); return; } try { const parsed = JSON.parse(stdout.trim()); resolve(parsed || { found: false }); } catch { resolve({ found: false, reason: 'parse-error' }); } }); }); } // Spawns wt.exe new-tab with the given title/color/cwd. If trailingArgs is // non-empty, those args are appended as the commandline that runs in the new // tab (e.g. ['cmd.exe', '/K', 'claude']). Returns immediately; falls back to // a plain cmd window on ENOENT (older Windows builds without WT). function spawnWtNewTab({ targetPath, alias, color, trailingArgs = [] }) { const args = ['new-tab', '-d', targetPath]; const safeTitle = sanitizeTitle(alias); if (safeTitle) args.push('--title', safeTitle); if (isHexColor(color)) args.push('--tabColor', color); for (const a of trailingArgs) args.push(a); const child = spawn('wt.exe', args, { detached: true, stdio: 'ignore', windowsHide: false, }); child.on('error', (err) => { if (err && err.code === 'ENOENT') spawnCmdFallback({ targetPath, trailingArgs }); }); child.unref(); } // cmd.exe fallback for systems without wt.exe. cmd has no tab/title/color // concept of its own — best effort: open a new console at the target path, // optionally running a trailing command. Color is silently dropped. // // Trailing args are inspected: if they target powershell.exe (used by the // Claude launcher), spawn a PowerShell host directly so the user gets the // same shell they would have gotten through wt. function spawnCmdFallback({ targetPath, trailingArgs = [] }) { const wantsPowerShell = trailingArgs[0] === 'powershell.exe'; if (wantsPowerShell) { const psArgs = ['/c', 'start', '""', '/D', targetPath, ...trailingArgs]; const child = spawn('cmd.exe', psArgs, { detached: true, stdio: 'ignore', windowsHide: false, }); child.unref(); return; } // start "" /D cmd.exe [/K ] // The empty "" after start is the window-title arg — start treats the first // quoted token as the title, so omitting it makes start interpret /D as the // title and break. const args = ['/c', 'start', '""', '/D', targetPath, 'cmd.exe']; if (trailingArgs.length > 0) { args.push('/K', trailingArgs.filter((a) => !['cmd.exe', '/K'].includes(a)).join(' ')); } const child = spawn('cmd.exe', args, { detached: true, stdio: 'ignore', windowsHide: false, }); child.unref(); } async function openTerminal({ targetPath, alias, color }) { const win = ensureWindows(); if (!win.ok) return win; const exists = ensureExists(targetPath); if (!exists.ok) return exists; // Try to focus an existing tab first. if (alias) { const focus = await findAndFocusTab(alias); if (focus && focus.found) { return { ok: true, focused: true, tabName: focus.tabName }; } } spawnWtNewTab({ targetPath, alias, color }); return { ok: true, focused: false }; } async function openClaude({ targetPath, alias, color }) { const win = ensureWindows(); if (!win.ok) return win; const exists = ensureExists(targetPath); if (!exists.ok) return exists; if (alias) { const focus = await findAndFocusTab(alias); if (focus && focus.found) { return { ok: true, focused: true, tabName: focus.tabName }; } } // -NoExit keeps the PowerShell window open after `claude` exits so the // user can read any final output before the tab closes itself. spawnWtNewTab({ targetPath, alias, color, trailingArgs: ['powershell.exe', '-NoExit', '-Command', 'claude'], }); return { ok: true, focused: false }; } function findSolution(folderPath) { try { const entries = fs.readdirSync(folderPath, { withFileTypes: true }); const slns = entries .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.sln')) .map((e) => e.name) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); if (slns.length === 0) return null; return path.join(folderPath, slns[0]); } catch { return null; } } function findRedeploy(folderPath) { try { const entries = fs.readdirSync(folderPath, { withFileTypes: true }); const match = entries.find( (e) => e.isFile() && e.name.toLowerCase() === '1redeploy.bat' ); if (!match) return null; return path.join(folderPath, match.name); } catch { return null; } } let networkDrivesCache = null; // Enumerate Windows network-mapped drive letters once. Cached per-process — // restart the app if the user maps or unmaps drives. Falls back to an empty // set on any failure so a missing PowerShell or unreadable provider just // means UNC paths are still detected and mapped drives are treated as local. function getNetworkDrives() { if (networkDrivesCache) return networkDrivesCache; networkDrivesCache = new Set(); if (process.platform !== 'win32') return networkDrivesCache; try { const { spawnSync } = require('child_process'); const result = spawnSync('powershell.exe', [ '-NoProfile', '-NonInteractive', '-Command', "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { $_.Name }", ], { encoding: 'utf8', timeout: 5000 }); if (result.status === 0 && result.stdout) { for (const line of result.stdout.split(/\r?\n/)) { const letter = line.trim().toLowerCase(); if (/^[a-z]$/.test(letter)) networkDrivesCache.add(letter); } } } catch { /* ignore */ } return networkDrivesCache; } function isNetworkPath(p) { if (!p) return false; if (/^\\\\/.test(p)) return true; // UNC const m = /^([a-zA-Z]):/.exec(p); if (!m) return false; return getNetworkDrives().has(m[1].toLowerCase()); } function runRedeploy(targetPath) { const win = ensureWindows(); if (!win.ok) return win; const exists = ensureExists(targetPath); if (!exists.ok) return exists; const batPath = findRedeploy(targetPath); if (!batPath) return { ok: false, error: 'No 1ReDeploy.bat found in this folder.' }; // Open in a new cmd window with /K so the user can see output and any // prompts the script needs (typical .bat deploys often pause on completion). const child = spawn('cmd.exe', ['/c', 'start', '""', '/D', targetPath, 'cmd.exe', '/K', batPath], { detached: true, stdio: 'ignore', windowsHide: false, }); child.unref(); return { ok: true, batPath }; } function openVisualStudio(targetPath) { const win = ensureWindows(); if (!win.ok) return win; const exists = ensureExists(targetPath); if (!exists.ok) return exists; const slnPath = findSolution(targetPath); if (!slnPath) return { ok: false, error: 'No .sln file found in this folder.' }; // Open via shell association — uses whichever Visual Studio version the // user has registered for .sln, no need to locate devenv.exe by version. const child = spawn('cmd.exe', ['/c', 'start', '""', slnPath], { detached: true, stdio: 'ignore', windowsHide: false, }); child.unref(); return { ok: true, slnPath }; } function copyPath(text) { if (typeof text !== 'string' || !text) return { ok: false, error: 'No path' }; clipboard.writeText(text); return { ok: true }; } function inspectFolder(folderPath) { const isNetwork = isNetworkPath(folderPath); const exists = (() => { try { fs.accessSync(folderPath); return true; } catch { return false; } })(); // Read .sln + 1ReDeploy.bat regardless of network status — the deploy // button is the main reason we'd want the row up for a network share. // The renderer separately hides Claude/VS for network paths. return { exists, slnPath: exists ? findSolution(folderPath) : null, redeployPath: exists ? findRedeploy(folderPath) : null, isNetwork, }; } async function pickFolder() { const result = await dialog.showOpenDialog({ title: 'Pick a folder to bookmark', properties: ['openDirectory'], }); if (result.canceled || !result.filePaths || result.filePaths.length === 0) return null; return result.filePaths[0]; } module.exports = { openInExplorer, openTerminal, openClaude, openVisualStudio, runRedeploy, copyPath, inspectFolder, findSolution, findRedeploy, isNetworkPath, pickFolder, isClaudeAvailable, };