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); } // Per-name cache: `where ` is a process spawn we don't want to repeat // per bookmark-row render. Restart the app if you install a CLI after launch. const commandAvailableCache = new Map(); function isCommandAvailable(name) { if (!name) return true; if (commandAvailableCache.has(name)) return commandAvailableCache.get(name); if (process.platform !== 'win32') { commandAvailableCache.set(name, false); return false; } try { const { spawnSync } = require('child_process'); const result = spawnSync('where', [name], { stdio: 'ignore' }); const ok = result.status === 0; commandAvailableCache.set(name, ok); return ok; } catch { commandAvailableCache.set(name, false); return false; } } // Back-compat shim — preload still exposes isClaudeAvailable, used to disable // the (now-seeded) Claude action button when the CLI isn't on PATH. function isClaudeAvailable() { return isCommandAvailable('claude'); } // 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 findFileByName(folderPath, name) { try { const entries = fs.readdirSync(folderPath, { withFileTypes: true }); const target = String(name || '').toLowerCase(); if (!target) return null; const match = entries.find((e) => e.isFile() && e.name.toLowerCase() === target); return match ? path.join(folderPath, match.name) : null; } catch { return null; } } // Match by either an exact filename or a simple glob with `*`. When multiple // files match, the alphabetically first wins so the result is stable across // runs (mirrors the existing findSolution behavior for *.sln). function findFileByPattern(folderPath, pattern) { if (!pattern) return null; if (!String(pattern).includes('*')) return findFileByName(folderPath, pattern); try { const entries = fs.readdirSync(folderPath, { withFileTypes: true }); const lowered = String(pattern).toLowerCase(); const regex = new RegExp( '^' + lowered.split(/(\*+)/).map((seg, i) => { if (i % 2 === 1) return '.*'; return seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); }).join('') + '$' ); const matches = entries .filter((e) => e.isFile() && regex.test(e.name.toLowerCase())) .map((e) => e.name) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); if (matches.length === 0) return null; return path.join(folderPath, matches[0]); } catch { return null; } } function findRedeploy(folderPath) { return findFileByName(folderPath, '1ReDeploy.bat'); } 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, actionFiles) { const isNetwork = isNetworkPath(folderPath); const exists = (() => { try { fs.accessSync(folderPath); return true; } catch { return false; } })(); // Per-action requiresFile lookups — renderer passes the patterns it needs // resolved (exact filenames or globs like *.sln) so it knows which action // buttons to render for this folder. const actionMatches = {}; if (Array.isArray(actionFiles)) { for (const pattern of actionFiles) { if (typeof pattern !== 'string' || !pattern) continue; actionMatches[pattern] = exists ? findFileByPattern(folderPath, pattern) : null; } } // 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, actionMatches, }; } // Generic action runner. Two kinds: // terminal — open Windows Terminal at folder, optionally run `command` // detached — spawn `command` (or matched requiresFile) in a new cmd window async function runAction(action, ctx) { if (!action || typeof action !== 'object') return { ok: false, error: 'Invalid action.' }; const win = ensureWindows(); if (!win.ok) return win; const exists = ensureExists(ctx.targetPath); if (!exists.ok) return exists; if (action.kind === 'terminal') { if (ctx.alias) { const focus = await findAndFocusTab(ctx.alias); if (focus && focus.found) return { ok: true, focused: true, tabName: focus.tabName }; } const trailingArgs = action.command ? ['powershell.exe', '-NoExit', '-Command', String(action.command)] : []; spawnWtNewTab({ targetPath: ctx.targetPath, alias: ctx.alias, color: ctx.color, trailingArgs, }); return { ok: true, focused: false }; } if (action.kind === 'detached') { let exe = action.command ? String(action.command) : ''; const extraArgs = Array.isArray(action.args) ? action.args.map(String) : []; if (action.requiresFile) { const matched = findFileByPattern(ctx.targetPath, action.requiresFile); if (!matched) return { ok: false, error: `${action.requiresFile} not found in folder.` }; if (!exe) exe = matched; else extraArgs.push(matched); } if (!exe) return { ok: false, error: 'No command configured.' }; // /K keeps the cmd window open after the command exits (good for .bat // scripts whose output you want to inspect); /C closes it (fire-and-forget). const switchArg = action.keepOpen ? '/K' : '/C'; const child = spawn( 'cmd.exe', ['/c', 'start', '""', '/D', ctx.targetPath, 'cmd.exe', switchArg, exe, ...extraArgs], { detached: true, stdio: 'ignore', windowsHide: false }, ); child.unref(); return { ok: true }; } if (action.kind === 'open') { // Resolve a target file (matched requiresFile, or command treated as a // path relative to the folder), then hand off to Windows shell association // so whichever app is registered for that extension launches it. let target = null; if (action.requiresFile) { target = findFileByPattern(ctx.targetPath, action.requiresFile); if (!target) return { ok: false, error: `${action.requiresFile} not found in folder.` }; } else if (action.command) { target = path.isAbsolute(action.command) ? action.command : path.join(ctx.targetPath, action.command); } else { return { ok: false, error: 'No file or command configured for open action.' }; } const result = await shell.openPath(target); if (result) return { ok: false, error: result }; return { ok: true }; } return { ok: false, error: `Unknown action kind: ${action.kind || '(none)'}` }; } 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, runAction, copyPath, inspectFolder, findSolution, findRedeploy, findFileByName, findFileByPattern, isNetworkPath, pickFolder, isClaudeAvailable, isCommandAvailable, };