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:
+232
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
>>_</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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />);
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user