Initial commit: Folder Bookmark Tray

Windows tray app for bookmarking folders with quick-action buttons
(Explorer, Terminal, Claude, Visual Studio, Redeploy).

Features:
- Tabs containing columns of bookmarks
- Per-row launcher buttons configurable from a header settings panel
- Optional auto-start on Windows login
- In-app confirm dialogs for destructive actions
- Dark theme, frameless tray-anchored popup, pinnable
This commit is contained in:
David
2026-04-29 09:36:59 -04:00
commit 56ef96026b
22 changed files with 10717 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import Popup from './components/Popup.jsx';
export default function App() {
const [tabs, setTabs] = useState([]);
const [activeTabId, setActiveTabId] = useState(null);
const [columns, setColumns] = useState([]);
const [bookmarks, setBookmarks] = useState([]);
const [pinned, setPinned] = useState(false);
const [loading, setLoading] = useState(true);
const [claudeAvailable, setClaudeAvailable] = useState(true);
const [buttonVisibility, setButtonVisibility] = useState({ claude: true, terminal: true, redeploy: true });
const [autoStart, setAutoStart] = useState(true);
// Bumped on each popup show so BookmarkRow re-runs its folder inspect.
// Rows stay mounted across hide/show cycles, so without a revision tick
// they'd never notice newly-added .sln or 1ReDeploy.bat files.
const [inspectRevision, setInspectRevision] = useState(0);
const loadConfig = useCallback(async () => {
const [cfg, hasClaude] = await Promise.all([
window.bookmarks.getConfig(),
window.bookmarks.isClaudeAvailable(),
]);
setTabs(Array.isArray(cfg.tabs) ? cfg.tabs : []);
setActiveTabId(cfg.activeTabId || null);
setColumns(Array.isArray(cfg.columns) ? cfg.columns : []);
setBookmarks(Array.isArray(cfg.bookmarks) ? cfg.bookmarks : []);
setPinned(!!cfg.pinned);
setClaudeAvailable(!!hasClaude);
if (cfg.buttonVisibility) {
setButtonVisibility({
claude: cfg.buttonVisibility.claude !== false,
terminal: cfg.buttonVisibility.terminal !== false,
redeploy: cfg.buttonVisibility.redeploy !== false,
});
}
setAutoStart(cfg.autoStart !== false);
setLoading(false);
}, []);
useEffect(() => {
loadConfig();
const offConfig = window.bookmarks.onConfigUpdated((payload) => {
if (!payload) return;
if (Array.isArray(payload.tabs)) setTabs(payload.tabs);
if (Array.isArray(payload.columns)) setColumns(payload.columns);
if (Array.isArray(payload.bookmarks)) setBookmarks(payload.bookmarks);
if (typeof payload.activeTabId === 'string' || payload.activeTabId === null) {
setActiveTabId(payload.activeTabId);
}
if (payload.buttonVisibility) {
setButtonVisibility({
claude: payload.buttonVisibility.claude !== false,
terminal: payload.buttonVisibility.terminal !== false,
redeploy: payload.buttonVisibility.redeploy !== false,
});
}
if (typeof payload.autoStart === 'boolean') setAutoStart(payload.autoStart);
});
const offShown = window.bookmarks.onPopupShown(() => {
setInspectRevision((r) => r + 1);
});
return () => { offConfig(); offShown(); };
}, [loadConfig]);
const handleAdd = useCallback(async ({ alias, path, color, columnId }) => {
const next = await window.bookmarks.addBookmark({ alias, path, color, columnId });
setBookmarks(next || []);
}, []);
const handleEdit = useCallback(async ({ id, alias, path, color, columnId, hideDeploy }) => {
const next = await window.bookmarks.addBookmark({ id, alias, path, color, columnId, hideDeploy });
setBookmarks(next || []);
}, []);
const handleRemove = useCallback(async (id) => {
const next = await window.bookmarks.removeBookmark(id);
setBookmarks(next || []);
}, []);
const handleMoveBookmark = useCallback(async (id, delta) => {
const target = bookmarks.find((b) => b.id === id);
if (!target) return;
const colMates = bookmarks.filter((b) => b.columnId === target.columnId);
const idx = colMates.findIndex((b) => b.id === id);
const swapIdx = idx + delta;
if (idx < 0 || swapIdx < 0 || swapIdx >= colMates.length) return;
const aId = colMates[idx].id;
const bId = colMates[swapIdx].id;
const newIds = bookmarks.map((b) => {
if (b.id === aId) return bId;
if (b.id === bId) return aId;
return b.id;
});
const next = await window.bookmarks.reorderBookmarks(newIds);
setBookmarks(next || []);
}, [bookmarks]);
const handleAddColumn = useCallback(async (name) => {
const next = await window.bookmarks.addColumn(name, activeTabId);
setColumns(next || []);
}, [activeTabId]);
const handleRenameColumn = useCallback(async (id, name) => {
const next = await window.bookmarks.renameColumn(id, name);
setColumns(next || []);
}, []);
const handleRemoveColumn = useCallback(async (id, reassignTo) => {
const res = await window.bookmarks.removeColumn(id, reassignTo);
if (res && Array.isArray(res.columns)) setColumns(res.columns);
if (res && Array.isArray(res.bookmarks)) setBookmarks(res.bookmarks);
}, []);
const handleReorderColumns = useCallback(async (ids) => {
const next = await window.bookmarks.reorderColumns(ids);
setColumns(next || []);
}, []);
const handleMoveColumnToTab = useCallback(async (columnId, tabId) => {
const res = await window.bookmarks.moveColumnToTab(columnId, tabId);
if (res && Array.isArray(res.columns)) setColumns(res.columns);
if (res && Array.isArray(res.tabs)) setTabs(res.tabs);
}, []);
const handleAddTab = useCallback(async (name) => {
const res = await window.bookmarks.addTab(name);
if (res && Array.isArray(res.tabs)) setTabs(res.tabs);
if (res && res.activeTabId) setActiveTabId(res.activeTabId);
}, []);
const handleRenameTab = useCallback(async (id, name) => {
const next = await window.bookmarks.renameTab(id, name);
setTabs(next || []);
}, []);
const handleRemoveTab = useCallback(async (id, reassignTo) => {
const res = await window.bookmarks.removeTab(id, reassignTo);
if (res && Array.isArray(res.tabs)) setTabs(res.tabs);
if (res && Array.isArray(res.columns)) setColumns(res.columns);
if (res && Array.isArray(res.bookmarks)) setBookmarks(res.bookmarks);
if (res && (typeof res.activeTabId === 'string' || res.activeTabId === null)) {
setActiveTabId(res.activeTabId);
}
}, []);
const handleReorderTabs = useCallback(async (ids) => {
const next = await window.bookmarks.reorderTabs(ids);
setTabs(next || []);
}, []);
const handleSelectTab = useCallback(async (id) => {
setActiveTabId(id);
await window.bookmarks.setActiveTab(id);
}, []);
const handleTogglePin = useCallback(async () => {
const next = await window.bookmarks.setPinned(!pinned);
setPinned(!!next);
}, [pinned]);
const handleSetButtonVisibility = useCallback(async (patch) => {
// Optimistic update so the checkbox flips immediately even before main responds.
setButtonVisibility((prev) => ({ ...prev, ...patch }));
const next = await window.bookmarks.setButtonVisibility(patch);
if (next) setButtonVisibility({
claude: next.claude !== false,
terminal: next.terminal !== false,
redeploy: next.redeploy !== false,
});
}, []);
const handleSetAutoStart = useCallback(async (value) => {
setAutoStart(value);
const next = await window.bookmarks.setAutoStart(value);
if (typeof next === 'boolean') setAutoStart(next);
}, []);
const recentColors = useMemo(() => {
const seen = new Set();
const ordered = [];
for (const b of bookmarks) {
if (b && typeof b.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(b.color)) {
const c = b.color.toLowerCase();
if (!seen.has(c)) { seen.add(c); ordered.push(c); }
}
}
return ordered;
}, [bookmarks]);
if (loading) {
return (
<div className="app loading">
<div className="spinner" />
</div>
);
}
return (
<div className="app">
<Popup
tabs={tabs}
activeTabId={activeTabId}
columns={columns}
bookmarks={bookmarks}
pinned={pinned}
claudeAvailable={claudeAvailable}
inspectRevision={inspectRevision}
recentColors={recentColors}
buttonVisibility={buttonVisibility}
onSetButtonVisibility={handleSetButtonVisibility}
autoStart={autoStart}
onSetAutoStart={handleSetAutoStart}
onTogglePin={handleTogglePin}
onAdd={handleAdd}
onEdit={handleEdit}
onRemove={handleRemove}
onMoveBookmark={handleMoveBookmark}
onAddColumn={handleAddColumn}
onRenameColumn={handleRenameColumn}
onRemoveColumn={handleRemoveColumn}
onReorderColumns={handleReorderColumns}
onMoveColumnToTab={handleMoveColumnToTab}
onAddTab={handleAddTab}
onRenameTab={handleRenameTab}
onRemoveTab={handleRemoveTab}
onReorderTabs={handleReorderTabs}
onSelectTab={handleSelectTab}
/>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { ColorPicker } from './BookmarkRow.jsx';
export default function AddBookmarkForm({ columnId, recentColors, onAdd, onCancel }) {
const [alias, setAlias] = useState('');
const [pathValue, setPathValue] = useState('');
const [color, setColor] = useState('');
const [busy, setBusy] = useState(false);
const handleBrowse = async () => {
const picked = await window.bookmarks.pickFolder();
if (!picked) return;
setPathValue(picked);
if (!alias.trim()) {
const parts = picked.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean);
setAlias(parts[parts.length - 1] || picked);
}
};
const handleAdd = async () => {
if (!alias.trim() || !pathValue.trim()) return;
setBusy(true);
try {
await onAdd({
alias: alias.trim(),
path: pathValue.trim(),
color: color || null,
columnId,
});
setAlias('');
setPathValue('');
setColor('');
} finally {
setBusy(false);
}
};
const canAdd = alias.trim() && pathValue.trim() && !busy;
return (
<div className="add-bookmark">
<div className="add-bookmark-label">Add bookmark</div>
<input
className="api-key-input small"
value={alias}
onChange={(e) => setAlias(e.target.value)}
placeholder="Alias (also used as terminal tab title)"
/>
<div className="add-path-row">
<input
className="api-key-input small"
value={pathValue}
onChange={(e) => setPathValue(e.target.value)}
placeholder="Folder path"
/>
<button className="secondary" onClick={handleBrowse} disabled={busy}>Browse</button>
</div>
<ColorPicker value={color} onChange={setColor} recentColors={recentColors} />
<div className="add-actions">
<button className="primary" onClick={handleAdd} disabled={!canAdd}>Add</button>
{onCancel && (
<button className="secondary" onClick={onCancel} disabled={busy}>Cancel</button>
)}
</div>
</div>
);
}
+335
View File
@@ -0,0 +1,335 @@
import React, { useEffect, useRef, useState } from 'react';
export default function BookmarkRow({
bookmark,
columns,
claudeAvailable,
inspectRevision,
recentColors,
buttonVisibility,
canMoveUp,
canMoveDown,
onEdit,
onRemove,
onMoveUp,
onMoveDown,
}) {
const showClaude = !buttonVisibility || buttonVisibility.claude !== false;
const showTerminal = !buttonVisibility || buttonVisibility.terminal !== false;
const showRedeploy = !buttonVisibility || buttonVisibility.redeploy !== false;
const [mode, setMode] = useState('view');
const [menuOpen, setMenuOpen] = useState(false);
const [inspect, setInspect] = useState({
checked: false,
slnPath: null,
redeployPath: null,
isNetwork: false,
});
const [status, setStatus] = useState(null);
const menuRef = useRef(null);
useEffect(() => {
let cancelled = false;
setInspect({ checked: false, slnPath: null, redeployPath: null, isNetwork: false });
window.bookmarks.inspectFolder(bookmark.path).then((res) => {
if (cancelled) return;
setInspect({
checked: true,
slnPath: res ? res.slnPath : null,
redeployPath: res ? res.redeployPath : null,
isNetwork: !!(res && res.isNetwork),
});
});
return () => { cancelled = true; };
}, [bookmark.path, inspectRevision]);
useEffect(() => {
if (!menuOpen) return undefined;
const onClick = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [menuOpen]);
const flash = (kind, text) => {
setStatus({ kind, text });
setTimeout(() => setStatus(null), 2200);
};
const runPathAction = async (fn, label) => {
const res = await fn(bookmark.path);
if (res && res.ok === false) {
flash('error', `${label}: ${res.error || 'failed'}`);
} else {
flash('info', `${label}`);
}
};
const runTabAction = async (fn, label) => {
const res = await fn({
targetPath: bookmark.path,
alias: bookmark.alias,
color: bookmark.color,
});
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}: opened new tab`);
}
};
const handleCopy = async () => {
setMenuOpen(false);
const res = await window.bookmarks.copyPath(bookmark.path);
if (res && res.ok === false) flash('error', res.error || 'Copy failed');
else flash('info', 'Path copied');
};
const handleRemove = () => {
setMenuOpen(false);
if (window.confirm(`Remove bookmark "${bookmark.alias}"?`)) onRemove(bookmark.id);
};
const startEdit = () => {
setMenuOpen(false);
setMode('edit');
};
if (mode === 'edit') {
return (
<EditForm
bookmark={bookmark}
columns={columns}
recentColors={recentColors}
redeployPath={inspect.redeployPath}
onCancel={() => setMode('view')}
onSave={async ({ alias, path, color, columnId, hideDeploy }) => {
await onEdit({ id: bookmark.id, alias, path, color, columnId, hideDeploy });
setMode('view');
}}
/>
);
}
const stripeStyle = bookmark.color ? { borderLeftColor: bookmark.color } : undefined;
return (
<li className={`bookmark-row${bookmark.color ? ' has-color' : ''}`} style={stripeStyle}>
<div className="bookmark-row-head">
<div className="bookmark-meta">
<strong className="bookmark-alias">{bookmark.alias}</strong>
<span className="bookmark-path" title={bookmark.path}>{bookmark.path}</span>
</div>
<div className="bookmark-overflow" ref={menuRef}>
<button
className="icon-button"
title="More"
onClick={() => setMenuOpen((v) => !v)}
></button>
{menuOpen && (
<div className="bookmark-menu">
<button
onClick={() => { setMenuOpen(false); onMoveUp(); }}
disabled={!canMoveUp}
>Move up</button>
<button
onClick={() => { setMenuOpen(false); onMoveDown(); }}
disabled={!canMoveDown}
>Move down</button>
<button onClick={handleCopy}>Copy path</button>
<button onClick={startEdit}>Edit</button>
<button className="danger" onClick={handleRemove}>Remove</button>
</div>
)}
</div>
</div>
<div className="bookmark-actions">
<button
className="icon-button"
title={inspect.isNetwork ? 'Open network share in Explorer' : 'Open in Explorer'}
onClick={() => runPathAction(window.bookmarks.openInExplorer, 'Explorer')}
>📁</button>
{showTerminal && (
<button
className="icon-button"
title={`Open Terminal for "${bookmark.alias}" (focuses existing tab if found)`}
onClick={() => runTabAction(window.bookmarks.openTerminal, 'Terminal')}
>&gt;_</button>
)}
{!inspect.isNetwork && (
<>
{showClaude && (
<button
className="icon-button"
title={
claudeAvailable
? `Open Claude for "${bookmark.alias}" (focuses existing tab if found)`
: 'Claude CLI not found on PATH — install @anthropic-ai/claude-code'
}
disabled={!claudeAvailable}
onClick={() => runTabAction(window.bookmarks.openClaude, 'Claude')}
>C</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>
{status && (
<div className={`row-status ${status.kind === 'error' ? 'error' : 'info'}`}>
{status.text}
</div>
)}
</li>
);
}
function EditForm({ bookmark, columns, recentColors, redeployPath, onSave, onCancel }) {
const [alias, setAlias] = useState(bookmark.alias);
const [pathValue, setPathValue] = useState(bookmark.path);
const [color, setColor] = useState(bookmark.color || '');
const [columnId, setColumnId] = useState(bookmark.columnId || (columns[0] && columns[0].id) || '');
const [hideDeploy, setHideDeploy] = useState(!!bookmark.hideDeploy);
const [busy, setBusy] = useState(false);
const handleBrowse = async () => {
const picked = await window.bookmarks.pickFolder();
if (picked) setPathValue(picked);
};
const handleSave = async () => {
if (!alias.trim() || !pathValue.trim()) return;
setBusy(true);
try {
await onSave({
alias: alias.trim(),
path: pathValue.trim(),
color: color || null,
columnId: columnId || null,
hideDeploy,
});
} finally {
setBusy(false);
}
};
return (
<li className="bookmark-row editing" style={color ? { borderLeftColor: color } : undefined}>
<div className="bookmark-edit">
<label className="edit-label">Alias</label>
<input
className="api-key-input small"
value={alias}
onChange={(e) => setAlias(e.target.value)}
placeholder="Display name (also used as terminal tab title)"
/>
<label className="edit-label">Path</label>
<div className="edit-path-row">
<input
className="api-key-input small"
value={pathValue}
onChange={(e) => setPathValue(e.target.value)}
placeholder="C:\\path\\to\\folder"
/>
<button className="secondary" onClick={handleBrowse}>Browse</button>
</div>
<ColorPicker value={color} onChange={setColor} recentColors={recentColors} />
{columns.length > 1 && (
<ColumnPicker columns={columns} value={columnId} onChange={setColumnId} />
)}
{redeployPath && (
<label className="edit-toggle">
<input
type="checkbox"
checked={hideDeploy}
onChange={(e) => setHideDeploy(e.target.checked)}
/>
<span>Hide deploy button</span>
</label>
)}
<div className="edit-actions">
<button
className="primary"
onClick={handleSave}
disabled={busy || !alias.trim() || !pathValue.trim()}
>Save</button>
<button className="secondary" onClick={onCancel} disabled={busy}>Cancel</button>
</div>
</div>
</li>
);
}
function ColorPicker({ value, onChange, recentColors = [] }) {
const valueLower = value ? value.toLowerCase() : '';
return (
<div className="color-picker-row">
<label className="edit-label">Tab color</label>
<div className="color-picker-controls">
<input
type="color"
className="color-swatch"
value={value || '#d97757'}
onChange={(e) => onChange(e.target.value)}
aria-label="Pick tab color"
/>
<span className="color-hex">{value || '(none)'}</span>
{value && (
<button className="link-button" type="button" onClick={() => onChange('')}>Clear</button>
)}
</div>
{recentColors.length > 0 && (
<div className="color-recent-row" role="listbox" aria-label="Previously used colors">
{recentColors.map((c) => (
<button
key={c}
type="button"
className={`recent-swatch${valueLower === c ? ' active' : ''}`}
style={{ background: c }}
title={c}
aria-label={`Use ${c}`}
onClick={() => onChange(c)}
/>
))}
</div>
)}
</div>
);
}
function ColumnPicker({ columns, value, onChange }) {
return (
<div className="column-picker-row">
<label className="edit-label">Column</label>
<select
className="api-key-input small"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
>
{columns.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
);
}
export { ColorPicker };
+215
View File
@@ -0,0 +1,215 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import BookmarkRow from './BookmarkRow.jsx';
import AddBookmarkForm from './AddBookmarkForm.jsx';
export default function Column({
column,
columns,
tabs,
bookmarks,
claudeAvailable,
inspectRevision,
recentColors,
buttonVisibility,
canMoveLeft,
canMoveRight,
onAdd,
onEdit,
onRemove,
onMoveBookmark,
onRenameColumn,
onRemoveColumn,
onMoveColumnToTab,
onMoveLeft,
onMoveRight,
onRequestConfirm,
}) {
const [renaming, setRenaming] = useState(false);
const [draftName, setDraftName] = useState(column.name);
const [menuOpen, setMenuOpen] = useState(false);
const [moveTabOpen, setMoveTabOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const menuRef = useRef(null);
const handleAddSubmitted = useCallback(async (payload) => {
await onAdd(payload);
setAddOpen(false);
}, [onAdd]);
useEffect(() => { setDraftName(column.name); }, [column.name]);
useEffect(() => {
if (!menuOpen) return undefined;
const onClick = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
setMenuOpen(false);
setMoveTabOpen(false);
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [menuOpen]);
const submitRename = async () => {
const name = draftName.trim();
if (!name || name === column.name) { setRenaming(false); setDraftName(column.name); return; }
await onRenameColumn(column.id, name);
setRenaming(false);
};
const handleDelete = () => {
setMenuOpen(false);
const count = bookmarks.length;
const otherCols = columns.filter((c) => c.id !== column.id);
const ask = onRequestConfirm || ((opts) => { if (window.confirm(`${opts.title}\n\n${opts.message || ''}`)) opts.onConfirm(); });
if (count === 0) {
ask({
title: `Delete column "${column.name}"?`,
message: 'This column has no bookmarks. It will be removed.',
confirmLabel: 'Delete column',
confirmKind: 'danger',
onConfirm: () => onRemoveColumn(column.id),
});
return;
}
if (otherCols.length === 0) {
const word = count === 1 ? 'bookmark' : 'bookmarks';
ask({
title: `Delete column "${column.name}"?`,
message: `This will permanently delete ${count} ${word}.`,
confirmLabel: 'Delete everything',
confirmKind: 'danger',
onConfirm: () => onRemoveColumn(column.id),
});
return;
}
const target = otherCols[0];
const word = count === 1 ? 'bookmark' : 'bookmarks';
ask({
title: `Delete column "${column.name}"?`,
message: `This column has ${count} ${word}. Move them to "${target.name}", or delete everything?`,
confirmLabel: `Move to "${target.name}"`,
confirmKind: 'primary',
extraActions: [
{
label: 'Delete with bookmarks',
kind: 'danger',
onClick: () => onRemoveColumn(column.id),
},
],
onConfirm: () => onRemoveColumn(column.id, target.id),
});
};
const otherTabs = (tabs || []).filter((t) => t.id !== column.tabId);
return (
<section className="column">
<header className="column-header">
{!renaming ? (
<h2
className="column-title"
title="Click to rename"
onClick={() => setRenaming(true)}
>{column.name}</h2>
) : (
<input
autoFocus
className="api-key-input small column-rename-input"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onBlur={submitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename();
else if (e.key === 'Escape') { setRenaming(false); setDraftName(column.name); }
}}
/>
)}
<div className="column-overflow" ref={menuRef}>
<button
className="icon-button"
title="Column options"
onClick={() => { setMenuOpen((v) => !v); setMoveTabOpen(false); }}
></button>
{menuOpen && (
<div className="bookmark-menu">
<button onClick={() => { setMenuOpen(false); setRenaming(true); }}>Rename</button>
<button
onClick={() => { setMenuOpen(false); onMoveLeft(); }}
disabled={!canMoveLeft}
>Move left</button>
<button
onClick={() => { setMenuOpen(false); onMoveRight(); }}
disabled={!canMoveRight}
>Move right</button>
{otherTabs.length > 0 && onMoveColumnToTab && (
moveTabOpen ? (
<div className="bookmark-submenu">
<div className="bookmark-submenu-label">Move to tab</div>
{otherTabs.map((t) => (
<button
key={t.id}
onClick={() => {
setMoveTabOpen(false);
setMenuOpen(false);
onMoveColumnToTab(column.id, t.id);
}}
>{t.name}</button>
))}
<button
className="submenu-back"
onClick={() => setMoveTabOpen(false)}
> Back</button>
</div>
) : (
<button onClick={() => setMoveTabOpen(true)}>Move to tab </button>
)
)}
<button className="danger" onClick={handleDelete}>Delete column</button>
</div>
)}
</div>
</header>
{bookmarks.length === 0 ? (
<div className="empty-state column-empty">No bookmarks here yet.</div>
) : (
<ul className="bookmark-list">
{bookmarks.map((b, idx) => (
<BookmarkRow
key={b.id}
bookmark={b}
columns={columns}
claudeAvailable={claudeAvailable}
inspectRevision={inspectRevision}
recentColors={recentColors}
buttonVisibility={buttonVisibility}
canMoveUp={idx > 0}
canMoveDown={idx < bookmarks.length - 1}
onEdit={onEdit}
onRemove={onRemove}
onMoveUp={() => onMoveBookmark(b.id, -1)}
onMoveDown={() => onMoveBookmark(b.id, 1)}
/>
))}
</ul>
)}
{addOpen ? (
<AddBookmarkForm
columnId={column.id}
recentColors={recentColors}
onAdd={handleAddSubmitted}
onCancel={() => setAddOpen(false)}
/>
) : (
<button
type="button"
className="add-bookmark-trigger"
title="Add bookmark"
onClick={() => setAddOpen(true)}
>+</button>
)}
</section>
);
}
+57
View File
@@ -0,0 +1,57 @@
import React, { useEffect } from 'react';
export default function ConfirmDialog({
title,
message,
confirmLabel = 'Confirm',
confirmKind = 'primary',
cancelLabel = 'Cancel',
extraActions = [],
onConfirm,
onCancel,
}) {
// Esc cancels; Enter confirms unless focus is on a different action.
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
} else if (e.key === 'Enter') {
e.preventDefault();
onConfirm();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onConfirm, onCancel]);
return (
<div className="confirm-overlay" onClick={onCancel}>
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
<div className="confirm-title">{title}</div>
{message && <div className="confirm-message">{message}</div>}
<div className="confirm-actions">
<button
className="secondary"
onClick={onCancel}
type="button"
>{cancelLabel}</button>
{extraActions.map((a, idx) => (
<button
key={idx}
className={a.kind === 'danger' ? 'primary danger-button' : 'secondary'}
onClick={() => { a.onClick(); onCancel(); }}
type="button"
>{a.label}</button>
))}
<button
autoFocus
className={confirmKind === 'danger' ? 'primary danger-button' : 'primary'}
onClick={() => { onConfirm(); onCancel(); }}
type="button"
>{confirmLabel}</button>
</div>
</div>
</div>
);
}
+308
View File
@@ -0,0 +1,308 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Column from './Column.jsx';
import TabBar from './TabBar.jsx';
import ConfirmDialog from './ConfirmDialog.jsx';
const COLUMN_WIDTH = 216;
// Padding budget so the popup doesn't clip the tab strip: popup-inner has
// 14px each side and the tab-bar adds 2px breathing room on either side.
const TAB_STRIP_PADDING = 32;
export default function Popup({
tabs,
activeTabId,
columns,
bookmarks,
pinned,
claudeAvailable,
inspectRevision,
recentColors,
buttonVisibility,
onSetButtonVisibility,
autoStart,
onSetAutoStart,
onTogglePin,
onAdd,
onEdit,
onRemove,
onMoveBookmark,
onAddColumn,
onRenameColumn,
onRemoveColumn,
onReorderColumns,
onMoveColumnToTab,
onAddTab,
onRenameTab,
onRemoveTab,
onReorderTabs,
onSelectTab,
}) {
const rootRef = useRef(null);
const heightDebounceRef = useRef(null);
const settingsRef = useRef(null);
const tabStripRef = useRef(null);
const [creatingColumn, setCreatingColumn] = useState(false);
const [draftColumnName, setDraftColumnName] = useState('');
const [settingsOpen, setSettingsOpen] = useState(false);
const [confirmRequest, setConfirmRequest] = useState(null);
const [tabStripScrollWidth, setTabStripScrollWidth] = useState(0);
const requestConfirm = useCallback((opts) => {
setConfirmRequest(opts);
}, []);
useEffect(() => {
if (!settingsOpen) return undefined;
const onClick = (e) => {
if (settingsRef.current && !settingsRef.current.contains(e.target)) setSettingsOpen(false);
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [settingsOpen]);
// Columns belonging to the active tab. Keep stable order from the global
// columns array so cross-tab moves don't shuffle siblings.
const visibleColumns = useMemo(
() => columns.filter((c) => c.tabId === activeTabId),
[columns, activeTabId],
);
// Auto-fit window height to inner content (capped in main).
useEffect(() => {
if (!rootRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const h = entry.target.offsetHeight;
if (heightDebounceRef.current) clearTimeout(heightDebounceRef.current);
heightDebounceRef.current = setTimeout(() => {
window.bookmarks.setPopupHeight(h);
}, 100);
}
});
observer.observe(rootRef.current);
return () => {
observer.disconnect();
if (heightDebounceRef.current) clearTimeout(heightDebounceRef.current);
};
}, []);
// Watch the tab strip's scrollWidth so renames or new tabs widen the popup.
// ResizeObserver picks up both layout changes (window width changes) and
// content changes (a tab rename growing the strip).
useEffect(() => {
if (!tabStripRef.current) return undefined;
const update = () => {
const el = tabStripRef.current;
if (!el) return;
setTabStripScrollWidth(el.scrollWidth);
};
update();
const observer = new ResizeObserver(update);
observer.observe(tabStripRef.current);
// Re-measure after children mutate (tabs added / renamed) so growth shows
// up even when the strip element itself hasn't resized yet.
const mutation = new MutationObserver(update);
mutation.observe(tabStripRef.current, { childList: true, subtree: true, characterData: true });
return () => { observer.disconnect(); mutation.disconnect(); };
}, []);
// Resize window width to max(columns-required, tabs-required). Main clamps
// to the screen's work area, so wide tab strips either fit (most monitors)
// or scroll horizontally inside the popup once they would push it off-screen.
useEffect(() => {
const colsWidth = Math.max(1, visibleColumns.length || 1) * COLUMN_WIDTH;
const tabsWidth = tabStripScrollWidth > 0 ? tabStripScrollWidth + TAB_STRIP_PADDING : 0;
window.bookmarks.setPopupWidth(Math.max(colsWidth, tabsWidth));
}, [visibleColumns.length, tabStripScrollWidth]);
const submitNewColumn = async () => {
const name = draftColumnName.trim();
if (!name) { setCreatingColumn(false); setDraftColumnName(''); return; }
await onAddColumn(name);
setCreatingColumn(false);
setDraftColumnName('');
};
const moveColumn = (id, delta) => {
// Reorder within the active tab only — convert local delta into a global
// ids array that preserves all other tabs' columns in place.
const visibleIds = visibleColumns.map((c) => c.id);
const idx = visibleIds.indexOf(id);
const target = idx + delta;
if (idx < 0 || target < 0 || target >= visibleIds.length) return;
const swappedVisible = visibleIds.slice();
[swappedVisible[idx], swappedVisible[target]] = [swappedVisible[target], swappedVisible[idx]];
const visibleSet = new Set(visibleIds);
let visiblePtr = 0;
const next = columns.map((c) => {
if (visibleSet.has(c.id)) {
const replacementId = swappedVisible[visiblePtr++];
return replacementId;
}
return c.id;
});
onReorderColumns(next);
};
const hasNoTabs = tabs.length === 0;
const hasNoColumns = !hasNoTabs && visibleColumns.length === 0;
const canAddColumn = !hasNoTabs && !!activeTabId;
return (
<div className="popup">
<div className="popup-inner" ref={rootRef}>
<div className="popup-header drag-region">
<h1>Folder Bookmarks</h1>
<div className="header-actions">
{!creatingColumn ? (
<button
className="icon-button"
onClick={() => setCreatingColumn(true)}
title={canAddColumn ? 'Add a new column to this tab' : 'Add a tab first'}
disabled={!canAddColumn}
>+</button>
) : (
<div className="header-add-column">
<input
autoFocus
className="api-key-input small"
value={draftColumnName}
onChange={(e) => setDraftColumnName(e.target.value)}
placeholder="Column name"
onKeyDown={(e) => {
if (e.key === 'Enter') submitNewColumn();
else if (e.key === 'Escape') {
setCreatingColumn(false);
setDraftColumnName('');
}
}}
/>
<button className="icon-button" onClick={submitNewColumn} title="Add"></button>
<button
className="icon-button"
onClick={() => { setCreatingColumn(false); setDraftColumnName(''); }}
title="Cancel"
>×</button>
</div>
)}
<div className="popup-settings" ref={settingsRef}>
<button
className={`icon-button ${settingsOpen ? 'active' : ''}`}
onClick={() => setSettingsOpen((v) => !v)}
title="Display settings"
></button>
{settingsOpen && (
<div className="settings-panel">
<div className="settings-panel-label">Row 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">
<input
type="checkbox"
checked={buttonVisibility.terminal !== false}
onChange={(e) => onSetButtonVisibility({ terminal: e.target.checked })}
/>
<span>Show Terminal button</span>
</label>
<label className="edit-toggle">
<input
type="checkbox"
checked={buttonVisibility.redeploy !== false}
onChange={(e) => onSetButtonVisibility({ redeploy: e.target.checked })}
/>
<span>Quick Run Redeploy</span>
</label>
<div className="settings-panel-label">Startup</div>
<label className="edit-toggle">
<input
type="checkbox"
checked={autoStart !== false}
onChange={(e) => onSetAutoStart(e.target.checked)}
/>
<span>Start with Windows</span>
</label>
</div>
)}
</div>
<button
className={`icon-button ${pinned ? 'active' : ''}`}
onClick={onTogglePin}
title={pinned ? 'Unpin (close on focus loss)' : 'Pin (keep open)'}
>
{pinned ? '📌' : '📍'}
</button>
</div>
</div>
<TabBar
tabs={tabs}
activeTabId={activeTabId}
columns={columns}
stripRef={tabStripRef}
onSelect={onSelectTab}
onAdd={onAddTab}
onRename={onRenameTab}
onRemove={onRemoveTab}
onReorder={onReorderTabs}
onRequestConfirm={requestConfirm}
/>
{hasNoTabs ? (
<div className="empty-state">
No tabs yet click <strong>+</strong> in the tab bar to create your first.
</div>
) : hasNoColumns ? (
<div className="empty-state">
No columns in this tab click <strong>+</strong> in the header to add one.
</div>
) : (
<div className="columns-row">
{visibleColumns.map((col, idx) => (
<Column
key={col.id}
column={col}
columns={visibleColumns}
tabs={tabs}
index={idx}
bookmarks={bookmarks.filter((b) => b.columnId === col.id)}
claudeAvailable={claudeAvailable}
inspectRevision={inspectRevision}
recentColors={recentColors}
buttonVisibility={buttonVisibility}
canMoveLeft={idx > 0}
canMoveRight={idx < visibleColumns.length - 1}
onAdd={onAdd}
onEdit={onEdit}
onRemove={onRemove}
onMoveBookmark={onMoveBookmark}
onRenameColumn={onRenameColumn}
onRemoveColumn={onRemoveColumn}
onMoveColumnToTab={onMoveColumnToTab}
onMoveLeft={() => moveColumn(col.id, -1)}
onMoveRight={() => moveColumn(col.id, 1)}
onRequestConfirm={requestConfirm}
/>
))}
</div>
)}
</div>
{confirmRequest && (
<ConfirmDialog
{...confirmRequest}
onCancel={() => setConfirmRequest(null)}
onConfirm={() => {
const fn = confirmRequest.onConfirm;
setConfirmRequest(null);
if (typeof fn === 'function') fn();
}}
/>
)}
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
import React, { useEffect, useRef, useState } from 'react';
export default function TabBar({
tabs,
activeTabId,
columns,
stripRef,
onSelect,
onAdd,
onRename,
onRemove,
onReorder,
onRequestConfirm,
}) {
const [creating, setCreating] = useState(false);
const [draftName, setDraftName] = useState('');
const submitNew = async () => {
const name = draftName.trim();
if (!name) { setCreating(false); setDraftName(''); return; }
await onAdd(name);
setCreating(false);
setDraftName('');
};
const moveTab = (id, delta) => {
const ids = tabs.map((t) => t.id);
const idx = ids.indexOf(id);
const target = idx + delta;
if (idx < 0 || target < 0 || target >= ids.length) return;
const next = ids.slice();
[next[idx], next[target]] = [next[target], next[idx]];
onReorder(next);
};
return (
<div className="tab-bar">
<div className="tab-strip" ref={stripRef}>
{tabs.map((t, idx) => (
<Tab
key={t.id}
tab={t}
active={t.id === activeTabId}
columnCount={columns.filter((c) => c.tabId === t.id).length}
tabs={tabs}
canMoveLeft={idx > 0}
canMoveRight={idx < tabs.length - 1}
onSelect={() => onSelect(t.id)}
onRename={(name) => onRename(t.id, name)}
onRemove={(reassignTo) => onRemove(t.id, reassignTo)}
onMoveLeft={() => moveTab(t.id, -1)}
onMoveRight={() => moveTab(t.id, 1)}
onRequestConfirm={onRequestConfirm}
/>
))}
{creating ? (
<div className="tab-add-input">
<input
autoFocus
className="api-key-input small"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="Tab name"
onBlur={submitNew}
onKeyDown={(e) => {
if (e.key === 'Enter') submitNew();
else if (e.key === 'Escape') { setCreating(false); setDraftName(''); }
}}
/>
</div>
) : (
<button
type="button"
className="tab-add-trigger"
title="Add a new tab"
onClick={() => setCreating(true)}
>+</button>
)}
</div>
</div>
);
}
function Tab({ tab, active, columnCount, tabs, canMoveLeft, canMoveRight, onSelect, onRename, onRemove, onMoveLeft, onMoveRight, onRequestConfirm }) {
const [renaming, setRenaming] = useState(false);
const [draftName, setDraftName] = useState(tab.name);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef(null);
useEffect(() => { setDraftName(tab.name); }, [tab.name]);
useEffect(() => {
if (!menuOpen) return undefined;
const onClick = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [menuOpen]);
const submitRename = async () => {
const name = draftName.trim();
if (!name || name === tab.name) { setRenaming(false); setDraftName(tab.name); return; }
await onRename(name);
setRenaming(false);
};
const handleDelete = () => {
setMenuOpen(false);
const otherTabs = tabs.filter((t) => t.id !== tab.id);
if (columnCount === 0) {
onRequestConfirm({
title: `Delete tab "${tab.name}"?`,
message: 'This tab has no columns. It will be removed.',
confirmLabel: 'Delete tab',
confirmKind: 'danger',
onConfirm: () => onRemove(null),
});
return;
}
if (otherTabs.length === 0) {
const colWord = columnCount === 1 ? 'column' : 'columns';
onRequestConfirm({
title: `Delete tab "${tab.name}"?`,
message: `This will permanently delete ${columnCount} ${colWord} and every bookmark inside.`,
confirmLabel: 'Delete everything',
confirmKind: 'danger',
onConfirm: () => onRemove(null),
});
return;
}
const target = otherTabs[0];
const colWord = columnCount === 1 ? 'column' : 'columns';
onRequestConfirm({
title: `Delete tab "${tab.name}"?`,
message: `This tab has ${columnCount} ${colWord}. Move them to "${target.name}", or delete everything?`,
confirmLabel: `Move to "${target.name}"`,
confirmKind: 'primary',
extraActions: [
{
label: 'Delete with bookmarks',
kind: 'danger',
onClick: () => onRemove(null),
},
],
onConfirm: () => onRemove(target.id),
});
};
if (renaming) {
return (
<div className={`tab${active ? ' active' : ''} renaming`}>
<input
autoFocus
className="api-key-input small tab-rename-input"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onBlur={submitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename();
else if (e.key === 'Escape') { setRenaming(false); setDraftName(tab.name); }
}}
/>
</div>
);
}
return (
<div className={`tab${active ? ' active' : ''}`}>
<button
type="button"
className="tab-label"
title={`Switch to "${tab.name}"`}
onClick={onSelect}
onDoubleClick={() => setRenaming(true)}
>{tab.name}</button>
<span className="tab-overflow" ref={menuRef}>
<button
type="button"
className="tab-rename-btn"
title="Rename tab"
onClick={(e) => { e.stopPropagation(); setRenaming(true); }}
></button>
<button
type="button"
className="tab-close-btn"
title="Delete tab"
onClick={(e) => { e.stopPropagation(); handleDelete(); }}
>×</button>
<button
type="button"
className="tab-menu-trigger"
title="More options"
onClick={(e) => { e.stopPropagation(); setMenuOpen((v) => !v); }}
></button>
{menuOpen && (
<div className="bookmark-menu tab-menu">
<button onClick={() => { setMenuOpen(false); setRenaming(true); }}>Rename</button>
<button onClick={() => { setMenuOpen(false); onMoveLeft(); }} disabled={!canMoveLeft}>Move left</button>
<button onClick={() => { setMenuOpen(false); onMoveRight(); }} disabled={!canMoveRight}>Move right</button>
<button className="danger" onClick={handleDelete}>Delete tab</button>
</div>
)}
</span>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles/index.css';
createRoot(document.getElementById('root')).render(<App />);
+800
View File
@@ -0,0 +1,800 @@
:root {
--bg: #1a1a1c;
--bg-elevated: #232327;
--bg-input: #2c2c30;
--border: #34343a;
--text: #f0f0f2;
--text-dim: #9a9aa3;
--accent: #d97757;
--accent-hover: #e08a6f;
--error: #ef6b6b;
--warn: #e6b450;
--ok: #7fc97f;
--radius: 6px;
}
* { box-sizing: border-box; }
html, body, #root {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, "Segoe UI", system-ui, Roboto, sans-serif;
font-size: 13px;
-webkit-user-select: none;
user-select: none;
}
html, body { height: 100%; }
#root { min-height: 100%; }
.app {
display: flex;
flex-direction: column;
min-height: 100%;
}
.app.loading {
align-items: center;
justify-content: center;
height: 100vh;
}
/* --- Popup chrome --- */
/* Scroll container: clamps to window height. ResizeObserver listens on
.popup-inner so the auto-fit logic sees true content height even when
the popup is being scroll-clamped to the cap. */
.popup {
display: flex;
flex-direction: column;
height: 100vh;
overflow: auto;
}
.popup-inner {
display: flex;
flex-direction: column;
padding: 12px 14px;
gap: 10px;
}
/* --- Columns layout --- */
.columns-row {
display: flex;
flex-direction: row;
gap: 12px;
align-items: flex-start;
/* When column count exceeds the visible cap, the popup width is fixed by
main and this row scrolls horizontally. */
overflow-x: auto;
}
.column {
display: flex;
flex-direction: column;
gap: 8px;
flex: 0 0 auto;
/* 60% of the original 320px column. Tuned to fit inside the 216 / 432 /
648 popup widths after popup-inner's 28px horizontal padding. */
width: 188px;
min-width: 188px;
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 2px 6px 2px;
border-bottom: 1px solid var(--border);
}
.column-title {
margin: 0;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-dim);
cursor: pointer;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.column-title:hover { color: var(--text); }
.column-rename-input {
flex: 1;
font-size: 12px;
padding: 4px 6px;
}
.column-overflow {
position: relative;
flex: 0 0 auto;
}
.column-empty {
font-size: 11px;
padding: 10px 8px;
}
.header-add-column {
display: flex;
gap: 4px;
align-items: center;
}
.header-add-column .api-key-input {
width: 110px;
}
.column-picker-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.column-picker-row select {
cursor: pointer;
}
.edit-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
user-select: none;
}
.edit-toggle input { cursor: pointer; }
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.popup-header h1 {
font-size: 14px;
font-weight: 600;
margin: 0;
letter-spacing: 0.2px;
}
.header-actions {
display: flex;
gap: 6px;
}
.icon-button {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
width: 26px;
height: 26px;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.icon-button:hover:not(:disabled) {
background: var(--bg-elevated);
border-color: var(--accent);
}
.icon-button.active {
background: rgba(217, 119, 87, 0.18);
border-color: var(--accent);
color: var(--accent);
}
.icon-button:disabled {
opacity: 0.4;
cursor: default;
}
.drag-region { -webkit-app-region: drag; }
.drag-region .icon-button,
.drag-region .header-actions,
.drag-region button {
-webkit-app-region: no-drag;
}
button.primary, button.secondary {
padding: 7px 10px;
border-radius: var(--radius);
border: 1px solid var(--border);
font-size: 12px;
cursor: pointer;
}
button.primary {
background: var(--accent);
color: #1a1a1c;
border-color: var(--accent);
font-weight: 600;
}
button.primary:hover:not(:disabled) { background: var(--accent-hover); }
button.secondary {
background: transparent;
color: var(--text);
}
button.secondary:hover:not(:disabled) { background: var(--bg-elevated); }
button:disabled { opacity: 0.5; cursor: default; }
.api-key-input {
width: 100%;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: var(--radius);
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 12px;
-webkit-user-select: text;
user-select: text;
}
.api-key-input:focus { outline: none; border-color: var(--accent); }
.api-key-input.small { font-size: 12px; padding: 6px 8px; }
.spinner {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--border);
border-top-color: var(--accent);
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state {
color: var(--text-dim);
font-size: 12px;
padding: 16px 8px;
text-align: center;
background: var(--bg-elevated);
border: 1px dashed var(--border);
border-radius: var(--radius);
}
/* --- Bookmark list & rows --- */
.bookmark-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.bookmark-row {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-left: 4px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
/* When a bookmark has a color set, BookmarkRow applies inline borderLeftColor;
.has-color is just a hook for future styling. */
.bookmark-row.has-color {
/* inline style sets borderLeftColor */
}
.bookmark-row-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.bookmark-meta {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
gap: 2px;
}
.bookmark-alias {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-path {
font-size: 10px;
color: var(--text-dim);
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-overflow {
position: relative;
flex: 0 0 auto;
}
.bookmark-menu {
position: absolute;
right: 0;
top: 30px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
min-width: 120px;
z-index: 10;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.bookmark-menu button {
background: transparent;
border: none;
color: var(--text);
padding: 7px 10px;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.bookmark-menu button:hover { background: rgba(217, 119, 87, 0.12); }
.bookmark-menu button.danger { color: var(--error); }
.bookmark-menu button.danger:hover { background: rgba(239, 107, 107, 0.12); }
.bookmark-actions {
display: flex;
gap: 4px;
}
.bookmark-actions .icon-button {
flex: 1;
width: auto;
height: 28px;
font-size: 12px;
font-weight: 600;
}
.row-status {
font-size: 11px;
padding: 4px 6px;
border-radius: 4px;
}
.row-status.error {
background: rgba(239, 107, 107, 0.12);
color: var(--error);
}
.row-status.info {
background: rgba(127, 201, 127, 0.12);
color: var(--ok);
}
/* Edit form (in-row) */
.bookmark-row.editing {
border-color: var(--accent);
background: rgba(217, 119, 87, 0.06);
}
.bookmark-edit {
display: flex;
flex-direction: column;
gap: 6px;
}
.edit-label {
font-size: 11px;
color: var(--text-dim);
font-weight: 500;
}
.edit-path-row {
display: flex;
gap: 6px;
}
.edit-path-row .api-key-input { flex: 1; min-width: 0; }
.edit-path-row .secondary { flex: 0 0 auto; }
.edit-actions {
display: flex;
gap: 6px;
margin-top: 2px;
}
.edit-actions button { flex: 1; }
/* Add bookmark form */
.add-bookmark {
display: flex;
flex-direction: column;
gap: 6px;
border: 1px dashed var(--border);
border-radius: var(--radius);
padding: 8px;
}
.add-bookmark-label {
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.4px;
font-weight: 600;
}
.add-path-row {
display: flex;
gap: 6px;
}
.add-path-row .api-key-input { flex: 1; min-width: 0; }
.add-path-row .secondary { flex: 0 0 auto; }
.add-actions {
display: flex;
gap: 6px;
}
.add-actions button { flex: 1; }
.add-bookmark-trigger {
background: transparent;
border: 1px dashed var(--border);
color: var(--text-dim);
border-radius: var(--radius);
padding: 8px 0;
font-size: 16px;
line-height: 1;
cursor: pointer;
width: 100%;
}
.add-bookmark-trigger:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(217, 119, 87, 0.08);
}
.color-picker-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.color-picker-controls {
display: flex;
align-items: center;
gap: 8px;
}
.color-swatch {
width: 32px;
height: 24px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-input);
cursor: pointer;
padding: 0;
}
.color-hex {
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 11px;
color: var(--text-dim);
}
.color-recent-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.recent-swatch {
width: 18px;
height: 18px;
border-radius: 4px;
border: 1px solid var(--border);
cursor: pointer;
padding: 0;
transition: transform 0.1s ease, border-color 0.1s ease;
}
.recent-swatch:hover {
border-color: var(--accent);
transform: scale(1.1);
}
.recent-swatch.active {
border-color: var(--text);
box-shadow: 0 0 0 1px var(--text);
}
.link-button {
background: transparent;
color: var(--accent);
border: none;
cursor: pointer;
font-size: 11px;
padding: 0;
text-decoration: underline;
}
/* --- Tab bar --- */
.tab-bar {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
margin: 0 -2px;
}
.tab-strip {
display: flex;
flex-direction: row;
gap: 2px;
align-items: center;
flex: 1;
overflow-x: auto;
padding-bottom: 1px;
}
.tab {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 0 2px 0 8px;
border: 1px solid var(--border);
border-bottom: none;
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
background: var(--bg);
color: var(--text-dim);
font-size: 12px;
height: 26px;
flex: 0 0 auto;
position: relative;
margin-bottom: -1px;
}
.tab:hover { color: var(--text); }
.tab.active {
background: var(--bg-elevated);
border-color: var(--accent);
color: var(--text);
z-index: 1;
}
.tab.renaming {
padding: 0 4px;
}
.tab-label {
background: transparent;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0 4px;
height: 100%;
max-width: 140px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-rename-input {
height: 22px;
font-size: 12px;
width: 100px;
}
.tab-overflow {
position: relative;
display: inline-flex;
}
.tab-rename-btn,
.tab-close-btn,
.tab-menu-trigger {
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
width: 20px;
height: 22px;
border-radius: 3px;
padding: 0;
font-size: 12px;
line-height: 1;
}
.tab-rename-btn:hover,
.tab-menu-trigger:hover {
background: rgba(217, 119, 87, 0.12);
color: var(--accent);
}
.tab-close-btn { font-size: 16px; }
.tab-close-btn:hover {
background: rgba(239, 107, 107, 0.18);
color: var(--error);
}
.tab-menu {
top: 26px;
right: 0;
}
.tab-add-trigger {
background: transparent;
border: 1px dashed var(--border);
border-bottom: none;
color: var(--text-dim);
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
height: 26px;
width: 26px;
font-size: 14px;
line-height: 1;
cursor: pointer;
flex: 0 0 auto;
margin-bottom: -1px;
padding: 0;
}
.tab-add-trigger:hover {
color: var(--accent);
border-color: var(--accent);
}
.tab-add-input {
display: inline-flex;
align-items: center;
height: 26px;
flex: 0 0 auto;
}
.tab-add-input .api-key-input {
width: 110px;
height: 22px;
font-size: 12px;
}
/* --- Settings panel (popup header gear) --- */
.popup-settings {
position: relative;
display: inline-flex;
}
.settings-panel {
position: absolute;
right: 0;
top: 30px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 6px;
min-width: 180px;
padding: 10px;
z-index: 20;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.settings-panel-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
font-weight: 600;
}
/* --- Confirm dialog --- */
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 12px;
}
.confirm-dialog {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
max-width: 320px;
width: 100%;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
gap: 10px;
}
.confirm-title {
font-size: 13px;
font-weight: 600;
}
.confirm-message {
font-size: 12px;
color: var(--text-dim);
line-height: 1.4;
}
.confirm-actions {
display: flex;
flex-direction: column;
gap: 6px;
}
.confirm-actions button { width: 100%; }
button.danger-button {
background: var(--error);
color: #1a1a1c;
border-color: var(--error);
}
button.danger-button:hover:not(:disabled) {
background: #f78787;
}
/* --- Submenu inside column overflow --- */
.bookmark-submenu {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border);
}
.bookmark-submenu-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
padding: 6px 10px 2px 10px;
}
.bookmark-submenu .submenu-back {
font-size: 11px;
color: var(--text-dim);
}