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 Folder Dialog */}
-
-
- {/* Rename Project Dialog */}
-
-
- {/* Rename Dialog */}
-
-
- {/* Delete confirmation */}
-
);
}
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 && (
+
- >
- )}
-
+ )}
+
+
);
}