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