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>
This commit is contained in:
David
2026-04-29 13:52:21 -04:00
co-authored by Claude Opus 4.7
parent c11bbf3026
commit 35c8ce5bd7
10 changed files with 883 additions and 107 deletions
Binary file not shown.
+121 -8
View File
@@ -60,6 +60,46 @@ function getBookmarks() {
return Array.isArray(cfg.bookmarks) ? cfg.bookmarks.filter((b) => b && b.id && b.alias && b.path) : []; 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() { function getActiveTabId() {
const cfg = readConfig(); const cfg = readConfig();
const tabs = getTabs(); const tabs = getTabs();
@@ -69,15 +109,13 @@ function getActiveTabId() {
return tabs[0].id; return tabs[0].id;
} }
// Per-button visibility for the row action bar. Defaults to true so existing // Per-button visibility for the built-in action buttons. Claude and Redeploy
// installs see no behavior change. // have moved into the configurable `actions` list — Terminal stays built-in.
function getButtonVisibility() { function getButtonVisibility() {
const cfg = readConfig(); const cfg = readConfig();
const v = cfg.buttonVisibility || {}; const v = cfg.buttonVisibility || {};
return { return {
claude: v.claude !== false,
terminal: v.terminal !== 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() { function getCurrentHeight() {
@@ -301,6 +377,7 @@ function broadcastConfig() {
tabs: getTabs(), tabs: getTabs(),
columns: getColumns(), columns: getColumns(),
bookmarks: getBookmarks(), bookmarks: getBookmarks(),
actions: getActions(),
activeTabId: getActiveTabId(), activeTabId: getActiveTabId(),
buttonVisibility: getButtonVisibility(), buttonVisibility: getButtonVisibility(),
autoStart: getAutoStart(), autoStart: getAutoStart(),
@@ -487,6 +564,7 @@ ipcMain.handle('get-config', () => {
tabs: getTabs(), tabs: getTabs(),
columns: getColumns(), columns: getColumns(),
bookmarks: getBookmarks(), bookmarks: getBookmarks(),
actions: getActions(),
activeTabId: getActiveTabId(), activeTabId: getActiveTabId(),
buttonVisibility: getButtonVisibility(), buttonVisibility: getButtonVisibility(),
autoStart: getAutoStart(), autoStart: getAutoStart(),
@@ -506,15 +584,50 @@ ipcMain.handle('set-button-visibility', (_event, payload) => {
if (!payload || typeof payload !== 'object') return getButtonVisibility(); if (!payload || typeof payload !== 'object') return getButtonVisibility();
const current = getButtonVisibility(); const current = getButtonVisibility();
const next = { const next = {
claude: typeof payload.claude === 'boolean' ? payload.claude : current.claude,
terminal: typeof payload.terminal === 'boolean' ? payload.terminal : current.terminal, terminal: typeof payload.terminal === 'boolean' ? payload.terminal : current.terminal,
redeploy: typeof payload.redeploy === 'boolean' ? payload.redeploy : current.redeploy,
}; };
writeConfig({ buttonVisibility: next }); writeConfig({ buttonVisibility: next });
broadcastConfig(); broadcastConfig();
return getButtonVisibility(); 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) => { ipcMain.handle('set-config', (_event, patch) => {
return writeConfig(patch || {}); return writeConfig(patch || {});
}); });
@@ -699,7 +812,7 @@ ipcMain.handle('set-active-tab', (_event, id) => {
}); });
ipcMain.handle('pick-folder', () => launchers.pickFolder()); 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-in-explorer', (_event, p) => launchers.openInExplorer(p));
ipcMain.handle('open-terminal', (_event, payload) => { ipcMain.handle('open-terminal', (_event, payload) => {
+4 -1
View File
@@ -27,7 +27,7 @@ contextBridge.exposeInMainWorld('bookmarks', {
// Folder picking + inspection // Folder picking + inspection
pickFolder: () => ipcRenderer.invoke('pick-folder'), pickFolder: () => ipcRenderer.invoke('pick-folder'),
inspectFolder: (path) => ipcRenderer.invoke('inspect-folder', path), inspectFolder: (path, actionFiles) => ipcRenderer.invoke('inspect-folder', path, actionFiles),
// Launchers // Launchers
openInExplorer: (path) => ipcRenderer.invoke('open-in-explorer', path), openInExplorer: (path) => ipcRenderer.invoke('open-in-explorer', path),
@@ -37,6 +37,8 @@ contextBridge.exposeInMainWorld('bookmarks', {
runRedeploy: (path) => ipcRenderer.invoke('run-redeploy', path), runRedeploy: (path) => ipcRenderer.invoke('run-redeploy', path),
copyPath: (text) => ipcRenderer.invoke('copy-path', text), copyPath: (text) => ipcRenderer.invoke('copy-path', text),
isClaudeAvailable: () => ipcRenderer.invoke('is-claude-available'), isClaudeAvailable: () => ipcRenderer.invoke('is-claude-available'),
isCommandAvailable: (name) => ipcRenderer.invoke('is-command-available', name),
runAction: (payload) => ipcRenderer.invoke('run-action', payload),
// Window // Window
setPinned: (value) => ipcRenderer.invoke('set-pinned', value), setPinned: (value) => ipcRenderer.invoke('set-pinned', value),
@@ -47,6 +49,7 @@ contextBridge.exposeInMainWorld('bookmarks', {
// Settings // Settings
setButtonVisibility: (payload) => ipcRenderer.invoke('set-button-visibility', payload), setButtonVisibility: (payload) => ipcRenderer.invoke('set-button-visibility', payload),
setAutoStart: (value) => ipcRenderer.invoke('set-auto-start', value), setAutoStart: (value) => ipcRenderer.invoke('set-auto-start', value),
setActions: (list) => ipcRenderer.invoke('set-actions', list),
// Events from main → renderer // Events from main → renderer
onConfigUpdated: (cb) => { onConfigUpdated: (cb) => {
+12 -7
View File
@@ -6,11 +6,12 @@ export default function App() {
const [activeTabId, setActiveTabId] = useState(null); const [activeTabId, setActiveTabId] = useState(null);
const [columns, setColumns] = useState([]); const [columns, setColumns] = useState([]);
const [bookmarks, setBookmarks] = useState([]); const [bookmarks, setBookmarks] = useState([]);
const [actions, setActions] = useState([]);
const [pinned, setPinned] = useState(false); const [pinned, setPinned] = useState(false);
const [minimized, setMinimized] = useState(false); const [minimized, setMinimized] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [claudeAvailable, setClaudeAvailable] = 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); const [autoStart, setAutoStart] = useState(true);
// Bumped on each popup show so BookmarkRow re-runs its folder inspect. // Bumped on each popup show so BookmarkRow re-runs its folder inspect.
// Rows stay mounted across hide/show cycles, so without a revision tick // Rows stay mounted across hide/show cycles, so without a revision tick
@@ -26,14 +27,13 @@ export default function App() {
setActiveTabId(cfg.activeTabId || null); setActiveTabId(cfg.activeTabId || null);
setColumns(Array.isArray(cfg.columns) ? cfg.columns : []); setColumns(Array.isArray(cfg.columns) ? cfg.columns : []);
setBookmarks(Array.isArray(cfg.bookmarks) ? cfg.bookmarks : []); setBookmarks(Array.isArray(cfg.bookmarks) ? cfg.bookmarks : []);
setActions(Array.isArray(cfg.actions) ? cfg.actions : []);
setPinned(!!cfg.pinned); setPinned(!!cfg.pinned);
setMinimized(!!cfg.minimized); setMinimized(!!cfg.minimized);
setClaudeAvailable(!!hasClaude); setClaudeAvailable(!!hasClaude);
if (cfg.buttonVisibility) { if (cfg.buttonVisibility) {
setButtonVisibility({ setButtonVisibility({
claude: cfg.buttonVisibility.claude !== false,
terminal: cfg.buttonVisibility.terminal !== false, terminal: cfg.buttonVisibility.terminal !== false,
redeploy: cfg.buttonVisibility.redeploy !== false,
}); });
} }
setAutoStart(cfg.autoStart !== 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.tabs)) setTabs(payload.tabs);
if (Array.isArray(payload.columns)) setColumns(payload.columns); if (Array.isArray(payload.columns)) setColumns(payload.columns);
if (Array.isArray(payload.bookmarks)) setBookmarks(payload.bookmarks); if (Array.isArray(payload.bookmarks)) setBookmarks(payload.bookmarks);
if (Array.isArray(payload.actions)) setActions(payload.actions);
if (typeof payload.activeTabId === 'string' || payload.activeTabId === null) { if (typeof payload.activeTabId === 'string' || payload.activeTabId === null) {
setActiveTabId(payload.activeTabId); setActiveTabId(payload.activeTabId);
} }
if (payload.buttonVisibility) { if (payload.buttonVisibility) {
setButtonVisibility({ setButtonVisibility({
claude: payload.buttonVisibility.claude !== false,
terminal: payload.buttonVisibility.terminal !== false, terminal: payload.buttonVisibility.terminal !== false,
redeploy: payload.buttonVisibility.redeploy !== false,
}); });
} }
if (typeof payload.autoStart === 'boolean') setAutoStart(payload.autoStart); if (typeof payload.autoStart === 'boolean') setAutoStart(payload.autoStart);
@@ -173,12 +172,16 @@ export default function App() {
setButtonVisibility((prev) => ({ ...prev, ...patch })); setButtonVisibility((prev) => ({ ...prev, ...patch }));
const next = await window.bookmarks.setButtonVisibility(patch); const next = await window.bookmarks.setButtonVisibility(patch);
if (next) setButtonVisibility({ if (next) setButtonVisibility({
claude: next.claude !== false,
terminal: next.terminal !== 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) => { const handleSetAutoStart = useCallback(async (value) => {
setAutoStart(value); setAutoStart(value);
const next = await window.bookmarks.setAutoStart(value); const next = await window.bookmarks.setAutoStart(value);
@@ -212,6 +215,8 @@ export default function App() {
activeTabId={activeTabId} activeTabId={activeTabId}
columns={columns} columns={columns}
bookmarks={bookmarks} bookmarks={bookmarks}
actions={actions}
onSetActions={handleSetActions}
pinned={pinned} pinned={pinned}
claudeAvailable={claudeAvailable} claudeAvailable={claudeAvailable}
inspectRevision={inspectRevision} inspectRevision={inspectRevision}
+452
View File
@@ -0,0 +1,452 @@
import React, { useState } from 'react';
// Curated icon dropdown — emoji + short text marks. Users can also type a
// custom 14 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 (
<div className="confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="action-editor">
<div className="action-editor-head">
<h2>Action Buttons</h2>
<button className="icon-button" onClick={onClose} title="Close">×</button>
</div>
<p className="action-editor-help">
Per-bookmark buttons. <strong>Terminal</strong> opens Windows Terminal in
the folder and runs the command. <strong>Detached</strong> launches
a command in a new cmd window.
</p>
<ul className="action-editor-list">
{draft.length === 0 && (
<li className="empty-state">No action buttons. Click Add to create one.</li>
)}
{draft.map((a, idx) => (
<li key={a.id} className="action-editor-row">
<span className="action-editor-icon" title={a.kind}>{a.icon || '?'}</span>
<span className="action-editor-label">{a.label || '(unnamed)'}</span>
<div className="action-editor-row-buttons">
<button
className="icon-button"
title="Move up"
disabled={idx === 0}
onClick={() => move(a.id, -1)}
></button>
<button
className="icon-button"
title="Move down"
disabled={idx === draft.length - 1}
onClick={() => move(a.id, 1)}
></button>
<button
className="icon-button"
title="Edit"
onClick={() => { setAdding(false); setEditingId(a.id); }}
></button>
<button
className="icon-button"
title="Delete"
onClick={() => {
if (window.confirm(`Delete action "${a.label}"?`)) removeOne(a.id);
}}
>×</button>
</div>
</li>
))}
</ul>
{!adding && !editing && (
<button
className="add-bookmark-trigger"
type="button"
onClick={() => { setEditingId(null); setAdding(true); }}
>+ Add action</button>
)}
{adding && (
<ActionForm
initial={blankAction()}
mode="add"
onCancel={() => setAdding(false)}
onSubmit={addNew}
/>
)}
{editing && (
<ActionForm
initial={editing}
mode="edit"
onCancel={() => setEditingId(null)}
onSubmit={(patch) => { updateOne(editing.id, patch); setEditingId(null); }}
/>
)}
<div className="action-editor-footer">
<button className="primary" onClick={handleSave}>Save</button>
<button className="secondary" onClick={onClose}>Cancel</button>
</div>
</div>
</div>
);
}
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 (
<div className="action-form">
<div className="action-form-title">{mode === 'add' ? 'New action' : 'Edit action'}</div>
{mode === 'add' && (
<>
<label className="edit-label">Start from preset</label>
<select
className="api-key-input small"
defaultValue=""
onChange={(e) => {
if (e.target.value) applyPreset(e.target.value);
e.target.value = '';
}}
>
<option value="">Custom (blank)</option>
{PRESETS.map((p) => (
<option key={p.name} value={p.name}>{p.name}</option>
))}
</select>
</>
)}
<label className="edit-label">Label</label>
<input
className="api-key-input small"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Run dev server"
/>
<label className="edit-label">Icon</label>
<div className="icon-pick-row">
<select
className="api-key-input small"
value={iconInList ? icon : '__custom'}
onChange={(e) => {
if (e.target.value !== '__custom') setIcon(e.target.value);
}}
>
{ICON_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
<option value="__custom">Custom</option>
</select>
<input
className="api-key-input small action-form-icon"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="custom"
maxLength={4}
title="Override the dropdown with your own 14 character icon"
/>
</div>
<label className="edit-label">Kind</label>
<div className="action-form-kind">
<label>
<input
type="radio"
name="kind"
value="terminal"
checked={kind === 'terminal'}
onChange={() => setKind('terminal')}
/>
<span>Terminal (opens Windows Terminal in folder)</span>
</label>
<label>
<input
type="radio"
name="kind"
value="detached"
checked={kind === 'detached'}
onChange={() => setKind('detached')}
/>
<span>Detached (spawns command in new cmd window)</span>
</label>
<label>
<input
type="radio"
name="kind"
value="open"
checked={kind === 'open'}
onChange={() => setKind('open')}
/>
<span>Open (launch matched file via Windows shell e.g. .sln in VS)</span>
</label>
</div>
{kind !== 'open' && (
<>
<label className="edit-label">
Command {kind === 'detached' && requiresFile ? '(optional — leave blank to run the matched file directly)' : ''}
</label>
<input
className="api-key-input small"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder={kind === 'terminal' ? 'claude' : 'npm'}
/>
</>
)}
{kind === 'detached' && (
<>
<label className="edit-label">Extra args (one per line, optional)</label>
<textarea
className="api-key-input small action-form-args"
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
placeholder="install"
rows={3}
/>
</>
)}
<label className="edit-label">
Required file in folder {kind === 'open' ? '(required — supports * wildcards)' : '(optional — supports * wildcards)'}
</label>
<input
className="api-key-input small"
value={requiresFile}
onChange={(e) => setRequiresFile(e.target.value)}
placeholder={kind === 'open' ? '*.sln' : 'e.g. package.json or *.csproj'}
/>
{kind !== 'open' && (
<>
<label className="edit-label">Required CLI on PATH (optional)</label>
<input
className="api-key-input small"
value={requiresCommand}
onChange={(e) => setRequiresCommand(e.target.value)}
placeholder="e.g. claude"
/>
</>
)}
<label className="edit-toggle">
<input
type="checkbox"
checked={hideOnNetwork}
onChange={(e) => setHideOnNetwork(e.target.checked)}
/>
<span>Hide on network shares</span>
</label>
{kind === 'detached' && (
<label className="edit-toggle">
<input
type="checkbox"
checked={keepOpen}
onChange={(e) => setKeepOpen(e.target.checked)}
/>
<span>Keep cmd window open after exit</span>
</label>
)}
<div className="edit-actions">
<button className="primary" onClick={handleSubmit} disabled={!canSubmit}>
{mode === 'add' ? 'Add' : 'Update'}
</button>
<button className="secondary" onClick={onCancel}>Cancel</button>
</div>
</div>
);
}
+51 -35
View File
@@ -3,7 +3,8 @@ import React, { useEffect, useRef, useState } from 'react';
export default function BookmarkRow({ export default function BookmarkRow({
bookmark, bookmark,
columns, columns,
claudeAvailable, actions,
commandAvailability,
inspectRevision, inspectRevision,
recentColors, recentColors,
buttonVisibility, buttonVisibility,
@@ -14,9 +15,7 @@ export default function BookmarkRow({
onMoveUp, onMoveUp,
onMoveDown, onMoveDown,
}) { }) {
const showClaude = !buttonVisibility || buttonVisibility.claude !== false;
const showTerminal = !buttonVisibility || buttonVisibility.terminal !== false; const showTerminal = !buttonVisibility || buttonVisibility.terminal !== false;
const showRedeploy = !buttonVisibility || buttonVisibility.redeploy !== false;
const [mode, setMode] = useState('view'); const [mode, setMode] = useState('view');
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [inspect, setInspect] = useState({ const [inspect, setInspect] = useState({
@@ -24,24 +23,35 @@ export default function BookmarkRow({
slnPath: null, slnPath: null,
redeployPath: null, redeployPath: null,
isNetwork: false, isNetwork: false,
actionMatches: {},
}); });
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const menuRef = useRef(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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setInspect({ checked: false, slnPath: null, redeployPath: null, isNetwork: false }); setInspect({ checked: false, slnPath: null, redeployPath: null, isNetwork: false, actionMatches: {} });
window.bookmarks.inspectFolder(bookmark.path).then((res) => { window.bookmarks.inspectFolder(bookmark.path, actionFiles).then((res) => {
if (cancelled) return; if (cancelled) return;
setInspect({ setInspect({
checked: true, checked: true,
slnPath: res ? res.slnPath : null, slnPath: res ? res.slnPath : null,
redeployPath: res ? res.redeployPath : null, redeployPath: res ? res.redeployPath : null,
isNetwork: !!(res && res.isNetwork), isNetwork: !!(res && res.isNetwork),
actionMatches: (res && res.actionMatches) || {},
}); });
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [bookmark.path, inspectRevision]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookmark.path, inspectRevision, actionFilesKey]);
useEffect(() => { useEffect(() => {
if (!menuOpen) return undefined; 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 () => { const handleCopy = async () => {
setMenuOpen(false); setMenuOpen(false);
const res = await window.bookmarks.copyPath(bookmark.path); const res = await window.bookmarks.copyPath(bookmark.path);
@@ -159,39 +182,32 @@ export default function BookmarkRow({
onClick={() => runTabAction(window.bookmarks.openTerminal, 'Terminal')} onClick={() => runTabAction(window.bookmarks.openTerminal, 'Terminal')}
>&gt;_</button> >&gt;_</button>
)} )}
{!inspect.isNetwork && ( {(actions || []).map((action) => {
<> if (!action || !action.id) return null;
{showClaude && ( 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 (
<button <button
key={action.id}
className="icon-button" className="icon-button"
title={ title={
claudeAvailable cmdMissing
? `Open Claude for "${bookmark.alias}" (focuses existing tab if found)` ? `${action.requiresCommand} not found on PATH`
: 'Claude CLI not found on PATH — install @anthropic-ai/claude-code' : `${action.label}${action.kind === 'terminal' ? ` for "${bookmark.alias}"` : ''}`
} }
disabled={!claudeAvailable} disabled={cmdMissing}
onClick={() => runTabAction(window.bookmarks.openClaude, 'Claude')} onClick={() => runConfiguredAction(action)}
>C</button> >{action.icon || '?'}</button>
)} );
<button })}
className="icon-button"
title={
!inspect.checked ? 'Checking for .sln…'
: inspect.slnPath ? `Open ${inspect.slnPath.split(/[\\/]/).pop()} in Visual Studio`
: 'No .sln found in this folder'
}
disabled={!inspect.checked || !inspect.slnPath}
onClick={() => runPathAction(window.bookmarks.openVisualStudio, 'Visual Studio')}
>VS</button>
</>
)}
{showRedeploy && inspect.redeployPath && !bookmark.hideDeploy && (
<button
className="icon-button"
title={`Run ${inspect.redeployPath.split(/[\\/]/).pop()}`}
onClick={() => runPathAction(window.bookmarks.runRedeploy, 'Deploy')}
></button>
)}
</div> </div>
{status && ( {status && (
<div className={`row-status ${status.kind === 'error' ? 'error' : 'info'}`}> <div className={`row-status ${status.kind === 'error' ? 'error' : 'info'}`}>
+4 -2
View File
@@ -7,7 +7,8 @@ export default function Column({
columns, columns,
tabs, tabs,
bookmarks, bookmarks,
claudeAvailable, actions,
commandAvailability,
inspectRevision, inspectRevision,
recentColors, recentColors,
buttonVisibility, buttonVisibility,
@@ -180,7 +181,8 @@ export default function Column({
key={b.id} key={b.id}
bookmark={b} bookmark={b}
columns={columns} columns={columns}
claudeAvailable={claudeAvailable} actions={actions}
commandAvailability={commandAvailability}
inspectRevision={inspectRevision} inspectRevision={inspectRevision}
recentColors={recentColors} recentColors={recentColors}
buttonVisibility={buttonVisibility} buttonVisibility={buttonVisibility}
+47 -32
View File
@@ -7,7 +7,8 @@ export default function MinimizedList({
tabs, tabs,
columns, columns,
bookmarks, bookmarks,
claudeAvailable, actions,
commandAvailability,
inspectRevision, inspectRevision,
buttonVisibility, buttonVisibility,
}) { }) {
@@ -41,7 +42,8 @@ export default function MinimizedList({
<MinimizedRow <MinimizedRow
key={b.id} key={b.id}
bookmark={b} bookmark={b}
claudeAvailable={claudeAvailable} actions={actions}
commandAvailability={commandAvailability}
inspectRevision={inspectRevision} inspectRevision={inspectRevision}
buttonVisibility={buttonVisibility} buttonVisibility={buttonVisibility}
/> />
@@ -53,32 +55,38 @@ export default function MinimizedList({
); );
} }
function MinimizedRow({ bookmark, claudeAvailable, inspectRevision, buttonVisibility }) { function MinimizedRow({ bookmark, actions, commandAvailability, inspectRevision, buttonVisibility }) {
const showClaude = !buttonVisibility || buttonVisibility.claude !== false;
const showTerminal = !buttonVisibility || buttonVisibility.terminal !== false; const showTerminal = !buttonVisibility || buttonVisibility.terminal !== false;
const showRedeploy = !buttonVisibility || buttonVisibility.redeploy !== false;
const [inspect, setInspect] = useState({ const [inspect, setInspect] = useState({
checked: false, checked: false,
slnPath: null, slnPath: null,
redeployPath: null, redeployPath: null,
isNetwork: false, isNetwork: false,
actionMatches: {},
}); });
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const actionFiles = (actions || [])
.map((a) => a && a.requiresFile)
.filter(Boolean);
const actionFilesKey = actionFiles.join('|');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setInspect({ checked: false, slnPath: null, redeployPath: null, isNetwork: false }); setInspect({ checked: false, slnPath: null, redeployPath: null, isNetwork: false, actionMatches: {} });
window.bookmarks.inspectFolder(bookmark.path).then((res) => { window.bookmarks.inspectFolder(bookmark.path, actionFiles).then((res) => {
if (cancelled) return; if (cancelled) return;
setInspect({ setInspect({
checked: true, checked: true,
slnPath: res ? res.slnPath : null, slnPath: res ? res.slnPath : null,
redeployPath: res ? res.redeployPath : null, redeployPath: res ? res.redeployPath : null,
isNetwork: !!(res && res.isNetwork), isNetwork: !!(res && res.isNetwork),
actionMatches: (res && res.actionMatches) || {},
}); });
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [bookmark.path, inspectRevision]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookmark.path, inspectRevision, actionFilesKey]);
const flash = (kind, text) => { const flash = (kind, text) => {
setStatus({ kind, text }); setStatus({ kind, text });
@@ -102,6 +110,19 @@ function MinimizedRow({ bookmark, claudeAvailable, inspectRevision, buttonVisibi
else flash('info', `${label}: opened`); 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; const stripeStyle = bookmark.color ? { borderLeftColor: bookmark.color } : undefined;
return ( return (
@@ -124,33 +145,27 @@ function MinimizedRow({ bookmark, claudeAvailable, inspectRevision, buttonVisibi
onClick={() => runTabAction(window.bookmarks.openTerminal, 'Terminal')} onClick={() => runTabAction(window.bookmarks.openTerminal, 'Terminal')}
>&gt;_</button> >&gt;_</button>
)} )}
{!inspect.isNetwork && showClaude && ( {(actions || []).map((action) => {
<button if (!action || !action.id) return null;
className="icon-button mini-btn" if (action.hideOnNetwork && inspect.isNetwork) return null;
title={claudeAvailable ? `Claude: ${bookmark.alias}` : 'Claude CLI not found'} if (action.requiresFile) {
disabled={!claudeAvailable} const matched = inspect.actionMatches && inspect.actionMatches[action.requiresFile];
onClick={() => runTabAction(window.bookmarks.openClaude, 'Claude')} if (!matched) return null;
>C</button> if (action.requiresFile === '1ReDeploy.bat' && bookmark.hideDeploy) return null;
)}
{!inspect.isNetwork && (
<button
className="icon-button mini-btn"
title={
!inspect.checked ? 'Checking…'
: inspect.slnPath ? `Open ${inspect.slnPath.split(/[\\/]/).pop()}`
: 'No .sln found'
} }
disabled={!inspect.checked || !inspect.slnPath} const cmdMissing = action.requiresCommand
onClick={() => runPathAction(window.bookmarks.openVisualStudio, 'Visual Studio')} && commandAvailability
>VS</button> && commandAvailability[action.requiresCommand] === false;
)} return (
{showRedeploy && inspect.redeployPath && !bookmark.hideDeploy && (
<button <button
key={action.id}
className="icon-button mini-btn" className="icon-button mini-btn"
title={`Run ${inspect.redeployPath.split(/[\\/]/).pop()}`} title={cmdMissing ? `${action.requiresCommand} not found on PATH` : `${action.label}: ${bookmark.alias}`}
onClick={() => runPathAction(window.bookmarks.runRedeploy, 'Deploy')} disabled={cmdMissing}
></button> onClick={() => runConfiguredAction(action)}
)} >{action.icon || '?'}</button>
);
})}
</div> </div>
{status && ( {status && (
<div className={`mini-status ${status.kind === 'error' ? 'error' : 'info'}`}> <div className={`mini-status ${status.kind === 'error' ? 'error' : 'info'}`}>
+40 -19
View File
@@ -3,6 +3,7 @@ import Column from './Column.jsx';
import TabBar from './TabBar.jsx'; import TabBar from './TabBar.jsx';
import ConfirmDialog from './ConfirmDialog.jsx'; import ConfirmDialog from './ConfirmDialog.jsx';
import MinimizedList from './MinimizedList.jsx'; import MinimizedList from './MinimizedList.jsx';
import ActionEditor from './ActionEditor.jsx';
import iconUrl from '../../assets/tray-icon.png'; import iconUrl from '../../assets/tray-icon.png';
const COLUMN_WIDTH = 216; const COLUMN_WIDTH = 216;
@@ -18,6 +19,8 @@ export default function Popup({
activeTabId, activeTabId,
columns, columns,
bookmarks, bookmarks,
actions,
onSetActions,
pinned, pinned,
claudeAvailable, claudeAvailable,
inspectRevision, inspectRevision,
@@ -51,8 +54,27 @@ export default function Popup({
const [creatingColumn, setCreatingColumn] = useState(false); const [creatingColumn, setCreatingColumn] = useState(false);
const [draftColumnName, setDraftColumnName] = useState(''); const [draftColumnName, setDraftColumnName] = useState('');
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [actionEditorOpen, setActionEditorOpen] = useState(false);
const [confirmRequest, setConfirmRequest] = useState(null); const [confirmRequest, setConfirmRequest] = useState(null);
const [tabStripScrollWidth, setTabStripScrollWidth] = useState(0); 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) => { const requestConfirm = useCallback((opts) => {
setConfirmRequest(opts); setConfirmRequest(opts);
@@ -215,15 +237,7 @@ export default function Popup({
></button> ></button>
{settingsOpen && ( {settingsOpen && (
<div className="settings-panel"> <div className="settings-panel">
<div className="settings-panel-label">Row buttons</div> <div className="settings-panel-label">Built-in buttons</div>
<label className="edit-toggle">
<input
type="checkbox"
checked={buttonVisibility.claude !== false}
onChange={(e) => onSetButtonVisibility({ claude: e.target.checked })}
/>
<span>Show Claude button</span>
</label>
<label className="edit-toggle"> <label className="edit-toggle">
<input <input
type="checkbox" type="checkbox"
@@ -232,14 +246,12 @@ export default function Popup({
/> />
<span>Show Terminal button</span> <span>Show Terminal button</span>
</label> </label>
<label className="edit-toggle"> <div className="settings-panel-label">Action buttons</div>
<input <button
type="checkbox" type="button"
checked={buttonVisibility.redeploy !== false} className="secondary"
onChange={(e) => onSetButtonVisibility({ redeploy: e.target.checked })} onClick={() => { setSettingsOpen(false); setActionEditorOpen(true); }}
/> >Manage actions ({(actions || []).length})</button>
<span>Quick Run Redeploy</span>
</label>
<div className="settings-panel-label">Startup</div> <div className="settings-panel-label">Startup</div>
<label className="edit-toggle"> <label className="edit-toggle">
<input <input
@@ -282,7 +294,8 @@ export default function Popup({
tabs={tabs} tabs={tabs}
columns={columns} columns={columns}
bookmarks={bookmarks} bookmarks={bookmarks}
claudeAvailable={claudeAvailable} actions={actions}
commandAvailability={commandAvailability}
inspectRevision={inspectRevision} inspectRevision={inspectRevision}
buttonVisibility={buttonVisibility} buttonVisibility={buttonVisibility}
/> />
@@ -304,7 +317,8 @@ export default function Popup({
tabs={tabs} tabs={tabs}
index={idx} index={idx}
bookmarks={bookmarks.filter((b) => b.columnId === col.id)} bookmarks={bookmarks.filter((b) => b.columnId === col.id)}
claudeAvailable={claudeAvailable} actions={actions}
commandAvailability={commandAvailability}
inspectRevision={inspectRevision} inspectRevision={inspectRevision}
recentColors={recentColors} recentColors={recentColors}
buttonVisibility={buttonVisibility} buttonVisibility={buttonVisibility}
@@ -336,6 +350,13 @@ export default function Popup({
}} }}
/> />
)} )}
{actionEditorOpen && (
<ActionEditor
actions={actions || []}
onSave={(next) => { onSetActions(next); }}
onClose={() => setActionEditorOpen(false)}
/>
)}
</div> </div>
); );
} }
+149
View File
@@ -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.error { background: rgba(239, 107, 107, 0.12); color: var(--error); }
.mini-status.info { background: rgba(127, 201, 127, 0.12); color: var(--ok); } .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 --- */ /* --- Submenu inside column overflow --- */
.bookmark-submenu { .bookmark-submenu {