@tooee/view 0.1.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 +5 -0
- package/dist/DirectoryView.d.ts +7 -0
- package/dist/DirectoryView.d.ts.map +1 -0
- package/dist/DirectoryView.js +45 -0
- package/dist/DirectoryView.js.map +1 -0
- package/dist/View.d.ts +10 -0
- package/dist/View.d.ts.map +1 -0
- package/dist/View.js +367 -0
- package/dist/View.js.map +1 -0
- package/dist/default-provider.d.ts +6 -0
- package/dist/default-provider.d.ts.map +1 -0
- package/dist/default-provider.js +102 -0
- package/dist/default-provider.js.map +1 -0
- package/dist/directory-provider.d.ts +8 -0
- package/dist/directory-provider.d.ts.map +1 -0
- package/dist/directory-provider.js +61 -0
- package/dist/directory-provider.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/launch.d.ts +14 -0
- package/dist/launch.d.ts.map +1 -0
- package/dist/launch.js +11 -0
- package/dist/launch.js.map +1 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +48 -0
- package/dist/types.js.map +1 -0
- package/package.json +50 -0
- package/src/DirectoryView.tsx +68 -0
- package/src/View.tsx +497 -0
- package/src/default-provider.ts +110 -0
- package/src/directory-provider.ts +67 -0
- package/src/index.ts +22 -0
- package/src/launch.tsx +35 -0
- package/src/types.ts +151 -0
package/src/View.tsx
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useMemo, useCallback } from "react"
|
|
2
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
3
|
+
import { MarkdownView, CodeView, ImageView, Table } from "@tooee/renderers"
|
|
4
|
+
import { AppLayout } from "@tooee/layout"
|
|
5
|
+
import { useTheme } from "@tooee/themes"
|
|
6
|
+
import { useHasOverlay } from "@tooee/overlays"
|
|
7
|
+
import { useActions, useProvideCommandContext } from "@tooee/commands"
|
|
8
|
+
import type { ActionDefinition } from "@tooee/commands"
|
|
9
|
+
import {
|
|
10
|
+
useThemeCommands,
|
|
11
|
+
useQuitCommand,
|
|
12
|
+
useCopyCommand,
|
|
13
|
+
useModalNavigationCommands,
|
|
14
|
+
useCommandPalette,
|
|
15
|
+
} from "@tooee/shell"
|
|
16
|
+
import { useConfig } from "@tooee/config"
|
|
17
|
+
import { marked } from "marked"
|
|
18
|
+
import {
|
|
19
|
+
getTextContent,
|
|
20
|
+
isBuiltinContent,
|
|
21
|
+
isCustomContent,
|
|
22
|
+
type AnyContent,
|
|
23
|
+
type Content,
|
|
24
|
+
type ContentFormat,
|
|
25
|
+
type ContentProvider,
|
|
26
|
+
type ContentChunk,
|
|
27
|
+
type ContentRenderer,
|
|
28
|
+
type CustomContent,
|
|
29
|
+
} from "./types.js"
|
|
30
|
+
|
|
31
|
+
interface ViewProps {
|
|
32
|
+
contentProvider: ContentProvider
|
|
33
|
+
actions?: ActionDefinition[]
|
|
34
|
+
renderers?: Record<string, ContentRenderer>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isAsyncIterable(value: unknown): value is AsyncIterable<ContentChunk> {
|
|
38
|
+
return value != null && typeof value === "object" && Symbol.asyncIterator in value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createEmptyContent(format: string, title?: string): AnyContent {
|
|
42
|
+
switch (format) {
|
|
43
|
+
case "markdown":
|
|
44
|
+
return { format, markdown: "", title }
|
|
45
|
+
case "code":
|
|
46
|
+
return { format, code: "", title }
|
|
47
|
+
case "text":
|
|
48
|
+
return { format, text: "", title }
|
|
49
|
+
case "image":
|
|
50
|
+
return { format, src: "", title }
|
|
51
|
+
case "table":
|
|
52
|
+
return { format, columns: [], rows: [], title }
|
|
53
|
+
default:
|
|
54
|
+
return { format, data: undefined, title } as CustomContent
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ensureContentFormat<F extends ContentFormat>(
|
|
59
|
+
current: AnyContent | null,
|
|
60
|
+
format: F,
|
|
61
|
+
title?: string,
|
|
62
|
+
): Extract<Content, { format: F }> {
|
|
63
|
+
if (!current || current.format !== format) {
|
|
64
|
+
return createEmptyContent(format, title) as Extract<Content, { format: F }>
|
|
65
|
+
}
|
|
66
|
+
return current as Extract<Content, { format: F }>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function applyContentChunk(
|
|
70
|
+
current: AnyContent | null,
|
|
71
|
+
chunk: ContentChunk,
|
|
72
|
+
title?: string,
|
|
73
|
+
): AnyContent {
|
|
74
|
+
switch (chunk.type) {
|
|
75
|
+
case "replace":
|
|
76
|
+
return chunk.content
|
|
77
|
+
case "append":
|
|
78
|
+
if (chunk.format === "markdown") {
|
|
79
|
+
const target = ensureContentFormat(current, "markdown", title)
|
|
80
|
+
return { ...target, markdown: target.markdown + chunk.data }
|
|
81
|
+
}
|
|
82
|
+
if (chunk.format === "code") {
|
|
83
|
+
const target = ensureContentFormat(current, "code", title)
|
|
84
|
+
return {
|
|
85
|
+
...target,
|
|
86
|
+
code: target.code + chunk.data,
|
|
87
|
+
language: chunk.language ?? target.language,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
{
|
|
91
|
+
const target = ensureContentFormat(current, "text", title)
|
|
92
|
+
return { ...target, text: target.text + chunk.data }
|
|
93
|
+
}
|
|
94
|
+
case "patch":
|
|
95
|
+
return chunk.apply(current)
|
|
96
|
+
default:
|
|
97
|
+
return current ?? createEmptyContent("markdown", title)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function View({ contentProvider, actions, renderers }: ViewProps) {
|
|
102
|
+
const { theme } = useTheme()
|
|
103
|
+
const config = useConfig()
|
|
104
|
+
const [content, setContent] = useState<AnyContent | null>(null)
|
|
105
|
+
const [streaming, setStreaming] = useState(false)
|
|
106
|
+
const [error, setError] = useState<string | null>(null)
|
|
107
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
108
|
+
const [reloadTrigger, setReloadTrigger] = useState(0)
|
|
109
|
+
const reload = useCallback(() => setReloadTrigger((n) => n + 1), [])
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
setError(null)
|
|
113
|
+
setStreaming(false)
|
|
114
|
+
const loaded = contentProvider.load()
|
|
115
|
+
|
|
116
|
+
if (isAsyncIterable(loaded)) {
|
|
117
|
+
const fallbackFormat = contentProvider.format ?? "markdown"
|
|
118
|
+
const title = contentProvider.title
|
|
119
|
+
let cancelled = false
|
|
120
|
+
let current: AnyContent | null = createEmptyContent(fallbackFormat, title)
|
|
121
|
+
setContent(current)
|
|
122
|
+
setStreaming(true)
|
|
123
|
+
|
|
124
|
+
;(async () => {
|
|
125
|
+
try {
|
|
126
|
+
for await (const chunk of loaded) {
|
|
127
|
+
if (cancelled) break
|
|
128
|
+
current = applyContentChunk(current, chunk, title)
|
|
129
|
+
setContent(current)
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!cancelled && error instanceof Error) {
|
|
133
|
+
setError(error.message)
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
if (!cancelled) {
|
|
137
|
+
setStreaming(false)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
})()
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
cancelled = true
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (loaded instanceof Promise) {
|
|
148
|
+
let active = true
|
|
149
|
+
loaded
|
|
150
|
+
.then((value) => {
|
|
151
|
+
if (active) setContent(value)
|
|
152
|
+
})
|
|
153
|
+
.catch((error: Error) => {
|
|
154
|
+
if (active) setError(error.message)
|
|
155
|
+
})
|
|
156
|
+
return () => {
|
|
157
|
+
active = false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
setContent(loaded)
|
|
162
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
|
+
}, [contentProvider, reloadTrigger])
|
|
164
|
+
|
|
165
|
+
const TABLE_VISUAL_HEADER_OFFSET = 3
|
|
166
|
+
const TABLE_SEARCH_HEADER_OFFSET = 1
|
|
167
|
+
|
|
168
|
+
const textContent = useMemo(() => (content ? getTextContent(content) : ""), [content])
|
|
169
|
+
|
|
170
|
+
const lineCount = useMemo(() => {
|
|
171
|
+
if (!content) return 0
|
|
172
|
+
if (isBuiltinContent(content) && content.format === "table") {
|
|
173
|
+
return content.rows.length + 3
|
|
174
|
+
}
|
|
175
|
+
return textContent.split("\n").length
|
|
176
|
+
}, [content, textContent])
|
|
177
|
+
|
|
178
|
+
const { blockCount, blockLineMap } = useMemo(() => {
|
|
179
|
+
if (!content) return { blockCount: undefined, blockLineMap: undefined }
|
|
180
|
+
if (!isBuiltinContent(content)) return { blockCount: undefined, blockLineMap: undefined }
|
|
181
|
+
|
|
182
|
+
if (content.format === "table") {
|
|
183
|
+
const map = content.rows.map((_: unknown, i: number) => i + TABLE_VISUAL_HEADER_OFFSET)
|
|
184
|
+
return { blockCount: content.rows.length, blockLineMap: map }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (content.format !== "markdown") {
|
|
188
|
+
return { blockCount: undefined, blockLineMap: undefined }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const tokens = marked.lexer(content.markdown)
|
|
192
|
+
const blocks = tokens.filter((t) => t.type !== "space")
|
|
193
|
+
const lineMap: number[] = []
|
|
194
|
+
let lineOffset = 0
|
|
195
|
+
for (const token of tokens) {
|
|
196
|
+
if (token.type === "space") {
|
|
197
|
+
if ("raw" in token && typeof token.raw === "string") {
|
|
198
|
+
lineOffset += token.raw.split("\n").length - 1
|
|
199
|
+
}
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
lineMap.push(lineOffset)
|
|
203
|
+
if ("raw" in token && typeof token.raw === "string") {
|
|
204
|
+
lineOffset += token.raw.split("\n").length - 1
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { blockCount: blocks.length, blockLineMap: lineMap }
|
|
208
|
+
}, [content])
|
|
209
|
+
|
|
210
|
+
const nav = useModalNavigationCommands({
|
|
211
|
+
totalLines: lineCount,
|
|
212
|
+
getText: () => (content ? textContent : undefined),
|
|
213
|
+
blockCount,
|
|
214
|
+
blockLineMap,
|
|
215
|
+
searchLineOffset: content?.format === "table" ? TABLE_SEARCH_HEADER_OFFSET : 0,
|
|
216
|
+
multiSelect: true,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (scrollRef.current) {
|
|
221
|
+
scrollRef.current.scrollTop = nav.scrollOffset
|
|
222
|
+
}
|
|
223
|
+
}, [nav.scrollOffset])
|
|
224
|
+
|
|
225
|
+
const { name: themeName } = useThemeCommands()
|
|
226
|
+
useQuitCommand()
|
|
227
|
+
useCopyCommand({
|
|
228
|
+
getText: () => (content ? textContent : undefined),
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const _palette = useCommandPalette()
|
|
232
|
+
|
|
233
|
+
useActions(actions)
|
|
234
|
+
const hasOverlay = useHasOverlay()
|
|
235
|
+
|
|
236
|
+
function getActiveRow(): Record<string, unknown> | undefined {
|
|
237
|
+
if (!content || !isBuiltinContent(content) || content.format !== "table") return undefined
|
|
238
|
+
if (!nav.cursor) return undefined
|
|
239
|
+
return content.rows[nav.cursor.line]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getSelectedRows(): Record<string, unknown>[] {
|
|
243
|
+
if (!content || !isBuiltinContent(content) || content.format !== "table") return []
|
|
244
|
+
if (nav.toggledIndices.size) {
|
|
245
|
+
return Array.from(nav.toggledIndices)
|
|
246
|
+
.map((i) => content.rows[i])
|
|
247
|
+
.filter(Boolean)
|
|
248
|
+
}
|
|
249
|
+
if (nav.selection) {
|
|
250
|
+
const start = nav.selection.start.line
|
|
251
|
+
const end = nav.selection.end.line
|
|
252
|
+
return content.rows.slice(start, end + 1)
|
|
253
|
+
}
|
|
254
|
+
return []
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
useProvideCommandContext(() => ({
|
|
258
|
+
view: {
|
|
259
|
+
content,
|
|
260
|
+
format: content?.format,
|
|
261
|
+
cursor: nav.cursor,
|
|
262
|
+
selection: nav.selection,
|
|
263
|
+
mode: nav.mode,
|
|
264
|
+
activeRow: getActiveRow(),
|
|
265
|
+
selectedRows: getSelectedRows(),
|
|
266
|
+
toggledIndices: nav.toggledIndices,
|
|
267
|
+
reload,
|
|
268
|
+
},
|
|
269
|
+
}))
|
|
270
|
+
|
|
271
|
+
const matchingLinesSet = useMemo(
|
|
272
|
+
() => (nav.matchingLines.length > 0 ? new Set<number>(nav.matchingLines) : undefined),
|
|
273
|
+
[nav.matchingLines],
|
|
274
|
+
)
|
|
275
|
+
const currentMatchLine =
|
|
276
|
+
nav.matchingLines.length > 0 ? nav.matchingLines[nav.currentMatchIndex] : undefined
|
|
277
|
+
|
|
278
|
+
// For markdown: convert matching lines to matching blocks
|
|
279
|
+
const matchingBlocks = useMemo(() => {
|
|
280
|
+
if (!matchingLinesSet || !blockLineMap || blockLineMap.length === 0) return undefined
|
|
281
|
+
if (content?.format === "table") return undefined // table uses matchingRows instead
|
|
282
|
+
const blocks = new Set<number>()
|
|
283
|
+
for (const line of matchingLinesSet) {
|
|
284
|
+
let blockIdx = 0
|
|
285
|
+
for (let i = blockLineMap.length - 1; i >= 0; i--) {
|
|
286
|
+
if (blockLineMap[i] <= line) {
|
|
287
|
+
blockIdx = i
|
|
288
|
+
break
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
blocks.add(blockIdx)
|
|
292
|
+
}
|
|
293
|
+
return blocks.size > 0 ? blocks : undefined
|
|
294
|
+
}, [matchingLinesSet, blockLineMap, content?.format])
|
|
295
|
+
|
|
296
|
+
const currentMatchBlock = useMemo(() => {
|
|
297
|
+
if (currentMatchLine == null || !blockLineMap || blockLineMap.length === 0) return undefined
|
|
298
|
+
if (content?.format === "table") return undefined
|
|
299
|
+
for (let i = blockLineMap.length - 1; i >= 0; i--) {
|
|
300
|
+
if (blockLineMap[i] <= currentMatchLine) return i
|
|
301
|
+
}
|
|
302
|
+
return 0
|
|
303
|
+
}, [currentMatchLine, blockLineMap, content?.format])
|
|
304
|
+
|
|
305
|
+
// For table: convert matching lines to matching row indices
|
|
306
|
+
const matchingRowsSet = useMemo(() => {
|
|
307
|
+
if (!matchingLinesSet || content?.format !== "table") return undefined
|
|
308
|
+
const rows = new Set<number>()
|
|
309
|
+
for (const line of matchingLinesSet) {
|
|
310
|
+
const row = line - TABLE_SEARCH_HEADER_OFFSET
|
|
311
|
+
if (row >= 0) rows.add(row)
|
|
312
|
+
}
|
|
313
|
+
return rows.size > 0 ? rows : undefined
|
|
314
|
+
}, [matchingLinesSet, content?.format])
|
|
315
|
+
|
|
316
|
+
const currentMatchRow = useMemo(() => {
|
|
317
|
+
if (content?.format !== "table" || currentMatchLine == null) return undefined
|
|
318
|
+
const row = currentMatchLine - TABLE_SEARCH_HEADER_OFFSET
|
|
319
|
+
return row >= 0 ? row : undefined
|
|
320
|
+
}, [content?.format, currentMatchLine])
|
|
321
|
+
|
|
322
|
+
const toggledBlocks = useMemo(() => {
|
|
323
|
+
if (content?.format !== "markdown" || nav.toggledIndices.size === 0) return undefined
|
|
324
|
+
return new Set<number>(nav.toggledIndices)
|
|
325
|
+
}, [content?.format, nav.toggledIndices])
|
|
326
|
+
|
|
327
|
+
const toggledRows = useMemo(() => {
|
|
328
|
+
if (content?.format !== "table" || nav.toggledIndices.size === 0) return undefined
|
|
329
|
+
return new Set<number>(nav.toggledIndices)
|
|
330
|
+
}, [content?.format, nav.toggledIndices])
|
|
331
|
+
|
|
332
|
+
const toggledLines = useMemo(() => {
|
|
333
|
+
if (!content || (content.format !== "code" && content.format !== "text")) return undefined
|
|
334
|
+
return nav.toggledIndices.size > 0 ? new Set<number>(nav.toggledIndices) : undefined
|
|
335
|
+
}, [content, nav.toggledIndices])
|
|
336
|
+
|
|
337
|
+
if (error) {
|
|
338
|
+
return (
|
|
339
|
+
<box style={{ flexDirection: "column" }}>
|
|
340
|
+
<text content={`Error: ${error}`} fg={theme.error} />
|
|
341
|
+
</box>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!content) {
|
|
346
|
+
return (
|
|
347
|
+
<box>
|
|
348
|
+
<text content="Loading..." fg={theme.textMuted} />
|
|
349
|
+
</box>
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const cursorLine = nav.cursor?.line ?? undefined
|
|
354
|
+
const selectionStart = nav.selection?.start.line ?? undefined
|
|
355
|
+
const selectionEnd = nav.selection?.end.line ?? undefined
|
|
356
|
+
const gutterConfig = config.view?.gutter
|
|
357
|
+
|
|
358
|
+
const renderContent = () => {
|
|
359
|
+
// Custom content: use registered renderer or fall back to text
|
|
360
|
+
if (isCustomContent(content)) {
|
|
361
|
+
const customRenderer = renderers?.[content.format]
|
|
362
|
+
if (customRenderer) {
|
|
363
|
+
return customRenderer({
|
|
364
|
+
content,
|
|
365
|
+
lineCount,
|
|
366
|
+
cursor: cursorLine,
|
|
367
|
+
selectionStart,
|
|
368
|
+
selectionEnd,
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
// No renderer for this custom format — fall back to text
|
|
372
|
+
const text = getTextContent(content)
|
|
373
|
+
return (
|
|
374
|
+
<CodeView
|
|
375
|
+
content={text}
|
|
376
|
+
showLineNumbers={false}
|
|
377
|
+
cursor={cursorLine}
|
|
378
|
+
selectionStart={selectionStart}
|
|
379
|
+
selectionEnd={selectionEnd}
|
|
380
|
+
/>
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Built-in formats
|
|
385
|
+
switch (content.format) {
|
|
386
|
+
case "markdown":
|
|
387
|
+
return (
|
|
388
|
+
<MarkdownView
|
|
389
|
+
content={content.markdown}
|
|
390
|
+
activeBlock={cursorLine}
|
|
391
|
+
selectedBlocks={
|
|
392
|
+
selectionStart != null && selectionEnd != null
|
|
393
|
+
? { start: selectionStart, end: selectionEnd }
|
|
394
|
+
: undefined
|
|
395
|
+
}
|
|
396
|
+
matchingBlocks={matchingBlocks}
|
|
397
|
+
currentMatchBlock={currentMatchBlock}
|
|
398
|
+
toggledBlocks={toggledBlocks}
|
|
399
|
+
/>
|
|
400
|
+
)
|
|
401
|
+
case "code":
|
|
402
|
+
return (
|
|
403
|
+
<CodeView
|
|
404
|
+
content={content.code}
|
|
405
|
+
language={content.language}
|
|
406
|
+
showLineNumbers={gutterConfig ?? true}
|
|
407
|
+
cursor={cursorLine}
|
|
408
|
+
selectionStart={selectionStart}
|
|
409
|
+
selectionEnd={selectionEnd}
|
|
410
|
+
matchingLines={matchingLinesSet}
|
|
411
|
+
currentMatchLine={currentMatchLine}
|
|
412
|
+
toggledLines={toggledLines}
|
|
413
|
+
/>
|
|
414
|
+
)
|
|
415
|
+
case "text":
|
|
416
|
+
return (
|
|
417
|
+
<CodeView
|
|
418
|
+
content={content.text}
|
|
419
|
+
showLineNumbers={gutterConfig ?? false}
|
|
420
|
+
cursor={cursorLine}
|
|
421
|
+
selectionStart={selectionStart}
|
|
422
|
+
selectionEnd={selectionEnd}
|
|
423
|
+
matchingLines={matchingLinesSet}
|
|
424
|
+
currentMatchLine={currentMatchLine}
|
|
425
|
+
toggledLines={toggledLines}
|
|
426
|
+
/>
|
|
427
|
+
)
|
|
428
|
+
case "image":
|
|
429
|
+
return <ImageView src={content.src} />
|
|
430
|
+
case "table":
|
|
431
|
+
return (
|
|
432
|
+
<Table
|
|
433
|
+
columns={content.columns}
|
|
434
|
+
rows={content.rows}
|
|
435
|
+
cursor={cursorLine}
|
|
436
|
+
selectionStart={selectionStart}
|
|
437
|
+
selectionEnd={selectionEnd}
|
|
438
|
+
matchingRows={matchingRowsSet}
|
|
439
|
+
currentMatchRow={currentMatchRow}
|
|
440
|
+
toggledRows={toggledRows}
|
|
441
|
+
/>
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const selectionCount =
|
|
447
|
+
nav.selection != null ? nav.selection.end.line - nav.selection.start.line + 1 : 0
|
|
448
|
+
const toggledCount = nav.toggledIndices.size
|
|
449
|
+
const selectionItems =
|
|
450
|
+
toggledCount > 0
|
|
451
|
+
? [{ label: "Selected:", value: String(toggledCount) }]
|
|
452
|
+
: selectionCount > 0
|
|
453
|
+
? [{ label: "Selected:", value: String(selectionCount) }]
|
|
454
|
+
: []
|
|
455
|
+
|
|
456
|
+
const statusItems = [
|
|
457
|
+
{ label: "Theme:", value: themeName },
|
|
458
|
+
{ label: "Format:", value: content.format },
|
|
459
|
+
...(isBuiltinContent(content) && content.format === "table"
|
|
460
|
+
? [
|
|
461
|
+
{ label: "Rows:", value: String(content.rows.length) },
|
|
462
|
+
{ label: "Cols:", value: String(content.columns.length) },
|
|
463
|
+
]
|
|
464
|
+
: [{ label: "Lines:", value: String(lineCount) }]),
|
|
465
|
+
{ label: "Mode:", value: nav.mode },
|
|
466
|
+
{ label: "Scroll:", value: String(nav.scrollOffset) },
|
|
467
|
+
...selectionItems,
|
|
468
|
+
...(streaming ? [{ label: "Status:", value: "streaming" }] : []),
|
|
469
|
+
...(nav.searchActive ? [{ label: "Search:", value: nav.searchQuery }] : []),
|
|
470
|
+
]
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
<AppLayout
|
|
474
|
+
titleBar={
|
|
475
|
+
content.title
|
|
476
|
+
? { title: content.title, subtitle: content.format }
|
|
477
|
+
: { title: content.format }
|
|
478
|
+
}
|
|
479
|
+
statusBar={{ items: statusItems }}
|
|
480
|
+
scrollRef={scrollRef}
|
|
481
|
+
scrollProps={{ focused: !nav.searchActive && !hasOverlay }}
|
|
482
|
+
searchBar={{
|
|
483
|
+
active: nav.searchActive,
|
|
484
|
+
query: nav.searchQuery,
|
|
485
|
+
onQueryChange: nav.setSearchQuery,
|
|
486
|
+
onSubmit: nav.submitSearch,
|
|
487
|
+
onCancel: () => {
|
|
488
|
+
nav.setSearchQuery("")
|
|
489
|
+
},
|
|
490
|
+
matchCount: nav.matchingLines.length,
|
|
491
|
+
currentMatch: nav.currentMatchIndex,
|
|
492
|
+
}}
|
|
493
|
+
>
|
|
494
|
+
{renderContent()}
|
|
495
|
+
</AppLayout>
|
|
496
|
+
)
|
|
497
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { parseAuto } from "@tooee/renderers"
|
|
2
|
+
import type { Content, ContentFormat, ContentProvider } from "./types.js"
|
|
3
|
+
|
|
4
|
+
function detectFormat(filePath: string): { format: ContentFormat; language?: string } {
|
|
5
|
+
const ext = filePath.split(".").pop()?.toLowerCase()
|
|
6
|
+
if (!ext) return { format: "text" }
|
|
7
|
+
|
|
8
|
+
const imageExts = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif"])
|
|
9
|
+
if (imageExts.has(ext)) return { format: "image" }
|
|
10
|
+
|
|
11
|
+
const tableExts = new Set(["csv", "tsv"])
|
|
12
|
+
if (tableExts.has(ext)) return { format: "table" }
|
|
13
|
+
|
|
14
|
+
const markdownExts = new Set(["md", "mdx", "markdown"])
|
|
15
|
+
if (markdownExts.has(ext)) return { format: "markdown" }
|
|
16
|
+
|
|
17
|
+
const codeExts: Record<string, string> = {
|
|
18
|
+
ts: "typescript",
|
|
19
|
+
tsx: "typescript",
|
|
20
|
+
js: "javascript",
|
|
21
|
+
jsx: "javascript",
|
|
22
|
+
py: "python",
|
|
23
|
+
rs: "rust",
|
|
24
|
+
go: "go",
|
|
25
|
+
rb: "ruby",
|
|
26
|
+
sh: "bash",
|
|
27
|
+
bash: "bash",
|
|
28
|
+
zsh: "zsh",
|
|
29
|
+
json: "json",
|
|
30
|
+
yaml: "yaml",
|
|
31
|
+
yml: "yaml",
|
|
32
|
+
toml: "toml",
|
|
33
|
+
css: "css",
|
|
34
|
+
html: "html",
|
|
35
|
+
sql: "sql",
|
|
36
|
+
c: "c",
|
|
37
|
+
cpp: "cpp",
|
|
38
|
+
h: "c",
|
|
39
|
+
hpp: "cpp",
|
|
40
|
+
java: "java",
|
|
41
|
+
kt: "kotlin",
|
|
42
|
+
swift: "swift",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const language = codeExts[ext]
|
|
46
|
+
if (language) return { format: "code", language }
|
|
47
|
+
|
|
48
|
+
return { format: "text" }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createFileProvider(filePath: string): ContentProvider {
|
|
52
|
+
return {
|
|
53
|
+
async load(): Promise<Content> {
|
|
54
|
+
const { format, language } = detectFormat(filePath)
|
|
55
|
+
const title = filePath.split("/").pop()
|
|
56
|
+
|
|
57
|
+
if (format === "image") {
|
|
58
|
+
return { format: "image", src: filePath, title }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const file = Bun.file(filePath)
|
|
62
|
+
if (format === "table") {
|
|
63
|
+
const text = await file.text()
|
|
64
|
+
const parsed = parseAuto(text)
|
|
65
|
+
return { format: "table", columns: parsed.columns, rows: parsed.rows, title }
|
|
66
|
+
}
|
|
67
|
+
const text = await file.text()
|
|
68
|
+
switch (format) {
|
|
69
|
+
case "markdown":
|
|
70
|
+
return { format: "markdown", markdown: text, title }
|
|
71
|
+
case "code":
|
|
72
|
+
return { format: "code", code: text, language, title }
|
|
73
|
+
case "text":
|
|
74
|
+
default:
|
|
75
|
+
return { format: "text", text, title }
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createStdinProvider(): ContentProvider {
|
|
82
|
+
return {
|
|
83
|
+
async load(): Promise<Content> {
|
|
84
|
+
const text = await new Response(Bun.stdin.stream() as unknown as ReadableStream).text()
|
|
85
|
+
return { format: "markdown", markdown: text, title: "stdin" }
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createTableFileProvider(filePath: string): ContentProvider {
|
|
91
|
+
return {
|
|
92
|
+
async load(): Promise<Content> {
|
|
93
|
+
const title = filePath.split("/").pop()
|
|
94
|
+
const file = Bun.file(filePath)
|
|
95
|
+
const text = await file.text()
|
|
96
|
+
const parsed = parseAuto(text)
|
|
97
|
+
return { format: "table", columns: parsed.columns, rows: parsed.rows, title }
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createTableStdinProvider(): ContentProvider {
|
|
103
|
+
return {
|
|
104
|
+
async load(): Promise<Content> {
|
|
105
|
+
const text = await new Response(Bun.stdin.stream() as unknown as ReadableStream).text()
|
|
106
|
+
const parsed = parseAuto(text)
|
|
107
|
+
return { format: "table", columns: parsed.columns, rows: parsed.rows, title: "stdin" }
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "fs"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import type { ContentProvider } from "./types.js"
|
|
4
|
+
import { createFileProvider } from "./default-provider.js"
|
|
5
|
+
|
|
6
|
+
const SUPPORTED_EXTENSIONS = new Set([
|
|
7
|
+
"md",
|
|
8
|
+
"mdx",
|
|
9
|
+
"markdown",
|
|
10
|
+
"ts",
|
|
11
|
+
"tsx",
|
|
12
|
+
"js",
|
|
13
|
+
"jsx",
|
|
14
|
+
"json",
|
|
15
|
+
"txt",
|
|
16
|
+
"css",
|
|
17
|
+
"html",
|
|
18
|
+
"yaml",
|
|
19
|
+
"yml",
|
|
20
|
+
"toml",
|
|
21
|
+
"sh",
|
|
22
|
+
"bash",
|
|
23
|
+
"zsh",
|
|
24
|
+
"go",
|
|
25
|
+
"rs",
|
|
26
|
+
"py",
|
|
27
|
+
"rb",
|
|
28
|
+
"c",
|
|
29
|
+
"cpp",
|
|
30
|
+
"h",
|
|
31
|
+
"hpp",
|
|
32
|
+
"java",
|
|
33
|
+
"kt",
|
|
34
|
+
"swift",
|
|
35
|
+
"sql",
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
export interface DirectoryEntry {
|
|
39
|
+
name: string
|
|
40
|
+
path: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function listDirectoryFiles(dirPath: string): DirectoryEntry[] {
|
|
44
|
+
const entries = readdirSync(dirPath)
|
|
45
|
+
const files: DirectoryEntry[] = []
|
|
46
|
+
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.startsWith(".")) continue
|
|
49
|
+
const fullPath = join(dirPath, entry)
|
|
50
|
+
try {
|
|
51
|
+
const stat = statSync(fullPath)
|
|
52
|
+
if (!stat.isFile()) continue
|
|
53
|
+
} catch {
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
const ext = entry.split(".").pop()?.toLowerCase()
|
|
57
|
+
if (!ext || !SUPPORTED_EXTENSIONS.has(ext)) continue
|
|
58
|
+
files.push({ name: entry, path: fullPath })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
files.sort((a, b) => a.name.localeCompare(b.name))
|
|
62
|
+
return files
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createDirectoryFileProvider(filePath: string): ContentProvider {
|
|
66
|
+
return createFileProvider(filePath)
|
|
67
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { View } from "./View.jsx"
|
|
2
|
+
export { DirectoryView } from "./DirectoryView.jsx"
|
|
3
|
+
export { launch, launchDirectory } from "./launch.jsx"
|
|
4
|
+
export type { ViewLaunchOptions } from "./launch.jsx"
|
|
5
|
+
export { createFileProvider, createStdinProvider, createTableFileProvider, createTableStdinProvider } from "./default-provider.js"
|
|
6
|
+
export { listDirectoryFiles } from "./directory-provider.js"
|
|
7
|
+
export type { DirectoryEntry } from "./directory-provider.js"
|
|
8
|
+
export type {
|
|
9
|
+
AnyContent,
|
|
10
|
+
Content,
|
|
11
|
+
ContentChunk,
|
|
12
|
+
ContentFormat,
|
|
13
|
+
ContentProvider,
|
|
14
|
+
ContentRenderer,
|
|
15
|
+
ContentRendererProps,
|
|
16
|
+
CustomContent,
|
|
17
|
+
ViewContent,
|
|
18
|
+
ViewContentProvider,
|
|
19
|
+
ColumnDef,
|
|
20
|
+
TableRow,
|
|
21
|
+
} from "./types.js"
|
|
22
|
+
export { getTextContent, isBuiltinContent, isCustomContent } from "./types.js"
|