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 (
+
+ );
+}
diff --git a/src/components/BookmarkRow.jsx b/src/components/BookmarkRow.jsx
index 781e429..99379d0 100644
--- a/src/components/BookmarkRow.jsx
+++ b/src/components/BookmarkRow.jsx
@@ -3,7 +3,8 @@ import React, { useEffect, useRef, useState } from 'react';
export default function BookmarkRow({
bookmark,
columns,
- claudeAvailable,
+ actions,
+ commandAvailability,
inspectRevision,
recentColors,
buttonVisibility,
@@ -14,9 +15,7 @@ export default function BookmarkRow({
onMoveUp,
onMoveDown,
}) {
- const showClaude = !buttonVisibility || buttonVisibility.claude !== false;
const showTerminal = !buttonVisibility || buttonVisibility.terminal !== false;
- const showRedeploy = !buttonVisibility || buttonVisibility.redeploy !== false;
const [mode, setMode] = useState('view');
const [menuOpen, setMenuOpen] = useState(false);
const [inspect, setInspect] = useState({
@@ -24,24 +23,35 @@ export default function BookmarkRow({
slnPath: null,
redeployPath: null,
isNetwork: false,
+ actionMatches: {},
});
const [status, setStatus] = useState(null);
const menuRef = useRef(null);
+ // Names of files to probe — the union of every active action's requiresFile.
+ // Joined into a stable string so the inspect effect re-runs only when the
+ // set actually changes (not on every render).
+ const actionFiles = (actions || [])
+ .map((a) => a && a.requiresFile)
+ .filter(Boolean);
+ const actionFilesKey = actionFiles.join('|');
+
useEffect(() => {
let cancelled = false;
- setInspect({ checked: false, slnPath: null, redeployPath: null, isNetwork: false });
- window.bookmarks.inspectFolder(bookmark.path).then((res) => {
+ setInspect({ checked: false, slnPath: null, redeployPath: null, isNetwork: false, actionMatches: {} });
+ window.bookmarks.inspectFolder(bookmark.path, actionFiles).then((res) => {
if (cancelled) return;
setInspect({
checked: true,
slnPath: res ? res.slnPath : null,
redeployPath: res ? res.redeployPath : null,
isNetwork: !!(res && res.isNetwork),
+ actionMatches: (res && res.actionMatches) || {},
});
});
return () => { cancelled = true; };
- }, [bookmark.path, inspectRevision]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [bookmark.path, inspectRevision, actionFilesKey]);
useEffect(() => {
if (!menuOpen) return undefined;
@@ -81,6 +91,19 @@ export default function BookmarkRow({
}
};
+ const runConfiguredAction = async (action) => {
+ const res = await window.bookmarks.runAction({
+ actionId: action.id,
+ targetPath: bookmark.path,
+ alias: bookmark.alias,
+ color: bookmark.color,
+ });
+ const label = action.label || 'Action';
+ if (res && res.ok === false) flash('error', `${label}: ${res.error || 'failed'}`);
+ else if (res && res.focused) flash('info', `${label}: focused existing tab`);
+ else flash('info', `${label} ✓`);
+ };
+
const handleCopy = async () => {
setMenuOpen(false);
const res = await window.bookmarks.copyPath(bookmark.path);
@@ -159,39 +182,32 @@ export default function BookmarkRow({
onClick={() => runTabAction(window.bookmarks.openTerminal, 'Terminal')}
>>_
)}
- {!inspect.isNetwork && (
- <>
- {showClaude && (
-
- )}
+ {(actions || []).map((action) => {
+ if (!action || !action.id) return null;
+ if (action.hideOnNetwork && inspect.isNetwork) return null;
+ if (action.requiresFile) {
+ const matched = inspect.actionMatches && inspect.actionMatches[action.requiresFile];
+ if (!matched) return null;
+ // Legacy: hideDeploy on a bookmark suppresses the seeded redeploy action.
+ if (action.requiresFile === '1ReDeploy.bat' && bookmark.hideDeploy) return null;
+ }
+ const cmdMissing = action.requiresCommand
+ && commandAvailability
+ && commandAvailability[action.requiresCommand] === false;
+ return (
- >
- )}
- {showRedeploy && inspect.redeployPath && !bookmark.hideDeploy && (
-
- )}
+ disabled={cmdMissing}
+ onClick={() => runConfiguredAction(action)}
+ >{action.icon || '?'}
+ );
+ })}
{status && (
diff --git a/src/components/Column.jsx b/src/components/Column.jsx
index 4c27993..e47235a 100644
--- a/src/components/Column.jsx
+++ b/src/components/Column.jsx
@@ -7,7 +7,8 @@ export default function Column({
columns,
tabs,
bookmarks,
- claudeAvailable,
+ actions,
+ commandAvailability,
inspectRevision,
recentColors,
buttonVisibility,
@@ -180,7 +181,8 @@ export default function Column({
key={b.id}
bookmark={b}
columns={columns}
- claudeAvailable={claudeAvailable}
+ actions={actions}
+ commandAvailability={commandAvailability}
inspectRevision={inspectRevision}
recentColors={recentColors}
buttonVisibility={buttonVisibility}
diff --git a/src/components/MinimizedList.jsx b/src/components/MinimizedList.jsx
index fe08781..6cc4e0c 100644
--- a/src/components/MinimizedList.jsx
+++ b/src/components/MinimizedList.jsx
@@ -7,7 +7,8 @@ export default function MinimizedList({
tabs,
columns,
bookmarks,
- claudeAvailable,
+ actions,
+ commandAvailability,
inspectRevision,
buttonVisibility,
}) {
@@ -41,7 +42,8 @@ export default function MinimizedList({
@@ -53,32 +55,38 @@ export default function MinimizedList({
);
}
-function MinimizedRow({ bookmark, claudeAvailable, inspectRevision, buttonVisibility }) {
- const showClaude = !buttonVisibility || buttonVisibility.claude !== false;
+function MinimizedRow({ bookmark, actions, commandAvailability, inspectRevision, buttonVisibility }) {
const showTerminal = !buttonVisibility || buttonVisibility.terminal !== false;
- const showRedeploy = !buttonVisibility || buttonVisibility.redeploy !== false;
const [inspect, setInspect] = useState({
checked: false,
slnPath: null,
redeployPath: null,
isNetwork: false,
+ actionMatches: {},
});
const [status, setStatus] = useState(null);
+ const actionFiles = (actions || [])
+ .map((a) => a && a.requiresFile)
+ .filter(Boolean);
+ const actionFilesKey = actionFiles.join('|');
+
useEffect(() => {
let cancelled = false;
- setInspect({ checked: false, slnPath: null, redeployPath: null, isNetwork: false });
- window.bookmarks.inspectFolder(bookmark.path).then((res) => {
+ setInspect({ checked: false, slnPath: null, redeployPath: null, isNetwork: false, actionMatches: {} });
+ window.bookmarks.inspectFolder(bookmark.path, actionFiles).then((res) => {
if (cancelled) return;
setInspect({
checked: true,
slnPath: res ? res.slnPath : null,
redeployPath: res ? res.redeployPath : null,
isNetwork: !!(res && res.isNetwork),
+ actionMatches: (res && res.actionMatches) || {},
});
});
return () => { cancelled = true; };
- }, [bookmark.path, inspectRevision]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [bookmark.path, inspectRevision, actionFilesKey]);
const flash = (kind, text) => {
setStatus({ kind, text });
@@ -102,6 +110,19 @@ function MinimizedRow({ bookmark, claudeAvailable, inspectRevision, buttonVisibi
else flash('info', `${label}: opened`);
};
+ const runConfiguredAction = async (action) => {
+ const res = await window.bookmarks.runAction({
+ actionId: action.id,
+ targetPath: bookmark.path,
+ alias: bookmark.alias,
+ color: bookmark.color,
+ });
+ const label = action.label || 'Action';
+ if (res && res.ok === false) flash('error', `${label}: ${res.error || 'failed'}`);
+ else if (res && res.focused) flash('info', `${label}: focused`);
+ else flash('info', `${label} ✓`);
+ };
+
const stripeStyle = bookmark.color ? { borderLeftColor: bookmark.color } : undefined;
return (
@@ -124,33 +145,27 @@ function MinimizedRow({ bookmark, claudeAvailable, inspectRevision, buttonVisibi
onClick={() => runTabAction(window.bookmarks.openTerminal, 'Terminal')}
>>_
)}
- {!inspect.isNetwork && showClaude && (
-
- )}
- {!inspect.isNetwork && (
-
- )}
- {showRedeploy && inspect.redeployPath && !bookmark.hideDeploy && (
-
- )}
+ {(actions || []).map((action) => {
+ if (!action || !action.id) return null;
+ if (action.hideOnNetwork && inspect.isNetwork) return null;
+ if (action.requiresFile) {
+ const matched = inspect.actionMatches && inspect.actionMatches[action.requiresFile];
+ if (!matched) return null;
+ if (action.requiresFile === '1ReDeploy.bat' && bookmark.hideDeploy) return null;
+ }
+ const cmdMissing = action.requiresCommand
+ && commandAvailability
+ && commandAvailability[action.requiresCommand] === false;
+ return (
+
+ );
+ })}
{status && (
diff --git a/src/components/Popup.jsx b/src/components/Popup.jsx
index ffb7287..f79d296 100644
--- a/src/components/Popup.jsx
+++ b/src/components/Popup.jsx
@@ -3,6 +3,7 @@ import Column from './Column.jsx';
import TabBar from './TabBar.jsx';
import ConfirmDialog from './ConfirmDialog.jsx';
import MinimizedList from './MinimizedList.jsx';
+import ActionEditor from './ActionEditor.jsx';
import iconUrl from '../../assets/tray-icon.png';
const COLUMN_WIDTH = 216;
@@ -18,6 +19,8 @@ export default function Popup({
activeTabId,
columns,
bookmarks,
+ actions,
+ onSetActions,
pinned,
claudeAvailable,
inspectRevision,
@@ -51,8 +54,27 @@ export default function Popup({
const [creatingColumn, setCreatingColumn] = useState(false);
const [draftColumnName, setDraftColumnName] = useState('');
const [settingsOpen, setSettingsOpen] = useState(false);
+ const [actionEditorOpen, setActionEditorOpen] = useState(false);
const [confirmRequest, setConfirmRequest] = useState(null);
const [tabStripScrollWidth, setTabStripScrollWidth] = useState(0);
+ const [commandAvailability, setCommandAvailability] = useState({});
+
+ // Probe each action's requiresCommand so its button can show as disabled
+ // when the CLI isn't on PATH. Re-probe whenever the action set or the
+ // popup re-shows (claudeAvailable bumping is the existing signal).
+ useEffect(() => {
+ const names = Array.from(new Set(
+ (actions || []).map((a) => a && a.requiresCommand).filter(Boolean),
+ ));
+ if (names.length === 0) { setCommandAvailability({}); return; }
+ let cancelled = false;
+ Promise.all(names.map((n) => window.bookmarks.isCommandAvailable(n).then((ok) => [n, !!ok])))
+ .then((entries) => {
+ if (cancelled) return;
+ setCommandAvailability(Object.fromEntries(entries));
+ });
+ return () => { cancelled = true; };
+ }, [actions, claudeAvailable]);
const requestConfirm = useCallback((opts) => {
setConfirmRequest(opts);
@@ -215,15 +237,7 @@ export default function Popup({
>⚙
{settingsOpen && (
);
}
diff --git a/src/styles/index.css b/src/styles/index.css
index b540955..dfc4122 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -910,6 +910,155 @@ button.danger-button:hover:not(:disabled) {
.mini-status.error { background: rgba(239, 107, 107, 0.12); color: var(--error); }
.mini-status.info { background: rgba(127, 201, 127, 0.12); color: var(--ok); }
+/* --- Action editor modal --- */
+
+.action-editor {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 14px;
+ width: 380px;
+ max-width: 95vw;
+ max-height: 85vh;
+ overflow-y: auto;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.action-editor-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.action-editor-head h2 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.action-editor-help {
+ margin: 0;
+ font-size: 11px;
+ color: var(--text-dim);
+ line-height: 1.4;
+}
+
+.action-editor-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.action-editor-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 4px 6px;
+}
+
+.action-editor-icon {
+ width: 24px;
+ height: 24px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ font-weight: 600;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ flex: 0 0 auto;
+}
+
+.action-editor-label {
+ flex: 1;
+ min-width: 0;
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.action-editor-row-buttons {
+ display: flex;
+ gap: 2px;
+ flex: 0 0 auto;
+}
+
+.action-editor-row-buttons .icon-button {
+ width: 22px;
+ height: 22px;
+ font-size: 10px;
+}
+
+.action-form {
+ border: 1px solid var(--accent);
+ border-radius: var(--radius);
+ padding: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ background: rgba(217, 119, 87, 0.06);
+}
+
+.action-form-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent);
+ margin-bottom: 2px;
+}
+
+.action-form-icon {
+ width: 80px;
+}
+
+.icon-pick-row {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+.icon-pick-row select { flex: 1; min-width: 0; }
+.icon-pick-row .action-form-icon { flex: 0 0 80px; }
+
+.action-form-kind {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.action-form-kind label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ cursor: pointer;
+}
+
+.action-form-args {
+ font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
+ resize: vertical;
+}
+
+.action-editor-footer {
+ display: flex;
+ gap: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--border);
+}
+
+.action-editor-footer button { flex: 1; }
+
/* --- Submenu inside column overflow --- */
.bookmark-submenu {