Mark fbfdee21a5 feat: redesign workspace UI — glassmorphism dropdowns, Chrome-style tabs, chat popover
- Move chat history from left sidebar to floating popover on tab bar
- Add dench-ui components (Button, Card, Input, Label, Switch) with deps
- Glassmorphism styling for all dropdowns/context menus with dark mode
- Chrome-style active tab that merges with content area
- Align sidebar header with tab bar (34px)
- Condense sidebar header to single line
- Move sidebar expand button into tab bar
- Add next-themes for proper dark mode with system preference support
- Add Tailwind v4 class-based dark mode via @custom-variant
- Add dench-ui CSS tokens (light + dark)
- Restore pointer cursor for all interactive elements
- New chat button always visible in tab bar
- "Delete this chat" label in dropdown menu

Made-with: Cursor
2026-03-12 13:30:04 -07:00

237 lines
7.2 KiB
TypeScript

"use client";
import { useEffect, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
// --- Types ---
export type ContextMenuAction =
| "open"
| "newFile"
| "newFolder"
| "rename"
| "duplicate"
| "copy"
| "paste"
| "moveTo"
| "getInfo"
| "delete";
export type ContextMenuItem = {
action: ContextMenuAction;
label: string;
shortcut?: string;
icon?: React.ReactNode;
disabled?: boolean;
danger?: boolean;
separator?: false;
} | {
separator: true;
};
export type ContextMenuTarget =
| { kind: "file"; path: string; name: string; isSystem: boolean }
| { kind: "folder"; path: string; name: string; isSystem: boolean }
| { kind: "empty" };
// --- Menu item definitions per target kind ---
function getMenuItems(target: ContextMenuTarget): ContextMenuItem[] {
const isSystem = target.kind !== "empty" && target.isSystem;
if (target.kind === "file") {
return [
{ action: "open", label: "Open" },
{ separator: true },
{ action: "rename", label: "Rename", shortcut: "Enter", disabled: isSystem },
{ action: "duplicate", label: "Duplicate", shortcut: "\u2318D", disabled: isSystem },
{ action: "copy", label: "Copy Path", shortcut: "\u2318C" },
{ separator: true },
{ action: "getInfo", label: "Get Info", shortcut: "\u2318I" },
{ separator: true },
{ action: "delete", label: "Move to Trash", shortcut: "\u2318\u232B", disabled: isSystem, danger: true },
];
}
if (target.kind === "folder") {
return [
{ action: "open", label: "Open" },
{ separator: true },
{ action: "newFile", label: "New File", shortcut: "\u2318N", disabled: isSystem },
{ action: "newFolder", label: "New Folder", shortcut: "\u21E7\u2318N", disabled: isSystem },
{ separator: true },
{ action: "rename", label: "Rename", shortcut: "Enter", disabled: isSystem },
{ action: "duplicate", label: "Duplicate", shortcut: "\u2318D", disabled: isSystem },
{ action: "copy", label: "Copy Path", shortcut: "\u2318C" },
{ separator: true },
{ action: "getInfo", label: "Get Info", shortcut: "\u2318I" },
{ separator: true },
{ action: "delete", label: "Move to Trash", shortcut: "\u2318\u232B", disabled: isSystem, danger: true },
];
}
// Empty area
return [
{ action: "newFile", label: "New File", shortcut: "\u2318N" },
{ action: "newFolder", label: "New Folder", shortcut: "\u21E7\u2318N" },
{ separator: true },
{ action: "paste", label: "Paste", shortcut: "\u2318V", disabled: true },
];
}
// --- Lock icon for system files ---
function LockIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5 }}>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
// --- Context Menu Component ---
type ContextMenuProps = {
x: number;
y: number;
target: ContextMenuTarget;
onAction: (action: ContextMenuAction) => void;
onClose: () => void;
};
export function ContextMenu({ x, y, target, onAction, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const items = getMenuItems(target);
const isSystem = target.kind !== "empty" && target.isSystem;
// Clamp position to viewport
const clampedPos = useRef({ x, y });
useEffect(() => {
const el = menuRef.current;
if (!el) {return;}
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let cx = x;
let cy = y;
if (cx + rect.width > vw - 8) {cx = vw - rect.width - 8;}
if (cy + rect.height > vh - 8) {cy = vh - rect.height - 8;}
if (cx < 8) {cx = 8;}
if (cy < 8) {cy = 8;}
clampedPos.current = { x: cx, y: cy };
el.style.left = `${cx}px`;
el.style.top = `${cy}px`;
}, [x, y]);
// Close on click-outside, escape, scroll
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {onClose();}
}
function handleScroll() {
onClose();
}
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("scroll", handleScroll, true);
};
}, [onClose]);
const handleItemClick = useCallback(
(action: ContextMenuAction, disabled?: boolean) => {
if (disabled) {return;}
onAction(action);
onClose();
},
[onAction, onClose],
);
return createPortal(
<div
ref={menuRef}
className="fixed z-[9999] min-w-[200px] p-1 rounded-2xl bg-neutral-100/[0.67] dark:bg-neutral-900/[0.67] border border-white dark:border-white/10 backdrop-blur-md shadow-[0_0_25px_0_rgba(0,0,0,0.16)]"
style={{
left: x,
top: y,
animation: "contextMenuFadeIn 100ms ease-out",
}}
role="menu"
>
{/* System file badge */}
{isSystem && (
<div
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px]"
style={{ color: "var(--color-text-muted)" }}
>
<LockIcon />
<span>System file (locked)</span>
</div>
)}
{items.map((item, i) => {
if ("separator" in item && item.separator) {
return (
<div
key={`sep-${i}`}
className="my-0.5 mx-1 h-px bg-neutral-400/15"
/>
);
}
const menuItem = item;
const isDisabled = menuItem.disabled;
return (
<button
key={menuItem.action}
type="button"
role="menuitem"
disabled={isDisabled}
className={`w-full flex items-center gap-2 px-2.5 py-1.5 text-[13px] text-left rounded-xl transition-all ${isDisabled ? "opacity-50" : "hover:bg-neutral-400/15"}`}
style={{
color: isDisabled
? "var(--color-text-muted)"
: menuItem.danger
? "var(--color-error)"
: "var(--color-text)",
}}
onClick={() => handleItemClick(menuItem.action, isDisabled)}
>
{menuItem.icon}
<span className="flex-1">{menuItem.label}</span>
{isDisabled && isSystem && <LockIcon />}
{menuItem.shortcut && (
<span
className="text-[11px] ml-4"
style={{ color: "var(--color-text-muted)" }}
>
{menuItem.shortcut}
</span>
)}
</button>
);
})}
{/* Global animation style */}
<style>{`
@keyframes contextMenuFadeIn {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
</div>,
document.body,
);
}