Files
aiturk-hermes-ide/apps/desktop/src/app/contrib/panes.tsx
T

183 lines
7.0 KiB
TypeScript

/**
* Real-data panes + composable bar items for the contrib root:
*
* - `PreviewRailPane` — the REAL ChatPreviewRail; files-pane clicks feed it.
* - `FilesPane` — real file browser; activating a file opens it in preview.
* - Core statusbar items with LIVE store-backed labels, registered as DATA
* contributions (`area: 'statusBar.left' / 'statusBar.right'`, payload =
* StatusbarItem) — plugins add theirs through the identical call.
*/
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { atom } from 'nanostores'
import { RightSidebarPane } from '@/app/right-sidebar'
import { ReviewPane } from '@/app/right-sidebar/review'
import type { GroupSetter } from '@/app/shell/group-setter'
import type { StatusbarItem } from '@/app/shell/statusbar-controls'
import type { TitlebarTool } from '@/app/shell/titlebar-controls'
import { DecodeText } from '@/components/ui/decode-text'
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import { getLogs } from '@/hermes'
import { useI18n } from '@/i18n'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { openPreview } from '@/store/preview'
import { $currentCwd } from '@/store/session'
// ---------------------------------------------------------------------------
// Logs — live agent-log tail. ⌘K-only chrome: the pane contribution exists
// only while the "Toggle logs" palette command has it summoned ($logsOpen in
// the controller) — never in a default layout, never a standing tab.
// ---------------------------------------------------------------------------
export function LogsPane() {
const { t } = useI18n()
const { data, error } = useQuery({
queryKey: ['contrib-logs-tail'],
queryFn: () => getLogs({ lines: 300 }),
refetchInterval: 5000
})
if (error) {
return <div className="p-3 text-xs text-(--ui-text-quaternary)">{t.surfaces.logUnavailable(String(error))}</div>
}
if (!data) {
return (
<div className="grid h-full place-items-center">
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="LOGS" />
</div>
)
}
// No chrome of its own — the zone header (when the user summons it) is the
// pane's only label. Just the tail.
return (
<pre className="h-full min-h-0 overflow-auto whitespace-pre-wrap break-words p-2.5 font-mono text-[0.66rem] leading-relaxed text-(--ui-text-secondary)">
{data.lines.join('\n')}
</pre>
)
}
// ---------------------------------------------------------------------------
// Preview — the real rail, fed by the files pane
// ---------------------------------------------------------------------------
/** Preview-server restart handler, provided by the wiring (usePreviewRouting).
* Atom-bridged: this module can't import contrib-wiring (it imports us). */
export const $restartPreviewServer = atom<((url: string, context?: string) => Promise<string>) | null>(null)
/** Open a file from the tree in the real preview pipeline. */
function previewFile(path: string) {
void normalizeOrLocalPreviewTarget(path, $currentCwd.get() || undefined)
.then(target => {
if (target) {
openPreview(target, 'file-browser')
}
})
.catch(() => undefined)
}
// Layout fit for wrapped asides. Edge chrome (borders/shadows) is neutralized
// GLOBALLY by the tree's seam invariant (see LayoutTreeRoot) — only sizing
// and titlebar clearance are per-wrapper concerns.
const ZONE_CONTENT = 'h-full [&>aside]:h-full [&>aside]:w-full [&>aside]:pt-0'
export function FilesPane() {
return (
<div className={ZONE_CONTENT}>
<RightSidebarPane onActivateFile={previewFile} onActivateFolder={previewFile} />
</div>
)
}
// ---------------------------------------------------------------------------
// Review — the real git diff pane (⌘G / $reviewOpen)
// ---------------------------------------------------------------------------
export function ReviewPaneContent() {
const cwd = useStore($currentCwd)
// Keyed by cwd like DesktopController so switching projects rebuilds the
// diff state instead of showing the previous repo's files.
return (
<div className={cn(ZONE_CONTENT, 'flex min-h-0 flex-col [&>aside]:min-h-0 [&>aside]:flex-1')}>
<ReviewPane key={cwd || 'no-cwd'} />
</div>
)
}
// ---------------------------------------------------------------------------
// Statusbar composability: plugins contribute DATA items into
// `statusBar.left` / `statusBar.right`; the wiring feeds them into the REAL
// useStatusbarItems as extraLeftItems/extraRightItems. No core filler here —
// the real statusbar owns the core items (model pill, terminal toggle, …).
// ---------------------------------------------------------------------------
/** Collect statusbar contributions for one side. A `render()` contribution
* becomes a render-item (arbitrary stateful node); otherwise the declarative
* `data` payload is the StatusbarItem. */
export function useStatusbarContributions(side: 'left' | 'right'): StatusbarItem[] {
const items = useContributions(`statusBar.${side}`)
return items
.map(c =>
c.render
? ({
id: c.id,
render: () => (
<ContribBoundary id={c.id} variant="chip">
<ContribRender render={c.render!} />
</ContribBoundary>
)
} satisfies StatusbarItem)
: (c.data as StatusbarItem)
)
.filter(Boolean)
}
/** Collect TitlebarTool data contributions for one side of the titlebar. */
export function useTitlebarToolContributions(side: 'left' | 'right'): TitlebarTool[] {
const items = useContributions(`titleBar.tools.${side}`)
return items.map(c => c.data as TitlebarTool).filter(Boolean)
}
/**
* Bridge a page's `GroupSetter` extension point (SkillsView, MessagingView,
* ChatPreviewRail, …) into the registry: each call replaces the group's items
* as DATA contributions in `<prefix>.<side>`, so page-owned items flow through
* the same pipe plugins use. Setting an empty list clears the group.
*/
export function registryGroupSetter<T>(prefix: string): GroupSetter<T> {
const disposers = new Map<string, () => void>()
return (id, items, side = 'right') => {
const key = `${side}:${id}`
disposers.get(key)?.()
disposers.set(
key,
registry.registerMany(
items.map((item, i) => ({
id: `${id}-${i}`,
area: `${prefix}.${side}`,
source: 'core',
order: 100 + i,
data: item as object
}))
)
)
}
}
/** The app's page-facing setters — the same `GroupSetter` shape pages already
* take as props, backed by the registry instead of component state. */
export const setStatusbarItemGroup = registryGroupSetter<StatusbarItem>('statusBar')
export const setTitlebarToolGroup = registryGroupSetter<TitlebarTool>('titleBar.tools')