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
+12
View File
@@ -0,0 +1,12 @@
node_modules/
dist/
build/
.vite/
*.log
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
.claude/
+55
View File
@@ -0,0 +1,55 @@
# Folder Bookmark Tray — initial brief
A Windows system tray app for **bookmarking folder paths** with **quick-action buttons**. Spawned as a sibling of `claude-usage-tray` (`E:\Personal Repo\PersonalProjects\ClaudeTracker`) and intended to look and feel similar.
## What it does
A persistent tray icon. Left-click → popup window listing the user's saved folders. Each saved folder is a row with:
- An **alias** (display name) + the underlying path
- A row of **quick-action buttons**, e.g.:
- **Explorer** — open the folder in File Explorer
- **Terminal** — open a new Windows Terminal / cmd in that directory
- **VS Code** — `code <path>`
- **Claude** — `cmd /K claude` started in that directory (mirrors `launchClaude` in ClaudeTracker)
- **Copy path** — copy the path string to the clipboard
- An **edit/remove** affordance
Right-click tray menu mirrors the action set: each saved folder appears as a submenu with the same actions, plus "Browse for folder…" and "Manage…".
## Look and feel
Match `claude-usage-tray`:
- **Electron + React + Vite** stack (see its `package.json`, `electron/main.js`, `vite.config.mjs`)
- Frameless, non-resizable popup, tray-anchored (~360px wide), dark theme
- Pin-to-keep-open toggle that persists window position (see `popupWindow.on('moved', ...)` debounce in ClaudeTracker)
- Saved-paths persistence via `app.getPath('userData')/config.json` — same pattern ClaudeTracker uses for `savedPaths`
- IPC architecture: `contextBridge` in `preload.js` exposing `window.bookmarks` (or similar), main-process handlers for folder picker, launcher, clipboard, etc.
- NSIS installer via `electron-builder`
## Reference reading (do this before planning)
Skim these in `E:\Personal Repo\PersonalProjects\ClaudeTracker\` to match conventions:
- `README.md` — project layout summary
- `package.json` — scripts (`dev`, `build`, `dist`), electron-builder config
- `electron/main.js` — tray creation, popup window, context-menu structure, `launchClaude`, `pickFolder`, `add-saved-path` / `remove-saved-path` IPC handlers, config read/write helpers
- `electron/preload.js` — contextBridge surface
- `src/styles/index.css` — color tokens, popup chrome, button styles (dark theme baseline)
- `src/components/` — popup layout, list-row composition (especially how ChatList renders detail rows with action buttons)
## Non-goals (v1)
- macOS / Linux support
- Cloud sync of bookmarks
- Tagging / folder grouping
- Drag-to-reorder (nice-to-have, not required v1)
## What I want from you next
Produce a step-by-step plan that:
1. Confirms the directory layout (mirroring ClaudeTracker's structure)
2. Lists the IPC surface with handler signatures
3. Describes the config schema (saved bookmarks shape)
4. Outlines components and their props
5. Calls out anything you want to deviate from ClaudeTracker's patterns and why
Don't write code yet — plan first.
+92
View File
@@ -0,0 +1,92 @@
# Folder Bookmark Tray
A Windows system-tray app for bookmarking folder paths with quick-action buttons (Explorer, Terminal, Claude, Visual Studio).
Personal utility, Windows-only.
## Screenshots
_TBD — drop popup + context-menu screenshots in `assets/` and link them here._
## Install / dev / build
```sh
npm install # also runs `npm run icon` to generate the tray PNG
npm run dev # Vite (5173) + Electron, hot reload
npm run build # vite build → dist/
npm run start # run Electron against built dist/
npm run dist # build + electron-builder NSIS installer → build/
npm run icon # regenerate assets/tray-icon.png
```
## Usage
- **Left-click** the tray icon → popup with the bookmark list. Each row shows alias + path + four action buttons and a `⋯` overflow menu (Copy path / Edit / Remove).
- **Right-click** the tray icon → native context menu. Each bookmark is a submenu with the same actions, plus **Browse for folder…** (quick-add via folder picker) and **Manage…** (opens the popup).
- **Pin toggle** in the popup header keeps it open after focus loss; pinned position is persisted across runs.
- **Auto-fitting popup**: width is fixed at 360px; height grows with content from 180px up to a 600px cap, then scrolls internally.
## Action buttons
| Button | Behavior |
|---|---|
| 📁 Explorer | `shell.openPath(path)` |
| `>_` Terminal | `wt.exe -d <path>` (Windows Terminal); falls back to `cmd.exe` on `ENOENT` |
| `C` Claude | `cmd.exe /c start "" /D <path> cmd.exe /K claude` (detached, ignored stdio, unref'd) |
| `VS` Visual Studio | Finds the first `*.sln` in the folder and opens it via shell association — uses whichever VS the user has registered for `.sln`. Disabled with a tooltip when no `.sln` is present. |
| Copy path | `clipboard.writeText(path)` |
## Config file
Persisted to `app.getPath('userData')/config.json`. On Windows that's:
```
%APPDATA%\Folder Bookmark Tray\config.json
```
Schema:
```ts
{
bookmarks: [
{ id: "uuid", alias: "string", path: "string", createdAt: "ISO8601" }
],
pinned: false,
pinnedBounds?: { x, y }, // remembered drag position when pinned
popupHeight?: number // last auto-fit height, clamped 180..600
}
```
## Layout
```
electron/
main.js tray + popup window + IPC handlers + context menu
preload.js contextBridge → window.bookmarks
launchers.js Explorer / Terminal / Claude / Visual Studio / clipboard / folder inspect
src/
main.jsx, App.jsx
components/
Popup.jsx ResizeObserver auto-fits Electron window height
BookmarkList.jsx
BookmarkRow.jsx view + inline edit, ⋯ menu, VS-disabled-when-no-sln
AddBookmarkForm.jsx
styles/index.css
scripts/generate-icon.js produces assets/tray-icon.png at install time
assets/tray-icon.png orange folder pictogram, 32×32
```
## Stack
Electron 33, React 18, Vite 5. NSIS installer via `electron-builder` (`appId: com.dbeuttel.folder-bookmark-tray`). No native deps.
## Non-goals
- macOS / Linux support
- Cloud sync of bookmarks
- Drag-to-reorder
- Tagging or folder grouping
## Why this exists
Sibling utility to [`claude-usage-tray`](../ClaudeTracker) — same window chrome, same dark-theme tokens, same `userData/config.json` persistence pattern, same tray-anchored popup behavior. Where ClaudeTracker is a usage dashboard with a saved-paths side feature, this app makes folder bookmarks the whole point: a fast keyboard-free way to jump from the tray into Explorer, a terminal, Claude, or Visual Studio at a known directory.
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

+166
View File
@@ -0,0 +1,166 @@
# find-tab.ps1
# Searches every running Windows Terminal / OpenConsole window for a tab whose
# title matches -Title (case-insensitive, with whitespace-stripped fuzzy
# fallback). On match, brings the host window to the foreground and selects
# the tab via UIA, mirroring focus-window.ps1's selection-fallback chain.
#
# Adapted from claude-usage-tray's focus-window.ps1, but inverted: that script
# walks UP from a known PID to find a windowed ancestor; this one searches
# DOWN across all WT windows by tab title.
#
# Usage:
# powershell -NoProfile -ExecutionPolicy Bypass -File find-tab.ps1 -Title 'MyAlias'
#
# Always exits 0 (unless catastrophic). Emits a single JSON line:
# { "found": true, "hostPid": 12345, "tabName": "MyAlias",
# "tabSelected": true, "selectionError": null }
# { "found": false }
param(
[Parameter(Mandatory=$true)][string]$Title
)
$ErrorActionPreference = 'Stop'
$winSig = @'
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr h, int n);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach);
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
'@
Add-Type -MemberDefinition $winSig -Name W -Namespace U -ErrorAction SilentlyContinue | Out-Null
# SetForegroundWindow has anti-focus-stealing rules; the AttachThreadInput
# dance is the standard workaround when the calling process is not foreground.
function Set-Foreground {
param([IntPtr]$Handle)
if ([U.W]::IsIconic($Handle)) { [U.W]::ShowWindowAsync($Handle, 9) | Out-Null }
$fgHwnd = [U.W]::GetForegroundWindow()
$fgPid = 0
$fgThread = [U.W]::GetWindowThreadProcessId($fgHwnd, [ref]$fgPid)
$myThread = [U.W]::GetCurrentThreadId()
if ($fgThread -ne 0 -and $fgThread -ne $myThread) {
[U.W]::AttachThreadInput($fgThread, $myThread, $true) | Out-Null
}
$ok = [U.W]::SetForegroundWindow($Handle)
if ($fgThread -ne 0 -and $fgThread -ne $myThread) {
[U.W]::AttachThreadInput($fgThread, $myThread, $false) | Out-Null
}
return $ok
}
try {
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes -ErrorAction SilentlyContinue | Out-Null
} catch { }
function Norm([string]$s) {
if (-not $s) { return '' }
return ($s -replace '\s+','').ToLowerInvariant()
}
$root = [System.Windows.Automation.AutomationElement]::RootElement
$wtHosts = @(Get-Process | Where-Object { 'WindowsTerminal','OpenConsole' -contains $_.ProcessName })
if ($wtHosts.Count -eq 0) {
[pscustomobject]@{ found = $false; reason = 'no-wt-running' } | ConvertTo-Json -Compress
exit 0
}
$matchedTab = $null
$matchedIndex = -1
$matchedName = $null
$matchedHostPid = 0
$matchedHwnd = [IntPtr]::Zero
$titleNorm = Norm $Title
$titleLower = $Title.ToLowerInvariant()
foreach ($h in $wtHosts) {
try {
$procCond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ProcessIdProperty, [int]$h.Id)
$window = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $procCond)
if (-not $window) { continue }
$tabCond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, [System.Windows.Automation.ControlType]::TabItem)
$tabs = @($window.FindAll([System.Windows.Automation.TreeScope]::Descendants, $tabCond))
# Pass 1: case-insensitive equality.
for ($i = 0; $i -lt $tabs.Count; $i++) {
$name = $tabs[$i].Current.Name
if (-not $name) { continue }
if ($name.ToLowerInvariant() -eq $titleLower) {
$matchedTab = $tabs[$i]; $matchedIndex = $i; $matchedName = $name
$matchedHostPid = $h.Id; $matchedHwnd = $h.MainWindowHandle
break
}
}
if ($matchedTab) { break }
# Pass 2: whitespace/case-insensitive substring match.
for ($i = 0; $i -lt $tabs.Count; $i++) {
$name = $tabs[$i].Current.Name
if (-not $name) { continue }
$nameNorm = Norm $name
if ($titleNorm -and $nameNorm.Contains($titleNorm)) {
$matchedTab = $tabs[$i]; $matchedIndex = $i; $matchedName = $name
$matchedHostPid = $h.Id; $matchedHwnd = $h.MainWindowHandle
break
}
}
if ($matchedTab) { break }
} catch { }
}
if (-not $matchedTab) {
[pscustomobject]@{ found = $false } | ConvertTo-Json -Compress
exit 0
}
# Foreground first; UIA Select frequently no-ops when host is in background.
[void](Set-Foreground -Handle $matchedHwnd)
Start-Sleep -Milliseconds 200
$tabSelected = $false
$selectionError = $null
# Strategy 1: SelectionItemPattern.Select
try {
$sel = $matchedTab.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)
if ($sel) { $sel.Select(); $tabSelected = $true }
} catch { $selectionError = "Select: $($_.Exception.Message)" }
# Strategy 2: InvokePattern.Invoke (acts like a click)
if (-not $tabSelected) {
try {
$inv = $matchedTab.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)
if ($inv) { $inv.Invoke(); $tabSelected = $true; $selectionError = $null }
} catch {
if (-not $selectionError) { $selectionError = "Invoke: $($_.Exception.Message)" }
else { $selectionError = "$selectionError; Invoke: $($_.Exception.Message)" }
}
}
# Strategy 3: SendKeys Ctrl+<N> — WT's built-in tab-by-position keybind.
if (-not $tabSelected -and $matchedIndex -ge 0 -and $matchedIndex -lt 9) {
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null
$key = '^{0}' -f ($matchedIndex + 1)
[System.Windows.Forms.SendKeys]::SendWait($key)
$tabSelected = $true; $selectionError = $null
} catch {
if (-not $selectionError) { $selectionError = "SendKeys: $($_.Exception.Message)" }
else { $selectionError = "$selectionError; SendKeys: $($_.Exception.Message)" }
}
}
[pscustomobject]@{
found = $true
hostPid = $matchedHostPid
tabName = $matchedName
tabIndex = $matchedIndex
tabSelected = $tabSelected
selectionError = $selectionError
} | ConvertTo-Json -Compress -Depth 4
exit 0
Binary file not shown.
+765
View File
@@ -0,0 +1,765 @@
const { app, BrowserWindow, Tray, Menu, ipcMain, nativeImage, screen, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const launchers = require('./launchers');
// 60% of the original 360px slot, rounded to a clean number.
const POPUP_COLUMN_WIDTH = 216;
const POPUP_MIN_WIDTH = POPUP_COLUMN_WIDTH;
const POPUP_MIN_HEIGHT = 180;
const POPUP_MAX_HEIGHT = 600;
const POPUP_DEFAULT_HEIGHT = 360;
const DEV_URL = 'http://localhost:5174';
const isDev = !app.isPackaged;
let tray = null;
let popupWindow = null;
let configCache = null;
let pinned = false;
let saveBoundsDebounce = null;
function configPath() {
return path.join(app.getPath('userData'), 'config.json');
}
function readConfig() {
if (configCache) return configCache;
try {
configCache = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
} catch {
configCache = {};
}
return configCache;
}
function writeConfig(patch) {
const next = { ...readConfig(), ...patch };
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
fs.writeFileSync(configPath(), JSON.stringify(next, null, 2));
configCache = next;
return next;
}
function getIconPath() {
return path.join(__dirname, '..', 'assets', 'tray-icon.png');
}
function getTabs() {
const cfg = readConfig();
return Array.isArray(cfg.tabs) ? cfg.tabs.filter((t) => t && t.id && t.name) : [];
}
function getColumns() {
const cfg = readConfig();
return Array.isArray(cfg.columns) ? cfg.columns.filter((c) => c && c.id && c.name) : [];
}
function getBookmarks() {
const cfg = readConfig();
return Array.isArray(cfg.bookmarks) ? cfg.bookmarks.filter((b) => b && b.id && b.alias && b.path) : [];
}
function getActiveTabId() {
const cfg = readConfig();
const tabs = getTabs();
if (!tabs.length) return null;
const stored = cfg.activeTabId;
if (stored && tabs.find((t) => t.id === stored)) return stored;
return tabs[0].id;
}
// Per-button visibility for the row action bar. Defaults to true so existing
// installs see no behavior change.
function getButtonVisibility() {
const cfg = readConfig();
const v = cfg.buttonVisibility || {};
return {
claude: v.claude !== false,
terminal: v.terminal !== false,
redeploy: v.redeploy !== false,
};
}
// Default-on autostart. Honor an explicit `false` so users can opt out.
function getAutoStart() {
const cfg = readConfig();
return cfg.autoStart !== false;
}
// Sync the OS login-item state to config. Skipped in dev so the running
// devtools/electron build never gets registered against Windows logon.
function applyAutoStart() {
if (process.platform !== 'win32') return;
if (isDev) return;
try {
app.setLoginItemSettings({
openAtLogin: getAutoStart(),
// Hide the popup window on auto-launch — the app is tray-resident.
args: ['--hidden'],
});
} catch {
// Setting login items can fail under restricted policies; ignore so the
// app still launches even if startup registration is blocked.
}
}
// One-time migration when loading older configs:
// 1. Ensure `columns` and `tabs` are arrays.
// 2. If bookmarks exist but no columns, create a default "Bookmarks" column
// and assign every existing bookmark to it.
// 3. Reassign any orphan bookmarks (referencing a missing columnId) to the
// first column.
// 4. If columns exist but no tabs, create a default "Main" tab and assign
// every existing column to it.
// 5. Reassign any orphan columns (referencing a missing tabId) to the first
// tab.
function migrateConfigIfNeeded() {
const cfg = readConfig();
let mutated = false;
let columns = Array.isArray(cfg.columns) ? cfg.columns.slice() : [];
let bookmarks = Array.isArray(cfg.bookmarks) ? cfg.bookmarks.slice() : [];
let tabs = Array.isArray(cfg.tabs) ? cfg.tabs.slice() : [];
let activeTabId = cfg.activeTabId;
if (!Array.isArray(cfg.columns)) mutated = true;
if (!Array.isArray(cfg.tabs)) mutated = true;
if (bookmarks.length > 0 && columns.length === 0) {
const defaultCol = { id: crypto.randomUUID(), name: 'Bookmarks' };
columns = [defaultCol];
bookmarks = bookmarks.map((b) => (b && !b.columnId ? { ...b, columnId: defaultCol.id } : b));
mutated = true;
} else if (bookmarks.length > 0 && columns.length > 0) {
const validIds = new Set(columns.map((c) => c.id));
const firstId = columns[0].id;
bookmarks = bookmarks.map((b) => {
if (!b) return b;
if (!b.columnId || !validIds.has(b.columnId)) {
mutated = true;
return { ...b, columnId: firstId };
}
return b;
});
}
if (columns.length > 0 && tabs.length === 0) {
const defaultTab = { id: crypto.randomUUID(), name: 'Main' };
tabs = [defaultTab];
columns = columns.map((c) => (c && !c.tabId ? { ...c, tabId: defaultTab.id } : c));
activeTabId = defaultTab.id;
mutated = true;
} else if (columns.length > 0 && tabs.length > 0) {
const validTabIds = new Set(tabs.map((t) => t.id));
const firstTabId = tabs[0].id;
columns = columns.map((c) => {
if (!c) return c;
if (!c.tabId || !validTabIds.has(c.tabId)) {
mutated = true;
return { ...c, tabId: firstTabId };
}
return c;
});
if (!activeTabId || !validTabIds.has(activeTabId)) {
activeTabId = firstTabId;
mutated = true;
}
}
if (mutated) writeConfig({ columns, bookmarks, tabs, activeTabId });
}
function getCurrentHeight() {
const cfg = readConfig();
const stored = Number(cfg.popupHeight);
if (Number.isFinite(stored) && stored > 0) {
return Math.max(POPUP_MIN_HEIGHT, Math.min(POPUP_MAX_HEIGHT, stored));
}
return POPUP_DEFAULT_HEIGHT;
}
// Cap the popup width at the screen's work area minus a small margin so it
// never overflows. With many columns the user gets internal horizontal
// scroll instead of the window pushing off-screen.
function getMaxWidth() {
try {
return Math.max(POPUP_MIN_WIDTH, screen.getPrimaryDisplay().workArea.width - 8);
} catch {
return POPUP_MIN_WIDTH * 6;
}
}
function getCurrentWidth() {
const cfg = readConfig();
const stored = Number(cfg.popupWidth);
const max = getMaxWidth();
if (Number.isFinite(stored) && stored > 0) {
return Math.max(POPUP_MIN_WIDTH, Math.min(max, stored));
}
return POPUP_COLUMN_WIDTH;
}
function createPopupWindow() {
popupWindow = new BrowserWindow({
width: getCurrentWidth(),
height: getCurrentHeight(),
show: false,
frame: false,
resizable: false,
skipTaskbar: true,
alwaysOnTop: true,
fullscreenable: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
if (isDev) {
popupWindow.loadURL(DEV_URL);
} else {
popupWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
}
popupWindow.on('blur', () => {
if (pinned) return;
if (!popupWindow.webContents.isDevToolsOpened()) popupWindow.hide();
});
popupWindow.on('close', (e) => {
if (!app.isQuitting) {
e.preventDefault();
popupWindow.hide();
}
});
// Persist drag position only when pinned. 300ms debounce because Electron
// fires 'moved' on every pixel during a drag — writing config that often
// would thrash the disk and produce a stale position if the drag is
// interrupted mid-flight.
popupWindow.on('moved', () => {
if (!pinned) return;
if (saveBoundsDebounce) clearTimeout(saveBoundsDebounce);
saveBoundsDebounce = setTimeout(() => {
const b = popupWindow.getBounds();
writeConfig({ pinnedBounds: { x: b.x, y: b.y } });
}, 300);
});
}
function positionPopup() {
const trayBounds = tray.getBounds();
const display = screen.getDisplayNearestPoint({ x: trayBounds.x, y: trayBounds.y });
const workArea = display.workArea;
const width = getCurrentWidth();
const height = getCurrentHeight();
let x = Math.round(trayBounds.x + trayBounds.width / 2 - width / 2);
let y = Math.round(trayBounds.y - height - 8);
x = Math.max(workArea.x + 4, Math.min(x, workArea.x + workArea.width - width - 4));
if (y < workArea.y + 4) y = trayBounds.y + trayBounds.height + 4;
popupWindow.setBounds({ x, y, width, height });
}
function togglePopup() {
if (!popupWindow) return;
if (popupWindow.isVisible()) {
popupWindow.hide();
} else {
if (pinned) {
const saved = readConfig().pinnedBounds;
const width = getCurrentWidth();
const height = getCurrentHeight();
if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
popupWindow.setBounds({ x: saved.x, y: saved.y, width, height });
} else {
positionPopup();
}
} else {
positionPopup();
}
popupWindow.show();
popupWindow.focus();
// Tell the renderer to re-inspect folders. Rows mount once and stay
// mounted while the window is hidden, so without this the redeploy/sln
// detection won't notice files added between popup opens.
if (popupWindow.webContents) popupWindow.webContents.send('popup-shown');
}
}
function truncatePath(p) {
if (!p) return '';
if (p.length <= 38) return p;
return p.slice(0, 18) + '…' + p.slice(-18);
}
function broadcastConfig() {
if (popupWindow && !popupWindow.isDestroyed()) {
popupWindow.webContents.send('config-updated', {
tabs: getTabs(),
columns: getColumns(),
bookmarks: getBookmarks(),
activeTabId: getActiveTabId(),
buttonVisibility: getButtonVisibility(),
autoStart: getAutoStart(),
});
}
}
function buildContextMenu() {
const bookmarks = getBookmarks();
const items = [
{ label: 'Open', click: togglePopup },
{ type: 'separator' },
];
if (bookmarks.length === 0) {
items.push({ label: 'No bookmarks yet', enabled: false });
} else {
for (const b of bookmarks) {
const inspected = launchers.inspectFolder(b.path);
const submenu = [
{ label: 'Open in Explorer', click: () => launchers.openInExplorer(b.path) },
{
label: 'Open Terminal here',
click: () => launchers.openTerminal({ targetPath: b.path, alias: b.alias, color: b.color }),
},
];
// Network shares hide Claude / VS — neither is meaningful there.
if (!inspected.isNetwork) {
submenu.push({
label: 'Open Claude here',
click: () => launchers.openClaude({ targetPath: b.path, alias: b.alias, color: b.color }),
});
submenu.push({
label: inspected.slnPath ? 'Open in Visual Studio' : 'Open in Visual Studio (no .sln)',
enabled: !!inspected.slnPath,
click: () => launchers.openVisualStudio(b.path),
});
}
if (inspected.redeployPath && !b.hideDeploy) {
submenu.push({
label: 'Run 1ReDeploy.bat',
click: () => launchers.runRedeploy(b.path),
});
}
submenu.push({ type: 'separator' });
submenu.push({ label: 'Copy path', click: () => launchers.copyPath(b.path) });
items.push({
label: `${b.alias}${truncatePath(b.path)}`,
submenu,
});
}
}
items.push({ type: 'separator' });
items.push({
label: 'Browse for folder…',
click: async () => {
const picked = await launchers.pickFolder();
if (!picked) return;
const alias = lastSegment(picked);
addBookmarkInternal({ alias, path: picked });
},
});
items.push({
label: 'Manage…',
click: () => {
if (!popupWindow.isVisible()) togglePopup();
},
});
items.push({ type: 'separator' });
items.push({
label: 'Quit',
click: () => {
app.isQuitting = true;
app.quit();
},
});
return Menu.buildFromTemplate(items);
}
function rebuildContextMenu() {
if (tray) tray.setContextMenu(buildContextMenu());
}
function lastSegment(p) {
if (!p) return '';
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || p;
}
// Internal helper used by both the tray "Browse for folder…" item and the
// add-bookmark IPC handler so they share creation logic.
function addBookmarkInternal({ id, alias, path: pathValue, color, columnId, hideDeploy }) {
if (!alias || !pathValue) return getBookmarks();
const cleanColor = (typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color)) ? color : null;
// Treat undefined as "don't touch" (so add-without-flag preserves prior value
// on edit). Only true / false explicitly modify the field.
const cleanHideDeploy = typeof hideDeploy === 'boolean' ? hideDeploy : undefined;
const cfg = readConfig();
const cols = Array.isArray(cfg.columns) ? cfg.columns : [];
const tabs = Array.isArray(cfg.tabs) ? cfg.tabs : [];
let firstColId = cols.length > 0 ? cols[0].id : null;
// Auto-create a "Main" tab + "Bookmarks" column if none exist yet so new
// entries always have somewhere to live.
let columnsToWrite = null;
let tabsToWrite = null;
let activeTabIdToWrite = null;
let firstTabId = tabs.length > 0 ? tabs[0].id : null;
if (!firstTabId) {
const defaultTab = { id: crypto.randomUUID(), name: 'Main' };
tabsToWrite = [defaultTab];
firstTabId = defaultTab.id;
activeTabIdToWrite = defaultTab.id;
}
if (!firstColId) {
const defaultCol = { id: crypto.randomUUID(), name: 'Bookmarks', tabId: firstTabId };
columnsToWrite = [defaultCol];
firstColId = defaultCol.id;
}
const finalColId = columnId && cols.find((c) => c.id === columnId) ? columnId : firstColId;
const list = Array.isArray(cfg.bookmarks) ? cfg.bookmarks.slice() : [];
if (id) {
const idx = list.findIndex((b) => b && b.id === id);
if (idx >= 0) {
const merged = { ...list[idx], alias, path: pathValue };
if (cleanColor) merged.color = cleanColor; else delete merged.color;
// Only overwrite columnId when the caller explicitly supplied one.
if (columnId !== undefined) merged.columnId = finalColId;
if (cleanHideDeploy === true) merged.hideDeploy = true;
else if (cleanHideDeploy === false) delete merged.hideDeploy;
list[idx] = merged;
} else {
const entry = {
id, alias, path: pathValue,
createdAt: new Date().toISOString(),
columnId: finalColId,
};
if (cleanColor) entry.color = cleanColor;
if (cleanHideDeploy === true) entry.hideDeploy = true;
list.push(entry);
}
} else {
const entry = {
id: crypto.randomUUID(),
alias,
path: pathValue,
createdAt: new Date().toISOString(),
columnId: finalColId,
};
if (cleanColor) entry.color = cleanColor;
if (cleanHideDeploy === true) entry.hideDeploy = true;
list.push(entry);
}
const patch = { bookmarks: list };
if (columnsToWrite) patch.columns = columnsToWrite;
if (tabsToWrite) patch.tabs = tabsToWrite;
if (activeTabIdToWrite) patch.activeTabId = activeTabIdToWrite;
writeConfig(patch);
rebuildContextMenu();
broadcastConfig();
return getBookmarks();
}
function createTray() {
const image = nativeImage.createFromPath(getIconPath());
tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image);
tray.setToolTip('Folder Bookmark Tray');
tray.setContextMenu(buildContextMenu());
tray.on('click', togglePopup);
}
// ---- IPC handlers ----
ipcMain.handle('get-config', () => {
const cfg = readConfig();
return {
...cfg,
tabs: getTabs(),
columns: getColumns(),
bookmarks: getBookmarks(),
activeTabId: getActiveTabId(),
buttonVisibility: getButtonVisibility(),
autoStart: getAutoStart(),
};
});
ipcMain.handle('set-auto-start', (_event, value) => {
const next = !!value;
writeConfig({ autoStart: next });
applyAutoStart();
broadcastConfig();
return getAutoStart();
});
ipcMain.handle('set-button-visibility', (_event, payload) => {
if (!payload || typeof payload !== 'object') return getButtonVisibility();
const current = getButtonVisibility();
const next = {
claude: typeof payload.claude === 'boolean' ? payload.claude : current.claude,
terminal: typeof payload.terminal === 'boolean' ? payload.terminal : current.terminal,
redeploy: typeof payload.redeploy === 'boolean' ? payload.redeploy : current.redeploy,
};
writeConfig({ buttonVisibility: next });
broadcastConfig();
return getButtonVisibility();
});
ipcMain.handle('set-config', (_event, patch) => {
return writeConfig(patch || {});
});
ipcMain.handle('add-bookmark', (_event, payload) => {
if (!payload) return getBookmarks();
return addBookmarkInternal(payload);
});
ipcMain.handle('remove-bookmark', (_event, id) => {
const cfg = readConfig();
const list = (cfg.bookmarks || []).filter((b) => b && b.id !== id);
writeConfig({ bookmarks: list });
rebuildContextMenu();
broadcastConfig();
return getBookmarks();
});
ipcMain.handle('reorder-bookmarks', (_event, ids) => {
if (!Array.isArray(ids)) return getBookmarks();
const current = getBookmarks();
const byId = new Map(current.map((b) => [b.id, b]));
const reordered = ids.map((id) => byId.get(id)).filter(Boolean);
for (const b of current) if (!ids.includes(b.id)) reordered.push(b);
writeConfig({ bookmarks: reordered });
rebuildContextMenu();
broadcastConfig();
return getBookmarks();
});
ipcMain.handle('add-column', (_event, payload) => {
const name = payload && typeof payload.name === 'string' ? payload.name.trim() : '';
if (!name) return getColumns();
const cfg = readConfig();
const cols = Array.isArray(cfg.columns) ? cfg.columns.slice() : [];
const tabs = Array.isArray(cfg.tabs) ? cfg.tabs.slice() : [];
const patch = {};
// Auto-create a tab if none exists yet so the new column has a parent.
let tabId = payload && typeof payload.tabId === 'string' ? payload.tabId : null;
if (!tabId || !tabs.find((t) => t.id === tabId)) tabId = getActiveTabId();
if (!tabId) {
const defaultTab = { id: crypto.randomUUID(), name: 'Main' };
tabs.push(defaultTab);
tabId = defaultTab.id;
patch.tabs = tabs;
patch.activeTabId = defaultTab.id;
}
cols.push({ id: crypto.randomUUID(), name, tabId });
patch.columns = cols;
writeConfig(patch);
rebuildContextMenu();
broadcastConfig();
return getColumns();
});
ipcMain.handle('rename-column', (_event, payload) => {
const id = payload && payload.id;
const name = payload && typeof payload.name === 'string' ? payload.name.trim() : '';
if (!id || !name) return getColumns();
const cfg = readConfig();
const cols = (cfg.columns || []).map((c) => (c.id === id ? { ...c, name } : c));
writeConfig({ columns: cols });
rebuildContextMenu();
broadcastConfig();
return getColumns();
});
ipcMain.handle('remove-column', (_event, payload) => {
const id = payload && payload.id;
const reassignTo = payload && payload.reassignTo;
if (!id) return { columns: getColumns(), bookmarks: getBookmarks() };
const cfg = readConfig();
const cols = (cfg.columns || []).filter((c) => c.id !== id);
let bookmarks = cfg.bookmarks || [];
if (reassignTo && cols.find((c) => c.id === reassignTo)) {
bookmarks = bookmarks.map((b) => (b && b.columnId === id ? { ...b, columnId: reassignTo } : b));
} else {
bookmarks = bookmarks.filter((b) => b && b.columnId !== id);
}
writeConfig({ columns: cols, bookmarks });
rebuildContextMenu();
broadcastConfig();
return { columns: getColumns(), bookmarks: getBookmarks() };
});
ipcMain.handle('reorder-columns', (_event, ids) => {
if (!Array.isArray(ids)) return getColumns();
const cfg = readConfig();
const byId = new Map((cfg.columns || []).map((c) => [c.id, c]));
const reordered = ids.map((id) => byId.get(id)).filter(Boolean);
for (const c of (cfg.columns || [])) if (!ids.includes(c.id)) reordered.push(c);
writeConfig({ columns: reordered });
rebuildContextMenu();
broadcastConfig();
return getColumns();
});
ipcMain.handle('move-column-to-tab', (_event, payload) => {
const columnId = payload && payload.columnId;
const tabId = payload && payload.tabId;
if (!columnId || !tabId) return { tabs: getTabs(), columns: getColumns(), activeTabId: getActiveTabId() };
const cfg = readConfig();
const tabs = Array.isArray(cfg.tabs) ? cfg.tabs : [];
if (!tabs.find((t) => t.id === tabId)) {
return { tabs: getTabs(), columns: getColumns(), activeTabId: getActiveTabId() };
}
const cols = (cfg.columns || []).map((c) => (c && c.id === columnId ? { ...c, tabId } : c));
writeConfig({ columns: cols });
rebuildContextMenu();
broadcastConfig();
return { tabs: getTabs(), columns: getColumns(), activeTabId: getActiveTabId() };
});
ipcMain.handle('add-tab', (_event, payload) => {
const name = payload && typeof payload.name === 'string' ? payload.name.trim() : '';
if (!name) return { tabs: getTabs(), activeTabId: getActiveTabId() };
const cfg = readConfig();
const tabs = Array.isArray(cfg.tabs) ? cfg.tabs.slice() : [];
const newTab = { id: crypto.randomUUID(), name };
tabs.push(newTab);
// Switch to the freshly-created tab so the user sees their new (empty) workspace.
writeConfig({ tabs, activeTabId: newTab.id });
rebuildContextMenu();
broadcastConfig();
return { tabs: getTabs(), activeTabId: getActiveTabId() };
});
ipcMain.handle('rename-tab', (_event, payload) => {
const id = payload && payload.id;
const name = payload && typeof payload.name === 'string' ? payload.name.trim() : '';
if (!id || !name) return getTabs();
const cfg = readConfig();
const tabs = (cfg.tabs || []).map((t) => (t.id === id ? { ...t, name } : t));
writeConfig({ tabs });
rebuildContextMenu();
broadcastConfig();
return getTabs();
});
ipcMain.handle('remove-tab', (_event, payload) => {
const id = payload && payload.id;
const reassignTo = payload && payload.reassignTo;
if (!id) return { tabs: getTabs(), columns: getColumns(), bookmarks: getBookmarks(), activeTabId: getActiveTabId() };
const cfg = readConfig();
const remainingTabs = (cfg.tabs || []).filter((t) => t.id !== id);
let columns = cfg.columns || [];
let bookmarks = cfg.bookmarks || [];
if (reassignTo && remainingTabs.find((t) => t.id === reassignTo)) {
columns = columns.map((c) => (c && c.tabId === id ? { ...c, tabId: reassignTo } : c));
} else {
// Cascade: drop columns assigned to this tab, then drop bookmarks orphaned by that.
const droppedColIds = new Set(columns.filter((c) => c && c.tabId === id).map((c) => c.id));
columns = columns.filter((c) => c && c.tabId !== id);
bookmarks = bookmarks.filter((b) => b && !droppedColIds.has(b.columnId));
}
let activeTabId = cfg.activeTabId === id
? (remainingTabs[0] && remainingTabs[0].id) || null
: cfg.activeTabId;
writeConfig({ tabs: remainingTabs, columns, bookmarks, activeTabId });
rebuildContextMenu();
broadcastConfig();
return { tabs: getTabs(), columns: getColumns(), bookmarks: getBookmarks(), activeTabId: getActiveTabId() };
});
ipcMain.handle('reorder-tabs', (_event, ids) => {
if (!Array.isArray(ids)) return getTabs();
const cfg = readConfig();
const byId = new Map((cfg.tabs || []).map((t) => [t.id, t]));
const reordered = ids.map((id) => byId.get(id)).filter(Boolean);
for (const t of (cfg.tabs || [])) if (!ids.includes(t.id)) reordered.push(t);
writeConfig({ tabs: reordered });
rebuildContextMenu();
broadcastConfig();
return getTabs();
});
ipcMain.handle('set-active-tab', (_event, id) => {
const tabs = getTabs();
if (!id || !tabs.find((t) => t.id === id)) return getActiveTabId();
writeConfig({ activeTabId: id });
return getActiveTabId();
});
ipcMain.handle('pick-folder', () => launchers.pickFolder());
ipcMain.handle('inspect-folder', (_event, p) => launchers.inspectFolder(p));
ipcMain.handle('open-in-explorer', (_event, p) => launchers.openInExplorer(p));
ipcMain.handle('open-terminal', (_event, payload) => {
if (typeof payload === 'string') return launchers.openTerminal({ targetPath: payload });
return launchers.openTerminal(payload || {});
});
ipcMain.handle('open-claude', (_event, payload) => {
if (typeof payload === 'string') return launchers.openClaude({ targetPath: payload });
return launchers.openClaude(payload || {});
});
ipcMain.handle('open-visual-studio', (_event, p) => launchers.openVisualStudio(p));
ipcMain.handle('run-redeploy', (_event, p) => launchers.runRedeploy(p));
ipcMain.handle('copy-path', (_event, text) => launchers.copyPath(text));
ipcMain.handle('is-claude-available', () => launchers.isClaudeAvailable());
ipcMain.handle('set-pinned', (_event, value) => {
pinned = !!value;
writeConfig({ pinned });
if (pinned && popupWindow && popupWindow.isVisible()) {
popupWindow.focus();
}
return pinned;
});
ipcMain.handle('set-popup-height', (_event, h) => {
if (!popupWindow || popupWindow.isDestroyed()) return null;
const target = Math.max(POPUP_MIN_HEIGHT, Math.min(POPUP_MAX_HEIGHT, Math.round(Number(h) || 0)));
if (!Number.isFinite(target) || target <= 0) return null;
const bounds = popupWindow.getBounds();
popupWindow.setBounds({ x: bounds.x, y: bounds.y, width: bounds.width, height: target });
writeConfig({ popupHeight: target });
return target;
});
ipcMain.handle('set-popup-width', (_event, w) => {
if (!popupWindow || popupWindow.isDestroyed()) return null;
const bounds = popupWindow.getBounds();
const display = screen.getDisplayNearestPoint({ x: bounds.x, y: bounds.y });
const workArea = display.workArea;
const maxWidth = Math.max(POPUP_MIN_WIDTH, workArea.width - 8);
const target = Math.max(POPUP_MIN_WIDTH, Math.min(maxWidth, Math.round(Number(w) || 0)));
if (!Number.isFinite(target) || target <= 0) return null;
// Re-clamp x against the work area so a wider popup doesn't fall off-screen.
let x = bounds.x;
if (x + target > workArea.x + workArea.width - 4) x = workArea.x + workArea.width - target - 4;
if (x < workArea.x + 4) x = workArea.x + 4;
popupWindow.setBounds({ x, y: bounds.y, width: target, height: bounds.height });
writeConfig({ popupWidth: target });
return target;
});
app.whenReady().then(() => {
if (process.platform === 'win32') {
app.setAppUserModelId('com.dbeuttel.folder-bookmark-tray');
}
migrateConfigIfNeeded();
pinned = !!readConfig().pinned;
applyAutoStart();
createTray();
createPopupWindow();
});
app.on('window-all-closed', (e) => e.preventDefault());
app.on('before-quit', () => {
app.isQuitting = true;
});
+61
View File
@@ -0,0 +1,61 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('bookmarks', {
// Config
getConfig: () => ipcRenderer.invoke('get-config'),
setConfig: (patch) => ipcRenderer.invoke('set-config', patch),
// Bookmarks CRUD
addBookmark: ({ id, alias, path, color, columnId, hideDeploy }) =>
ipcRenderer.invoke('add-bookmark', { id, alias, path, color, columnId, hideDeploy }),
removeBookmark: (id) => ipcRenderer.invoke('remove-bookmark', id),
reorderBookmarks: (ids) => ipcRenderer.invoke('reorder-bookmarks', ids),
// Columns CRUD
addColumn: (name, tabId) => ipcRenderer.invoke('add-column', { name, tabId }),
renameColumn: (id, name) => ipcRenderer.invoke('rename-column', { id, name }),
removeColumn: (id, reassignTo) => ipcRenderer.invoke('remove-column', { id, reassignTo }),
reorderColumns: (ids) => ipcRenderer.invoke('reorder-columns', ids),
moveColumnToTab: (columnId, tabId) => ipcRenderer.invoke('move-column-to-tab', { columnId, tabId }),
// Tabs CRUD
addTab: (name) => ipcRenderer.invoke('add-tab', { name }),
renameTab: (id, name) => ipcRenderer.invoke('rename-tab', { id, name }),
removeTab: (id, reassignTo) => ipcRenderer.invoke('remove-tab', { id, reassignTo }),
reorderTabs: (ids) => ipcRenderer.invoke('reorder-tabs', ids),
setActiveTab: (id) => ipcRenderer.invoke('set-active-tab', id),
// Folder picking + inspection
pickFolder: () => ipcRenderer.invoke('pick-folder'),
inspectFolder: (path) => ipcRenderer.invoke('inspect-folder', path),
// Launchers
openInExplorer: (path) => ipcRenderer.invoke('open-in-explorer', path),
openTerminal: (payload) => ipcRenderer.invoke('open-terminal', payload),
openClaude: (payload) => ipcRenderer.invoke('open-claude', payload),
openVisualStudio: (path) => ipcRenderer.invoke('open-visual-studio', path),
runRedeploy: (path) => ipcRenderer.invoke('run-redeploy', path),
copyPath: (text) => ipcRenderer.invoke('copy-path', text),
isClaudeAvailable: () => ipcRenderer.invoke('is-claude-available'),
// Window
setPinned: (value) => ipcRenderer.invoke('set-pinned', value),
setPopupHeight: (height) => ipcRenderer.invoke('set-popup-height', height),
setPopupWidth: (width) => ipcRenderer.invoke('set-popup-width', width),
// Settings
setButtonVisibility: (payload) => ipcRenderer.invoke('set-button-visibility', payload),
setAutoStart: (value) => ipcRenderer.invoke('set-auto-start', value),
// Events from main → renderer
onConfigUpdated: (cb) => {
const handler = (_event, payload) => cb(payload);
ipcRenderer.on('config-updated', handler);
return () => ipcRenderer.removeListener('config-updated', handler);
},
onPopupShown: (cb) => {
const handler = () => cb();
ipcRenderer.on('popup-shown', handler);
return () => ipcRenderer.removeListener('popup-shown', handler);
},
});
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:5174 ws://localhost:5174" />
<title>Folder Bookmark Tray</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+7163
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "folder-bookmark-tray",
"version": "0.1.0",
"description": "Windows system tray app for bookmarking folders with quick-action buttons (Explorer, Terminal, Claude, Visual Studio).",
"main": "electron/main.js",
"author": "dbeuttel",
"license": "MIT",
"scripts": {
"icon": "node scripts/generate-icon.js",
"dev:vite": "vite",
"dev:electron": "wait-on http://localhost:5174 && electron .",
"dev": "concurrently -k \"npm:dev:vite\" \"npm:dev:electron\"",
"build": "vite build",
"start": "electron .",
"dist": "npm run build && electron-builder",
"postinstall": "node scripts/generate-icon.js"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
"vite": "^5.4.11",
"wait-on": "^8.0.1"
},
"build": {
"appId": "com.dbeuttel.folder-bookmark-tray",
"productName": "Folder Bookmark Tray",
"files": [
"electron/**/*",
"dist/**/*",
"assets/**/*"
],
"win": {
"target": "nsis",
"icon": "assets/tray-icon.png"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"directories": {
"output": "build"
}
}
}
+97
View File
@@ -0,0 +1,97 @@
// Generates assets/tray-icon.png — a 32x32 folder pictogram in orange on a
// transparent background. No external image deps; raw PNG bytes via zlib.
// Replace with a designer asset when one is available.
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const SIZE = 32;
const OUT = path.join(__dirname, '..', 'assets', 'tray-icon.png');
const ORANGE = [217, 119, 87, 255];
const ORANGE_DARK = [180, 92, 64, 255];
const TRANSPARENT = [0, 0, 0, 0];
function colorAt(x, y) {
// Folder tab (top): smaller rectangle on the left
const tabLeft = 4, tabRight = 13, tabTop = 7, tabBottom = 11;
// Folder body: larger rectangle below
const bodyLeft = 3, bodyRight = 28, bodyTop = 11, bodyBottom = 25;
const inTab = x >= tabLeft && x <= tabRight && y >= tabTop && y <= tabBottom;
const inBody = x >= bodyLeft && x <= bodyRight && y >= bodyTop && y <= bodyBottom;
if (!inTab && !inBody) return TRANSPARENT;
// Soft top edge on body where the tab steps down — paints a single-row
// shadow line to give the folder some dimension at small sizes.
if (inBody && y === bodyTop && x > tabRight + 1) return ORANGE_DARK;
return ORANGE;
}
function buildRawImage() {
const rowLen = SIZE * 4 + 1;
const raw = Buffer.alloc(rowLen * SIZE);
for (let y = 0; y < SIZE; y++) {
raw[y * rowLen] = 0;
for (let x = 0; x < SIZE; x++) {
const [r, g, b, a] = colorAt(x, y);
const off = y * rowLen + 1 + x * 4;
raw[off] = r;
raw[off + 1] = g;
raw[off + 2] = b;
raw[off + 3] = a;
}
}
return raw;
}
function crc32(buf) {
let c;
const table = crc32.table || (crc32.table = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let v = n;
for (let k = 0; k < 8; k++) v = (v & 1) ? (0xedb88320 ^ (v >>> 1)) : (v >>> 1);
t[n] = v >>> 0;
}
return t;
})());
c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function buildPng() {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(SIZE, 0);
ihdr.writeUInt32BE(SIZE, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
const idat = zlib.deflateSync(buildRawImage());
return Buffer.concat([
sig,
chunk('IHDR', ihdr),
chunk('IDAT', idat),
chunk('IEND', Buffer.alloc(0)),
]);
}
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, buildPng());
console.log(`Wrote ${OUT}`);
+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);
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: './',
server: {
// 5174 instead of Vite's default 5173 so this app can run alongside the
// sibling claude-usage-tray dev server, which uses 5173.
port: 5174,
strictPort: true,
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});