@workerdeck/ui 0.7.0 → 0.10.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 +44 -3
- package/build/index.d.mts +767 -21
- package/build/index.mjs +3268 -348
- package/build/index.mjs.map +1 -1
- package/package.json +5 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +379 -56
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/EditorTabs.tsx +165 -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/McpDialog.tsx +363 -0
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +99 -22
- package/src/components/agent/PermissionPrompt.tsx +72 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +380 -40
- 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 +80 -4
- package/src/components/agent/Transcript.tsx +109 -12
- package/src/components/agent/UsageDialog.tsx +168 -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/CopyButton.tsx +8 -1
- 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/index.ts +53 -1
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +48 -0
- package/src/lib/tool-icon.ts +74 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { OpenFile } from '@workerdeck/react'
|
|
3
|
+
import { currentText, isDirty } from '@workerdeck/react'
|
|
4
|
+
import { FileWarning, TriangleAlert } from 'lucide-react'
|
|
5
|
+
import { cn } from '../../lib/utils.ts'
|
|
6
|
+
import { Button } from '../ui/Button.tsx'
|
|
7
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
8
|
+
import { CodeEditor } from './CodeEditor.tsx'
|
|
9
|
+
|
|
10
|
+
export interface FileViewerProps {
|
|
11
|
+
file: OpenFile | undefined
|
|
12
|
+
/** From `/fs/roots`. False renders the editor read-only rather than letting
|
|
13
|
+
* someone type into a file this gateway will refuse to write. */
|
|
14
|
+
canWrite?: boolean
|
|
15
|
+
onChange?: (path: string, content: string) => void
|
|
16
|
+
onSave?: (path: string) => void
|
|
17
|
+
/** Discard this tab's edits — local only, no re-read. */
|
|
18
|
+
onRevert?: (path: string) => void
|
|
19
|
+
/** Take the version on disk, discarding this tab's edits. */
|
|
20
|
+
onReload?: (path: string) => void
|
|
21
|
+
/** Take this tab's version, over whatever is on disk now. */
|
|
22
|
+
onOverwrite?: (path: string) => void
|
|
23
|
+
onDismissConflict?: (path: string) => void
|
|
24
|
+
className?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The focused file: Monaco, plus the states a file can be in that are not
|
|
29
|
+
* "here is some text".
|
|
30
|
+
*
|
|
31
|
+
* No path row — the tab's tooltip carries the path and the size, and a line of
|
|
32
|
+
* monospace above every file is chrome that never earns its height.
|
|
33
|
+
*/
|
|
34
|
+
export function FileViewer({
|
|
35
|
+
file,
|
|
36
|
+
canWrite,
|
|
37
|
+
onChange,
|
|
38
|
+
onSave,
|
|
39
|
+
onRevert,
|
|
40
|
+
onReload,
|
|
41
|
+
onOverwrite,
|
|
42
|
+
onDismissConflict,
|
|
43
|
+
className,
|
|
44
|
+
}: FileViewerProps) {
|
|
45
|
+
if (!file) return null
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
data-slot='file-viewer'
|
|
50
|
+
className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-bg', className)}>
|
|
51
|
+
{/* The one failure with a choice attached, so it gets a bar rather than a
|
|
52
|
+
toast: the agent rewrote this file while it was open, and which
|
|
53
|
+
version wins is not something to decide on the user's behalf. */}
|
|
54
|
+
{file.conflict ? (
|
|
55
|
+
<div className='flex shrink-0 flex-wrap items-center gap-2 border-b border-warning/40 bg-warning-bg px-3 py-2'>
|
|
56
|
+
<TriangleAlert className='size-3.5 shrink-0 text-warning' />
|
|
57
|
+
<span className='min-w-0 flex-1 text-body-sm text-warning'>
|
|
58
|
+
This file changed on disk since you opened it.
|
|
59
|
+
</span>
|
|
60
|
+
<Button variant='outline' size='xs' onClick={() => onReload?.(file.path)}>
|
|
61
|
+
Use the version on disk
|
|
62
|
+
</Button>
|
|
63
|
+
<Button variant='outline' size='xs' onClick={() => onOverwrite?.(file.path)}>
|
|
64
|
+
Keep mine
|
|
65
|
+
</Button>
|
|
66
|
+
<Button variant='ghost' size='xs' onClick={() => onDismissConflict?.(file.path)}>
|
|
67
|
+
Dismiss
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
) : file.saveError ? (
|
|
71
|
+
<div className='flex shrink-0 items-center gap-2 border-b border-danger/40 bg-danger-bg px-3 py-2'>
|
|
72
|
+
<TriangleAlert className='size-3.5 shrink-0 text-danger' />
|
|
73
|
+
<span className='min-w-0 flex-1 text-body-sm text-danger'>{file.saveError}</span>
|
|
74
|
+
<Button variant='ghost' size='xs' onClick={() => onDismissConflict?.(file.path)}>
|
|
75
|
+
Dismiss
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
) : null}
|
|
79
|
+
|
|
80
|
+
{file.status === 'loading' ? (
|
|
81
|
+
<Centred>
|
|
82
|
+
<Spinner className='size-4 text-fg-4' />
|
|
83
|
+
</Centred>
|
|
84
|
+
) : file.status === 'error' ? (
|
|
85
|
+
<Centred>
|
|
86
|
+
<TriangleAlert className='size-4 text-danger' />
|
|
87
|
+
<p className='text-body-sm text-danger'>{file.error}</p>
|
|
88
|
+
</Centred>
|
|
89
|
+
) : file.status === 'binary' ? (
|
|
90
|
+
<Centred>
|
|
91
|
+
<FileWarning className='size-4 text-fg-4' />
|
|
92
|
+
<p className='text-body-sm text-fg-4'>This file isn’t text.</p>
|
|
93
|
+
{/* Said out loud, because "can't show it" and "editing it here would
|
|
94
|
+
destroy it" are different reassurances. */}
|
|
95
|
+
<p className='text-label text-fg-4'>It can’t be edited here without corrupting it.</p>
|
|
96
|
+
</Centred>
|
|
97
|
+
) : (
|
|
98
|
+
<CodeEditor
|
|
99
|
+
path={file.path}
|
|
100
|
+
value={currentText(file)}
|
|
101
|
+
readOnly={!canWrite}
|
|
102
|
+
onChange={onChange ? (content) => onChange(file.path, content) : undefined}
|
|
103
|
+
onSave={onSave ? () => onSave(file.path) : undefined}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{/* A status strip only while there is something to say. Saving is fast
|
|
108
|
+
enough that a permanent row would mostly be blank. */}
|
|
109
|
+
{file.status === 'ready' && (file.saving || isDirty(file) || !canWrite) ? (
|
|
110
|
+
<div className='flex shrink-0 items-center gap-2 border-t border-border px-3 py-1'>
|
|
111
|
+
{file.saving ? (
|
|
112
|
+
<>
|
|
113
|
+
<Spinner className='size-3 text-fg-4' />
|
|
114
|
+
<span className='text-label text-fg-4'>Saving…</span>
|
|
115
|
+
</>
|
|
116
|
+
) : !canWrite ? (
|
|
117
|
+
<span className='text-label text-fg-4'>
|
|
118
|
+
Read-only — this gateway doesn’t allow writes.
|
|
119
|
+
</span>
|
|
120
|
+
) : (
|
|
121
|
+
<>
|
|
122
|
+
<span className='text-label text-fg-3'>Unsaved changes</span>
|
|
123
|
+
<span className='flex-1' />
|
|
124
|
+
{/* Revert, not reload: discard *my* edits and go back to what
|
|
125
|
+
this tab read. Re-reading is the conflict bar's job, and it is
|
|
126
|
+
a different question. */}
|
|
127
|
+
<Button variant='ghost' size='xs' onClick={() => onRevert?.(file.path)}>
|
|
128
|
+
Revert
|
|
129
|
+
</Button>
|
|
130
|
+
<Button variant='outline' size='xs' onClick={() => onSave?.(file.path)}>
|
|
131
|
+
Save
|
|
132
|
+
<span className='ml-1 text-fg-4'>⌘S</span>
|
|
133
|
+
</Button>
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
) : null}
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function Centred({ children }: { children: ReactNode }) {
|
|
143
|
+
return (
|
|
144
|
+
<div className='flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-6'>
|
|
145
|
+
{children}
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|