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">
|
||||
<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" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function BookmarkRow({
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
>⋯</button>
|
||||
{menuOpen && (
|
||||
<div className="bookmark-menu">
|
||||
<div className={`bookmark-menu${!canMoveDown ? ' drop-up' : ''}`}>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); onMoveUp(); }}
|
||||
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 TabBar from './TabBar.jsx';
|
||||
import ConfirmDialog from './ConfirmDialog.jsx';
|
||||
import MinimizedList from './MinimizedList.jsx';
|
||||
import iconUrl from '../../assets/tray-icon.png';
|
||||
|
||||
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
|
||||
// 14px each side and the tab-bar adds 2px breathing room on either side.
|
||||
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.
|
||||
useEffect(() => {
|
||||
if (minimized) {
|
||||
window.bookmarks.setPopupWidth(COLUMN_WIDTH);
|
||||
window.bookmarks.setPopupWidth(MINIMIZED_WIDTH);
|
||||
return;
|
||||
}
|
||||
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">
|
||||
No tabs yet — click <strong>+</strong> in the tab bar to create your first.
|
||||
</div>
|
||||
|
||||
@@ -390,6 +390,14 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
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 {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -818,6 +826,90 @@ button.danger-button:hover:not(:disabled) {
|
||||
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 --- */
|
||||
|
||||
.bookmark-submenu {
|
||||
|
||||
Reference in New Issue
Block a user