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
+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>
);
}