This makes Dench apps behave like first-class workspace views with persistent tabs and embedded app loading instead of exposing raw folders.
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback, useEffect } from "react";
|
|
import { type Tab, HOME_TAB_ID } from "@/lib/tab-state";
|
|
import { appServeUrl } from "./app-viewer";
|
|
|
|
type TabBarProps = {
|
|
tabs: Tab[];
|
|
activeTabId: string | null;
|
|
onActivate: (tabId: string) => void;
|
|
onClose: (tabId: string) => void;
|
|
onCloseOthers: (tabId: string) => void;
|
|
onCloseToRight: (tabId: string) => void;
|
|
onCloseAll: () => void;
|
|
onReorder: (fromIndex: number, toIndex: number) => void;
|
|
onTogglePin: (tabId: string) => void;
|
|
};
|
|
|
|
type ContextMenuState = {
|
|
tabId: string;
|
|
x: number;
|
|
y: number;
|
|
} | null;
|
|
|
|
export function TabBar({
|
|
tabs,
|
|
activeTabId,
|
|
onActivate,
|
|
onClose,
|
|
onCloseOthers,
|
|
onCloseToRight,
|
|
onCloseAll,
|
|
onReorder,
|
|
onTogglePin,
|
|
}: TabBarProps) {
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!contextMenu) return;
|
|
const close = () => setContextMenu(null);
|
|
window.addEventListener("click", close);
|
|
window.addEventListener("contextmenu", close);
|
|
return () => {
|
|
window.removeEventListener("click", close);
|
|
window.removeEventListener("contextmenu", close);
|
|
};
|
|
}, [contextMenu]);
|
|
|
|
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setContextMenu({ tabId, x: e.clientX, y: e.clientY });
|
|
}, []);
|
|
|
|
const handleMiddleClick = useCallback((e: React.MouseEvent, tabId: string) => {
|
|
if (e.button === 1) {
|
|
e.preventDefault();
|
|
onClose(tabId);
|
|
}
|
|
}, [onClose]);
|
|
|
|
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
|
setDragIndex(index);
|
|
e.dataTransfer.effectAllowed = "move";
|
|
e.dataTransfer.setData("text/plain", String(index));
|
|
}, []);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
setDragOverIndex(index);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
|
|
e.preventDefault();
|
|
if (dragIndex !== null && dragIndex !== toIndex) {
|
|
onReorder(dragIndex, toIndex);
|
|
}
|
|
setDragIndex(null);
|
|
setDragOverIndex(null);
|
|
}, [dragIndex, onReorder]);
|
|
|
|
const handleDragEnd = useCallback(() => {
|
|
setDragIndex(null);
|
|
setDragOverIndex(null);
|
|
}, []);
|
|
|
|
if (tabs.length === 0) return null;
|
|
|
|
const contextTab = contextMenu ? tabs.find((t) => t.id === contextMenu.tabId) : null;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={scrollRef}
|
|
className="flex items-end overflow-x-auto flex-shrink-0"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
borderBottom: "1px solid var(--color-border)",
|
|
scrollbarWidth: "none",
|
|
}}
|
|
>
|
|
{tabs.map((tab, index) => {
|
|
const isActive = tab.id === activeTabId;
|
|
const isDragOver = dragOverIndex === index && dragIndex !== index;
|
|
const isHome = tab.id === HOME_TAB_ID;
|
|
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
draggable={!isHome}
|
|
onClick={() => onActivate(tab.id)}
|
|
onMouseDown={isHome ? undefined : (e) => handleMiddleClick(e, tab.id)}
|
|
onContextMenu={isHome ? undefined : (e) => handleContextMenu(e, tab.id)}
|
|
onDragStart={isHome ? undefined : (e) => handleDragStart(e, index)}
|
|
onDragOver={isHome ? undefined : (e) => handleDragOver(e, index)}
|
|
onDrop={isHome ? undefined : (e) => handleDrop(e, index)}
|
|
onDragEnd={isHome ? undefined : handleDragEnd}
|
|
className={`group flex items-center gap-1.5 h-[34px] text-[12.5px] font-medium cursor-pointer flex-shrink-0 relative transition-colors duration-75 select-none ${isHome ? "px-2.5" : "pl-3 pr-1.5"}`}
|
|
style={{
|
|
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
|
|
background: isActive ? "var(--color-bg)" : "transparent",
|
|
borderBottom: isActive ? "2px solid var(--color-accent)" : "2px solid transparent",
|
|
borderLeft: isDragOver && !isHome ? "2px solid var(--color-accent)" : "2px solid transparent",
|
|
opacity: dragIndex === index ? 0.5 : 1,
|
|
maxWidth: isHome ? undefined : 200,
|
|
borderRight: isHome ? "1px solid var(--color-border)" : undefined,
|
|
}}
|
|
title={isHome ? "Home (New Chat)" : undefined}
|
|
>
|
|
{isHome ? (
|
|
<HomeIcon />
|
|
) : (
|
|
<>
|
|
{tab.pinned && <PinIcon />}
|
|
<TabIcon type={tab.type} icon={tab.icon} appPath={tab.path} />
|
|
<span className="truncate max-w-[140px]">{tab.title}</span>
|
|
{!tab.pinned && (
|
|
<span
|
|
role="button"
|
|
tabIndex={-1}
|
|
onClick={(e) => { e.stopPropagation(); onClose(tab.id); }}
|
|
onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); onClose(tab.id); } }}
|
|
className="ml-0.5 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
|
}}
|
|
>
|
|
<CloseIcon />
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Context menu */}
|
|
{contextMenu && contextTab && (
|
|
<div
|
|
className="fixed z-[9999] min-w-[180px] rounded-lg border py-1 shadow-lg"
|
|
style={{
|
|
left: contextMenu.x,
|
|
top: contextMenu.y,
|
|
background: "var(--color-surface)",
|
|
borderColor: "var(--color-border)",
|
|
}}
|
|
>
|
|
<ContextMenuItem
|
|
label={contextTab.pinned ? "Unpin Tab" : "Pin Tab"}
|
|
onClick={() => { onTogglePin(contextMenu.tabId); setContextMenu(null); }}
|
|
/>
|
|
<div className="h-px my-1" style={{ background: "var(--color-border)" }} />
|
|
<ContextMenuItem
|
|
label="Close"
|
|
shortcut="⌘W"
|
|
disabled={contextTab.pinned}
|
|
onClick={() => { onClose(contextMenu.tabId); setContextMenu(null); }}
|
|
/>
|
|
<ContextMenuItem
|
|
label="Close Others"
|
|
onClick={() => { onCloseOthers(contextMenu.tabId); setContextMenu(null); }}
|
|
/>
|
|
<ContextMenuItem
|
|
label="Close to the Right"
|
|
onClick={() => { onCloseToRight(contextMenu.tabId); setContextMenu(null); }}
|
|
/>
|
|
<ContextMenuItem
|
|
label="Close All"
|
|
onClick={() => { onCloseAll(); setContextMenu(null); }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ContextMenuItem({
|
|
label,
|
|
shortcut,
|
|
disabled,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
shortcut?: string;
|
|
disabled?: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
className="w-full flex items-center justify-between px-3 py-1.5 text-[12.5px] text-left transition-colors disabled:opacity-40 cursor-pointer disabled:cursor-default"
|
|
style={{ color: "var(--color-text)" }}
|
|
onMouseEnter={(e) => {
|
|
if (!disabled) (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
|
}}
|
|
>
|
|
<span>{label}</span>
|
|
{shortcut && (
|
|
<span className="ml-4 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
|
{shortcut}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function TabIcon({ type, icon, appPath }: { type: string; icon?: string; appPath?: string }) {
|
|
if (icon && appPath && (icon.endsWith(".png") || icon.endsWith(".svg") || icon.endsWith(".jpg") || icon.endsWith(".jpeg") || icon.endsWith(".webp"))) {
|
|
return (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={appServeUrl(appPath, icon)}
|
|
alt=""
|
|
width={14}
|
|
height={14}
|
|
className="rounded-sm flex-shrink-0"
|
|
style={{ objectFit: "cover" }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
switch (type) {
|
|
case "home":
|
|
return <HomeIcon />;
|
|
case "app":
|
|
return <AppIcon />;
|
|
case "chat":
|
|
return <ChatIcon />;
|
|
case "cron":
|
|
return <CronIcon />;
|
|
case "object":
|
|
return <ObjectIcon />;
|
|
default:
|
|
return <FileIcon />;
|
|
}
|
|
}
|
|
|
|
function CloseIcon() {
|
|
return (
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function PinIcon() {
|
|
return (
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" stroke="none" className="flex-shrink-0" style={{ opacity: 0.5 }}>
|
|
<circle cx="12" cy="12" r="4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function HomeIcon() {
|
|
return (
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.7 }}>
|
|
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
<polyline points="9 22 9 12 15 12 15 22" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function FileIcon() {
|
|
return (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
|
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function AppIcon() {
|
|
return (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
|
<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" />
|
|
<rect width="7" height="7" x="3" y="14" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ChatIcon() {
|
|
return (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CronIcon() {
|
|
return (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
|
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ObjectIcon() {
|
|
return (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
<path d="M3 9h18" /><path d="M9 21V9" />
|
|
</svg>
|
|
);
|
|
}
|