Files
DavidandClaude Opus 4.7 35c8ce5bd7 Configurable action buttons with preset templates
Convert the hardcoded Claude / Visual Studio / Redeploy buttons into
a configurable action-button system with an editor UI.

- New config field actions[] with kinds: terminal, detached, open
  (open uses Windows shell association — used by the .sln preset)
- requiresFile supports * globs (e.g. *.sln matches solution files)
- Migration seeds Claude + Visual Studio + Redeploy on first run, with
  a one-time vsActionBackfilled pass to add VS to users who already
  migrated under the previous two-seed flow
- ActionEditor modal: list with reorder/edit/delete, add form with
  preset dropdown (Claude, Visual Studio, VS Code, Cursor, Run
  1ReDeploy.bat, Commit & Push) and a curated icon dropdown plus
  free-text override
- runAction IPC dispatches by kind; isCommandAvailable generalizes
  the per-CLI PATH probe used to disable buttons whose CLI is missing
- Built-in VS button removed from BookmarkRow + MinimizedList; Claude
  visibility toggle removed from settings (now an editable action)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:52:21 -04:00

464 lines
16 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);
}
// Per-name cache: `where <name>` 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 <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 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,
};