@workerdeck/ui 0.9.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.
Files changed (38) hide show
  1. package/README.md +44 -3
  2. package/build/index.d.mts +762 -21
  3. package/build/index.mjs +3245 -348
  4. package/build/index.mjs.map +1 -1
  5. package/package.json +5 -4
  6. package/src/components/agent/CodeEditor.tsx +300 -0
  7. package/src/components/agent/Composer.tsx +379 -56
  8. package/src/components/agent/ContextDialog.tsx +99 -0
  9. package/src/components/agent/EditorTabs.tsx +165 -0
  10. package/src/components/agent/FileTree.tsx +287 -0
  11. package/src/components/agent/FileViewer.tsx +148 -0
  12. package/src/components/agent/HostFilesDialog.tsx +218 -0
  13. package/src/components/agent/McpDialog.tsx +363 -0
  14. package/src/components/agent/ModelSelect.tsx +34 -6
  15. package/src/components/agent/PermissionModeSelect.tsx +99 -22
  16. package/src/components/agent/PermissionPrompt.tsx +72 -6
  17. package/src/components/agent/PromptTokenText.tsx +39 -0
  18. package/src/components/agent/SessionEmptyState.tsx +65 -0
  19. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  20. package/src/components/agent/SessionPanel.tsx +379 -40
  21. package/src/components/agent/SessionWorkspace.tsx +282 -0
  22. package/src/components/agent/SkillsDialog.tsx +195 -0
  23. package/src/components/agent/StatusBar.tsx +85 -18
  24. package/src/components/agent/ToolCallCard.tsx +80 -4
  25. package/src/components/agent/Transcript.tsx +66 -17
  26. package/src/components/agent/UsageDialog.tsx +168 -0
  27. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  28. package/src/components/prompt-area/types.ts +15 -0
  29. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  30. package/src/components/ui/CopyButton.tsx +8 -1
  31. package/src/components/ui/Dialog.tsx +92 -0
  32. package/src/components/ui/Menu.tsx +55 -0
  33. package/src/components/ui/Splitter.tsx +133 -0
  34. package/src/components/ui/Tooltip.tsx +22 -5
  35. package/src/index.ts +53 -1
  36. package/src/lib/clipboard.ts +56 -0
  37. package/src/lib/format.ts +48 -0
  38. 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
+ }