mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 01:59:24 +02:00
Merge pull request #13 from multica-ai/forrestchang/folder-select-font
feat: add drag-to-resize sidebar and right panel
This commit is contained in:
@@ -76,7 +76,7 @@ export function MessageInput({
|
||||
{/* Folder selection prompt */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Folder className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-muted-foreground flex-1">Select a folder to start...</span>
|
||||
<span className="text-sm text-muted-foreground flex-1">Select a folder to start...</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -25,7 +25,7 @@ export function StatusBar({
|
||||
return (
|
||||
<div className={cn(
|
||||
"titlebar-drag-region flex h-11 items-center justify-between px-4",
|
||||
needsTrafficLightPadding && "pl-20"
|
||||
needsTrafficLightPadding && "pl-24"
|
||||
)}>
|
||||
{/* Left: Sidebar trigger + Session info */}
|
||||
<div className="titlebar-no-drag flex items-center gap-3">
|
||||
|
||||
@@ -6,10 +6,13 @@ import * as React from 'react'
|
||||
import { PanelLeftIcon, PanelRightIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useUIStore } from '@/stores/uiStore'
|
||||
import {
|
||||
useUIStore,
|
||||
RIGHT_PANEL_MIN_WIDTH,
|
||||
RIGHT_PANEL_MAX_WIDTH,
|
||||
} from '@/stores/uiStore'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
|
||||
const RIGHT_PANEL_WIDTH = '20rem' // 320px
|
||||
import { useResize } from '@/hooks/useResize'
|
||||
|
||||
interface RightPanelProps {
|
||||
children: React.ReactNode
|
||||
@@ -18,25 +21,47 @@ interface RightPanelProps {
|
||||
|
||||
export function RightPanel({ children, className }: RightPanelProps) {
|
||||
const isOpen = useUIStore((s) => s.rightPanelOpen)
|
||||
const rightPanelWidth = useUIStore((s) => s.rightPanelWidth)
|
||||
const setRightPanelWidth = useUIStore((s) => s.setRightPanelWidth)
|
||||
|
||||
const { isResizing, handleProps } = useResize({
|
||||
width: rightPanelWidth,
|
||||
minWidth: RIGHT_PANEL_MIN_WIDTH,
|
||||
maxWidth: RIGHT_PANEL_MAX_WIDTH,
|
||||
onWidthChange: setRightPanelWidth,
|
||||
direction: 'left',
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'hidden lg:block',
|
||||
'transition-[width] duration-200 ease-linear',
|
||||
// Disable transition during resize for smoother dragging
|
||||
!isResizing && 'transition-[width] duration-200 ease-linear',
|
||||
isOpen ? 'w-[var(--right-panel-width)]' : 'w-0'
|
||||
)}
|
||||
style={{ '--right-panel-width': RIGHT_PANEL_WIDTH } as React.CSSProperties}
|
||||
style={{ '--right-panel-width': `${rightPanelWidth}px` } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 right-0 z-10 h-svh border-l bg-background',
|
||||
'transition-[transform,opacity] duration-200 ease-linear',
|
||||
// Disable transition during resize for smoother dragging
|
||||
!isResizing && 'transition-[transform,opacity,width] duration-200 ease-linear',
|
||||
isOpen ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0',
|
||||
className
|
||||
)}
|
||||
style={{ width: RIGHT_PANEL_WIDTH }}
|
||||
style={{ width: `${rightPanelWidth}px` }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-y-0 left-0 w-1 hover:bg-primary/20 active:bg-primary/30',
|
||||
isResizing && 'bg-primary/30'
|
||||
)}
|
||||
{...handleProps}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,23 +122,12 @@ export function RightPanelHeader({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
const toggle = useUIStore((s) => s.toggleRightPanel)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group flex h-11 items-center border-b px-4', className)}
|
||||
className={cn('flex h-11 items-center border-b px-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={toggle}
|
||||
>
|
||||
<PanelRightIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close Right Panel</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,14 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { useResize } from "@/hooks/useResize"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
useUIStore,
|
||||
SIDEBAR_MIN_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
} from "@/stores/uiStore"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
@@ -27,7 +33,6 @@ import {
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
@@ -126,6 +131,9 @@ function SidebarProvider({
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
// Get sidebar width from store
|
||||
const sidebarWidth = useUIStore((s) => s.sidebarWidth)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
@@ -133,7 +141,7 @@ function SidebarProvider({
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width": `${sidebarWidth}px`,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
@@ -165,6 +173,17 @@ function Sidebar({
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
// Resize functionality
|
||||
const sidebarWidth = useUIStore((s) => s.sidebarWidth)
|
||||
const setSidebarWidth = useUIStore((s) => s.setSidebarWidth)
|
||||
const { isResizing, handleProps } = useResize({
|
||||
width: sidebarWidth,
|
||||
minWidth: SIDEBAR_MIN_WIDTH,
|
||||
maxWidth: SIDEBAR_MAX_WIDTH,
|
||||
onWidthChange: setSidebarWidth,
|
||||
direction: side === "left" ? "right" : "left",
|
||||
})
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
@@ -218,7 +237,9 @@ function Sidebar({
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"relative w-(--sidebar-width) bg-transparent",
|
||||
// Disable transition during resize for smoother dragging
|
||||
!isResizing && "transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
@@ -229,7 +250,9 @@ function Sidebar({
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
|
||||
// Disable transition during resize for smoother dragging
|
||||
!isResizing && "transition-[left,right,width] duration-200 ease-linear",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
@@ -248,6 +271,18 @@ function Sidebar({
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{/* Resize handle - only show when expanded */}
|
||||
{state === "expanded" && (
|
||||
<div
|
||||
data-slot="sidebar-resize-handle"
|
||||
className={cn(
|
||||
"absolute inset-y-0 w-1 hover:bg-primary/20 active:bg-primary/30",
|
||||
side === "left" ? "right-0" : "left-0",
|
||||
isResizing && "bg-primary/30"
|
||||
)}
|
||||
{...handleProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
91
src/renderer/src/hooks/useResize.ts
Normal file
91
src/renderer/src/hooks/useResize.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Hook for drag-to-resize functionality
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface UseResizeOptions {
|
||||
/** Current width */
|
||||
width: number
|
||||
/** Minimum width */
|
||||
minWidth: number
|
||||
/** Maximum width */
|
||||
maxWidth: number
|
||||
/** Callback when width changes */
|
||||
onWidthChange: (width: number) => void
|
||||
/** Direction of resize: 'left' means drag from left edge, 'right' means drag from right edge */
|
||||
direction: 'left' | 'right'
|
||||
}
|
||||
|
||||
interface UseResizeReturn {
|
||||
/** Whether currently resizing */
|
||||
isResizing: boolean
|
||||
/** Props to spread on the resize handle element */
|
||||
handleProps: {
|
||||
onMouseDown: (e: React.MouseEvent) => void
|
||||
style: React.CSSProperties
|
||||
}
|
||||
}
|
||||
|
||||
export function useResize({
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
onWidthChange,
|
||||
direction,
|
||||
}: UseResizeOptions): UseResizeReturn {
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const startXRef = useRef(0)
|
||||
const startWidthRef = useRef(0)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
startXRef.current = e.clientX
|
||||
startWidthRef.current = width
|
||||
},
|
||||
[width]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - startXRef.current
|
||||
// For left sidebar, dragging right increases width
|
||||
// For right panel, dragging left increases width
|
||||
const newWidth =
|
||||
direction === 'right'
|
||||
? startWidthRef.current + delta
|
||||
: startWidthRef.current - delta
|
||||
const clampedWidth = Math.max(minWidth, Math.min(maxWidth, newWidth))
|
||||
onWidthChange(clampedWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
|
||||
// Add cursor style to body during resize
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
}, [isResizing, minWidth, maxWidth, onWidthChange, direction])
|
||||
|
||||
return {
|
||||
isResizing,
|
||||
handleProps: {
|
||||
onMouseDown: handleMouseDown,
|
||||
style: { cursor: 'col-resize' },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,29 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
// Width constraints
|
||||
export const SIDEBAR_MIN_WIDTH = 200
|
||||
export const SIDEBAR_MAX_WIDTH = 400
|
||||
export const SIDEBAR_DEFAULT_WIDTH = 256
|
||||
|
||||
export const RIGHT_PANEL_MIN_WIDTH = 240
|
||||
export const RIGHT_PANEL_MAX_WIDTH = 480
|
||||
export const RIGHT_PANEL_DEFAULT_WIDTH = 320
|
||||
|
||||
interface UIStore {
|
||||
// Sidebar state
|
||||
sidebarOpen: boolean
|
||||
sidebarWidth: number
|
||||
toggleSidebar: () => void
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
setSidebarWidth: (width: number) => void
|
||||
|
||||
// Right panel state
|
||||
rightPanelOpen: boolean
|
||||
rightPanelWidth: number
|
||||
toggleRightPanel: () => void
|
||||
setRightPanelOpen: (open: boolean) => void
|
||||
setRightPanelWidth: (width: number) => void
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIStore>()(
|
||||
@@ -22,13 +35,19 @@ export const useUIStore = create<UIStore>()(
|
||||
(set) => ({
|
||||
// Sidebar - default open
|
||||
sidebarOpen: true,
|
||||
sidebarWidth: SIDEBAR_DEFAULT_WIDTH,
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
setSidebarWidth: (width) =>
|
||||
set({ sidebarWidth: Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, width)) }),
|
||||
|
||||
// Right panel - default open
|
||||
rightPanelOpen: true,
|
||||
rightPanelWidth: RIGHT_PANEL_DEFAULT_WIDTH,
|
||||
toggleRightPanel: () => set((state) => ({ rightPanelOpen: !state.rightPanelOpen })),
|
||||
setRightPanelOpen: (open) => set({ rightPanelOpen: open }),
|
||||
setRightPanelWidth: (width) =>
|
||||
set({ rightPanelWidth: Math.max(RIGHT_PANEL_MIN_WIDTH, Math.min(RIGHT_PANEL_MAX_WIDTH, width)) }),
|
||||
}),
|
||||
{
|
||||
name: 'ui-state', // localStorage key
|
||||
|
||||
Reference in New Issue
Block a user