Files
cadSet/text-to-cad/viewer/src/client/components/workbench/CadWorkspaceTopBar.js
T
2026-07-22 19:38:36 +08:00

1324 lines
46 KiB
JavaScript

import { Fragment, useEffect, useRef, useState } from "react";
import {
Bot,
Boxes,
Check,
CircleCheck,
Code,
Copy,
Cuboid,
DraftingCompass,
FileBox,
Folder,
Layers3,
LoaderCircle,
Moon,
Package,
Route,
SlidersHorizontal,
Sun
} from "lucide-react";
import {
DEFAULT_VIEWER_SKILLS_INSTALL_COMMAND,
isViewerReleaseMajorMinorNewer,
isViewerReleaseNewer,
viewerGithubLatestReleaseApiUrl,
viewerGithubLatestReleaseUrl,
normalizeViewerDiscordUrl,
normalizeViewerGithubUrl,
viewerGithubReleaseUrl,
viewerSkillsInstallCommandFromText
} from "../../../shared/viewerConfig.mjs";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SidebarTrigger } from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip";
import { cn } from "@/ui/utils";
import { copyTextToClipboard } from "@/ui/clipboard";
import {
themeSettingsSupportsSystemColorMode
} from "cadjs/lib/themeSettings";
import {
DARK_COLOR_SCHEME_ID,
LIGHT_COLOR_SCHEME_ID
} from "@/ui/colorScheme";
import {
ENTRY_ICON_KIND,
entryIconKind
} from "@/workbench/entryIconKind";
import { entryIconStatus } from "@/workbench/entryIconStatus";
import { ThemePresetDropdown } from "./ThemeSettingsPopover";
import FileAccessContextMenu from "./FileAccessContextMenu";
import {
fileKey,
listSidebarItems,
} from "@/workbench/sidebar";
import {
buildBreadcrumbNodes,
collapsedBreadcrumbNodes,
directoryTitle,
ellipsisBreadcrumbMenuDirectory
} from "@/workbench/breadcrumbs";
function fileSheetLabel(fileSheetKind) {
if (fileSheetKind === "dxf") {
return "DXF sheet";
}
if (fileSheetKind === "urdf") {
return "URDF sheet";
}
if (fileSheetKind === "srdf") {
return "SRDF sheet";
}
if (fileSheetKind === "sdf") {
return "SDF sheet";
}
if (fileSheetKind === "step") {
return "STEP sheet";
}
if (fileSheetKind === "implicit") {
return "Implicit CAD sheet";
}
return "file sheet";
}
function sourceFormatForEntry(entry, entrySourceFormat) {
const sourceFormat = typeof entrySourceFormat === "function"
? entrySourceFormat(entry)
: (entry?.kind || "");
return String(sourceFormat || "").trim().toLowerCase();
}
function entryStatusForMenu(entry, {
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles = [],
activeStepArtifactGenerationFile = "",
stepArtifactGenerationAvailable = true
}) {
const sourceFormat = sourceFormatForEntry(entry, entrySourceFormat);
const hasDxf = typeof entryHasDxf === "function" ? entryHasDxf(entry) : true;
const hasGcode = typeof entryHasGcode === "function" ? entryHasGcode(entry) : true;
const hasUrdf = typeof entryHasUrdf === "function" ? entryHasUrdf(entry) : true;
const hasMesh = typeof entryHasMesh === "function" ? entryHasMesh(entry) : true;
return entryIconStatus(entry, {
sourceFormat,
entryKey: fileKey(entry),
hasMesh,
hasDxf,
hasGcode,
hasUrdf,
activeGenerationFiles,
activeStepArtifactGenerationFile,
stepArtifactGenerationAvailable
});
}
const ENTRY_ICON_COMPONENTS = {
[ENTRY_ICON_KIND.LOADING]: LoaderCircle,
[ENTRY_ICON_KIND.ASSEMBLY]: Boxes,
[ENTRY_ICON_KIND.DXF]: DraftingCompass,
[ENTRY_ICON_KIND.GCODE]: Route,
[ENTRY_ICON_KIND.IMPLICIT]: Code,
[ENTRY_ICON_KIND.ROBOT]: Bot,
[ENTRY_ICON_KIND.STEP_PART]: Package,
[ENTRY_ICON_KIND.STL_MESH]: Cuboid,
[ENTRY_ICON_KIND.THREE_MF_MESH]: Layers3,
[ENTRY_ICON_KIND.GLB_MESH]: FileBox
};
function iconForEntry(entry, sourceFormat, status) {
return ENTRY_ICON_COMPONENTS[entryIconKind(entry, { sourceFormat, status })] || Package;
}
function BreadcrumbEntryMenuItem({
entry,
selectedKey,
onSelectEntry,
sidebarLabelForEntry,
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles = [],
activeStepArtifactGenerationFile = "",
stepArtifactGenerationAvailable = true
}) {
const key = fileKey(entry);
const active = key === selectedKey;
const label = typeof sidebarLabelForEntry === "function"
? sidebarLabelForEntry(entry)
: key;
const status = entryStatusForMenu(entry, {
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles,
activeStepArtifactGenerationFile,
stepArtifactGenerationAvailable
});
const { sourceFormat } = status;
const EntryIcon = iconForEntry(entry, sourceFormat, status);
const title = [
label,
status.statusLabel,
entry?.kind,
String(entry?.file || key)
].filter(Boolean).join(" | ");
return (
<DropdownMenuItem
data-active={active}
className={cn(
"min-w-0 max-w-80 text-xs focus:bg-sidebar-accent focus:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground"
)}
title={title}
disabled={!key || typeof onSelectEntry !== "function"}
aria-current={active ? "page" : undefined}
onSelect={() => {
if (key && typeof onSelectEntry === "function") {
onSelectEntry(key);
}
}}
>
<EntryIcon
className={cn(
"size-3.5 shrink-0",
status.loading && "animate-spin"
)}
aria-hidden="true"
/>
<span className="block min-w-0 flex-1 truncate">{label}</span>
</DropdownMenuItem>
);
}
function BreadcrumbDirectoryMenuItems({
directory,
selectedKey,
onSelectEntry,
sidebarLabelForEntry,
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles = [],
activeStepArtifactGenerationFile = "",
stepArtifactGenerationAvailable = true,
canRevealFileAssets = false,
canCopyFileAssetLinks = false,
canCopyFileAssetPaths = false,
fileAccessBusyKey = "",
onDownloadFileAsset,
onExportImplicitFile,
onRevealFileAsset,
onCopyFileAssetReference
}) {
const items = listSidebarItems(directory);
if (!items.length) {
return (
<DropdownMenuItem disabled className="max-w-80 text-xs">
<Folder className="size-3.5 shrink-0" aria-hidden="true" />
<span className="block min-w-0 truncate">{String(directory?.name || "Empty")}</span>
</DropdownMenuItem>
);
}
return items.map((item) => {
if (item.type === "directory") {
return (
<BreadcrumbDirectorySubMenu
key={item.key}
directory={item.value}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
sidebarLabelForEntry={sidebarLabelForEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onCopyFileAssetReference={onCopyFileAssetReference}
/>
);
}
return (
<BreadcrumbEntryMenuItem
key={item.key}
entry={item.value}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
sidebarLabelForEntry={sidebarLabelForEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onCopyFileAssetReference={onCopyFileAssetReference}
/>
);
});
}
function DropdownMenuScrollArea({ children }) {
return (
<ScrollArea
className="max-h-96 w-full"
type="auto"
viewportClassName="max-h-96"
>
{children}
</ScrollArea>
);
}
function BreadcrumbDirectorySubMenu({
directory,
label = "",
title: titleProp = "",
selectedKey,
onSelectEntry,
sidebarLabelForEntry,
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles = [],
activeStepArtifactGenerationFile = "",
stepArtifactGenerationAvailable = true,
canRevealFileAssets = false,
canCopyFileAssetLinks = false,
canCopyFileAssetPaths = false,
fileAccessBusyKey = "",
onDownloadFileAsset,
onExportImplicitFile,
onRevealFileAsset,
onCopyFileAssetReference
}) {
const labelText = String(label || directory?.name || "Folder");
const title = String(titleProp || directoryTitle(directory));
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="min-w-0 max-w-80 text-xs"
title={title}
>
<Folder className="size-3.5 shrink-0" aria-hidden="true" />
<span className="block min-w-0 flex-1 truncate">{labelText}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-max max-w-80">
<DropdownMenuScrollArea>
<BreadcrumbDirectoryMenuItems
directory={directory}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
sidebarLabelForEntry={sidebarLabelForEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onCopyFileAssetReference={onCopyFileAssetReference}
/>
</DropdownMenuScrollArea>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
function BreadcrumbNodeDropdown({
node,
current,
selectedKey,
onSelectEntry,
sidebarLabelForEntry,
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles = [],
activeStepArtifactGenerationFile = "",
stepArtifactGenerationAvailable = true,
selectedStepSourceStatus = null,
canRevealFileAssets = false,
canCopyFileAssetLinks = false,
canCopyFileAssetPaths = false,
fileAccessBusyKey = "",
onDownloadFileAsset,
onExportImplicitFile,
onRevealFileAsset,
onRevealInExplorerView,
onCopyFileAssetReference,
filenameLoadActivity
}) {
const label = String(node?.label || "");
const title = String(node?.title || label);
const menuDirectory = node?.type === "directory" || node?.type === "placeholder" || node?.type === "entry"
? node?.menuDirectory || null
: null;
const canBrowse = !!menuDirectory && listSidebarItems(menuDirectory).length > 0;
if (!canBrowse) {
const labelNode = (
<span
className={cn(
"inline-flex min-w-0 items-center gap-2 text-xs font-medium",
current ? "max-w-[min(36rem,55vw)] text-foreground" : "max-w-32"
)}
title={title}
>
<span className="block min-w-0 truncate">{label}</span>
{current && node?.type === "entry" ? (
<FilenameLoadStatus activity={filenameLoadActivity} />
) : null}
</span>
);
if (node?.type !== "entry" || !node?.entry) {
return labelNode;
}
return (
<FileAccessContextMenu
entry={node.entry}
stepSourceStatus={selectedStepSourceStatus}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
busyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onRevealInExplorerView={onRevealInExplorerView}
onCopyFileAssetReference={onCopyFileAssetReference}
>
{labelNode}
</FileAccessContextMenu>
);
}
const triggerButton = (
<button
type="button"
className={cn(
"inline-flex min-w-0 items-center gap-2 rounded-sm text-xs font-medium transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
current
? "max-w-[min(36rem,55vw)] text-foreground"
: "max-w-32 text-muted-foreground"
)}
aria-label={`Browse ${label}`}
aria-current={current ? "page" : undefined}
title={title}
onPointerDown={(event) => {
if (event.button === 1) {
event.preventDefault();
}
}}
>
<span className="block min-w-0 truncate">{label}</span>
{current && node?.type === "entry" ? (
<FilenameLoadStatus activity={filenameLoadActivity} />
) : null}
</button>
);
const dropdownTrigger = (
<DropdownMenuTrigger asChild>
{triggerButton}
</DropdownMenuTrigger>
);
const trigger = node?.type === "entry" && node?.entry ? (
<FileAccessContextMenu
entry={node.entry}
stepSourceStatus={selectedStepSourceStatus}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
busyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onRevealInExplorerView={onRevealInExplorerView}
onCopyFileAssetReference={onCopyFileAssetReference}
>
{dropdownTrigger}
</FileAccessContextMenu>
) : dropdownTrigger;
return (
<DropdownMenu>
{trigger}
<DropdownMenuContent align="start" sideOffset={6} className="w-max max-w-80">
<DropdownMenuScrollArea>
<BreadcrumbDirectoryMenuItems
directory={menuDirectory}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
sidebarLabelForEntry={sidebarLabelForEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onCopyFileAssetReference={onCopyFileAssetReference}
/>
</DropdownMenuScrollArea>
</DropdownMenuContent>
</DropdownMenu>
);
}
function BreadcrumbEllipsisDropdown({
nodes,
selectedKey,
onSelectEntry,
sidebarLabelForEntry,
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles = [],
activeStepArtifactGenerationFile = "",
stepArtifactGenerationAvailable = true,
canRevealFileAssets = false,
canCopyFileAssetLinks = false,
canCopyFileAssetPaths = false,
fileAccessBusyKey = "",
onDownloadFileAsset,
onExportImplicitFile,
onRevealFileAsset,
onCopyFileAssetReference,
title
}) {
const hiddenNodes = Array.isArray(nodes) ? nodes.filter(Boolean) : [];
const menuTitle = hiddenNodes.map((node) => node.label).join(" / ") || title;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-6 min-w-7 items-center justify-center rounded-md px-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Show collapsed path folders"
title={menuTitle}
>
<span aria-hidden="true">...</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={6} className="w-max max-w-80">
<DropdownMenuScrollArea>
{hiddenNodes.map((node, index) => {
const directory = ellipsisBreadcrumbMenuDirectory(node);
if (node.type === "directory" && directory) {
return (
<BreadcrumbDirectorySubMenu
key={`${node.type}:${node.id}:${index}`}
directory={directory}
label={node.label}
title={node.title}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
sidebarLabelForEntry={sidebarLabelForEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onCopyFileAssetReference={onCopyFileAssetReference}
/>
);
}
if (node.type === "entry" && node.entry) {
return (
<BreadcrumbEntryMenuItem
key={`${node.type}:${fileKey(node.entry)}:${index}`}
entry={node.entry}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
sidebarLabelForEntry={sidebarLabelForEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onCopyFileAssetReference={onCopyFileAssetReference}
/>
);
}
return (
<DropdownMenuItem key={`${node.type}:${node.label}:${index}`} disabled className="max-w-80 text-xs">
<span className="block min-w-0 truncate">{node.label}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuScrollArea>
</DropdownMenuContent>
</DropdownMenu>
);
}
function FilenameLoadStatus({ activity }) {
const label = String(activity?.label || "").trim();
if (!activity?.loading || !label) {
return null;
}
const title = String(activity?.title || label).trim();
return (
<span
role="status"
aria-live="polite"
title={title}
className="inline-flex min-w-0 max-w-36 shrink items-center gap-1 rounded-md border border-border/70 bg-sidebar-accent px-1.5 py-0.5 text-[10px] font-medium leading-none text-sidebar-accent-foreground"
>
<LoaderCircle className="size-3 shrink-0 animate-spin" aria-hidden="true" />
<span className="min-w-0 truncate">{label}</span>
</span>
);
}
function DiscordMark(props) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
<path d="M20.32 4.37a19.8 19.8 0 0 0-4.89-1.51.07.07 0 0 0-.07.03c-.21.38-.44.86-.61 1.25a18.27 18.27 0 0 0-5.49 0 12.64 12.64 0 0 0-.62-1.25.08.08 0 0 0-.07-.03 19.74 19.74 0 0 0-4.89 1.51.07.07 0 0 0-.03.03C.53 9.05-.32 13.58.1 18.06a.08.08 0 0 0 .03.06 19.9 19.9 0 0 0 5.99 3.03.08.08 0 0 0 .08-.03c.46-.63.87-1.3 1.23-1.99a.08.08 0 0 0-.04-.11 13.1 13.1 0 0 1-1.87-.89.08.08 0 0 1-.01-.13c.13-.09.25-.19.37-.29a.07.07 0 0 1 .08-.01c3.93 1.79 8.18 1.79 12.06 0a.07.07 0 0 1 .08.01c.12.1.25.2.37.29a.08.08 0 0 1-.01.13 12.3 12.3 0 0 1-1.87.89.08.08 0 0 0-.04.11c.36.7.77 1.36 1.23 1.99a.08.08 0 0 0 .08.03 19.84 19.84 0 0 0 6-3.03.08.08 0 0 0 .03-.05c.5-5.18-.84-9.67-3.55-13.66a.06.06 0 0 0-.02-.04ZM8.02 15.33c-1.18 0-2.16-1.09-2.16-2.42s.96-2.42 2.16-2.42c1.21 0 2.18 1.1 2.16 2.42 0 1.33-.96 2.42-2.16 2.42Zm7.98 0c-1.18 0-2.16-1.09-2.16-2.42s.96-2.42 2.16-2.42c1.21 0 2.18 1.1 2.16 2.42 0 1.33-.95 2.42-2.16 2.42Z" />
</svg>
);
}
const topBarIconButtonClasses = "size-7";
const topBarIconClasses = "size-4";
const latestReleaseCacheKeyPrefix = "cad-viewer:latest-release:v1:";
const latestReleaseCacheTtlMs = 6 * 60 * 60 * 1000;
const updateVersionTooltipDelayMs = 250;
const passiveVersionTooltipDelayMs = 700;
const emptyLatestReleaseCheck = Object.freeze({
updateAvailable: false,
latestVersion: "",
releaseUrl: "",
installCommand: DEFAULT_VIEWER_SKILLS_INSTALL_COMMAND,
latestReleaseNewer: false
});
function nextColorMode(currentColorMode) {
return currentColorMode === DARK_COLOR_SCHEME_ID
? LIGHT_COLOR_SCHEME_ID
: DARK_COLOR_SCHEME_ID;
}
function latestReleaseCacheKey(apiUrl) {
return `${latestReleaseCacheKeyPrefix}${apiUrl}`;
}
function readLatestReleaseCache(apiUrl, now = Date.now()) {
if (!apiUrl || typeof window === "undefined" || !window.localStorage) {
return null;
}
try {
const rawValue = window.localStorage.getItem(latestReleaseCacheKey(apiUrl));
const value = rawValue ? JSON.parse(rawValue) : null;
const expiresAt = Number(value?.expiresAt || 0);
const latestVersion = String(value?.latestVersion || "").trim();
const releaseUrl = String(value?.releaseUrl || "").trim();
const installCommand = String(value?.installCommand || "").trim();
if (!latestVersion || expiresAt <= now) {
return null;
}
return { latestVersion, releaseUrl, installCommand };
} catch {
return null;
}
}
function writeLatestReleaseCache(apiUrl, release, now = Date.now()) {
if (!apiUrl || typeof window === "undefined" || !window.localStorage) {
return;
}
const latestVersion = String(release?.latestVersion || "").trim();
if (!latestVersion) {
return;
}
try {
window.localStorage.setItem(latestReleaseCacheKey(apiUrl), JSON.stringify({
latestVersion,
releaseUrl: String(release?.releaseUrl || "").trim(),
installCommand: String(release?.installCommand || "").trim(),
expiresAt: now + latestReleaseCacheTtlMs
}));
} catch {
// Local storage availability is browser-policy dependent; the release check is optional.
}
}
function latestReleaseFromPayload(payload, fallbackReleaseUrl = "") {
const latestVersion = String(payload?.tag_name || "").trim();
if (!latestVersion) {
return null;
}
return {
latestVersion,
releaseUrl: String(payload?.html_url || fallbackReleaseUrl || "").trim(),
installCommand: viewerSkillsInstallCommandFromText(
payload?.body,
DEFAULT_VIEWER_SKILLS_INSTALL_COMMAND
)
};
}
function latestReleaseCheckState(currentVersion, release) {
const latestVersion = String(release?.latestVersion || "").trim();
const releaseUrl = String(release?.releaseUrl || "").trim();
const installCommand = String(release?.installCommand || DEFAULT_VIEWER_SKILLS_INSTALL_COMMAND).trim();
if (!latestVersion) {
return emptyLatestReleaseCheck;
}
const latestReleaseNewer = isViewerReleaseNewer(currentVersion, latestVersion);
return {
updateAvailable: isViewerReleaseMajorMinorNewer(currentVersion, latestVersion),
latestVersion,
releaseUrl,
installCommand,
latestReleaseNewer
};
}
function useViewerLatestReleaseCheck({
currentVersion,
latestReleaseApiUrl,
latestReleaseUrl,
mockLatestVersion = "",
mockLatestReleaseUrl = ""
}) {
const [releaseCheck, setReleaseCheck] = useState(emptyLatestReleaseCheck);
useEffect(() => {
const version = String(currentVersion || "").trim();
const apiUrl = String(latestReleaseApiUrl || "").trim();
const mockedVersion = String(mockLatestVersion || "").trim();
if (!version) {
setReleaseCheck(emptyLatestReleaseCheck);
return undefined;
}
if (mockedVersion) {
setReleaseCheck(latestReleaseCheckState(version, {
latestVersion: mockedVersion,
releaseUrl: String(mockLatestReleaseUrl || latestReleaseUrl || "").trim(),
installCommand: DEFAULT_VIEWER_SKILLS_INSTALL_COMMAND
}));
return undefined;
}
if (!apiUrl || typeof fetch !== "function") {
setReleaseCheck(emptyLatestReleaseCheck);
return undefined;
}
const cachedRelease = readLatestReleaseCache(apiUrl);
if (cachedRelease) {
setReleaseCheck(latestReleaseCheckState(version, cachedRelease));
return undefined;
}
setReleaseCheck(emptyLatestReleaseCheck);
const controller = new AbortController();
fetch(apiUrl, {
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json"
}
})
.then((response) => {
if (!response.ok) {
throw new Error(`GitHub latest release check failed with ${response.status}`);
}
return response.json();
})
.then((payload) => {
if (controller.signal.aborted) {
return;
}
const release = latestReleaseFromPayload(payload, latestReleaseUrl);
if (!release) {
if (!cachedRelease) {
setReleaseCheck(emptyLatestReleaseCheck);
}
return;
}
writeLatestReleaseCache(apiUrl, release);
setReleaseCheck(latestReleaseCheckState(version, release));
})
.catch((error) => {
if (error?.name === "AbortError") {
return;
}
if (!cachedRelease) {
setReleaseCheck(emptyLatestReleaseCheck);
}
});
return () => {
controller.abort();
};
}, [currentVersion, latestReleaseApiUrl, latestReleaseUrl, mockLatestReleaseUrl, mockLatestVersion]);
return releaseCheck;
}
function VersionTooltipRow({ label, version, action = null }) {
const normalizedVersion = String(version || "").trim();
if (!normalizedVersion) {
return null;
}
return (
<div className="flex min-w-0 flex-col items-start gap-1.5 px-0.5 text-left">
<span className="text-[11px] font-medium leading-none text-muted-foreground">{label}</span>
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 text-left font-mono text-[12px] leading-5 text-foreground tabular-nums">
{normalizedVersion}
</span>
{action}
</div>
</div>
);
}
function VersionReleaseLink({ version, releaseUrl, releaseCheck = emptyLatestReleaseCheck }) {
const normalizedVersion = String(version || "").trim();
const [installCopyStatus, setInstallCopyStatus] = useState("");
const copyGestureHandledRef = useRef(false);
if (!normalizedVersion) {
return null;
}
const updateAvailable = Boolean(releaseCheck?.updateAvailable);
const targetReleaseUrl = updateAvailable
? String(releaseCheck?.releaseUrl || releaseUrl || "").trim()
: String(releaseUrl || "").trim();
const latestVersion = String(releaseCheck?.latestVersion || "").trim();
const latestReleaseNewer = Boolean(releaseCheck?.latestReleaseNewer);
const latestVersionVisible = latestVersion && latestReleaseNewer;
const latestReleaseUrl = latestVersionVisible
? String(releaseCheck?.releaseUrl || "").trim()
: "";
const installCommand = String(
releaseCheck?.installCommand || DEFAULT_VIEWER_SKILLS_INSTALL_COMMAND
).trim() || DEFAULT_VIEWER_SKILLS_INSTALL_COMMAND;
const upToDate = Boolean(latestVersion) && !latestReleaseNewer;
const label = updateAvailable
? "Update CAD Viewer"
: (targetReleaseUrl ? `Open release ${normalizedVersion}` : `Version ${normalizedVersion}`);
const handleCopyInstallCommand = async (event) => {
event.preventDefault();
event.stopPropagation();
if (copyGestureHandledRef.current) {
return;
}
copyGestureHandledRef.current = true;
globalThis.setTimeout(() => {
copyGestureHandledRef.current = false;
}, 250);
try {
await copyTextToClipboard(installCommand);
setInstallCopyStatus("copied");
globalThis.setTimeout(() => {
setInstallCopyStatus("");
}, 1600);
} catch {
setInstallCopyStatus("failed");
}
};
const releaseButton = (
<Button
asChild={Boolean(targetReleaseUrl)}
variant={updateAvailable ? "default" : "ghost"}
size="xs"
className={cn(
"inline-flex rounded-sm px-2 text-xs font-medium leading-none",
updateAvailable
? "h-6 px-2 text-[11px]"
: "h-7 text-muted-foreground tabular-nums hover:text-sidebar-foreground"
)}
aria-label={label}
>
{targetReleaseUrl ? (
<a href={targetReleaseUrl} target="_blank" rel="noreferrer">
<span className="inline-flex items-center gap-1">
{updateAvailable ? (
<span>Update</span>
) : (
<span>{normalizedVersion}</span>
)}
</span>
</a>
) : (
<span className="inline-flex items-center gap-1">
{updateAvailable ? (
<span>Update</span>
) : (
<span>{normalizedVersion}</span>
)}
</span>
)}
</Button>
);
return (
<Tooltip delayDuration={updateAvailable ? updateVersionTooltipDelayMs : passiveVersionTooltipDelayMs}>
<TooltipTrigger asChild>
{releaseButton}
</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="cad-glass-popover w-fit max-w-[calc(100vw-1rem)] border border-border bg-popover p-2 text-left text-popover-foreground shadow-lg shadow-black/10"
arrowClassName="bg-popover fill-popover"
>
<div className="inline-flex max-w-full flex-col gap-3">
{latestVersionVisible ? (
<div className="grid w-full min-w-0 grid-cols-2 gap-3">
<VersionTooltipRow
label="Current Version"
version={normalizedVersion}
/>
<VersionTooltipRow
label="Latest Version"
version={latestVersion}
action={latestReleaseUrl ? (
<Button
asChild
variant="default"
size="xs"
className="h-4 !min-h-0 rounded-sm !px-1.5 !py-0 text-[10px] font-medium leading-none"
aria-label={`Update CAD Viewer to ${latestVersion}`}
>
<a href={latestReleaseUrl} target="_blank" rel="noreferrer">
Update
</a>
</Button>
) : null}
/>
</div>
) : (
<VersionTooltipRow
label="Current Version"
version={normalizedVersion}
/>
)}
<div className="flex min-w-0 flex-col gap-1.5">
<div className="px-0.5 text-[11px] font-medium leading-none text-muted-foreground">Update Command</div>
<div className="flex h-8 min-w-0 items-center gap-2 rounded-sm border border-border/60 bg-muted/35 p-1 pl-2">
<code className="min-w-0 flex-1 whitespace-nowrap font-mono text-[11px] leading-5 text-foreground">
{installCommand}
</code>
<Button
type="button"
variant="ghost"
size="icon"
className="inline-flex size-6 shrink-0 items-center justify-center rounded-sm border border-border text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label={installCopyStatus === "copied" ? "Install command copied" : "Copy install command"}
onPointerDown={handleCopyInstallCommand}
onClick={handleCopyInstallCommand}
>
{installCopyStatus === "copied" ? (
<Check className="size-3" aria-hidden="true" />
) : (
<Copy className="size-3" aria-hidden="true" />
)}
</Button>
</div>
</div>
{installCopyStatus === "failed" ? (
<div className="text-[11px] text-muted-foreground">Copy failed</div>
) : null}
{upToDate ? (
<div className="flex items-center gap-1.5 px-0.5 text-[11px] font-medium text-muted-foreground">
<CircleCheck className="size-3 text-primary" aria-hidden="true" />
<span>You are up to date</span>
</div>
) : null}
</div>
</TooltipContent>
</Tooltip>
);
}
export default function CadWorkspaceTopBar({
previewMode,
sidebarLabelForEntry,
directoryTree = null,
selectedKey = "",
selectedEntry,
onSelectEntry,
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles = [],
activeStepArtifactGenerationFile = "",
stepArtifactGenerationAvailable = true,
themePresets = [],
themeSettings,
themePresetId = "",
resolvedColorSchemeMode = LIGHT_COLOR_SCHEME_ID,
onColorSchemePreferenceChange,
updateThemeSettings,
handleResetThemeSettings,
handleSaveCustomThemePreset,
handleUpdateThemePresetSettings,
handleDeleteCustomThemePreset,
handleEditThemePreset,
handleResetThemePresetToDefault,
handleRestoreDefaultThemePresets,
filenameLoadActivity = null,
selectedStepSourceStatus = null,
canRevealFileAssets = false,
canCopyFileAssetLinks = false,
canCopyFileAssetPaths = false,
fileAccessBusyKey = "",
onDownloadFileAsset,
onExportImplicitFile,
onRevealFileAsset,
onRevealInExplorerView,
onCopyFileAssetReference,
fileSheetKind = "",
fileSheetOpen = false,
onToggleFileSheet,
navigationAvailable = true
}) {
const discordUrl = normalizeViewerDiscordUrl(import.meta.env?.VIEWER_DISCORD_URL);
if (previewMode) {
return null;
}
const selectedFileLabel = selectedEntry && typeof sidebarLabelForEntry === "function"
? sidebarLabelForEntry(selectedEntry)
: "Select a file";
const selectedFileTitle = selectedEntry
? String(selectedEntry.file || selectedEntry.id || selectedFileLabel)
: selectedFileLabel;
const breadcrumbAvailable = navigationAvailable || Boolean(selectedEntry);
const breadcrumbNodes = buildBreadcrumbNodes({
directoryTree: navigationAvailable ? directoryTree : null,
selectedEntry,
selectedFileLabel,
selectedFileTitle
});
const breadcrumbItems = collapsedBreadcrumbNodes(breadcrumbNodes);
const mobileBreadcrumbNode = breadcrumbNodes[breadcrumbNodes.length - 1] || null;
const activeIconButtonClasses = "bg-accent text-accent-foreground";
const showFileSheetToggle = !!fileSheetKind && typeof onToggleFileSheet === "function";
const fileSheetToggleLabel = fileSheetOpen
? `Collapse ${fileSheetLabel(fileSheetKind)}`
: `Expand ${fileSheetLabel(fileSheetKind)}`;
const showThemeColorModeToggle = themeSettingsSupportsSystemColorMode(themeSettings);
const activeColorSchemeMode = resolvedColorSchemeMode === DARK_COLOR_SCHEME_ID
? DARK_COLOR_SCHEME_ID
: LIGHT_COLOR_SCHEME_ID;
const nextColorSchemeMode = nextColorMode(activeColorSchemeMode);
const activeColorSchemeModeLabel = activeColorSchemeMode === DARK_COLOR_SCHEME_ID ? "dark" : "light";
const nextColorSchemeModeLabel = nextColorSchemeMode === DARK_COLOR_SCHEME_ID ? "dark" : "light";
const colorModeToggleLabel = `Browser color mode: ${activeColorSchemeModeLabel}. Switch to ${nextColorSchemeModeLabel} mode.`;
const ColorModeIcon = activeColorSchemeMode === DARK_COLOR_SCHEME_ID ? Moon : Sun;
const handleThemeColorModeToggle = () => {
if (typeof onColorSchemePreferenceChange !== "function") {
return;
}
onColorSchemePreferenceChange(nextColorSchemeMode);
};
return (
<header
className="cad-glass-surface pointer-events-auto flex h-11 shrink-0 items-center gap-2 border-b border-sidebar-border px-2 text-sidebar-foreground"
>
{navigationAvailable ? (
<SidebarTrigger
title="Toggle CAD Viewer"
aria-label="Toggle CAD Viewer"
className="shrink-0"
/>
) : null}
{breadcrumbAvailable ? (
<Breadcrumb className="min-w-0 flex-1 overflow-hidden">
<ScrollArea
className="h-8 min-w-0 whitespace-nowrap"
type="auto"
viewportClassName="overflow-y-hidden"
scrollbars="horizontal"
>
{mobileBreadcrumbNode ? (
<BreadcrumbList className="flex h-8 min-w-full flex-nowrap gap-1.5 pr-2 text-xs sm:hidden">
<BreadcrumbItem className="min-w-0">
<BreadcrumbNodeDropdown
node={mobileBreadcrumbNode}
current
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
sidebarLabelForEntry={sidebarLabelForEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
selectedStepSourceStatus={selectedStepSourceStatus}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onRevealInExplorerView={onRevealInExplorerView}
onCopyFileAssetReference={onCopyFileAssetReference}
filenameLoadActivity={filenameLoadActivity}
/>
</BreadcrumbItem>
</BreadcrumbList>
) : null}
<BreadcrumbList className="hidden h-8 min-w-full w-max flex-nowrap gap-1.5 pr-2 text-xs sm:flex sm:gap-1.5">
{breadcrumbItems.map((item, index) => (
<Fragment key={`${item.type}:${item.node?.type || ""}:${item.node?.id || item.node?.label || index}:${index}`}>
<BreadcrumbItem className="min-w-0">
{item.type === "ellipsis" ? (
<BreadcrumbEllipsisDropdown
nodes={item.nodes}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
sidebarLabelForEntry={sidebarLabelForEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onCopyFileAssetReference={onCopyFileAssetReference}
title={selectedFileTitle}
/>
) : (
<BreadcrumbNodeDropdown
node={item.node}
current={index === breadcrumbItems.length - 1}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
sidebarLabelForEntry={sidebarLabelForEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
selectedStepSourceStatus={selectedStepSourceStatus}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onRevealInExplorerView={onRevealInExplorerView}
onCopyFileAssetReference={onCopyFileAssetReference}
filenameLoadActivity={filenameLoadActivity}
/>
)}
</BreadcrumbItem>
{index < breadcrumbItems.length - 1 ? (
<BreadcrumbSeparator className="text-muted-foreground/60" />
) : null}
</Fragment>
))}
</BreadcrumbList>
</ScrollArea>
</Breadcrumb>
) : (
<div className="min-w-0 flex-1" />
)}
<TooltipProvider delayDuration={250}>
<div className="flex shrink-0 items-center gap-1.5">
<Button
asChild
variant="ghost"
size="icon-sm"
aria-label="Join the CAD Skills Discord"
title="Join the CAD Skills Discord"
className={topBarIconButtonClasses}
>
<a href={discordUrl} target="_blank" rel="noreferrer">
<DiscordMark className={topBarIconClasses} />
</a>
</Button>
<ThemePresetDropdown
themePresets={themePresets}
themeSettings={themeSettings}
themePresetId={themePresetId}
updateThemeSettings={updateThemeSettings}
handleResetThemeSettings={handleResetThemeSettings}
handleSaveCustomThemePreset={handleSaveCustomThemePreset}
handleUpdateThemePresetSettings={handleUpdateThemePresetSettings}
handleDeleteCustomThemePreset={handleDeleteCustomThemePreset}
handleEditThemePreset={handleEditThemePreset}
handleResetThemePresetToDefault={handleResetThemePresetToDefault}
handleRestoreDefaultThemePresets={handleRestoreDefaultThemePresets}
triggerClassName={topBarIconButtonClasses}
iconClassName={topBarIconClasses}
/>
{showThemeColorModeToggle ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={colorModeToggleLabel}
title={colorModeToggleLabel}
disabled={typeof onColorSchemePreferenceChange !== "function"}
onClick={handleThemeColorModeToggle}
className={topBarIconButtonClasses}
>
<ColorModeIcon className={topBarIconClasses} />
<span className="sr-only">{colorModeToggleLabel}</span>
</Button>
) : null}
{showFileSheetToggle ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={fileSheetToggleLabel}
title={fileSheetToggleLabel}
aria-pressed={fileSheetOpen}
onClick={onToggleFileSheet}
className={`${topBarIconButtonClasses} ${fileSheetOpen ? activeIconButtonClasses : ""}`}
>
<SlidersHorizontal className={topBarIconClasses} />
<span className="sr-only">{fileSheetToggleLabel}</span>
</Button>
) : null}
</div>
</TooltipProvider>
</header>
);
}