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:
Binary file not shown.
+121
-8
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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 1–4 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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')}
|
||||||
>>_</button>
|
>>_</button>
|
||||||
)}
|
)}
|
||||||
{!inspect.isNetwork && (
|
{(actions || []).map((action) => {
|
||||||
<>
|
if (!action || !action.id) return null;
|
||||||
{showClaude && (
|
if (action.hideOnNetwork && inspect.isNetwork) return null;
|
||||||
<button
|
if (action.requiresFile) {
|
||||||
className="icon-button"
|
const matched = inspect.actionMatches && inspect.actionMatches[action.requiresFile];
|
||||||
title={
|
if (!matched) return null;
|
||||||
claudeAvailable
|
// Legacy: hideDeploy on a bookmark suppresses the seeded redeploy action.
|
||||||
? `Open Claude for "${bookmark.alias}" (focuses existing tab if found)`
|
if (action.requiresFile === '1ReDeploy.bat' && bookmark.hideDeploy) return null;
|
||||||
: 'Claude CLI not found on PATH — install @anthropic-ai/claude-code'
|
}
|
||||||
}
|
const cmdMissing = action.requiresCommand
|
||||||
disabled={!claudeAvailable}
|
&& commandAvailability
|
||||||
onClick={() => runTabAction(window.bookmarks.openClaude, 'Claude')}
|
&& commandAvailability[action.requiresCommand] === false;
|
||||||
>C</button>
|
return (
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
|
key={action.id}
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
title={
|
title={
|
||||||
!inspect.checked ? 'Checking for .sln…'
|
cmdMissing
|
||||||
: inspect.slnPath ? `Open ${inspect.slnPath.split(/[\\/]/).pop()} in Visual Studio`
|
? `${action.requiresCommand} not found on PATH`
|
||||||
: 'No .sln found in this folder'
|
: `${action.label}${action.kind === 'terminal' ? ` for "${bookmark.alias}"` : ''}`
|
||||||
}
|
}
|
||||||
disabled={!inspect.checked || !inspect.slnPath}
|
disabled={cmdMissing}
|
||||||
onClick={() => runPathAction(window.bookmarks.openVisualStudio, 'Visual Studio')}
|
onClick={() => runConfiguredAction(action)}
|
||||||
>VS</button>
|
>{action.icon || '?'}</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'}`}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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')}
|
||||||
>>_</button>
|
>>_</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 && (
|
const cmdMissing = action.requiresCommand
|
||||||
<button
|
&& commandAvailability
|
||||||
className="icon-button mini-btn"
|
&& commandAvailability[action.requiresCommand] === false;
|
||||||
title={
|
return (
|
||||||
!inspect.checked ? 'Checking…'
|
<button
|
||||||
: inspect.slnPath ? `Open ${inspect.slnPath.split(/[\\/]/).pop()}`
|
key={action.id}
|
||||||
: 'No .sln found'
|
className="icon-button mini-btn"
|
||||||
}
|
title={cmdMissing ? `${action.requiresCommand} not found on PATH` : `${action.label}: ${bookmark.alias}`}
|
||||||
disabled={!inspect.checked || !inspect.slnPath}
|
disabled={cmdMissing}
|
||||||
onClick={() => runPathAction(window.bookmarks.openVisualStudio, 'Visual Studio')}
|
onClick={() => runConfiguredAction(action)}
|
||||||
>VS</button>
|
>{action.icon || '?'}</button>
|
||||||
)}
|
);
|
||||||
{showRedeploy && inspect.redeployPath && !bookmark.hideDeploy && (
|
})}
|
||||||
<button
|
|
||||||
className="icon-button mini-btn"
|
|
||||||
title={`Run ${inspect.redeployPath.split(/[\\/]/).pop()}`}
|
|
||||||
onClick={() => runPathAction(window.bookmarks.runRedeploy, 'Deploy')}
|
|
||||||
>▶</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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user