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