@workerdeck/ui 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -3
- package/build/SessionPanel-CyhygZx_.d.mts +277 -0
- package/build/SessionPanel-_U8tjX29.mjs +8409 -0
- package/build/SessionPanel-_U8tjX29.mjs.map +1 -0
- package/build/format-DqR56Y8l.mjs +162 -0
- package/build/format-DqR56Y8l.mjs.map +1 -0
- package/build/format-ljc3lKpA.d.mts +59 -0
- package/build/format.d.mts +2 -0
- package/build/format.mjs +2 -0
- package/build/index.d.mts +610 -87
- package/build/index.mjs +6 -5104
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +199 -0
- package/build/workspace.mjs +849 -0
- package/build/workspace.mjs.map +1 -0
- package/package.json +22 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +522 -87
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/Conversation.tsx +11 -3
- package/src/components/agent/EditorTabs.tsx +165 -0
- package/src/components/agent/FileCard.tsx +26 -0
- package/src/components/agent/FileTree.tsx +287 -0
- package/src/components/agent/FileViewer.tsx +148 -0
- package/src/components/agent/HostFilesDialog.tsx +218 -0
- package/src/components/agent/Loader.tsx +120 -14
- package/src/components/agent/McpDialog.tsx +363 -0
- package/src/components/agent/Message.tsx +51 -17
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +133 -22
- package/src/components/agent/PermissionPrompt.tsx +164 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/QuestionPrompt.tsx +122 -0
- package/src/components/agent/Reasoning.tsx +20 -5
- package/src/components/agent/Response.tsx +128 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +756 -91
- package/src/components/agent/SessionWorkspace.tsx +282 -0
- package/src/components/agent/SkillsDialog.tsx +195 -0
- package/src/components/agent/StatusBar.tsx +85 -18
- package/src/components/agent/ToolCallCard.tsx +243 -30
- package/src/components/agent/Transcript.tsx +495 -30
- package/src/components/agent/UsageDialog.tsx +168 -0
- package/src/components/agent/line-prompt.tsx +249 -0
- package/src/components/agent/transcript-variant.tsx +61 -0
- package/src/components/prompt-area/prompt-area-engine.ts +53 -0
- package/src/components/prompt-area/types.ts +15 -0
- package/src/components/prompt-area/use-prompt-area.ts +20 -0
- package/src/components/ui/CodeBlock.tsx +40 -2
- package/src/components/ui/CopyButton.tsx +28 -3
- package/src/components/ui/Dialog.tsx +92 -0
- package/src/components/ui/Menu.tsx +55 -0
- package/src/components/ui/Splitter.tsx +133 -0
- package/src/components/ui/Tooltip.tsx +22 -5
- package/src/format.ts +10 -0
- package/src/index.ts +63 -2
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +114 -0
- package/src/lib/tool-icon.ts +96 -0
- package/src/workspace.ts +28 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ContextUsage } from '@workerdeck/protocol'
|
|
2
|
+
import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { formatTokens } from '../../lib/format.ts'
|
|
5
|
+
|
|
6
|
+
export interface ContextDialogProps {
|
|
7
|
+
usage?: ContextUsage
|
|
8
|
+
open: boolean
|
|
9
|
+
onOpenChange: (open: boolean) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The CLI reports category colors as its own theme token names ('inactive',
|
|
13
|
+
* 'promptBorder', ...), not CSS colors — only pass through what CSS can render. */
|
|
14
|
+
const cssColor = (color: string): string | undefined =>
|
|
15
|
+
typeof CSS !== 'undefined' && CSS.supports('color', color) ? color : undefined
|
|
16
|
+
|
|
17
|
+
const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What is in the model's context window right now, category by category.
|
|
21
|
+
*
|
|
22
|
+
* One of the three panels the status bar opens. Context, usage and session info
|
|
23
|
+
* are different questions asked at different moments, so they are different
|
|
24
|
+
* screens rather than one "details" list you scroll past two answers to reach.
|
|
25
|
+
*/
|
|
26
|
+
export function ContextDialog({ usage, open, onOpenChange }: ContextDialogProps) {
|
|
27
|
+
return (
|
|
28
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
29
|
+
<DialogContent>
|
|
30
|
+
<DialogHeader title='Context' description={usage?.model} />
|
|
31
|
+
<DialogBody>
|
|
32
|
+
{!usage ? (
|
|
33
|
+
<p className='py-6 text-center text-body-sm text-fg-4'>
|
|
34
|
+
No reading yet — the context window is measured after a turn completes.
|
|
35
|
+
</p>
|
|
36
|
+
) : (
|
|
37
|
+
<>
|
|
38
|
+
<div className='flex items-baseline justify-between gap-4'>
|
|
39
|
+
<span className='text-label text-fg-3'>Used</span>
|
|
40
|
+
<span className='font-mono text-body-sm text-fg-1'>
|
|
41
|
+
{formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} ·{' '}
|
|
42
|
+
{usage.percentage.toFixed(0)}%
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
<div className='mt-2 h-2 overflow-hidden rounded-full bg-border'>
|
|
46
|
+
<div
|
|
47
|
+
className={cn('h-full rounded-full', usageTint(usage.percentage))}
|
|
48
|
+
style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
{/* Codex reports occupancy with no breakdown, so an engine can send
|
|
52
|
+
a real reading and an empty `categories`. A "Breakdown" heading
|
|
53
|
+
over nothing reads as a failed load; the row above already says
|
|
54
|
+
everything that is known. */}
|
|
55
|
+
{usage.categories.length > 0 ? (
|
|
56
|
+
<div className='mt-5'>
|
|
57
|
+
<h3 className='text-label font-medium text-fg-3'>Breakdown</h3>
|
|
58
|
+
<div className='mt-2 flex flex-col gap-3'>
|
|
59
|
+
{usage.categories.map((category) => {
|
|
60
|
+
const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
|
|
61
|
+
const color = cssColor(category.color)
|
|
62
|
+
return (
|
|
63
|
+
<div key={category.name}>
|
|
64
|
+
<div className='flex items-baseline justify-between gap-3'>
|
|
65
|
+
<span className='flex min-w-0 items-center gap-2'>
|
|
66
|
+
<span
|
|
67
|
+
className='size-2 shrink-0 rounded-full bg-fg-4'
|
|
68
|
+
style={color ? { backgroundColor: color } : undefined}
|
|
69
|
+
/>
|
|
70
|
+
<span className='truncate text-body-sm text-fg-1'>
|
|
71
|
+
{category.name}
|
|
72
|
+
</span>
|
|
73
|
+
</span>
|
|
74
|
+
<span className='shrink-0 font-mono text-label text-fg-3'>
|
|
75
|
+
{formatTokens(category.tokens)}
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div className='mt-1 h-1 overflow-hidden rounded-full bg-border'>
|
|
79
|
+
<div
|
|
80
|
+
className='h-full rounded-full bg-fg-4'
|
|
81
|
+
style={{
|
|
82
|
+
width: `${Math.min(100, share)}%`,
|
|
83
|
+
backgroundColor: color,
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
})}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</DialogBody>
|
|
96
|
+
</DialogContent>
|
|
97
|
+
</Dialog>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -6,17 +6,25 @@ import { cn } from '../../lib/utils.ts'
|
|
|
6
6
|
|
|
7
7
|
export interface ConversationProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
8
8
|
children: ReactNode
|
|
9
|
+
/**
|
|
10
|
+
* How the view follows content that grows. `'smooth'` is right for a live
|
|
11
|
+
* turn — the text scrolls as it is written. `'instant'` is right while a
|
|
12
|
+
* transcript is *filling* (an attach replaying a whole session), where
|
|
13
|
+
* animating each arrival makes opening a session a several-second journey
|
|
14
|
+
* from its first row to its last.
|
|
15
|
+
*/
|
|
16
|
+
resize?: 'smooth' | 'instant'
|
|
9
17
|
}
|
|
10
18
|
|
|
11
19
|
/** Scroll container that stays pinned to the bottom while streaming, unless the user
|
|
12
20
|
* scrolls up — plus a floating scroll-to-bottom button. */
|
|
13
|
-
export function Conversation({ className, children, ...props }: ConversationProps) {
|
|
21
|
+
export function Conversation({ className, children, resize = 'smooth', ...props }: ConversationProps) {
|
|
14
22
|
return (
|
|
15
23
|
<StickToBottom
|
|
16
24
|
data-slot='conversation'
|
|
17
25
|
className={cn('relative flex-1 overflow-y-auto', className)}
|
|
18
26
|
initial='instant'
|
|
19
|
-
resize=
|
|
27
|
+
resize={resize}
|
|
20
28
|
role='log'
|
|
21
29
|
{...props}>
|
|
22
30
|
{children}
|
|
@@ -32,7 +40,7 @@ export function ConversationContent({
|
|
|
32
40
|
return (
|
|
33
41
|
<StickToBottom.Content
|
|
34
42
|
data-slot='conversation-content'
|
|
35
|
-
className={cn('mx-auto flex w-full max-w-
|
|
43
|
+
className={cn('mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] flex-col gap-4 px-4 py-4', className)}
|
|
36
44
|
{...props}>
|
|
37
45
|
{children}
|
|
38
46
|
</StickToBottom.Content>
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useEffect, useRef, type MouseEvent as ReactMouseEvent } from 'react'
|
|
2
|
+
import { isDirty, type OpenFile } from '@workerdeck/react'
|
|
3
|
+
import { X } from 'lucide-react'
|
|
4
|
+
import { cn } from '../../lib/utils.ts'
|
|
5
|
+
import { Tip, TooltipProvider } from '../ui/Tooltip.tsx'
|
|
6
|
+
import { formatBytes } from '../../lib/format.ts'
|
|
7
|
+
|
|
8
|
+
export interface EditorTabsProps {
|
|
9
|
+
files: OpenFile[]
|
|
10
|
+
activePath?: string
|
|
11
|
+
onActivate: (path: string) => void
|
|
12
|
+
onClose: (path: string) => void
|
|
13
|
+
className?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The open-file tab strip.
|
|
18
|
+
*
|
|
19
|
+
* Hand-rolled rather than built on `@base-ui/react`'s `Tabs`: a VS Code tab
|
|
20
|
+
* carries a close button, and a `<button>` inside a `<button>` is invalid HTML —
|
|
21
|
+
* getting the primitive to render something else costs more than the roving
|
|
22
|
+
* tabindex it would have provided. So that part is here, explicitly, along with
|
|
23
|
+
* the two affordances that actually make a tab strip feel right: middle-click to
|
|
24
|
+
* close, and the active tab scrolling itself into view.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately state-free. Which files are open, which is focused and what
|
|
27
|
+
* closing does are all decided by `useOpenFiles`.
|
|
28
|
+
*/
|
|
29
|
+
export function EditorTabs({ files, activePath, onActivate, onClose, className }: EditorTabsProps) {
|
|
30
|
+
return (
|
|
31
|
+
// Grouped, so moving along the strip shows each tab's path immediately
|
|
32
|
+
// instead of re-serving the open delay on every tab.
|
|
33
|
+
<TooltipProvider delay={500} closeDelay={0}>
|
|
34
|
+
<div
|
|
35
|
+
data-slot='editor-tabs'
|
|
36
|
+
role='tablist'
|
|
37
|
+
aria-label='Open files'
|
|
38
|
+
className={cn(
|
|
39
|
+
'flex shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface',
|
|
40
|
+
className,
|
|
41
|
+
)}>
|
|
42
|
+
{files.map((file) => (
|
|
43
|
+
<Tab
|
|
44
|
+
key={file.path}
|
|
45
|
+
file={file}
|
|
46
|
+
active={file.path === activePath}
|
|
47
|
+
onActivate={() => onActivate(file.path)}
|
|
48
|
+
onClose={() => onClose(file.path)}
|
|
49
|
+
onArrow={(direction) => {
|
|
50
|
+
const index = files.findIndex((f) => f.path === file.path)
|
|
51
|
+
const next = files[index + direction]
|
|
52
|
+
if (next) onActivate(next.path)
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
</TooltipProvider>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Tab({
|
|
62
|
+
file,
|
|
63
|
+
active,
|
|
64
|
+
onActivate,
|
|
65
|
+
onClose,
|
|
66
|
+
onArrow,
|
|
67
|
+
}: {
|
|
68
|
+
file: OpenFile
|
|
69
|
+
active: boolean
|
|
70
|
+
onActivate: () => void
|
|
71
|
+
onClose: () => void
|
|
72
|
+
onArrow: (direction: 1 | -1) => void
|
|
73
|
+
}) {
|
|
74
|
+
const dirty = isDirty(file)
|
|
75
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
76
|
+
// Opening a file from the tree can push the new tab off the end of the strip;
|
|
77
|
+
// the point of opening it was to look at it. `nearest` scrolls the minimum, so
|
|
78
|
+
// a tab already on screen stays exactly where it is.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (active) ref.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
81
|
+
}, [active])
|
|
82
|
+
|
|
83
|
+
const onAuxClick = (event: ReactMouseEvent) => {
|
|
84
|
+
// Middle click. `auxclick` rather than `mousedown` so it matches how every
|
|
85
|
+
// other middle-click target on the platform behaves.
|
|
86
|
+
if (event.button !== 1) return
|
|
87
|
+
event.preventDefault()
|
|
88
|
+
onClose()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
// The tab *is* the trigger (`render`), and it carries the full path — which
|
|
93
|
+
// is why the viewer no longer spends a whole row on a line of monospace
|
|
94
|
+
// nobody reads. No `title` alongside it: the browser's native tooltip would
|
|
95
|
+
// show up underneath this one.
|
|
96
|
+
<Tip
|
|
97
|
+
side='bottom'
|
|
98
|
+
render={
|
|
99
|
+
<div
|
|
100
|
+
ref={ref}
|
|
101
|
+
role='tab'
|
|
102
|
+
tabIndex={active ? 0 : -1}
|
|
103
|
+
aria-selected={active}
|
|
104
|
+
onClick={onActivate}
|
|
105
|
+
onAuxClick={onAuxClick}
|
|
106
|
+
onKeyDown={(event) => {
|
|
107
|
+
if (event.key === 'ArrowRight') onArrow(1)
|
|
108
|
+
else if (event.key === 'ArrowLeft') onArrow(-1)
|
|
109
|
+
else if (event.key === 'Enter' || event.key === ' ') onActivate()
|
|
110
|
+
else return
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
}}
|
|
113
|
+
className={cn(
|
|
114
|
+
'group flex min-w-0 shrink-0 cursor-pointer items-center gap-1.5 border-r border-border px-3 py-1.5 transition-colors',
|
|
115
|
+
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-inset',
|
|
116
|
+
active ? 'bg-bg text-fg-1' : 'text-fg-3 hover:bg-surface-hover hover:text-fg-2',
|
|
117
|
+
)}
|
|
118
|
+
/>
|
|
119
|
+
}
|
|
120
|
+
content={
|
|
121
|
+
<span className='flex flex-col gap-0.5'>
|
|
122
|
+
<span className='font-mono break-all'>{file.path}</span>
|
|
123
|
+
{file.bytes !== undefined ? (
|
|
124
|
+
<span className='text-fg-4'>{formatBytes(file.bytes)}</span>
|
|
125
|
+
) : null}
|
|
126
|
+
</span>
|
|
127
|
+
}>
|
|
128
|
+
<span className={cn('max-w-40 truncate font-mono text-label', dirty && 'italic')}>
|
|
129
|
+
{file.name}
|
|
130
|
+
</span>
|
|
131
|
+
{/* Errors are the one state the strip shows, because a failed tab
|
|
132
|
+
otherwise looks identical to a loaded one until you focus it. */}
|
|
133
|
+
{file.status === 'error' ? <span className='shrink-0 text-danger'>!</span> : null}
|
|
134
|
+
<button
|
|
135
|
+
type='button'
|
|
136
|
+
aria-label={dirty ? `Close ${file.name} (unsaved changes)` : `Close ${file.name}`}
|
|
137
|
+
onClick={(event) => {
|
|
138
|
+
// Without this the click also activates the tab being closed, which
|
|
139
|
+
// fights the reducer's focus-the-neighbour rule.
|
|
140
|
+
event.stopPropagation()
|
|
141
|
+
onClose()
|
|
142
|
+
}}
|
|
143
|
+
className={cn(
|
|
144
|
+
'shrink-0 rounded p-0.5 text-fg-4 transition-opacity hover:bg-surface-hover hover:text-fg-1',
|
|
145
|
+
// Always reachable by keyboard and on touch; only *shown* on hover for
|
|
146
|
+
// the tab you are pointing at, as VS Code does.
|
|
147
|
+
active || dirty
|
|
148
|
+
? 'opacity-70'
|
|
149
|
+
: 'opacity-0 group-hover:opacity-70 focus-visible:opacity-100',
|
|
150
|
+
)}>
|
|
151
|
+
{/* VS Code's move: a dirty tab shows a dot where the ✕ goes, and the ✕
|
|
152
|
+
comes back when you point at it — so unsaved work is visible at rest
|
|
153
|
+
without taking away the way to close it. */}
|
|
154
|
+
<span className={cn('block', dirty && 'group-hover:hidden')}>
|
|
155
|
+
{dirty ? <span className='block size-3 rounded-full bg-fg-2' /> : <X className='size-3' />}
|
|
156
|
+
</span>
|
|
157
|
+
{dirty ? (
|
|
158
|
+
<span className='hidden group-hover:block'>
|
|
159
|
+
<X className='size-3' />
|
|
160
|
+
</span>
|
|
161
|
+
) : null}
|
|
162
|
+
</button>
|
|
163
|
+
</Tip>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { TranscriptItem } from '@workerdeck/react'
|
|
|
2
2
|
import { Download, FileText } from 'lucide-react'
|
|
3
3
|
import { cn } from '../../lib/utils.ts'
|
|
4
4
|
import { formatBytes } from '../../lib/format.ts'
|
|
5
|
+
import { LineGlyph, useLines } from './transcript-variant.tsx'
|
|
5
6
|
|
|
6
7
|
export type FileDeliveredItem = Extract<TranscriptItem, { kind: 'file_delivered' }>
|
|
7
8
|
|
|
@@ -17,7 +18,32 @@ export interface FileCardProps {
|
|
|
17
18
|
* The file lives in the session's in-memory VFS — the link works while the
|
|
18
19
|
* session lives. */
|
|
19
20
|
export function FileCard({ item, href, className }: FileCardProps) {
|
|
21
|
+
const lines = useLines()
|
|
20
22
|
const name = item.path.split('/').pop() || item.path
|
|
23
|
+
if (lines) {
|
|
24
|
+
// One row: what arrived, how big, and the link — no box around it.
|
|
25
|
+
return (
|
|
26
|
+
<div data-slot='file-delivered' className={cn('flex w-full items-baseline gap-2', className)}>
|
|
27
|
+
<LineGlyph className='text-fg-4'>◇</LineGlyph>
|
|
28
|
+
<span className='min-w-0 flex-1 truncate text-body-sm leading-5'>
|
|
29
|
+
<span className='font-medium text-fg-1'>{name}</span>
|
|
30
|
+
<span className='text-fg-4'>
|
|
31
|
+
{' · '}
|
|
32
|
+
{formatBytes(item.bytes)}
|
|
33
|
+
{item.description ? ` · ${item.description}` : ''}
|
|
34
|
+
</span>
|
|
35
|
+
</span>
|
|
36
|
+
{href ? (
|
|
37
|
+
<a
|
|
38
|
+
href={href}
|
|
39
|
+
download={name}
|
|
40
|
+
className='shrink-0 text-label text-fg-3 underline-offset-2 hover:text-fg-1 hover:underline'>
|
|
41
|
+
download
|
|
42
|
+
</a>
|
|
43
|
+
) : null}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
21
47
|
return (
|
|
22
48
|
<div
|
|
23
49
|
data-slot='file-delivered'
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
|
2
|
+
import type { UseHostFileSearchResult, UseHostFileTreeResult } from '@workerdeck/react'
|
|
3
|
+
import type { HostDirEntry, HostFileMatch } from '@workerdeck/protocol'
|
|
4
|
+
import {
|
|
5
|
+
ChevronRight,
|
|
6
|
+
File,
|
|
7
|
+
Folder,
|
|
8
|
+
FolderOpen,
|
|
9
|
+
Link2,
|
|
10
|
+
PanelLeftClose,
|
|
11
|
+
RefreshCw,
|
|
12
|
+
Search,
|
|
13
|
+
X,
|
|
14
|
+
} from 'lucide-react'
|
|
15
|
+
import { cn } from '../../lib/utils.ts'
|
|
16
|
+
import { Button } from '../ui/Button.tsx'
|
|
17
|
+
import { Input } from '../ui/Input.tsx'
|
|
18
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
19
|
+
|
|
20
|
+
export interface FileTreeProps {
|
|
21
|
+
/** Tree state from `useHostFileTree` — this component holds none of its own. */
|
|
22
|
+
tree: UseHostFileTreeResult
|
|
23
|
+
/** Optional search from `useHostFileSearch`; omit and the box is not offered. */
|
|
24
|
+
search?: UseHostFileSearchResult
|
|
25
|
+
/** Path of the focused file, highlighted in the tree. */
|
|
26
|
+
activePath?: string
|
|
27
|
+
onOpenFile: (path: string) => void
|
|
28
|
+
/** Offered as a button in the header when given. */
|
|
29
|
+
onCollapse?: () => void
|
|
30
|
+
style?: CSSProperties
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How far one level of nesting indents, in pixels. Inline rather than a Tailwind
|
|
35
|
+
* class because depth is a number at runtime and `pl-${n}` is not a class. */
|
|
36
|
+
const INDENT = 12
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The workspace's left rail: an expandable tree of the session's project,
|
|
40
|
+
* with a search box over the same fuzzy route `@file` completion uses.
|
|
41
|
+
*
|
|
42
|
+
* Presentational by construction — every piece of state it renders comes from
|
|
43
|
+
* the hooks in `@workerdeck/react`, and the only thing it owns is the search
|
|
44
|
+
* query, which is the text in its own input.
|
|
45
|
+
*
|
|
46
|
+
* Searching replaces the tree with matches rather than filtering it: the route
|
|
47
|
+
* answers with paths from all over the project, and threading those back into
|
|
48
|
+
* tree positions would mean expanding a dozen directories to show six results.
|
|
49
|
+
*/
|
|
50
|
+
export function FileTree({
|
|
51
|
+
tree,
|
|
52
|
+
search,
|
|
53
|
+
activePath,
|
|
54
|
+
onOpenFile,
|
|
55
|
+
onCollapse,
|
|
56
|
+
style,
|
|
57
|
+
className,
|
|
58
|
+
}: FileTreeProps) {
|
|
59
|
+
const [query, setQuery] = useState('')
|
|
60
|
+
const [matches, setMatches] = useState<HostFileMatch[] | undefined>()
|
|
61
|
+
const searching = query.trim().length > 0
|
|
62
|
+
|
|
63
|
+
// Debounced, and only while there is something to search for — an empty box
|
|
64
|
+
// means "show me the tree again", not "match everything".
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!search?.available) return
|
|
67
|
+
const q = query.trim()
|
|
68
|
+
if (!q) {
|
|
69
|
+
setMatches(undefined)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const controller = new AbortController()
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
void search.search(q, { limit: 60, signal: controller.signal }).then((found) => {
|
|
75
|
+
if (!controller.signal.aborted) setMatches(found)
|
|
76
|
+
})
|
|
77
|
+
}, 150)
|
|
78
|
+
return () => {
|
|
79
|
+
controller.abort()
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
}
|
|
82
|
+
}, [search, query])
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
data-slot='file-tree'
|
|
87
|
+
style={style}
|
|
88
|
+
className={cn('flex min-h-0 min-w-0 flex-col bg-surface', className)}>
|
|
89
|
+
<div className='flex items-center gap-1 px-2 pt-2'>
|
|
90
|
+
{search?.available ? (
|
|
91
|
+
<div className='relative min-w-0 flex-1'>
|
|
92
|
+
<Search className='absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4' />
|
|
93
|
+
<Input
|
|
94
|
+
value={query}
|
|
95
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
96
|
+
placeholder='Search files…'
|
|
97
|
+
className='h-7 pr-7 pl-7 text-label'
|
|
98
|
+
spellCheck={false}
|
|
99
|
+
/>
|
|
100
|
+
{searching ? (
|
|
101
|
+
<button
|
|
102
|
+
type='button'
|
|
103
|
+
onClick={() => setQuery('')}
|
|
104
|
+
aria-label='Clear search'
|
|
105
|
+
className='absolute top-1/2 right-1.5 -translate-y-1/2 text-fg-4 transition-colors hover:text-fg-2'>
|
|
106
|
+
<X className='size-3.5' />
|
|
107
|
+
</button>
|
|
108
|
+
) : null}
|
|
109
|
+
</div>
|
|
110
|
+
) : (
|
|
111
|
+
<span className='min-w-0 flex-1 truncate px-1 font-mono text-label text-fg-4'>
|
|
112
|
+
{tree.root}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
<Button
|
|
116
|
+
variant='ghost'
|
|
117
|
+
size='icon-sm'
|
|
118
|
+
aria-label='Refresh the file tree'
|
|
119
|
+
onClick={() => tree.refresh()}>
|
|
120
|
+
<RefreshCw className='size-3.5 text-fg-3' />
|
|
121
|
+
</Button>
|
|
122
|
+
{onCollapse ? (
|
|
123
|
+
<Button variant='ghost' size='icon-sm' aria-label='Hide project files' onClick={onCollapse}>
|
|
124
|
+
<PanelLeftClose className='size-3.5 text-fg-3' />
|
|
125
|
+
</Button>
|
|
126
|
+
) : null}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className='min-h-0 flex-1 overflow-auto px-1 py-1'>
|
|
130
|
+
{tree.error ? (
|
|
131
|
+
<p className='px-2 py-3 text-body-sm text-danger'>{tree.error}</p>
|
|
132
|
+
) : searching ? (
|
|
133
|
+
matches === undefined ? (
|
|
134
|
+
<div className='py-6 text-center'>
|
|
135
|
+
<Spinner className='size-4 text-fg-4' />
|
|
136
|
+
</div>
|
|
137
|
+
) : matches.length === 0 ? (
|
|
138
|
+
<p className='px-2 py-6 text-center text-body-sm text-fg-4'>No matching files.</p>
|
|
139
|
+
) : (
|
|
140
|
+
<ul>
|
|
141
|
+
{matches.map((match) => (
|
|
142
|
+
<li key={match.path}>
|
|
143
|
+
<Row
|
|
144
|
+
label={fileName(match.relative)}
|
|
145
|
+
// The directory, dimmed and truncated separately, so a deep
|
|
146
|
+
// path cannot push the filename out of the row — a rail full
|
|
147
|
+
// of `apps/ios/DerivedData/B…` says nothing about which file
|
|
148
|
+
// each hit is.
|
|
149
|
+
detail={directoryOf(match.relative)}
|
|
150
|
+
title={match.relative}
|
|
151
|
+
icon={<File className='size-3.5 shrink-0 text-fg-4' />}
|
|
152
|
+
active={match.path === activePath}
|
|
153
|
+
onClick={() => onOpenFile(match.path)}
|
|
154
|
+
/>
|
|
155
|
+
</li>
|
|
156
|
+
))}
|
|
157
|
+
</ul>
|
|
158
|
+
)
|
|
159
|
+
) : tree.loading ? (
|
|
160
|
+
<div className='py-6 text-center'>
|
|
161
|
+
<Spinner className='size-4 text-fg-4' />
|
|
162
|
+
</div>
|
|
163
|
+
) : tree.rows.length === 0 ? (
|
|
164
|
+
<p className='px-2 py-6 text-center text-body-sm text-fg-4'>This project is empty.</p>
|
|
165
|
+
) : (
|
|
166
|
+
<ul role='tree' aria-label='Project files'>
|
|
167
|
+
{tree.rows.map((row) => (
|
|
168
|
+
<li key={row.entry.path} role='treeitem' aria-expanded={row.expanded} aria-level={row.depth + 1}>
|
|
169
|
+
<Row
|
|
170
|
+
label={row.entry.name}
|
|
171
|
+
indent={row.depth}
|
|
172
|
+
active={row.entry.path === activePath}
|
|
173
|
+
icon={
|
|
174
|
+
row.entry.type === 'dir' ? (
|
|
175
|
+
row.loading ? (
|
|
176
|
+
<Spinner className='size-3.5 shrink-0 text-fg-4' />
|
|
177
|
+
) : row.expanded ? (
|
|
178
|
+
<FolderOpen className='size-3.5 shrink-0 text-accent' />
|
|
179
|
+
) : (
|
|
180
|
+
<Folder className='size-3.5 shrink-0 text-accent' />
|
|
181
|
+
)
|
|
182
|
+
) : (
|
|
183
|
+
<EntryIcon type={row.entry.type} />
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
chevron={
|
|
187
|
+
row.entry.type === 'dir' ? (
|
|
188
|
+
<ChevronRight
|
|
189
|
+
className={cn(
|
|
190
|
+
'size-3 shrink-0 text-fg-4 transition-transform',
|
|
191
|
+
row.expanded && 'rotate-90',
|
|
192
|
+
)}
|
|
193
|
+
/>
|
|
194
|
+
) : undefined
|
|
195
|
+
}
|
|
196
|
+
onClick={() =>
|
|
197
|
+
row.entry.type === 'dir'
|
|
198
|
+
? tree.toggle(row.entry.path)
|
|
199
|
+
: onOpenFile(row.entry.path)
|
|
200
|
+
}
|
|
201
|
+
/>
|
|
202
|
+
{row.truncated ? (
|
|
203
|
+
<p
|
|
204
|
+
className='truncate py-0.5 text-label text-fg-4'
|
|
205
|
+
style={{ paddingLeft: (row.depth + 1) * INDENT + 22 }}>
|
|
206
|
+
More entries than the server will return — use search.
|
|
207
|
+
</p>
|
|
208
|
+
) : null}
|
|
209
|
+
</li>
|
|
210
|
+
))}
|
|
211
|
+
</ul>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One clickable line. A directory and a file differ only in what the click does
|
|
219
|
+
* and whether there is a chevron — visually they are the same row. */
|
|
220
|
+
function Row({
|
|
221
|
+
label,
|
|
222
|
+
detail,
|
|
223
|
+
title,
|
|
224
|
+
icon,
|
|
225
|
+
chevron,
|
|
226
|
+
indent = 0,
|
|
227
|
+
active,
|
|
228
|
+
onClick,
|
|
229
|
+
}: {
|
|
230
|
+
label: string
|
|
231
|
+
/** Secondary text after the label, dimmed and the first thing to be truncated. */
|
|
232
|
+
detail?: string
|
|
233
|
+
title?: string
|
|
234
|
+
icon: ReactNode
|
|
235
|
+
chevron?: ReactNode
|
|
236
|
+
indent?: number
|
|
237
|
+
active?: boolean
|
|
238
|
+
onClick: () => void
|
|
239
|
+
}) {
|
|
240
|
+
// Scroll a row that became active elsewhere (a search hit, a `reveal`) into
|
|
241
|
+
// view, but never yank the list while someone is reading it — `nearest` moves
|
|
242
|
+
// the minimum and does nothing when the row is already visible.
|
|
243
|
+
const ref = useRef<HTMLButtonElement>(null)
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (active) ref.current?.scrollIntoView({ block: 'nearest' })
|
|
246
|
+
}, [active])
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<button
|
|
250
|
+
ref={ref}
|
|
251
|
+
type='button'
|
|
252
|
+
onClick={onClick}
|
|
253
|
+
title={title ?? label}
|
|
254
|
+
style={{ paddingLeft: indent * INDENT + 4 }}
|
|
255
|
+
className={cn(
|
|
256
|
+
'flex w-full items-center gap-1 rounded py-1 pr-2 text-left transition-colors',
|
|
257
|
+
active ? 'bg-surface-hover text-fg-1' : 'text-fg-2 hover:bg-surface-hover',
|
|
258
|
+
)}>
|
|
259
|
+
<span className='flex size-3 shrink-0 items-center justify-center'>{chevron}</span>
|
|
260
|
+
{icon}
|
|
261
|
+
{/* `shrink-0` on the name and `min-w-0` on the detail: when the row runs
|
|
262
|
+
out of room the directory gives way and the filename stays whole. */}
|
|
263
|
+
<span className='shrink-0 truncate font-mono text-label'>{label}</span>
|
|
264
|
+
{detail ? (
|
|
265
|
+
<span className='min-w-0 flex-1 truncate font-mono text-label text-fg-4'>{detail}</span>
|
|
266
|
+
) : null}
|
|
267
|
+
</button>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Last segment of a relative match. */
|
|
272
|
+
function fileName(relative: string): string {
|
|
273
|
+
return relative.slice(relative.lastIndexOf('/') + 1)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Everything before it, or `undefined` for a file at the search root. */
|
|
277
|
+
function directoryOf(relative: string): string | undefined {
|
|
278
|
+
const cut = relative.lastIndexOf('/')
|
|
279
|
+
return cut === -1 ? undefined : relative.slice(0, cut)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A symlink is reported as itself and never silently resolved — following it is
|
|
283
|
+
* the next request's problem, and that request is refused if it escapes the roots. */
|
|
284
|
+
function EntryIcon({ type }: { type: HostDirEntry['type'] }) {
|
|
285
|
+
if (type === 'symlink') return <Link2 className='size-3.5 shrink-0 text-fg-4' />
|
|
286
|
+
return <File className='size-3.5 shrink-0 text-fg-4' />
|
|
287
|
+
}
|