diff --git a/electron/launchers.js b/electron/launchers.js index d65891d..dc30ddc 100644 Binary files a/electron/launchers.js and b/electron/launchers.js differ diff --git a/electron/main.js b/electron/main.js index a865506..61cedf4 100644 --- a/electron/main.js +++ b/electron/main.js @@ -60,6 +60,46 @@ function getBookmarks() { return Array.isArray(cfg.bookmarks) ? cfg.bookmarks.filter((b) => b && b.id && b.alias && b.path) : []; } +function getActions() { + const cfg = readConfig(); + if (!Array.isArray(cfg.actions)) return []; + return cfg.actions.filter((a) => a && a.id && a.kind && a.label); +} + +// Default action seeds. Mirror the previously-hardcoded buttons (Claude, +// Visual Studio, Redeploy) so new installs see the same defaults that +// existing users had as built-ins. +function defaultActionSeeds() { + return [ + { + id: crypto.randomUUID(), + label: 'Claude', + icon: 'C', + kind: 'terminal', + command: 'claude', + requiresCommand: 'claude', + hideOnNetwork: true, + }, + { + id: crypto.randomUUID(), + label: 'Visual Studio', + icon: 'VS', + kind: 'open', + requiresFile: '*.sln', + hideOnNetwork: true, + }, + { + id: crypto.randomUUID(), + label: 'Run 1ReDeploy.bat', + icon: '▶', + kind: 'detached', + command: '', + requiresFile: '1ReDeploy.bat', + keepOpen: true, + }, + ]; +} + function getActiveTabId() { const cfg = readConfig(); const tabs = getTabs(); @@ -69,15 +109,13 @@ function getActiveTabId() { return tabs[0].id; } -// Per-button visibility for the row action bar. Defaults to true so existing -// installs see no behavior change. +// Per-button visibility for the built-in action buttons. Claude and Redeploy +// have moved into the configurable `actions` list — Terminal stays built-in. function getButtonVisibility() { const cfg = readConfig(); const v = cfg.buttonVisibility || {}; return { - claude: v.claude !== false, terminal: v.terminal !== false, - redeploy: v.redeploy !== false, }; } @@ -166,7 +204,45 @@ function migrateConfigIfNeeded() { } } - if (mutated) writeConfig({ columns, bookmarks, tabs, activeTabId }); + // Seed default action buttons if the user has none configured. Honor the + // legacy buttonVisibility flags: if claude/redeploy were toggled off, skip + // seeding that action so we don't resurrect a button the user dismissed. + const seedPatch = {}; + if (!Array.isArray(cfg.actions)) { + const visibility = cfg.buttonVisibility || {}; + const seeds = defaultActionSeeds().filter((a) => { + if (a.requiresCommand === 'claude') return visibility.claude !== false; + if (a.requiresFile === '1ReDeploy.bat') return visibility.redeploy !== false; + return true; + }); + seedPatch.actions = seeds; + mutated = true; + } else if (!cfg.vsActionBackfilled) { + // Older users got Claude + Redeploy seeded before VS became a configurable + // action. Backfill VS once so they don't silently lose the built-in button. + const hasVs = cfg.actions.some((a) => a && /\*\.sln$/i.test(a.requiresFile || '')); + if (!hasVs) { + const vs = defaultActionSeeds().find((a) => /\*\.sln$/i.test(a.requiresFile || '')); + if (vs) seedPatch.actions = [...cfg.actions, vs]; + } + seedPatch.vsActionBackfilled = true; + mutated = true; + } + // The claude/redeploy visibility keys are now expressed as the + // presence/absence of the matching action. Strip them so the settings + // panel doesn't render stale toggles. + if (cfg.buttonVisibility && ( + Object.prototype.hasOwnProperty.call(cfg.buttonVisibility, 'claude') || + Object.prototype.hasOwnProperty.call(cfg.buttonVisibility, 'redeploy') + )) { + const v = { ...cfg.buttonVisibility }; + delete v.claude; + delete v.redeploy; + seedPatch.buttonVisibility = v; + mutated = true; + } + + if (mutated) writeConfig({ columns, bookmarks, tabs, activeTabId, ...seedPatch }); } function getCurrentHeight() { @@ -301,6 +377,7 @@ function broadcastConfig() { tabs: getTabs(), columns: getColumns(), bookmarks: getBookmarks(), + actions: getActions(), activeTabId: getActiveTabId(), buttonVisibility: getButtonVisibility(), autoStart: getAutoStart(), @@ -487,6 +564,7 @@ ipcMain.handle('get-config', () => { tabs: getTabs(), columns: getColumns(), bookmarks: getBookmarks(), + actions: getActions(), activeTabId: getActiveTabId(), buttonVisibility: getButtonVisibility(), autoStart: getAutoStart(), @@ -506,15 +584,50 @@ ipcMain.handle('set-button-visibility', (_event, payload) => { if (!payload || typeof payload !== 'object') return getButtonVisibility(); const current = getButtonVisibility(); const next = { - claude: typeof payload.claude === 'boolean' ? payload.claude : current.claude, terminal: typeof payload.terminal === 'boolean' ? payload.terminal : current.terminal, - redeploy: typeof payload.redeploy === 'boolean' ? payload.redeploy : current.redeploy, }; writeConfig({ buttonVisibility: next }); broadcastConfig(); return getButtonVisibility(); }); +ipcMain.handle('set-actions', (_event, list) => { + if (!Array.isArray(list)) return getActions(); + const cleaned = list + .filter((a) => a && typeof a === 'object' && a.id && a.label && (a.kind === 'terminal' || a.kind === 'detached' || a.kind === 'open')) + .map((a) => { + const out = { + id: String(a.id), + label: String(a.label).slice(0, 40), + icon: typeof a.icon === 'string' ? a.icon.slice(0, 4) : '?', + kind: a.kind, + }; + if (typeof a.command === 'string' && a.command) out.command = a.command; + if (Array.isArray(a.args) && a.args.length > 0) out.args = a.args.map(String); + if (typeof a.requiresFile === 'string' && a.requiresFile) out.requiresFile = a.requiresFile; + if (typeof a.requiresCommand === 'string' && a.requiresCommand) out.requiresCommand = a.requiresCommand; + if (a.hideOnNetwork === true) out.hideOnNetwork = true; + if (a.keepOpen === true) out.keepOpen = true; + return out; + }); + writeConfig({ actions: cleaned }); + broadcastConfig(); + return getActions(); +}); + +ipcMain.handle('run-action', (_event, payload) => { + if (!payload || typeof payload !== 'object') return { ok: false, error: 'Invalid payload.' }; + const action = (getActions()).find((a) => a.id === payload.actionId); + if (!action) return { ok: false, error: 'Action not found.' }; + return launchers.runAction(action, { + targetPath: payload.targetPath, + alias: payload.alias, + color: payload.color, + }); +}); + +ipcMain.handle('is-command-available', (_event, name) => launchers.isCommandAvailable(name)); + ipcMain.handle('set-config', (_event, patch) => { return writeConfig(patch || {}); }); @@ -699,7 +812,7 @@ ipcMain.handle('set-active-tab', (_event, id) => { }); ipcMain.handle('pick-folder', () => launchers.pickFolder()); -ipcMain.handle('inspect-folder', (_event, p) => launchers.inspectFolder(p)); +ipcMain.handle('inspect-folder', (_event, p, actionFiles) => launchers.inspectFolder(p, actionFiles)); ipcMain.handle('open-in-explorer', (_event, p) => launchers.openInExplorer(p)); ipcMain.handle('open-terminal', (_event, payload) => { diff --git a/electron/preload.js b/electron/preload.js index 550ef21..ab53e2c 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -27,7 +27,7 @@ contextBridge.exposeInMainWorld('bookmarks', { // Folder picking + inspection pickFolder: () => ipcRenderer.invoke('pick-folder'), - inspectFolder: (path) => ipcRenderer.invoke('inspect-folder', path), + inspectFolder: (path, actionFiles) => ipcRenderer.invoke('inspect-folder', path, actionFiles), // Launchers openInExplorer: (path) => ipcRenderer.invoke('open-in-explorer', path), @@ -37,6 +37,8 @@ contextBridge.exposeInMainWorld('bookmarks', { runRedeploy: (path) => ipcRenderer.invoke('run-redeploy', path), copyPath: (text) => ipcRenderer.invoke('copy-path', text), isClaudeAvailable: () => ipcRenderer.invoke('is-claude-available'), + isCommandAvailable: (name) => ipcRenderer.invoke('is-command-available', name), + runAction: (payload) => ipcRenderer.invoke('run-action', payload), // Window setPinned: (value) => ipcRenderer.invoke('set-pinned', value), @@ -47,6 +49,7 @@ contextBridge.exposeInMainWorld('bookmarks', { // Settings setButtonVisibility: (payload) => ipcRenderer.invoke('set-button-visibility', payload), setAutoStart: (value) => ipcRenderer.invoke('set-auto-start', value), + setActions: (list) => ipcRenderer.invoke('set-actions', list), // Events from main → renderer onConfigUpdated: (cb) => { diff --git a/src/App.jsx b/src/App.jsx index 2b12b7a..ab4be26 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,11 +6,12 @@ export default function App() { const [activeTabId, setActiveTabId] = useState(null); const [columns, setColumns] = useState([]); const [bookmarks, setBookmarks] = useState([]); + const [actions, setActions] = useState([]); const [pinned, setPinned] = useState(false); const [minimized, setMinimized] = useState(false); const [loading, setLoading] = useState(true); const [claudeAvailable, setClaudeAvailable] = useState(true); - const [buttonVisibility, setButtonVisibility] = useState({ claude: true, terminal: true, redeploy: true }); + const [buttonVisibility, setButtonVisibility] = useState({ terminal: true }); const [autoStart, setAutoStart] = useState(true); // Bumped on each popup show so BookmarkRow re-runs its folder inspect. // Rows stay mounted across hide/show cycles, so without a revision tick @@ -26,14 +27,13 @@ export default function App() { setActiveTabId(cfg.activeTabId || null); setColumns(Array.isArray(cfg.columns) ? cfg.columns : []); setBookmarks(Array.isArray(cfg.bookmarks) ? cfg.bookmarks : []); + setActions(Array.isArray(cfg.actions) ? cfg.actions : []); setPinned(!!cfg.pinned); setMinimized(!!cfg.minimized); setClaudeAvailable(!!hasClaude); if (cfg.buttonVisibility) { setButtonVisibility({ - claude: cfg.buttonVisibility.claude !== false, terminal: cfg.buttonVisibility.terminal !== false, - redeploy: cfg.buttonVisibility.redeploy !== false, }); } setAutoStart(cfg.autoStart !== false); @@ -47,14 +47,13 @@ export default function App() { if (Array.isArray(payload.tabs)) setTabs(payload.tabs); if (Array.isArray(payload.columns)) setColumns(payload.columns); if (Array.isArray(payload.bookmarks)) setBookmarks(payload.bookmarks); + if (Array.isArray(payload.actions)) setActions(payload.actions); if (typeof payload.activeTabId === 'string' || payload.activeTabId === null) { setActiveTabId(payload.activeTabId); } if (payload.buttonVisibility) { setButtonVisibility({ - claude: payload.buttonVisibility.claude !== false, terminal: payload.buttonVisibility.terminal !== false, - redeploy: payload.buttonVisibility.redeploy !== false, }); } if (typeof payload.autoStart === 'boolean') setAutoStart(payload.autoStart); @@ -173,12 +172,16 @@ export default function App() { setButtonVisibility((prev) => ({ ...prev, ...patch })); const next = await window.bookmarks.setButtonVisibility(patch); if (next) setButtonVisibility({ - claude: next.claude !== false, terminal: next.terminal !== false, - redeploy: next.redeploy !== false, }); }, []); + const handleSetActions = useCallback(async (list) => { + setActions(list); + const next = await window.bookmarks.setActions(list); + if (Array.isArray(next)) setActions(next); + }, []); + const handleSetAutoStart = useCallback(async (value) => { setAutoStart(value); const next = await window.bookmarks.setAutoStart(value); @@ -212,6 +215,8 @@ export default function App() { activeTabId={activeTabId} columns={columns} bookmarks={bookmarks} + actions={actions} + onSetActions={handleSetActions} pinned={pinned} claudeAvailable={claudeAvailable} inspectRevision={inspectRevision} diff --git a/src/components/ActionEditor.jsx b/src/components/ActionEditor.jsx new file mode 100644 index 0000000..a4a1eed --- /dev/null +++ b/src/components/ActionEditor.jsx @@ -0,0 +1,452 @@ +import React, { useState } from 'react'; + +// Curated icon dropdown — emoji + short text marks. Users can also type a +// custom 1–4 char icon via the override input next to the select. +const ICON_OPTIONS = [ + { value: '▶', label: '▶ Play' }, + { value: '🚀', label: '🚀 Rocket' }, + { value: '⚡', label: '⚡ Lightning' }, + { value: '🔧', label: '🔧 Wrench' }, + { value: '🛠', label: '🛠 Tools' }, + { value: '📦', label: '📦 Package' }, + { value: '🐳', label: '🐳 Docker' }, + { value: '💻', label: '💻 Laptop' }, + { value: '⚙', label: '⚙ Gear' }, + { value: '🧪', label: '🧪 Test tube' }, + { value: '📝', label: '📝 Pencil' }, + { value: '🌐', label: '🌐 Globe' }, + { value: '🏗', label: '🏗 Build' }, + { value: '🔍', label: '🔍 Search' }, + { value: '🐛', label: '🐛 Bug' }, + { value: '💾', label: '💾 Save' }, + { value: '🔥', label: '🔥 Fire' }, + { value: '✨', label: '✨ Sparkles' }, + { value: '📊', label: '📊 Chart' }, + { value: 'C', label: 'C — letter' }, + { value: 'VS', label: 'VS — letters' }, + { value: 'VC', label: 'VC — letters' }, +]; + +// Preset templates that pre-fill the form when the user picks one. Convert +// the previously-hardcoded buttons into one-click choices, plus a couple of +// common dev-environment IDEs. +const PRESETS = [ + { + name: 'Claude', + template: { + label: 'Claude', icon: 'C', kind: 'terminal', + command: 'claude', requiresCommand: 'claude', hideOnNetwork: true, + }, + }, + { + name: 'Visual Studio', + template: { + label: 'Visual Studio', icon: 'VS', kind: 'open', + requiresFile: '*.sln', hideOnNetwork: true, + }, + }, + { + name: 'VS Code', + template: { + label: 'VS Code', icon: 'VC', kind: 'terminal', + command: 'code .', requiresCommand: 'code', hideOnNetwork: true, + }, + }, + { + name: 'Cursor', + template: { + label: 'Cursor', icon: '✨', kind: 'terminal', + command: 'cursor .', requiresCommand: 'cursor', hideOnNetwork: true, + }, + }, + { + name: 'Run 1ReDeploy.bat', + template: { + label: 'Run 1ReDeploy.bat', icon: '▶', kind: 'detached', + requiresFile: '1ReDeploy.bat', keepOpen: true, + }, + }, + { + name: 'Commit & Push', + template: { + label: 'Commit & Push', + icon: '🚀', + kind: 'terminal', + // Stage everything, show status for sanity, prompt for the message via + // Read-Host (PowerShell), commit + push. Empty message aborts the commit. + command: "git add -A; git status; $m = Read-Host 'Commit message'; if ($m) { git commit -m $m; git push } else { Write-Host 'Aborted (empty message)' }", + requiresCommand: 'git', + hideOnNetwork: true, + }, + }, +]; + +// Modal editor for the configurable action button list. Edits a working copy +// in local state; nothing is persisted until the user clicks Save. +export default function ActionEditor({ actions, onSave, onClose }) { + const [draft, setDraft] = useState(() => actions.map((a) => ({ ...a }))); + const [editingId, setEditingId] = useState(null); + const [adding, setAdding] = useState(false); + + const editing = editingId ? draft.find((a) => a.id === editingId) : null; + + const updateOne = (id, patch) => { + setDraft((prev) => prev.map((a) => (a.id === id ? { ...a, ...patch } : a))); + }; + + const removeOne = (id) => { + setDraft((prev) => prev.filter((a) => a.id !== id)); + if (editingId === id) setEditingId(null); + }; + + const move = (id, delta) => { + setDraft((prev) => { + const idx = prev.findIndex((a) => a.id === id); + const target = idx + delta; + if (idx < 0 || target < 0 || target >= prev.length) return prev; + const next = prev.slice(); + [next[idx], next[target]] = [next[target], next[idx]]; + return next; + }); + }; + + const addNew = (action) => { + const id = (typeof crypto !== 'undefined' && crypto.randomUUID) + ? crypto.randomUUID() + : `a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + setDraft((prev) => [...prev, { ...action, id }]); + setAdding(false); + }; + + const handleSave = () => { + onSave(draft); + onClose(); + }; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+

