Files
Trailhead/electron/launchers.js
T
David c860959631 Min width = 1 column, add header minimize, Claude → PowerShell
- Width: drop the header-fits-too floor; popup width is now
  max(COLUMN_WIDTH, columns-required, tabs-required). The brand title in
  the header is allowed to ellipsize at the 216px minimum (full name
  preserved in tray hover and h1 title attr).
- Minimize: new -/▢ button in the header next to pin. Toggling hides the
  tab strip + body, locks width at COLUMN_WIDTH, and lets the existing
  ResizeObserver collapse height to just the header. State persists in
  config.minimized so reopening the popup remembers it. Add-column +
  button is hidden while minimized.
- Claude launcher: now spawns powershell.exe -NoExit -Command claude in
  the wt new-tab (was cmd.exe /K claude). Cmd-fallback updated to also
  start powershell directly when the trailing args target it.
2026-04-29 11:05:10 -04:00

340 lines
11 KiB
JavaScript

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 <path> cmd.exe [/K <cmd>]
// 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,
};