291 lines
9.3 KiB
TypeScript
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>
|
|
)
|
|
}
|