454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
} from "react"
|
|
import {
|
|
Columns2,
|
|
EllipsisVertical,
|
|
FileCode2,
|
|
Menu,
|
|
MessageSquare,
|
|
PanelLeft,
|
|
PanelRight,
|
|
Search,
|
|
Settings,
|
|
SquareTerminal,
|
|
} from "lucide-react"
|
|
import { useTranslations } from "next-intl"
|
|
import { openSettingsWindow } from "@/lib/api"
|
|
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
|
import { useActiveFolder } from "@/contexts/active-folder-context"
|
|
import { isDesktop, openFileDialog } from "@/lib/platform"
|
|
import { Button } from "@/components/ui/button"
|
|
import { useSidebarContext } from "@/contexts/sidebar-context"
|
|
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
|
|
import { useTerminalContext } from "@/contexts/terminal-context"
|
|
import { useTabContext } from "@/contexts/tab-context"
|
|
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
|
import { useIsMac } from "@/hooks/use-is-mac"
|
|
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
|
import {
|
|
formatShortcutLabel,
|
|
matchShortcutEvent,
|
|
} from "@/lib/keyboard-shortcuts"
|
|
import { AppTitleBar } from "./app-title-bar"
|
|
import { BranchDropdown } from "./branch-dropdown"
|
|
import { CommandDropdown } from "./command-dropdown"
|
|
import { NewFolderDropdown } from "./new-folder-dropdown"
|
|
import { SearchCommandDialog } from "@/components/conversations/search-command-dialog"
|
|
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
|
import { cn } from "@/lib/utils"
|
|
import { useIsMobile } from "@/hooks/use-mobile"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
|
|
const MODE_TABS = [
|
|
{
|
|
mode: "conversation",
|
|
titleKey: "conversation",
|
|
icon: MessageSquare,
|
|
},
|
|
{
|
|
mode: "fusion",
|
|
titleKey: "fusion",
|
|
icon: Columns2,
|
|
},
|
|
{
|
|
mode: "files",
|
|
titleKey: "files",
|
|
icon: FileCode2,
|
|
},
|
|
] as const
|
|
|
|
export function FolderTitleBar() {
|
|
const tModes = useTranslations("Folder.modes")
|
|
const tTitleBar = useTranslations("Folder.folderTitleBar")
|
|
const { openFolder } = useAppWorkspace()
|
|
const { activeFolder } = useActiveFolder()
|
|
const { isOpen, toggle } = useSidebarContext()
|
|
const { isOpen: auxPanelOpen, toggle: toggleAuxPanel } = useAuxPanelContext()
|
|
const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext()
|
|
const { openNewConversationTab } = useTabContext()
|
|
const { mode, setMode } = useWorkspaceContext()
|
|
const isMac = useIsMac()
|
|
const { shortcuts } = useShortcutSettings()
|
|
const [searchOpen, setSearchOpen] = useState(false)
|
|
const [browserOpen, setBrowserOpen] = useState(false)
|
|
|
|
const handleOpenFolder = useCallback(async () => {
|
|
if (isDesktop()) {
|
|
try {
|
|
const result = await openFileDialog({
|
|
directory: true,
|
|
multiple: false,
|
|
})
|
|
if (!result) return
|
|
const selected = Array.isArray(result) ? result[0] : result
|
|
await openFolder(selected)
|
|
} catch (err) {
|
|
console.error("[FolderTitleBar] failed to open folder:", err)
|
|
}
|
|
} else {
|
|
setBrowserOpen(true)
|
|
}
|
|
}, [openFolder])
|
|
|
|
const handleOpenSettings = useCallback(() => {
|
|
openSettingsWindow().catch((err) => {
|
|
console.error("[FolderTitleBar] failed to open settings:", err)
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (matchShortcutEvent(e, shortcuts.toggle_search)) {
|
|
e.preventDefault()
|
|
setSearchOpen((prev) => !prev)
|
|
return
|
|
}
|
|
if (matchShortcutEvent(e, shortcuts.toggle_sidebar)) {
|
|
e.preventDefault()
|
|
toggle()
|
|
return
|
|
}
|
|
if (matchShortcutEvent(e, shortcuts.toggle_terminal)) {
|
|
e.preventDefault()
|
|
toggleTerminal()
|
|
return
|
|
}
|
|
if (matchShortcutEvent(e, shortcuts.toggle_aux_panel)) {
|
|
e.preventDefault()
|
|
toggleAuxPanel()
|
|
return
|
|
}
|
|
if (matchShortcutEvent(e, shortcuts.new_conversation)) {
|
|
if (!activeFolder) return
|
|
e.preventDefault()
|
|
openNewConversationTab(activeFolder.id, activeFolder.path)
|
|
return
|
|
}
|
|
if (matchShortcutEvent(e, shortcuts.open_folder)) {
|
|
e.preventDefault()
|
|
void handleOpenFolder()
|
|
return
|
|
}
|
|
if (matchShortcutEvent(e, shortcuts.open_settings)) {
|
|
e.preventDefault()
|
|
handleOpenSettings()
|
|
}
|
|
}
|
|
document.addEventListener("keydown", handleKeyDown)
|
|
return () => document.removeEventListener("keydown", handleKeyDown)
|
|
}, [
|
|
activeFolder,
|
|
handleOpenFolder,
|
|
handleOpenSettings,
|
|
openNewConversationTab,
|
|
shortcuts,
|
|
toggle,
|
|
toggleAuxPanel,
|
|
toggleTerminal,
|
|
])
|
|
|
|
const isMobile = useIsMobile()
|
|
const modeContainerRef = useRef<HTMLDivElement>(null)
|
|
const modeItemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
|
const [modeIndicator, setModeIndicator] = useState<{
|
|
left: number
|
|
width: number
|
|
} | null>(null)
|
|
|
|
useEffect(() => {
|
|
const container = modeContainerRef.current
|
|
if (!container) return
|
|
|
|
const measure = () => {
|
|
const btn = modeItemRefs.current.get(mode)
|
|
if (!btn || !container) {
|
|
setModeIndicator(null)
|
|
return
|
|
}
|
|
const containerRect = container.getBoundingClientRect()
|
|
const btnRect = btn.getBoundingClientRect()
|
|
setModeIndicator({
|
|
left: btnRect.left - containerRect.left,
|
|
width: btnRect.width,
|
|
})
|
|
}
|
|
|
|
const ro = new ResizeObserver(() => measure())
|
|
for (const btn of modeItemRefs.current.values()) {
|
|
ro.observe(btn)
|
|
}
|
|
ro.observe(container)
|
|
measure()
|
|
|
|
return () => {
|
|
ro.disconnect()
|
|
}
|
|
}, [mode])
|
|
|
|
const handleModeKeyDown = useCallback(
|
|
(event: ReactKeyboardEvent<HTMLDivElement>, nextMode: typeof mode) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault()
|
|
setMode(nextMode)
|
|
}
|
|
},
|
|
[setMode]
|
|
)
|
|
|
|
const modeTabsElement = (
|
|
<div
|
|
ref={modeContainerRef}
|
|
role="tablist"
|
|
aria-label={tModes("workspaceModesAria")}
|
|
className="relative inline-flex h-[1.6875rem] items-center rounded-full border border-border/50 bg-muted/50 p-0.5"
|
|
>
|
|
{modeIndicator && (
|
|
<div
|
|
className="pointer-events-none absolute top-0.5 bottom-0.5 rounded-full bg-background shadow-sm ring-1 ring-border/50 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
|
style={{
|
|
left: modeIndicator.left,
|
|
width: modeIndicator.width,
|
|
}}
|
|
/>
|
|
)}
|
|
{MODE_TABS.map((item) => {
|
|
const Icon = item.icon
|
|
const isActive = mode === item.mode
|
|
const title = tModes(item.titleKey)
|
|
return (
|
|
<div
|
|
key={item.mode}
|
|
ref={(el) => {
|
|
if (el) {
|
|
modeItemRefs.current.set(item.mode, el)
|
|
} else {
|
|
modeItemRefs.current.delete(item.mode)
|
|
}
|
|
}}
|
|
role="tab"
|
|
tabIndex={0}
|
|
className={cn(
|
|
"relative z-10 m-0 flex h-[1.4375rem] cursor-pointer select-none items-center justify-center gap-1 rounded-full border-0 bg-transparent p-0 align-middle text-xs font-medium leading-none transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
|
|
isActive ? "px-2.5" : "px-2",
|
|
isActive
|
|
? "text-foreground"
|
|
: "text-muted-foreground hover:text-foreground/70"
|
|
)}
|
|
onClick={() => setMode(item.mode)}
|
|
onKeyDown={(event) => handleModeKeyDown(event, item.mode)}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
title={!isActive ? title : undefined}
|
|
aria-label={title}
|
|
aria-selected={isActive}
|
|
>
|
|
<Icon
|
|
className="block h-3 w-3 shrink-0"
|
|
shapeRendering="geometricPrecision"
|
|
/>
|
|
{!isMobile && (
|
|
<span
|
|
className={cn(
|
|
"grid transition-[grid-template-columns] duration-300",
|
|
isActive ? "grid-cols-[1fr]" : "grid-cols-[0fr]"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"min-w-0 overflow-hidden whitespace-nowrap transition-opacity duration-300",
|
|
isActive ? "opacity-100" : "opacity-0"
|
|
)}
|
|
>
|
|
{title}
|
|
</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<AppTitleBar
|
|
centerInteractive
|
|
left={
|
|
isMobile ? (
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0"
|
|
onClick={toggle}
|
|
>
|
|
<Menu className="h-4 w-4" />
|
|
</Button>
|
|
<NewFolderDropdown />
|
|
<BranchDropdown />
|
|
</div>
|
|
) : (
|
|
<div className="flex h-8 flex-1 items-center gap-6">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 hover:text-foreground/80"
|
|
onClick={toggle}
|
|
title={tTitleBar("withShortcut", {
|
|
label: tTitleBar(isOpen ? "hideSidebar" : "showSidebar"),
|
|
shortcut: formatShortcutLabel(
|
|
shortcuts.toggle_sidebar,
|
|
isMac
|
|
),
|
|
})}
|
|
>
|
|
<PanelLeft className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<NewFolderDropdown />
|
|
</div>
|
|
<BranchDropdown />
|
|
<div data-tauri-drag-region className="h-8 flex-1" />
|
|
</div>
|
|
)
|
|
}
|
|
center={isMobile ? undefined : modeTabsElement}
|
|
right={
|
|
isMobile ? (
|
|
<div className="flex items-center gap-1">
|
|
<CommandDropdown />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => setSearchOpen(true)}
|
|
title={tTitleBar("search")}
|
|
>
|
|
<Search className="h-4 w-4" />
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
<EllipsisVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={toggleAuxPanel}
|
|
disabled={!activeFolder}
|
|
>
|
|
<PanelRight className="h-3.5 w-3.5" />
|
|
{tTitleBar("toggleAuxPanel")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => toggleTerminal()}
|
|
disabled={!activeFolder}
|
|
>
|
|
<SquareTerminal className="h-3.5 w-3.5" />
|
|
{tTitleBar("toggleTerminal")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleOpenSettings}>
|
|
<Settings className="h-3.5 w-3.5" />
|
|
{tTitleBar("openSettings")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-10">
|
|
<div className="flex items-center gap-2">
|
|
<CommandDropdown />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
|
|
onClick={() => toggleTerminal()}
|
|
disabled={!activeFolder}
|
|
title={tTitleBar("withShortcut", {
|
|
label: tTitleBar("toggleTerminal"),
|
|
shortcut: formatShortcutLabel(
|
|
shortcuts.toggle_terminal,
|
|
isMac
|
|
),
|
|
})}
|
|
>
|
|
<SquareTerminal className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={`h-6 w-6 hover:text-foreground/80 ${auxPanelOpen ? "bg-accent" : ""}`}
|
|
onClick={toggleAuxPanel}
|
|
disabled={!activeFolder}
|
|
title={tTitleBar("withShortcut", {
|
|
label: tTitleBar("toggleAuxPanel"),
|
|
shortcut: formatShortcutLabel(
|
|
shortcuts.toggle_aux_panel,
|
|
isMac
|
|
),
|
|
})}
|
|
>
|
|
<PanelRight className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 hover:text-foreground/80"
|
|
onClick={() => setSearchOpen(true)}
|
|
title={tTitleBar("withShortcut", {
|
|
label: tTitleBar("search"),
|
|
shortcut: formatShortcutLabel(
|
|
shortcuts.toggle_search,
|
|
isMac
|
|
),
|
|
})}
|
|
>
|
|
<Search className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 hover:text-foreground/80"
|
|
onClick={handleOpenSettings}
|
|
title={tTitleBar("withShortcut", {
|
|
label: tTitleBar("openSettings"),
|
|
shortcut: formatShortcutLabel(
|
|
shortcuts.open_settings,
|
|
isMac
|
|
),
|
|
})}
|
|
>
|
|
<Settings className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
/>
|
|
<SearchCommandDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
|
<DirectoryBrowserDialog
|
|
open={browserOpen}
|
|
onOpenChange={setBrowserOpen}
|
|
onSelect={(path) => {
|
|
openFolder(path).catch((err) => {
|
|
console.error("[FolderTitleBar] failed to open folder:", err)
|
|
})
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|