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 ( { if (key && typeof onSelectEntry === "function") { onSelectEntry(key); } }} > ); } 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 ( ); } return items.map((item) => { if (item.type === "directory") { return ( ); } return ( ); }); } function DropdownMenuScrollArea({ children }) { return ( {children} ); } 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 ( ); } 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 = ( {label} {current && node?.type === "entry" ? ( ) : null} ); if (node?.type !== "entry" || !node?.entry) { return labelNode; } return ( {labelNode} ); } const triggerButton = ( ); const dropdownTrigger = ( {triggerButton} ); const trigger = node?.type === "entry" && node?.entry ? ( {dropdownTrigger} ) : dropdownTrigger; return ( {trigger} ); } 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 ( {hiddenNodes.map((node, index) => { const directory = ellipsisBreadcrumbMenuDirectory(node); if (node.type === "directory" && directory) { return ( ); } if (node.type === "entry" && node.entry) { return ( ); } return ( {node.label} ); })} ); } function FilenameLoadStatus({ activity }) { const label = String(activity?.label || "").trim(); if (!activity?.loading || !label) { return null; } const title = String(activity?.title || label).trim(); return ( ); } function DiscordMark(props) { return ( ); } 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 (
{label}
{normalizedVersion} {action}
); } 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 = ( ); return ( {releaseButton}
{latestVersionVisible ? (
Update ) : null} />
) : ( )}
Update Command
{installCommand}
{installCopyStatus === "failed" ? (
Copy failed
) : null} {upToDate ? (
) : null}
); } 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 (
{navigationAvailable ? ( ) : null} {breadcrumbAvailable ? ( {mobileBreadcrumbNode ? ( ) : null} {breadcrumbItems.map((item, index) => ( {item.type === "ellipsis" ? ( ) : ( )} {index < breadcrumbItems.length - 1 ? ( ) : null} ))} ) : (
)}
{showThemeColorModeToggle ? ( ) : null} {showFileSheetToggle ? ( ) : null}
); }