Minimized list view, CSP fix, last-row menu drop-up
- Minimized popup now shows a compact bookmark list grouped by column header, with color stripe, alias, and inline action buttons per row - Add img-src 'self' data: to CSP so Vite's inlined tray icon loads in the production build (was blocked by default-src 'self') - Last-row ⋯ menu opens upward to avoid overflowing the popup edge Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://localhost:5174 ws://localhost:5174" />
|
||||||
<title>Trailhead Path Bookmarker</title>
|
<title>Trailhead Path Bookmarker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function BookmarkRow({
|
|||||||
onClick={() => setMenuOpen((v) => !v)}
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
>⋯</button>
|
>⋯</button>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="bookmark-menu">
|
<div className={`bookmark-menu${!canMoveDown ? ' drop-up' : ''}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMenuOpen(false); onMoveUp(); }}
|
onClick={() => { setMenuOpen(false); onMoveUp(); }}
|
||||||
disabled={!canMoveUp}
|
disabled={!canMoveUp}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
// Flat list of every bookmark, ordered tab → column → card. Used when the
|
||||||
|
// popup is minimized: the column grid collapses to one row per bookmark with
|
||||||
|
// the action buttons inlined to the right of the alias.
|
||||||
|
export default function MinimizedList({
|
||||||
|
tabs,
|
||||||
|
columns,
|
||||||
|
bookmarks,
|
||||||
|
claudeAvailable,
|
||||||
|
inspectRevision,
|
||||||
|
buttonVisibility,
|
||||||
|
}) {
|
||||||
|
// Group by column, walking tabs in order so cross-tab ordering is stable.
|
||||||
|
// Empty columns are skipped — the minimized view is for quick access, not
|
||||||
|
// for browsing the structure.
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const result = [];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const tabCols = columns.filter((c) => c.tabId === tab.id);
|
||||||
|
for (const col of tabCols) {
|
||||||
|
const cards = bookmarks.filter((b) => b.columnId === col.id);
|
||||||
|
if (cards.length === 0) continue;
|
||||||
|
result.push({ id: col.id, name: col.name, cards });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [tabs, columns, bookmarks]);
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return <div className="empty-state">No bookmarks yet.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mini-groups">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<section key={g.id} className="mini-group">
|
||||||
|
<h3 className="mini-group-header">{g.name}</h3>
|
||||||
|
<ul className="mini-list">
|
||||||
|
{g.cards.map((b) => (
|
||||||
|
<MinimizedRow
|
||||||
|
key={b.id}
|
||||||
|
bookmark={b}
|
||||||
|
claudeAvailable={claudeAvailable}
|
||||||
|
inspectRevision={inspectRevision}
|
||||||
|
buttonVisibility={buttonVisibility}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MinimizedRow({ bookmark, claudeAvailable, inspectRevision, buttonVisibility }) {
|
||||||
|
const showClaude = !buttonVisibility || buttonVisibility.claude !== false;
|
||||||
|
const showTerminal = !buttonVisibility || buttonVisibility.terminal !== false;
|
||||||
|
const showRedeploy = !buttonVisibility || buttonVisibility.redeploy !== false;
|
||||||
|
const [inspect, setInspect] = useState({
|
||||||
|
checked: false,
|
||||||
|
slnPath: null,
|
||||||
|
redeployPath: null,
|
||||||
|
isNetwork: false,
|
||||||
|
});
|
||||||
|
const [status, setStatus] = useState(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]);
|
||||||
|
|
||||||
|
const flash = (kind, text) => {
|
||||||
|
setStatus({ kind, text });
|
||||||
|
setTimeout(() => setStatus(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
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`);
|
||||||
|
else flash('info', `${label}: opened`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripeStyle = bookmark.color ? { borderLeftColor: bookmark.color } : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={`mini-row${bookmark.color ? ' has-color' : ''}`}
|
||||||
|
style={stripeStyle}
|
||||||
|
title={bookmark.path}
|
||||||
|
>
|
||||||
|
<span className="mini-alias">{bookmark.alias}</span>
|
||||||
|
<div className="mini-actions">
|
||||||
|
<button
|
||||||
|
className="icon-button mini-btn"
|
||||||
|
title={inspect.isNetwork ? 'Open share in Explorer' : 'Open in Explorer'}
|
||||||
|
onClick={() => runPathAction(window.bookmarks.openInExplorer, 'Explorer')}
|
||||||
|
>📁</button>
|
||||||
|
{showTerminal && (
|
||||||
|
<button
|
||||||
|
className="icon-button mini-btn"
|
||||||
|
title={`Terminal: ${bookmark.alias}`}
|
||||||
|
onClick={() => runTabAction(window.bookmarks.openTerminal, 'Terminal')}
|
||||||
|
>>_</button>
|
||||||
|
)}
|
||||||
|
{!inspect.isNetwork && showClaude && (
|
||||||
|
<button
|
||||||
|
className="icon-button mini-btn"
|
||||||
|
title={claudeAvailable ? `Claude: ${bookmark.alias}` : 'Claude CLI not found'}
|
||||||
|
disabled={!claudeAvailable}
|
||||||
|
onClick={() => runTabAction(window.bookmarks.openClaude, 'Claude')}
|
||||||
|
>C</button>
|
||||||
|
)}
|
||||||
|
{!inspect.isNetwork && (
|
||||||
|
<button
|
||||||
|
className="icon-button mini-btn"
|
||||||
|
title={
|
||||||
|
!inspect.checked ? 'Checking…'
|
||||||
|
: inspect.slnPath ? `Open ${inspect.slnPath.split(/[\\/]/).pop()}`
|
||||||
|
: 'No .sln found'
|
||||||
|
}
|
||||||
|
disabled={!inspect.checked || !inspect.slnPath}
|
||||||
|
onClick={() => runPathAction(window.bookmarks.openVisualStudio, 'Visual Studio')}
|
||||||
|
>VS</button>
|
||||||
|
)}
|
||||||
|
{showRedeploy && inspect.redeployPath && !bookmark.hideDeploy && (
|
||||||
|
<button
|
||||||
|
className="icon-button mini-btn"
|
||||||
|
title={`Run ${inspect.redeployPath.split(/[\\/]/).pop()}`}
|
||||||
|
onClick={() => runPathAction(window.bookmarks.runRedeploy, 'Deploy')}
|
||||||
|
>▶</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status && (
|
||||||
|
<div className={`mini-status ${status.kind === 'error' ? 'error' : 'info'}`}>
|
||||||
|
{status.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import Column from './Column.jsx';
|
import Column from './Column.jsx';
|
||||||
import TabBar from './TabBar.jsx';
|
import TabBar from './TabBar.jsx';
|
||||||
import ConfirmDialog from './ConfirmDialog.jsx';
|
import ConfirmDialog from './ConfirmDialog.jsx';
|
||||||
|
import MinimizedList from './MinimizedList.jsx';
|
||||||
import iconUrl from '../../assets/tray-icon.png';
|
import iconUrl from '../../assets/tray-icon.png';
|
||||||
|
|
||||||
const COLUMN_WIDTH = 216;
|
const COLUMN_WIDTH = 216;
|
||||||
|
// Minimized list view: alias + up to 5 inline action buttons. Slightly wider
|
||||||
|
// than a single column so the alias has room next to the buttons.
|
||||||
|
const MINIMIZED_WIDTH = 260;
|
||||||
// Padding budget so the popup doesn't clip the tab strip: popup-inner has
|
// 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.
|
// 14px each side and the tab-bar adds 2px breathing room on either side.
|
||||||
const TAB_STRIP_PADDING = 32;
|
const TAB_STRIP_PADDING = 32;
|
||||||
@@ -114,7 +118,7 @@ export default function Popup({
|
|||||||
// or scroll horizontally inside the popup once they would push it off-screen.
|
// or scroll horizontally inside the popup once they would push it off-screen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (minimized) {
|
if (minimized) {
|
||||||
window.bookmarks.setPopupWidth(COLUMN_WIDTH);
|
window.bookmarks.setPopupWidth(MINIMIZED_WIDTH);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const colsWidth = Math.max(1, visibleColumns.length || 1) * COLUMN_WIDTH;
|
const colsWidth = Math.max(1, visibleColumns.length || 1) * COLUMN_WIDTH;
|
||||||
@@ -273,7 +277,16 @@ export default function Popup({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{minimized ? null : hasNoTabs ? (
|
{minimized ? (
|
||||||
|
<MinimizedList
|
||||||
|
tabs={tabs}
|
||||||
|
columns={columns}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
claudeAvailable={claudeAvailable}
|
||||||
|
inspectRevision={inspectRevision}
|
||||||
|
buttonVisibility={buttonVisibility}
|
||||||
|
/>
|
||||||
|
) : hasNoTabs ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
No tabs yet — click <strong>+</strong> in the tab bar to create your first.
|
No tabs yet — click <strong>+</strong> in the tab bar to create your first.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -390,6 +390,14 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flip the menu above the row for the last bookmark in a column — otherwise
|
||||||
|
it overflows the popup's bottom edge (absolutely-positioned children don't
|
||||||
|
feed into popup-inner.offsetHeight, so auto-resize can't grow to fit). */
|
||||||
|
.bookmark-menu.drop-up {
|
||||||
|
top: auto;
|
||||||
|
bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
.bookmark-menu button {
|
.bookmark-menu button {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -818,6 +826,90 @@ button.danger-button:hover:not(:disabled) {
|
|||||||
background: #f78787;
|
background: #f78787;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Minimized list view --- */
|
||||||
|
|
||||||
|
.mini-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-group-header {
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 2px 2px 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 4px 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-alias {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.mini-btn {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-status {
|
||||||
|
flex: 1 0 100%;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-status.error { background: rgba(239, 107, 107, 0.12); color: var(--error); }
|
||||||
|
.mini-status.info { background: rgba(127, 201, 127, 0.12); color: var(--ok); }
|
||||||
|
|
||||||
/* --- Submenu inside column overflow --- */
|
/* --- Submenu inside column overflow --- */
|
||||||
|
|
||||||
.bookmark-submenu {
|
.bookmark-submenu {
|
||||||
|
|||||||
Reference in New Issue
Block a user