Files
hermes-agent/apps/desktop/src/app/right-sidebar/index.tsx
T

291 lines
9.3 KiB
TypeScript

import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentBranch, $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
import { $rightSidebarTab, type RightSidebarTabId, setRightSidebarTab } from './store'
import { TerminalTab } from './terminal'
interface RightSidebarPaneProps {
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
onAddTerminalSelection: (text: string, label?: string) => void
onChangeCwd: (path: string) => Promise<void> | void
}
interface RightSidebarTab {
icon: string
id: RightSidebarTabId
label: string
}
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
{ id: 'files', label: 'File system', icon: 'files' },
{ id: 'terminal', label: 'Terminal', icon: 'terminal' }
]
export function RightSidebarPane({
onActivateFile,
onActivateFolder,
onAddTerminalSelection,
onChangeCwd
}: RightSidebarPaneProps) {
const activeTab = useStore($rightSidebarTab)
const currentBranch = useStore($currentBranch).trim()
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
const cwdName = hasCwd
? (currentCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? currentCwd)
: 'No folder selected'
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
title: 'Change working directory'
})
if (selected?.[0]) {
await onChangeCwd(selected[0])
}
}
const previewFile = async (path: string) => {
try {
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
if (!preview) {
throw new Error(`Could not preview ${path}`)
}
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
} catch (error) {
notifyError(error, 'Preview unavailable')
}
}
return (
<aside
aria-label="Right sidebar"
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-secondary) bg-(--glass-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary) shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]"
>
<RightSidebarChrome activeTab={activeTab} branch={currentBranch} />
{activeTab === 'terminal' ? (
<TerminalTab cwd={currentCwd} onAddSelectionToChat={onAddTerminalSelection} />
) : (
<FilesystemTab
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
onRefresh={() => void refreshRoot()}
openState={openState}
/>
)}
</aside>
)
}
function RightSidebarChrome({ activeTab, branch }: { activeTab: RightSidebarTabId; branch: string }) {
return (
<header className="shrink-0 bg-transparent text-[0.75rem]">
<div className="flex h-8 items-center gap-2 border-b border-(--ui-stroke-tertiary) px-3">
{RIGHT_SIDEBAR_TABS.map(tab => (
<button
aria-label={tab.label}
aria-pressed={tab.id === activeTab}
className={cn(
'grid size-6 shrink-0 place-items-center rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring active:bg-sidebar-accent active:text-sidebar-accent-foreground',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground'
)}
data-active={tab.id === activeTab}
key={tab.id}
onClick={() => setRightSidebarTab(tab.id)}
title={tab.label}
type="button"
>
<Codicon name={tab.icon} size="0.875rem" />
</button>
))}
{branch && (
<span className="ml-auto flex min-w-0 items-center gap-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
<span className="truncate">{branch}</span>
</span>
)}
</div>
</header>
)
}
interface FilesystemTabProps extends FileTreeBodyProps {
cwdName: string
hasCwd: boolean
onChangeFolder: () => Promise<void> | void
onRefresh: () => void
}
function FilesystemTab({
cwd,
cwdName,
data,
error,
hasCwd,
loading,
onActivateFile,
onActivateFolder,
onChangeFolder,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
onRefresh,
openState
}: FilesystemTabProps) {
return (
<div className="group/project-header flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<button
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
title={hasCwd ? cwd : 'No folder selected'}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<Button
aria-label="Refresh tree"
className="pointer-events-none size-6 shrink-0 rounded-md text-sidebar-foreground/70 opacity-0 transition-opacity hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon"
title="Refresh tree"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</RightSidebarSectionHeader>
<FileTreeBody
cwd={cwd}
data={data}
error={error}
loading={loading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
openState={openState}
/>
</div>
)
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="flex h-7 shrink-0 items-center px-3">{children}</div>
}
interface FileTreeBodyProps {
cwd: string
data: ReturnType<typeof useProjectTree>['data']
error: string | null
loading: boolean
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
onLoadChildren: (id: string) => void | Promise<void>
onNodeOpenChange: (id: string, open: boolean) => void
onPreviewFile?: (path: string) => void
openState: ReturnType<typeof useProjectTree>['openState']
}
function FileTreeBody({
cwd,
data,
error,
loading,
onActivateFile,
onActivateFolder,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
openState
}: FileTreeBodyProps) {
if (!cwd) {
return <EmptyState body="Set a working directory from the status bar to browse files." title="No project" />
}
if (error) {
return <EmptyState body={`Could not read this folder (${error}).`} title="Unreadable" />
}
if (loading && data.length === 0) {
return <FileTreeLoadingState />
}
if (data.length === 0) {
return <EmptyState body="This folder is empty." title="Empty" />
}
return (
<ProjectTree
data={data}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
openState={openState}
/>
)
}
function FileTreeLoadingState() {
return (
<div aria-label="Loading file tree" className="grid min-h-0 flex-1 place-items-center px-3" role="status">
<Loader
aria-hidden="true"
className="size-8 text-(--ui-text-tertiary)"
pathSteps={180}
role="presentation"
strokeScale={0.68}
type="spiral-search"
/>
</div>
)
}
function EmptyState({ body, title }: { body: string; title: string }) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center">
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
<div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div>
</div>
)
}