diff --git a/apps/desktop/src/components/workspace/sidebar.tsx b/apps/desktop/src/components/workspace/sidebar.tsx index b5de39c..899de5f 100644 --- a/apps/desktop/src/components/workspace/sidebar.tsx +++ b/apps/desktop/src/components/workspace/sidebar.tsx @@ -17,6 +17,7 @@ import { ListIcon, HashIcon, GithubIcon, + PanelLeftIcon, ChevronRightIcon, ChevronDownIcon, FileCodeIcon, @@ -25,6 +26,7 @@ import { AppWindowIcon, FlaskConicalIcon, TerminalIcon, + type LucideIcon, } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { @@ -67,6 +69,11 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; import { Input } from "@/components/ui/input"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { getCurrentWebview } from "@tauri-apps/api/webview"; @@ -263,7 +270,134 @@ function useAppVersion() { // ─── Sidebar ─── -export function Sidebar() { +function LayoutPaneSwitcher({ + controls, + onQuickToggleSidebar, + side = "bottom", + align = "end", + buttonClassName, +}: { + controls?: LayoutControls; + onQuickToggleSidebar?: () => void; + side?: "top" | "right" | "bottom" | "left"; + align?: "start" | "center" | "end"; + buttonClassName?: string; +}) { + const trigger = ( + + ); + + if (!controls) return trigger; + + return ( + + {trigger} + +
+ + + +
+
+
+ ); +} + +function LayoutToggleRow({ + icon: Icon, + label, + checked, + onCheckedChange, +}: { + icon: LucideIcon; + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +interface SidebarProps { + collapsed?: boolean; + onToggleCollapsed?: () => void; + layoutControls?: LayoutControls; +} + +interface LayoutControls { + codeVisible: boolean; + pdfVisible: boolean; + sidebarVisible: boolean; + setCodeVisible: (visible: boolean) => void; + setPdfVisible: (visible: boolean) => void; + setSidebarVisible: (visible: boolean) => void; +} + +export function Sidebar({ + collapsed = false, + onToggleCollapsed, + layoutControls, +}: SidebarProps) { const appVersion = useAppVersion(); const files = useDocumentStore((s) => s.files); const activeFileId = useDocumentStore((s) => s.activeFileId); @@ -1014,484 +1148,598 @@ export function Sidebar() { const pendingDeleteCount = pendingDeleteItems?.length ?? 0; const pendingDeletePreview = pendingDeleteItems?.slice(0, 4) ?? []; - return ( -
- {/* Header — padded top for macOS overlay titlebar */} -
- -
- -
+ const collapsedRail = ( +
+
+
+
+ + + + +
+
+ +
+
+ ); - {/* Resizable sections */} - - {/* Files */} - -
-
-
- - Files -
-
- - - + return ( +
+
+ {collapsedRail} +
+
+
+ {/* Header — padded top for macOS overlay titlebar */} +
+
+ +
+ +
+ +
+
+ + {/* Resizable sections */} + + {/* Files */} + +
+
+
+ + Files +
+
- - - openNewFileDialog()}> - - New LaTeX File - - openNewFolderDialog()}> - - New Folder - - - handleImport()}> - - Import File - - - -
-
- - - - - {tree.map((node) => ( - { - const parent = parentFolderOfPath(id); - setPasteTargetFolder(parent); - setActiveFile(id); - }} - onItemClick={handleTreeItemClick} - onItemContextMenu={handleTreeItemContextMenu} - onNewFile={openNewFileDialog} - onNewFolder={openNewFolderDialog} - onImport={handleImport} - onRename={openRenameDialog} - onDeleteSelection={requestDeleteSelection} - canDeleteSelection={canDeleteSelection} - getEffectiveSelectionCount={getEffectiveSelectionCount} - nativeDragOver={nativeDragOver} + - ))} - - - - openNewFileDialog()}> - - New File - - openNewFolderDialog()}> - - New Folder - - - handleImport()}> - - Import File - - - - - {activeDrag && ( -
- {activeDrag.type === "folder" ? ( - - ) : ( - - )} - - {activeDrag.count > 1 - ? `${activeDrag.count} selected` - : activeDrag.name} - + + + + + + + openNewFileDialog()}> + + New LaTeX File + + openNewFolderDialog()}> + + New Folder + + + handleImport()}> + + Import File + + +
- )} -
-
-
-
- - - - {/* Outline */} - -
-
- - Outline -
-
- {toc.length > 0 ? ( - toc.map((item, index) => ( - - )) - ) : ( -
- No sections found
- )} + + + + + {tree.map((node) => ( + { + const parent = parentFolderOfPath(id); + setPasteTargetFolder(parent); + setActiveFile(id); + }} + onItemClick={handleTreeItemClick} + onItemContextMenu={handleTreeItemContextMenu} + onNewFile={openNewFileDialog} + onNewFolder={openNewFolderDialog} + onImport={handleImport} + onRename={openRenameDialog} + onDeleteSelection={requestDeleteSelection} + canDeleteSelection={canDeleteSelection} + getEffectiveSelectionCount={ + getEffectiveSelectionCount + } + nativeDragOver={nativeDragOver} + /> + ))} + + + + openNewFileDialog()}> + + New File + + openNewFolderDialog()}> + + New Folder + + + handleImport()}> + + Import File + + + + + {activeDrag && ( +
+ {activeDrag.type === "folder" ? ( + + ) : ( + + )} + + {activeDrag.count > 1 + ? `${activeDrag.count} selected` + : activeDrag.name} + +
+ )} +
+
+
+ + + + + {/* Outline */} + +
+
+ + Outline +
+
+ {toc.length > 0 ? ( + toc.map((item, index) => ( + + )) + ) : ( +
+ No sections found +
+ )} +
+
+
+ + + + {/* Zotero */} + +
+
+ +
+
+ +
+
+
+ + + {/* Environment section — Python + Skills */} + + + {/* Footer */} +
+ ClaudePrism v{appVersion} +
+ +
- - - - {/* Zotero */} - -
-
- -
-
- -
-
-
- - - {/* Environment section — Python + Skills */} - - - {/* Footer */} -
- ClaudePrism v{appVersion} -
- - -
-
- - {/* New File Dialog */} - - - - - New File{addDialogFolder ? ` in ${addDialogFolder}` : ""} - - -
- { - setNewFileName(e.target.value); - setNameError(""); - }} - onKeyDown={(e) => { - if (e.key === "Enter") handleAddFile(); - }} - autoFocus - /> - {nameError && ( -

{nameError}

- )} -
- - - - -
-
- - {/* New Folder Dialog */} - - - - - New Folder{folderDialogParent ? ` in ${folderDialogParent}` : ""} - - -
- { - setNewFolderName(e.target.value); - setNameError(""); - }} - onKeyDown={(e) => { - if (e.key === "Enter") handleCreateFolder(); - }} - autoFocus - /> - {nameError && ( -

{nameError}

- )} -
- - - - -
-
- - {/* Rename Project Dialog */} - { - if (!isRenamingProject) setProjectRenameDialogOpen(open); - }} - > - - - Rename Project - -
- { - setProjectRenameValue(e.target.value); - setProjectRenameError(""); - }} - onKeyDown={(e) => { - if (e.key === "Enter") handleProjectRename(); - }} - autoFocus - /> - {projectRenameError && ( -

{projectRenameError}

- )} -
- - - - -
-
- - {/* Rename Dialog */} - - - - Rename - -
- { - setRenameValue(e.target.value); - setNameError(""); - }} - onKeyDown={(e) => { - if (e.key === "Enter") handleRename(); - }} - autoFocus - /> - {nameError && ( -

{nameError}

- )} -
- - - - -
-
- - {/* Delete confirmation */} - { - if (!open && !isDeletingSelection) { - setPendingDeleteItems(null); - setDeleteError(""); - } - }} - > - - - - Delete {pendingDeleteCount === 1 ? "Item" : "Items"} - - - {pendingDeleteCount === 1 - ? "This item will be removed from disk." - : `${pendingDeleteCount} selected items will be removed from disk.`} - - - {pendingDeletePreview.length > 0 && ( -
-
- {pendingDeletePreview.map((item) => ( -
- {item.type === "folder" ? "Folder" : "File"}: {item.path} -
- ))} - {pendingDeleteCount > pendingDeletePreview.length && ( -
- +{pendingDeleteCount - pendingDeletePreview.length} more -
+ {/* New File Dialog */} + + + + + New File{addDialogFolder ? ` in ${addDialogFolder}` : ""} + + +
+ { + setNewFileName(e.target.value); + setNameError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleAddFile(); + }} + autoFocus + /> + {nameError && ( +

{nameError}

)}
-
- )} - {deleteError && ( -

- {deleteError} -

- )} - - + + + +
+ + {/* New Folder Dialog */} + + + + + New Folder + {folderDialogParent ? ` in ${folderDialogParent}` : ""} + + +
+ { + setNewFolderName(e.target.value); + setNameError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreateFolder(); + }} + autoFocus + /> + {nameError && ( +

{nameError}

+ )} +
+ + + + +
+
+ + {/* Rename Project Dialog */} + { + if (!isRenamingProject) setProjectRenameDialogOpen(open); + }} + > + + + Rename Project + +
+ { + setProjectRenameValue(e.target.value); + setProjectRenameError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleProjectRename(); + }} + autoFocus + /> + {projectRenameError && ( +

+ {projectRenameError} +

+ )} +
+ + + + +
+
+ + {/* Rename Dialog */} + + + + Rename + +
+ { + setRenameValue(e.target.value); + setNameError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(); + }} + autoFocus + /> + {nameError && ( +

{nameError}

+ )} +
+ + + + +
+
+ + {/* Delete confirmation */} + { + if (!open && !isDeletingSelection) { setPendingDeleteItems(null); setDeleteError(""); - }} - disabled={isDeletingSelection} - > - Cancel - - - - - + } + }} + > + + + + Delete {pendingDeleteCount === 1 ? "Item" : "Items"} + + + {pendingDeleteCount === 1 + ? "This item will be removed from disk." + : `${pendingDeleteCount} selected items will be removed from disk.`} + + + {pendingDeletePreview.length > 0 && ( +
+
+ {pendingDeletePreview.map((item) => ( +
+ {item.type === "folder" ? "Folder" : "File"}:{" "} + {item.path} +
+ ))} + {pendingDeleteCount > pendingDeletePreview.length && ( +
+ +{pendingDeleteCount - pendingDeletePreview.length} more +
+ )} +
+
+ )} + {deleteError && ( +

+ {deleteError} +

+ )} + + + + +
+ +
+
); } diff --git a/apps/desktop/src/components/workspace/workspace-layout.tsx b/apps/desktop/src/components/workspace/workspace-layout.tsx index 47e2a77..aa30836 100644 --- a/apps/desktop/src/components/workspace/workspace-layout.tsx +++ b/apps/desktop/src/components/workspace/workspace-layout.tsx @@ -1,27 +1,175 @@ -import { useEffect } from "react"; -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { + Panel, + PanelGroup, + PanelResizeHandle, + type ImperativePanelHandle, +} from "react-resizable-panels"; import { Sidebar } from "./sidebar"; import { LatexEditor } from "./editor/latex-editor"; import { PdfPreview } from "./preview/pdf-preview"; import { useDocumentStore } from "@/stores/document-store"; import { usePreviewStore } from "@/stores/preview-store"; +const SIDEBAR_DEFAULT_SIZE = 15; +const SIDEBAR_MIN_SIZE = 10; +const SIDEBAR_COLLAPSED_WIDTH_PX = 48; +const SIDEBAR_COLLAPSED_SIZE_FALLBACK = 8; +const SIDEBAR_ANIMATION_MS = 180; + +function easeOutCubic(progress: number) { + return 1 - (1 - progress) ** 3; +} + export function WorkspaceLayout() { const initialized = useDocumentStore((s) => s.initialized); const previewVisible = usePreviewStore((s) => s.visible); - const togglePreview = usePreviewStore((s) => s.toggle); + const setPreviewVisible = usePreviewStore((s) => s.setVisible); + const workspaceRef = useRef(null); + const sidebarPanelRef = useRef(null); + const sidebarAnimationFrameRef = useRef(null); + const sidebarAnimatingRef = useRef(false); + const expandedSidebarSizeRef = useRef(SIDEBAR_DEFAULT_SIZE); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [sidebarCollapsedSize, setSidebarCollapsedSize] = useState( + SIDEBAR_COLLAPSED_SIZE_FALLBACK, + ); + const [codeVisible, setCodeVisible] = useState(true); + + const getCollapsedSidebarSize = useCallback(() => { + const workspaceWidth = + workspaceRef.current?.clientWidth ?? window.innerWidth; + if (!workspaceWidth) return SIDEBAR_COLLAPSED_SIZE_FALLBACK; + return Math.min( + 18, + Math.max(2.5, (SIDEBAR_COLLAPSED_WIDTH_PX / workspaceWidth) * 100), + ); + }, []); + + const animateSidebarToSize = useCallback((targetSize: number) => { + const sidebarPanel = sidebarPanelRef.current; + if (!sidebarPanel) return; + + if (sidebarAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(sidebarAnimationFrameRef.current); + } + + const startSize = sidebarPanel.getSize(); + const sizeDelta = targetSize - startSize; + const startedAt = performance.now(); + sidebarAnimatingRef.current = true; + + const step = (now: number) => { + const progress = Math.min((now - startedAt) / SIDEBAR_ANIMATION_MS, 1); + const nextSize = startSize + sizeDelta * easeOutCubic(progress); + + sidebarPanel.resize(nextSize); + + if (progress < 1) { + sidebarAnimationFrameRef.current = window.requestAnimationFrame(step); + return; + } + + sidebarPanel.resize(targetSize); + sidebarAnimationFrameRef.current = null; + sidebarAnimatingRef.current = false; + }; + + sidebarAnimationFrameRef.current = window.requestAnimationFrame(step); + }, []); + + const setSidebarPaneCollapsed = useCallback( + (nextCollapsed: boolean) => { + const sidebarPanel = sidebarPanelRef.current; + if (!sidebarPanel) return; + + if (!nextCollapsed) { + setSidebarCollapsed(false); + animateSidebarToSize(expandedSidebarSizeRef.current); + } else { + const collapsedSize = getCollapsedSidebarSize(); + const currentSize = sidebarPanel.getSize(); + if (currentSize >= SIDEBAR_MIN_SIZE) { + expandedSidebarSizeRef.current = currentSize; + } + setSidebarCollapsedSize(collapsedSize); + setSidebarCollapsed(true); + animateSidebarToSize(collapsedSize); + } + }, + [animateSidebarToSize, getCollapsedSidebarSize], + ); + + const toggleSidebarCollapsed = useCallback(() => { + setSidebarPaneCollapsed(!sidebarCollapsed); + }, [setSidebarPaneCollapsed, sidebarCollapsed]); + + const setCodePaneVisible = useCallback( + (visible: boolean) => { + if (!visible && !previewVisible) { + setPreviewVisible(true); + } + setCodeVisible(visible); + }, + [previewVisible, setPreviewVisible], + ); + + const setPdfPaneVisible = useCallback( + (visible: boolean) => { + if (!visible && !codeVisible) { + setCodeVisible(true); + } + setPreviewVisible(visible); + }, + [codeVisible, setPreviewVisible], + ); // Cmd+\ / Ctrl+\ toggles the PDF preview pane. useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "\\") { e.preventDefault(); - togglePreview(); + setPdfPaneVisible(!previewVisible); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [togglePreview]); + }, [previewVisible, setPdfPaneVisible]); + + useEffect(() => { + return () => { + if (sidebarAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(sidebarAnimationFrameRef.current); + } + }; + }, []); + + useLayoutEffect(() => { + const updateCollapsedSize = () => { + const nextSize = getCollapsedSidebarSize(); + setSidebarCollapsedSize(nextSize); + + if (sidebarCollapsed && !sidebarAnimatingRef.current) { + sidebarPanelRef.current?.resize(nextSize); + } + }; + + updateCollapsedSize(); + + const workspaceElement = workspaceRef.current; + if (!workspaceElement) return; + + const resizeObserver = new ResizeObserver(updateCollapsedSize); + resizeObserver.observe(workspaceElement); + + return () => resizeObserver.disconnect(); + }, [getCollapsedSidebarSize, sidebarCollapsed]); if (!initialized) { return ( @@ -32,30 +180,64 @@ export function WorkspaceLayout() { } return ( - - - - +
+ + setSidebarCollapsed(true)} + onExpand={() => setSidebarCollapsed(false)} + onResize={(size) => { + if (!sidebarAnimatingRef.current && size >= SIDEBAR_MIN_SIZE) { + expandedSidebarSizeRef.current = size; + } + }} + className="min-w-0 overflow-hidden" + > + setSidebarPaneCollapsed(!visible), + }} + /> + - + - - - + {codeVisible && ( + + + + )} - {previewVisible && ( - <> + {codeVisible && previewVisible && ( + )} - + {previewVisible && ( + - - )} - + )} + +
); }