Action Buttons

+ +
+ +

+ Per-bookmark buttons. Terminal opens Windows Terminal in + the folder and runs the command. Detached launches + a command in a new cmd window. +

+ + + + {!adding && !editing && ( + + )} + + {adding && ( + setAdding(false)} + onSubmit={addNew} + /> + )} + + {editing && ( + setEditingId(null)} + onSubmit={(patch) => { updateOne(editing.id, patch); setEditingId(null); }} + /> + )} + +
+ + +
+
+
+ ); +} + +function blankAction() { + return { + label: '', + icon: '', + kind: 'terminal', + command: '', + args: [], + requiresFile: '', + requiresCommand: '', + hideOnNetwork: false, + keepOpen: false, + }; +} + +function ActionForm({ initial, mode, onCancel, onSubmit }) { + const [label, setLabel] = useState(initial.label || ''); + const [icon, setIcon] = useState(initial.icon || ''); + const [kind, setKind] = useState(initial.kind || 'terminal'); + const [command, setCommand] = useState(initial.command || ''); + const [argsText, setArgsText] = useState( + Array.isArray(initial.args) ? initial.args.join('\n') : '', + ); + const [requiresFile, setRequiresFile] = useState(initial.requiresFile || ''); + const [requiresCommand, setRequiresCommand] = useState(initial.requiresCommand || ''); + const [hideOnNetwork, setHideOnNetwork] = useState(!!initial.hideOnNetwork); + const [keepOpen, setKeepOpen] = useState(!!initial.keepOpen); + + const applyPreset = (name) => { + const preset = PRESETS.find((p) => p.name === name); + if (!preset) return; + const t = preset.template; + setLabel(t.label || ''); + setIcon(t.icon || ''); + setKind(t.kind || 'terminal'); + setCommand(t.command || ''); + setArgsText(Array.isArray(t.args) ? t.args.join('\n') : ''); + setRequiresFile(t.requiresFile || ''); + setRequiresCommand(t.requiresCommand || ''); + setHideOnNetwork(!!t.hideOnNetwork); + setKeepOpen(!!t.keepOpen); + }; + + const trimmedLabel = label.trim(); + const trimmedIcon = icon.trim(); + // For kind='open', requiresFile is what gets opened — required. + const canSubmit = trimmedLabel.length > 0 + && trimmedIcon.length > 0 + && (kind !== 'open' || requiresFile.trim().length > 0); + + const handleSubmit = () => { + if (!canSubmit) return; + const args = argsText + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); + onSubmit({ + label: trimmedLabel, + icon: trimmedIcon.slice(0, 4), + kind, + command: command.trim(), + args, + requiresFile: requiresFile.trim(), + requiresCommand: requiresCommand.trim(), + hideOnNetwork, + keepOpen, + }); + }; + + // Match the current icon string against the curated dropdown. If it isn't + // in the list (or is empty), the dropdown shows "Custom…" and the override + // input takes responsibility for the actual value. + const iconInList = ICON_OPTIONS.some((o) => o.value === icon); + + return ( +
+
{mode === 'add' ? 'New action' : 'Edit action'}
+ + {mode === 'add' && ( + <> + + + + )} + + + setLabel(e.target.value)} + placeholder="e.g. Run dev server" + /> + + +
+ + setIcon(e.target.value)} + placeholder="custom" + maxLength={4} + title="Override the dropdown with your own 1–4 character icon" + /> +
+ + +
+ + + +
+ + {kind !== 'open' && ( + <> + + setCommand(e.target.value)} + placeholder={kind === 'terminal' ? 'claude' : 'npm'} + /> + + )} + + {kind === 'detached' && ( + <> + +