feat: file tabs
This commit is contained in:
@@ -38,7 +38,13 @@ import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import { composerPlainText, placeCaretEnd, refChipElement, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import {
|
||||
composerPlainText,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
|
||||
@@ -36,6 +36,7 @@ type PreviewWebview = HTMLElement & {
|
||||
}
|
||||
|
||||
interface PreviewPaneProps {
|
||||
embedded?: boolean
|
||||
onClose: () => void
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
reloadRequest?: number
|
||||
@@ -359,15 +360,30 @@ function PreviewConsolePanel({
|
||||
const selectedLogIds = useStore(consoleState.$selectedLogIds)
|
||||
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
|
||||
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
|
||||
const stickScrollRafRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!consoleShouldStickRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const consoleBody = consoleBodyRef.current
|
||||
if (stickScrollRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(stickScrollRafRef.current)
|
||||
stickScrollRafRef.current = null
|
||||
}
|
||||
|
||||
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
|
||||
stickScrollRafRef.current = window.requestAnimationFrame(() => {
|
||||
stickScrollRafRef.current = null
|
||||
const consoleBody = consoleBodyRef.current
|
||||
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (stickScrollRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(stickScrollRafRef.current)
|
||||
stickScrollRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs])
|
||||
|
||||
function sendLogsToComposer(entries: ConsoleEntry[]) {
|
||||
@@ -917,6 +933,7 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
|
||||
const TITLEBAR_GROUP_ID = 'preview'
|
||||
|
||||
export function PreviewPane({
|
||||
embedded = false,
|
||||
onClose,
|
||||
onRestartServer,
|
||||
reloadRequest = 0,
|
||||
@@ -1136,9 +1153,12 @@ export function PreviewPane({
|
||||
|
||||
consoleShouldStickRef.current = true
|
||||
|
||||
const consoleBody = consoleBodyRef.current
|
||||
const handle = window.requestAnimationFrame(() => {
|
||||
const consoleBody = consoleBodyRef.current
|
||||
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
|
||||
})
|
||||
|
||||
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
|
||||
return () => window.cancelAnimationFrame(handle)
|
||||
}, [consoleOpen])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1423,21 +1443,23 @@ export function PreviewPane({
|
||||
}, [appendConsoleEntry, consoleState, isWebPreview, target.url])
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-background text-muted-foreground">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={`Open ${currentUrl}`}
|
||||
>
|
||||
{previewLabel || 'Preview'}
|
||||
</a>
|
||||
{!embedded && (
|
||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={`Open ${currentUrl}`}
|
||||
>
|
||||
{previewLabel || 'Preview'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background"
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type MouseEvent, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$filePreviewTarget,
|
||||
$rightRailActiveTabId,
|
||||
RIGHT_RAIL_PREVIEW_TAB_ID,
|
||||
type RightRailTabId,
|
||||
selectRightRailTab
|
||||
} from '@/store/layout'
|
||||
import {
|
||||
$filePreviewTabs,
|
||||
$previewReloadRequest,
|
||||
$previewTarget,
|
||||
dismissFilePreviewTarget,
|
||||
dismissPreviewTarget
|
||||
closeFilePreviewTab,
|
||||
dismissPreviewTarget,
|
||||
type FilePreviewTab,
|
||||
type PreviewTarget
|
||||
} from '@/store/preview'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
@@ -27,23 +38,152 @@ interface ChatPreviewRailProps {
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
interface RailTab {
|
||||
closeLabel: string
|
||||
id: RightRailTabId
|
||||
label: string
|
||||
target: PreviewTarget
|
||||
}
|
||||
|
||||
function previewTabLabel(target: PreviewTarget): string {
|
||||
const value = target.label || target.path || target.source || target.url
|
||||
const parts = value.split(/[\\/]/).filter(Boolean)
|
||||
|
||||
return parts.at(-1) || value || 'Preview'
|
||||
}
|
||||
|
||||
function tabLabel(tab: FilePreviewTab): string {
|
||||
return previewTabLabel(tab.target)
|
||||
}
|
||||
|
||||
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
|
||||
const previewReloadRequest = useStore($previewReloadRequest)
|
||||
const filePreviewTarget = useStore($filePreviewTarget)
|
||||
const activeTabId = useStore($rightRailActiveTabId)
|
||||
const filePreviewTabs = useStore($filePreviewTabs)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const target = filePreviewTarget ?? previewTarget
|
||||
|
||||
if (!target) {
|
||||
const tabs = useMemo<readonly RailTab[]>(
|
||||
() => [
|
||||
...(previewTarget
|
||||
? [
|
||||
{
|
||||
closeLabel: 'Close preview',
|
||||
id: RIGHT_RAIL_PREVIEW_TAB_ID,
|
||||
label: 'Preview',
|
||||
target: previewTarget
|
||||
} satisfies RailTab
|
||||
]
|
||||
: []),
|
||||
...filePreviewTabs.map(tab => ({
|
||||
closeLabel: `Close ${tabLabel(tab)}`,
|
||||
id: tab.id,
|
||||
label: tabLabel(tab),
|
||||
target: tab.target
|
||||
}))
|
||||
],
|
||||
[filePreviewTabs, previewTarget]
|
||||
)
|
||||
|
||||
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
|
||||
// Read-by-ref so close handlers stay reference-stable across renders.
|
||||
const activeTabRef = useRef<RailTab | undefined>(activeTab)
|
||||
activeTabRef.current = activeTab
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab && activeTab.id !== activeTabId) {
|
||||
selectRightRailTab(activeTab.id)
|
||||
}
|
||||
}, [activeTab, activeTabId])
|
||||
|
||||
const closeRailTab = useCallback((tab: RailTab) => {
|
||||
if (tab.id === RIGHT_RAIL_PREVIEW_TAB_ID) {
|
||||
dismissPreviewTarget()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
closeFilePreviewTab(tab.id)
|
||||
}, [])
|
||||
|
||||
// Stable: PreviewPane lists onClose in a useEffect dep array that pushes
|
||||
// titlebar tools. A fresh closure every render → setTitlebarToolGroup every
|
||||
// render → DesktopController setState → re-render → ∞.
|
||||
const handleCloseDocument = useCallback(() => {
|
||||
const tab = activeTabRef.current
|
||||
|
||||
if (tab) {
|
||||
closeRailTab(tab)
|
||||
}
|
||||
}, [closeRailTab])
|
||||
|
||||
const closeTab = (event: MouseEvent, tab: RailTab) => {
|
||||
event.stopPropagation()
|
||||
closeRailTab(tab)
|
||||
}
|
||||
|
||||
if (!activeTab) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
|
||||
|
||||
return (
|
||||
<PreviewPane
|
||||
onClose={filePreviewTarget ? dismissFilePreviewTarget : dismissPreviewTarget}
|
||||
onRestartServer={filePreviewTarget ? undefined : onRestartServer}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={target}
|
||||
/>
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
|
||||
<div
|
||||
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map(tab => {
|
||||
const active = tab.id === activeTab.id
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
|
||||
active
|
||||
? 'bg-background text-foreground'
|
||||
: 'border-r border-border/40 text-muted-foreground hover:bg-accent/30 hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
>
|
||||
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-primary/70" />}
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 flex-1 items-center truncate pl-3 pr-1.5 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
title={tab.label}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
<button
|
||||
aria-label={tab.closeLabel}
|
||||
className={cn(
|
||||
'mr-1.5 hidden size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/55 transition-colors hover:bg-accent hover:text-foreground focus-visible:grid group-hover/tab:grid',
|
||||
active && 'grid'
|
||||
)}
|
||||
onClick={event => closeTab(event, tab)}
|
||||
title={tab.closeLabel}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<PreviewPane
|
||||
embedded
|
||||
onClose={handleCloseDocument}
|
||||
onRestartServer={isPreview ? onRestartServer : undefined}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={activeTab.target}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user