@visulima/tui-kit 0.0.0 → 1.0.1
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/CHANGELOG.md +13 -0
- package/LICENSE.md +2095 -0
- package/README.md +115 -29
- package/dist/accordion.d.ts +52 -0
- package/dist/accordion.js +1 -0
- package/dist/alert.d.ts +22 -0
- package/dist/alert.js +1 -0
- package/dist/animate-presence.d.ts +16 -0
- package/dist/animate-presence.js +1 -0
- package/dist/approval-prompt.d.ts +55 -0
- package/dist/approval-prompt.js +1 -0
- package/dist/area-chart.d.ts +56 -0
- package/dist/area-chart.js +1 -0
- package/dist/badge.d.ts +19 -0
- package/dist/badge.js +1 -0
- package/dist/bar-chart.d.ts +58 -0
- package/dist/bar-chart.js +1 -0
- package/dist/big-text.d.ts +71 -0
- package/dist/big-text.js +1 -0
- package/dist/blink-dot.d.ts +31 -0
- package/dist/blink-dot.js +1 -0
- package/dist/breadcrumb.d.ts +29 -0
- package/dist/breadcrumb.js +1 -0
- package/dist/button.d.ts +38 -0
- package/dist/button.js +1 -0
- package/dist/calendar.d.ts +60 -0
- package/dist/calendar.js +1 -0
- package/dist/card.d.ts +47 -0
- package/dist/card.js +1 -0
- package/dist/checkbox-group.d.ts +50 -0
- package/dist/checkbox-group.js +1 -0
- package/dist/checkbox.d.ts +40 -0
- package/dist/checkbox.js +1 -0
- package/dist/code.d.ts +41 -0
- package/dist/code.js +2 -0
- package/dist/collapsible.d.ts +44 -0
- package/dist/collapsible.js +1 -0
- package/dist/color-picker.d.ts +59 -0
- package/dist/color-picker.js +1 -0
- package/dist/command-block.d.ts +39 -0
- package/dist/command-block.js +4 -0
- package/dist/command-palette.d.ts +73 -0
- package/dist/command-palette.js +1 -0
- package/dist/confirm-dialog.d.ts +63 -0
- package/dist/confirm-dialog.js +1 -0
- package/dist/confirm-input.d.ts +32 -0
- package/dist/confirm-input.js +1 -0
- package/dist/console-overlay.d.ts +47 -0
- package/dist/console-overlay.js +1 -0
- package/dist/content-switcher.d.ts +49 -0
- package/dist/content-switcher.js +1 -0
- package/dist/controlled-scroll-view.d.ts +3 -0
- package/dist/controlled-scroll-view.js +1 -0
- package/dist/data-grid.d.ts +43 -0
- package/dist/data-grid.js +1 -0
- package/dist/date-picker.d.ts +54 -0
- package/dist/date-picker.js +1 -0
- package/dist/definition-list.d.ts +38 -0
- package/dist/definition-list.js +1 -0
- package/dist/dialog.d.ts +94 -0
- package/dist/dialog.js +1 -0
- package/dist/diff-view.d.ts +72 -0
- package/dist/diff-view.js +1 -0
- package/dist/digits.d.ts +26 -0
- package/dist/digits.js +1 -0
- package/dist/directory-tree.d.ts +44 -0
- package/dist/directory-tree.js +1 -0
- package/dist/divider.d.ts +41 -0
- package/dist/divider.js +1 -0
- package/dist/drawer.d.ts +45 -0
- package/dist/drawer.js +1 -0
- package/dist/file-change.d.ts +34 -0
- package/dist/file-change.js +1 -0
- package/dist/file-picker-entry.d.ts +2 -0
- package/dist/file-picker-entry.js +1 -0
- package/dist/form.d.ts +74 -0
- package/dist/form.js +1 -0
- package/dist/gauge.d.ts +76 -0
- package/dist/gauge.js +1 -0
- package/dist/gradient.d.ts +38 -0
- package/dist/gradient.js +1 -0
- package/dist/grid.d.ts +34 -0
- package/dist/grid.js +1 -0
- package/dist/heading.d.ts +28 -0
- package/dist/heading.js +1 -0
- package/dist/heatmap.d.ts +52 -0
- package/dist/heatmap.js +1 -0
- package/dist/help.d.ts +45 -0
- package/dist/help.js +1 -0
- package/dist/histogram.d.ts +52 -0
- package/dist/histogram.js +1 -0
- package/dist/image.d.ts +23 -0
- package/dist/image.js +1 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +1 -0
- package/dist/json.d.ts +51 -0
- package/dist/json.js +1 -0
- package/dist/kbd.d.ts +27 -0
- package/dist/kbd.js +1 -0
- package/dist/line-chart.d.ts +100 -0
- package/dist/line-chart.js +1 -0
- package/dist/link.d.ts +34 -0
- package/dist/link.js +1 -0
- package/dist/loading-indicator.d.ts +25 -0
- package/dist/loading-indicator.js +1 -0
- package/dist/log.d.ts +33 -0
- package/dist/log.js +1 -0
- package/dist/markdown.d.ts +35 -0
- package/dist/markdown.js +1 -0
- package/dist/masked-input.d.ts +42 -0
- package/dist/masked-input.js +1 -0
- package/dist/menu.d.ts +70 -0
- package/dist/menu.js +1 -0
- package/dist/message-bubble.d.ts +36 -0
- package/dist/message-bubble.js +1 -0
- package/dist/model-badge.d.ts +33 -0
- package/dist/model-badge.js +1 -0
- package/dist/model-selector.d.ts +45 -0
- package/dist/model-selector.js +1 -0
- package/dist/multi-progress.d.ts +48 -0
- package/dist/multi-progress.js +1 -0
- package/dist/multi-select.d.ts +55 -0
- package/dist/multi-select.js +1 -0
- package/dist/notification-badge.d.ts +40 -0
- package/dist/notification-badge.js +1 -0
- package/dist/number-input.d.ts +71 -0
- package/dist/number-input.js +1 -0
- package/dist/operation-tree.d.ts +46 -0
- package/dist/operation-tree.js +1 -0
- package/dist/option-list.d.ts +41 -0
- package/dist/option-list.js +1 -0
- package/dist/ordered-list.d.ts +22 -0
- package/dist/ordered-list.js +1 -0
- package/dist/packem_shared/ControlledScrollView-BzlbIUph.js +1 -0
- package/dist/packem_shared/FilePicker-V5H2fb-v.js +1 -0
- package/dist/packem_shared/ScrollBar-C9i3z3Ft.js +1 -0
- package/dist/packem_shared/ScrollBarBox-DZEC8YnV.js +1 -0
- package/dist/packem_shared/ScrollList-Djtb2vVy.js +1 -0
- package/dist/packem_shared/ScrollView-ESxpl6Hy.js +1 -0
- package/dist/packem_shared/TreeNodeMap-cJlYqckB.js +1 -0
- package/dist/packem_shared/TreeView-q0l446NV.js +1 -0
- package/dist/packem_shared/controlled-scroll-view-DerVKKv6.js +1 -0
- package/dist/packem_shared/controlled-scroll-view.d-D3kZZzjI.d.ts +43 -0
- package/dist/packem_shared/file-picker.d-CH7pccC7.d.ts +92 -0
- package/dist/packem_shared/line-chart-BbhMhcZg.js +1 -0
- package/dist/packem_shared/scroll-bar-box.d-CpTYEcTZ.d.ts +13 -0
- package/dist/packem_shared/scroll-bar.d-B4sD1hBR.d.ts +18 -0
- package/dist/packem_shared/scroll-list.d-CPCF0RwC.d.ts +12 -0
- package/dist/packem_shared/scroll-view.d-GZcgAHl4.d.ts +98 -0
- package/dist/packem_shared/treeViewTheme-Dq3ekRAC.js +1 -0
- package/dist/packem_shared/use-tree-view.d-DeMQ6G2r.d.ts +314 -0
- package/dist/packem_shared/useTreeView-D0JEqT6W.js +1 -0
- package/dist/packem_shared/useTreeViewState-BiTJakkh.js +1 -0
- package/dist/packem_shared/variant-config-BJqpNo7H.js +1 -0
- package/dist/packem_shared/variant-config.d-D5NnrrQK.d.ts +2 -0
- package/dist/paginator.d.ts +59 -0
- package/dist/paginator.js +1 -0
- package/dist/paragraph.d.ts +32 -0
- package/dist/paragraph.js +1 -0
- package/dist/path-input.d.ts +56 -0
- package/dist/path-input.js +1 -0
- package/dist/pie-chart.d.ts +44 -0
- package/dist/pie-chart.js +1 -0
- package/dist/placeholder.d.ts +47 -0
- package/dist/placeholder.js +1 -0
- package/dist/popover.d.ts +39 -0
- package/dist/popover.js +1 -0
- package/dist/progress-bar.d.ts +43 -0
- package/dist/progress-bar.js +1 -0
- package/dist/progress-circle.d.ts +50 -0
- package/dist/progress-circle.js +1 -0
- package/dist/qr-code.d.ts +39 -0
- package/dist/qr-code.js +1 -0
- package/dist/r/accordion.json +24 -0
- package/dist/r/alert.json +24 -0
- package/dist/r/animate-presence.json +22 -0
- package/dist/r/approval-prompt.json +24 -0
- package/dist/r/area-chart.json +27 -0
- package/dist/r/badge.json +24 -0
- package/dist/r/bar-chart.json +24 -0
- package/dist/r/big-text.json +23 -0
- package/dist/r/blink-dot.json +24 -0
- package/dist/r/breadcrumb.json +24 -0
- package/dist/r/button.json +24 -0
- package/dist/r/calendar.json +24 -0
- package/dist/r/card.json +26 -0
- package/dist/r/chart-utils.json +24 -0
- package/dist/r/checkbox-group.json +24 -0
- package/dist/r/checkbox.json +24 -0
- package/dist/r/code.json +23 -0
- package/dist/r/collapsible.json +24 -0
- package/dist/r/color-picker.json +24 -0
- package/dist/r/command-block.json +26 -0
- package/dist/r/command-palette.json +24 -0
- package/dist/r/confirm-dialog.json +24 -0
- package/dist/r/confirm-input.json +22 -0
- package/dist/r/console-overlay.json +22 -0
- package/dist/r/content-switcher.json +24 -0
- package/dist/r/controlled-scroll-view.json +24 -0
- package/dist/r/data-grid.json +24 -0
- package/dist/r/date-picker.json +26 -0
- package/dist/r/definition-list.json +24 -0
- package/dist/r/dialog.json +24 -0
- package/dist/r/diff-view.json +24 -0
- package/dist/r/digits.json +24 -0
- package/dist/r/directory-tree.json +24 -0
- package/dist/r/divider.json +24 -0
- package/dist/r/drawer.json +24 -0
- package/dist/r/file-change.json +24 -0
- package/dist/r/file-picker.json +44 -0
- package/dist/r/form.json +24 -0
- package/dist/r/gauge.json +24 -0
- package/dist/r/gradient.json +24 -0
- package/dist/r/grid.json +22 -0
- package/dist/r/heading.json +24 -0
- package/dist/r/heatmap.json +24 -0
- package/dist/r/help.json +22 -0
- package/dist/r/histogram.json +26 -0
- package/dist/r/image.json +24 -0
- package/dist/r/json.json +24 -0
- package/dist/r/kbd.json +24 -0
- package/dist/r/line-chart.json +26 -0
- package/dist/r/link.json +23 -0
- package/dist/r/loading-indicator.json +27 -0
- package/dist/r/log.json +24 -0
- package/dist/r/markdown.json +29 -0
- package/dist/r/masked-input.json +22 -0
- package/dist/r/menu.json +24 -0
- package/dist/r/message-bubble.json +24 -0
- package/dist/r/model-badge.json +24 -0
- package/dist/r/model-selector.json +24 -0
- package/dist/r/multi-progress.json +24 -0
- package/dist/r/multi-select.json +24 -0
- package/dist/r/notification-badge.json +24 -0
- package/dist/r/number-input.json +24 -0
- package/dist/r/operation-tree.json +26 -0
- package/dist/r/option-list.json +24 -0
- package/dist/r/ordered-list.json +22 -0
- package/dist/r/paginator.json +22 -0
- package/dist/r/paragraph.json +24 -0
- package/dist/r/path-input.json +26 -0
- package/dist/r/pie-chart.json +24 -0
- package/dist/r/placeholder.json +24 -0
- package/dist/r/popover.json +24 -0
- package/dist/r/progress-bar.json +22 -0
- package/dist/r/progress-circle.json +24 -0
- package/dist/r/qr-code.json +24 -0
- package/dist/r/radio-group.json +24 -0
- package/dist/r/registry.json +1051 -0
- package/dist/r/scatter-plot.json +27 -0
- package/dist/r/scroll-bar-box.json +24 -0
- package/dist/r/scroll-bar.json +24 -0
- package/dist/r/scroll-list.json +24 -0
- package/dist/r/scroll-view.json +24 -0
- package/dist/r/scroll.json +58 -0
- package/dist/r/search-input.json +26 -0
- package/dist/r/select-input-indicator.json +24 -0
- package/dist/r/select-input-item.json +24 -0
- package/dist/r/select-input.json +28 -0
- package/dist/r/shimmer-text.json +24 -0
- package/dist/r/sidebar.json +24 -0
- package/dist/r/slider.json +24 -0
- package/dist/r/sparkline.json +24 -0
- package/dist/r/spinner.json +23 -0
- package/dist/r/status-line.json +24 -0
- package/dist/r/status-message.json +24 -0
- package/dist/r/stepper.json +24 -0
- package/dist/r/stopwatch.json +22 -0
- package/dist/r/streaming-text.json +24 -0
- package/dist/r/switch.json +24 -0
- package/dist/r/tab.json +22 -0
- package/dist/r/table.json +23 -0
- package/dist/r/tabs.json +26 -0
- package/dist/r/tag-input.json +24 -0
- package/dist/r/tag.json +24 -0
- package/dist/r/text-input.json +22 -0
- package/dist/r/textarea.json +22 -0
- package/dist/r/thinking-block.json +24 -0
- package/dist/r/time-picker.json +24 -0
- package/dist/r/timer.json +22 -0
- package/dist/r/toast.json +24 -0
- package/dist/r/token-usage.json +24 -0
- package/dist/r/tool-call.json +24 -0
- package/dist/r/tooltip.json +24 -0
- package/dist/r/transition.json +22 -0
- package/dist/r/tree-select.json +24 -0
- package/dist/r/tree-view.json +64 -0
- package/dist/r/unordered-list.json +22 -0
- package/dist/r/use-linked-scroll.json +24 -0
- package/dist/r/variant-config.json +22 -0
- package/dist/radio-group.d.ts +63 -0
- package/dist/radio-group.js +1 -0
- package/dist/scatter-plot.d.ts +24 -0
- package/dist/scatter-plot.js +1 -0
- package/dist/scroll-bar-box.d.ts +3 -0
- package/dist/scroll-bar-box.js +1 -0
- package/dist/scroll-bar.d.ts +3 -0
- package/dist/scroll-bar.js +1 -0
- package/dist/scroll-list.d.ts +4 -0
- package/dist/scroll-list.js +1 -0
- package/dist/scroll-view.d.ts +3 -0
- package/dist/scroll-view.js +1 -0
- package/dist/search-input.d.ts +46 -0
- package/dist/search-input.js +1 -0
- package/dist/select-input-indicator.d.ts +28 -0
- package/dist/select-input-indicator.js +1 -0
- package/dist/select-input-item.d.ts +35 -0
- package/dist/select-input-item.js +1 -0
- package/dist/select-input.d.ts +97 -0
- package/dist/select-input.js +1 -0
- package/dist/shimmer-text.d.ts +41 -0
- package/dist/shimmer-text.js +1 -0
- package/dist/sidebar.d.ts +40 -0
- package/dist/sidebar.js +1 -0
- package/dist/slider.d.ts +87 -0
- package/dist/slider.js +1 -0
- package/dist/sparkline.d.ts +36 -0
- package/dist/sparkline.js +1 -0
- package/dist/spinner.d.ts +15 -0
- package/dist/spinner.js +1 -0
- package/dist/status-line.d.ts +31 -0
- package/dist/status-line.js +1 -0
- package/dist/status-message.d.ts +18 -0
- package/dist/status-message.js +1 -0
- package/dist/stepper.d.ts +42 -0
- package/dist/stepper.js +1 -0
- package/dist/stopwatch.d.ts +42 -0
- package/dist/stopwatch.js +1 -0
- package/dist/streaming-text.d.ts +39 -0
- package/dist/streaming-text.js +1 -0
- package/dist/switch.d.ts +46 -0
- package/dist/switch.js +1 -0
- package/dist/tab.d.ts +17 -0
- package/dist/tab.js +1 -0
- package/dist/table.d.ts +118 -0
- package/dist/table.js +1 -0
- package/dist/tabs.d.ts +96 -0
- package/dist/tabs.js +1 -0
- package/dist/tag-input.d.ts +59 -0
- package/dist/tag-input.js +1 -0
- package/dist/tag.d.ts +30 -0
- package/dist/tag.js +1 -0
- package/dist/text-input.d.ts +40 -0
- package/dist/text-input.js +1 -0
- package/dist/textarea.d.ts +57 -0
- package/dist/textarea.js +2 -0
- package/dist/thinking-block.d.ts +48 -0
- package/dist/thinking-block.js +1 -0
- package/dist/time-picker.d.ts +57 -0
- package/dist/time-picker.js +1 -0
- package/dist/timer.d.ts +50 -0
- package/dist/timer.js +1 -0
- package/dist/toast.d.ts +41 -0
- package/dist/toast.js +1 -0
- package/dist/token-usage.d.ts +43 -0
- package/dist/token-usage.js +1 -0
- package/dist/tool-call.d.ts +28 -0
- package/dist/tool-call.js +1 -0
- package/dist/tooltip.d.ts +37 -0
- package/dist/tooltip.js +1 -0
- package/dist/transition.d.ts +66 -0
- package/dist/transition.js +1 -0
- package/dist/tree-select.d.ts +55 -0
- package/dist/tree-select.js +1 -0
- package/dist/tree-view-entry.d.ts +4 -0
- package/dist/tree-view-entry.js +1 -0
- package/dist/unordered-list.d.ts +27 -0
- package/dist/unordered-list.js +1 -0
- package/dist/use-linked-scroll.d.ts +42 -0
- package/dist/use-linked-scroll.js +1 -0
- package/package.json +553 -7
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "console-overlay",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Console Overlay",
|
|
6
|
+
"description": "Console Overlay component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"content": "/* eslint-disable react/function-component-definition, sonarjs/no-nested-conditional */\n\n/**\n * Console overlay component for Ink.\n *\n * Captures console.log/warn/error/debug output and displays it\n * in a dockable panel within the TUI, similar to browser devtools.\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ConsoleLevel } from \"@visulima/tui/hooks/use-console-capture\";\nimport useConsoleCapture from \"@visulima/tui/hooks/use-console-capture\";\nimport type { ReactElement } from \"react\";\nimport { useMemo } from \"react\";\n\nexport type ConsoleOverlayDock = \"bottom\" | \"top\";\n\nexport type Props = {\n /**\n * Dock position within the parent container.\n * @default \"bottom\"\n */\n readonly dock?: ConsoleOverlayDock;\n\n /**\n * Which console levels to capture.\n * @default [\"log\", \"info\", \"warn\", \"error\", \"debug\"]\n */\n readonly filter?: ReadonlyArray<ConsoleLevel>;\n\n /**\n * Visible height in rows.\n * @default 8\n */\n readonly height?: number;\n\n /**\n * Maximum entries to keep in the buffer.\n * @default 200\n */\n readonly maxEntries?: number;\n\n /**\n * Show the log level label.\n * @default true\n */\n readonly showLevel?: boolean;\n\n /**\n * Show timestamps on each entry.\n * @default true\n */\n readonly showTimestamp?: boolean;\n};\n\nconst LEVEL_COLORS: Record<ConsoleLevel, string> = {\n debug: \"gray\",\n error: \"red\",\n info: \"blue\",\n log: \"white\",\n warn: \"yellow\",\n};\n\nconst LEVEL_LABELS: Record<ConsoleLevel, string> = {\n debug: \"DBG\",\n error: \"ERR\",\n info: \"INF\",\n log: \"LOG\",\n warn: \"WRN\",\n};\n\nconst formatTimestamp = (ts: number): string => {\n const date = new Date(ts);\n const hours = String(date.getHours()).padStart(2, \"0\");\n const minutes = String(date.getMinutes()).padStart(2, \"0\");\n const seconds = String(date.getSeconds()).padStart(2, \"0\");\n\n return `${hours}:${minutes}:${seconds}`;\n};\n\n/**\n * A dockable console overlay that captures and displays console output.\n *\n * ```tsx\n * <Box flexDirection=\"column\" height=\"100%\">\n * <Box flexGrow={1}>{mainContent}</Box>\n * <ConsoleOverlay dock=\"bottom\" height={6} />\n * </Box>\n * ```\n */\nexport default function ConsoleOverlay({ dock = \"bottom\", filter, height = 8, maxEntries = 200, showLevel = true, showTimestamp = true }: Props): ReactElement {\n const { entries } = useConsoleCapture({ filter, maxEntries });\n\n // Show the last `height` entries (auto-scroll to bottom)\n const visibleEntries = useMemo(() => entries.slice(-height), [entries, height]);\n\n return (\n <Box\n borderBottom={dock === \"top\"}\n borderBottomColor=\"gray\"\n borderTop={dock === \"bottom\"}\n borderTopColor=\"gray\"\n flexDirection=\"column\"\n height={height + 1}\n >\n {visibleEntries.length === 0\n ? (\n <Text dimColor>Console output will appear here...</Text>\n )\n : visibleEntries.map((entry) => (\n <Box key={entry.id}>\n {showTimestamp\n ? (\n<Text dimColor>\n[\n{formatTimestamp(entry.timestamp)}\n]\n{\" \"}\n</Text>\n )\n : undefined}\n {showLevel\n ? (\n<Text color={LEVEL_COLORS[entry.level]}>\n{LEVEL_LABELS[entry.level]}\n{\" \"}\n</Text>\n )\n : undefined}\n <Text color={entry.level === \"error\" ? \"red\" : entry.level === \"warn\" ? \"yellow\" : undefined}>{entry.message}</Text>\n </Box>\n ))}\n </Box>\n );\n}\n\nexport { ConsoleOverlay };\nexport type { Props as ConsoleOverlayProps };\n",
|
|
17
|
+
"path": "registry/ui/console-overlay.tsx",
|
|
18
|
+
"target": "components/ui/console-overlay.tsx",
|
|
19
|
+
"type": "registry:ui"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "content-switcher",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Content Switcher",
|
|
6
|
+
"description": "Content Switcher component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type ContentSwitcherOption = {\n readonly content: ReactNode;\n readonly icon?: ReactNode;\n readonly id: string;\n readonly label: string;\n};\n\nexport type Props = {\n /**\n * Accent color for the selected segment.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus the switcher on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Initial selected id (uncontrolled).\n */\n readonly defaultValue?: string;\n\n /**\n * Disable input.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Called when the selection changes.\n */\n readonly onChange?: (id: string) => void;\n\n /**\n * Segments to toggle between.\n */\n readonly options: ReadonlyArray<ContentSwitcherOption>;\n\n /**\n * Controlled selected id.\n */\n readonly value?: string;\n};\n\n/**\n * Segmented control that flips between mutually exclusive content. More\n * compact than `Tabs`: the selected segment is highlighted as a pill, and\n * only the active segment's content is rendered.\n * @returns A `ReactElement` with a segmented header row and the active\n * panel's content beneath it.\n */\nexport default function ContentSwitcher({\n accentColor = \"blue\",\n autoFocus = false,\n defaultValue,\n isDisabled = false,\n onChange,\n options,\n value,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const initialId = defaultValue ?? options[0]?.id;\n const [internal, setInternal] = useState(initialId);\n const current = value ?? internal;\n\n const currentIndex = useMemo(() => {\n if (current === undefined) {\n return 0;\n }\n\n const index = options.findIndex((option) => option.id === current);\n\n return Math.max(index, 0);\n }, [options, current]);\n\n const commit = useCallback(\n (index: number) => {\n const target = options[index];\n\n if (!target) {\n return;\n }\n\n const currentId = value ?? internal;\n\n if (target.id === currentId) {\n return;\n }\n\n if (value === undefined) {\n setInternal(target.id);\n }\n\n onChange?.(target.id);\n },\n [options, value, internal, onChange],\n );\n\n useInput(\n useCallback(\n (input, key) => {\n if (!isFocused) {\n return;\n }\n\n if (key.leftArrow || input === \"h\") {\n commit(Math.max(0, currentIndex - 1));\n } else if (key.rightArrow || input === \"l\") {\n commit(Math.min(options.length - 1, currentIndex + 1));\n }\n },\n [isFocused, currentIndex, options.length, commit],\n ),\n { isActive: !isDisabled && isFocused },\n );\n\n const activeOption = options[currentIndex];\n\n return (\n <Box flexDirection=\"column\">\n <Box>\n {options.map((option, index) => {\n const isActive = index === currentIndex;\n const background = isActive ? accentColor : undefined;\n const color = isActive ? \"black\" : undefined;\n\n return (\n <Text backgroundColor={background} color={color} dimColor={isDisabled && !isActive} key={option.id}>\n {\" \"}\n {option.icon === undefined\n ? undefined\n : (\n<>\n{option.icon}\n{\" \"}\n</>\n )}\n {option.label}\n{\" \"}\n </Text>\n );\n })}\n </Box>\n {activeOption === undefined\n ? undefined\n : (\n <Box flexDirection=\"column\" marginTop={1}>\n {activeOption.content}\n </Box>\n )}\n </Box>\n );\n}\n\nexport { ContentSwitcher };\nexport type { Props as ContentSwitcherProps };\n",
|
|
19
|
+
"path": "registry/ui/content-switcher.tsx",
|
|
20
|
+
"target": "components/ui/content-switcher.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "controlled-scroll-view",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Controlled Scroll View",
|
|
6
|
+
"description": "Controlled Scroll View component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"https://visulima.com/r/scroll.json"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "export type { ControlledScrollViewProps, ControlledScrollViewRef } from \"@/components/ui/scroll/controlled-scroll-view\";\nexport { ControlledScrollView } from \"@/components/ui/scroll/controlled-scroll-view\";\n",
|
|
19
|
+
"path": "registry/ui/controlled-scroll-view.ts",
|
|
20
|
+
"target": "components/ui/controlled-scroll-view.ts",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "data-grid",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Data Grid",
|
|
6
|
+
"description": "Data Grid component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type DataGridColumn<Row> = {\n readonly align?: \"left\" | \"right\";\n readonly header: string;\n readonly key: keyof Row & string;\n\n /**\n * Render a cell to a string. Defaults to `String(row[key])`.\n */\n readonly render?: (row: Row) => string;\n readonly width?: number;\n};\n\nexport type Props<Row extends Record<string, unknown>> = {\n /**\n * Accent color for the cursor row and active sort column.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus for keyboard navigation.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Column definitions in display order.\n */\n readonly columns: ReadonlyArray<DataGridColumn<Row>>;\n\n /**\n * The rows to display.\n */\n readonly data: ReadonlyArray<Row>;\n\n /**\n * Fires with the row under the cursor when Enter is pressed.\n */\n readonly onSelect?: (row: Row) => void;\n};\n\ntype SortState = { readonly direction: \"asc\" | \"desc\"; readonly key: string } | undefined;\n\nfunction wrap(value: number, size: number): number {\n return size === 0 ? 0 : ((value % size) + size) % size;\n}\n\nconst cellText = <Row extends Record<string, unknown>>(column: DataGridColumn<Row>, row: Row): string => {\n if (column.render) {\n return column.render(row);\n }\n\n return String(row[column.key] ?? \"\");\n};\n\nconst pad = (text: string, width: number, align: \"left\" | \"right\"): string => {\n if (text.length >= width) {\n return text.slice(0, width);\n }\n\n return align === \"right\" ? text.padStart(width) : text.padEnd(width);\n};\n\n/**\n * A navigable, sortable table. ↑/↓ move the row cursor, ←/→ move the column\n * cursor, `s` sorts by the cursor column (toggling direction), and Enter fires\n * `onSelect` with the current row. Column widths default to the widest cell.\n */\nexport default function DataGrid<Row extends Record<string, unknown>>({\n accentColor = \"blue\",\n autoFocus = false,\n columns,\n data,\n onSelect,\n}: Props<Row>): ReactElement {\n const { isFocused } = useFocus({ autoFocus });\n\n const [rowCursor, setRowCursor] = useState(0);\n const [columnCursor, setColumnCursor] = useState(0);\n const [sort, setSort] = useState<SortState>(undefined);\n\n const widths = useMemo(\n () =>\n columns.map((column) => {\n if (column.width !== undefined) {\n return column.width;\n }\n\n // Reserve two trailing cells for the \" ▲\"/\" ▼\" sort indicator so\n // it never truncates the header once the column becomes active.\n let max = column.header.length + 2;\n\n for (const row of data) {\n max = Math.max(max, cellText(column, row).length);\n }\n\n return max;\n }),\n [columns, data],\n );\n\n const sorted = useMemo(() => {\n if (sort === undefined) {\n return data;\n }\n\n const column = columns.find((candidate) => candidate.key === sort.key);\n\n if (column === undefined) {\n return data;\n }\n\n const factor = sort.direction === \"asc\" ? 1 : -1;\n\n return [...data].toSorted((a, b) => {\n const left = cellText(column, a);\n const right = cellText(column, b);\n const leftNumber = Number(left);\n const rightNumber = Number(right);\n const bothNumeric = left.length > 0 && right.length > 0 && !Number.isNaN(leftNumber) && !Number.isNaN(rightNumber);\n\n return factor * (bothNumeric ? leftNumber - rightNumber : left.localeCompare(right));\n });\n }, [columns, data, sort]);\n\n const inputHandler = useCallback(\n (input: string, key: { downArrow: boolean; leftArrow: boolean; return: boolean; rightArrow: boolean; upArrow: boolean }) => {\n if (key.upArrow) {\n setRowCursor((index) => wrap(index - 1, sorted.length));\n } else if (key.downArrow) {\n setRowCursor((index) => wrap(index + 1, sorted.length));\n } else if (key.leftArrow) {\n setColumnCursor((index) => wrap(index - 1, columns.length));\n } else if (key.rightArrow) {\n setColumnCursor((index) => wrap(index + 1, columns.length));\n } else if (input === \"s\") {\n const column = columns[columnCursor];\n\n if (column !== undefined) {\n setSort((current) => {\n if (current?.key === column.key && current.direction === \"asc\") {\n return { direction: \"desc\", key: column.key };\n }\n\n return { direction: \"asc\", key: column.key };\n });\n }\n } else if (key.return) {\n const row = sorted[rowCursor];\n\n if (row !== undefined) {\n onSelect?.(row);\n }\n }\n },\n [columnCursor, columns, onSelect, rowCursor, sorted],\n );\n\n useInput(inputHandler, { isActive: isFocused });\n\n const sortArrow = (columnKey: string): string => {\n if (sort?.key !== columnKey) {\n return \"\";\n }\n\n return sort.direction === \"asc\" ? \" ▲\" : \" ▼\";\n };\n\n return (\n <Box flexDirection=\"column\">\n <Box>\n <Text> </Text>\n {columns.map((column, index) => {\n const isSortColumn = isFocused && index === columnCursor;\n\n return (\n <Box key={column.key} marginRight={1}>\n <Text bold color={isSortColumn ? accentColor : undefined}>\n {pad(`${column.header}${sortArrow(column.key)}`, widths[index]!, column.align ?? \"left\")}\n </Text>\n </Box>\n );\n })}\n </Box>\n {sorted.map((row, rowIndex) => {\n const isActive = isFocused && rowIndex === rowCursor;\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- row index is stable for the render\n <Box key={rowIndex}>\n <Text color={isActive ? accentColor : undefined}>{isActive ? \"❯\" : \" \"}</Text>\n {columns.map((column, columnIndex) => (\n <Box key={column.key} marginRight={1}>\n <Text color={isActive ? accentColor : undefined} dimColor={!isActive}>\n {pad(cellText(column, row), widths[columnIndex]!, column.align ?? \"left\")}\n </Text>\n </Box>\n ))}\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { DataGrid };\nexport type { Props as DataGridProps };\n",
|
|
19
|
+
"path": "registry/ui/data-grid.tsx",
|
|
20
|
+
"target": "components/ui/data-grid.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "date-picker",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Date Picker",
|
|
6
|
+
"description": "Date Picker component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"https://visulima.com/r/calendar.json"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nimport Calendar from \"@/components/ui/calendar\";\n\nexport type Props = {\n /**\n * Accent color used by the inner calendar when open.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus the picker on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Initial selected date when uncontrolled.\n */\n readonly defaultValue?: Date;\n\n /**\n * Override the way the selected date is rendered in the input row.\n */\n readonly formatValue?: (date: Date) => string;\n\n /**\n * Disable the picker.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Upper bound for selectable dates.\n */\n readonly maxDate?: Date;\n\n /**\n * Lower bound for selectable dates.\n */\n readonly minDate?: Date;\n\n /**\n * Fired when the user commits a date with Enter.\n */\n readonly onChange?: (date: Date) => void;\n\n /**\n * Placeholder rendered when no date is selected.\n * @default \"Pick a date\"\n */\n readonly placeholder?: string;\n\n /**\n * Controlled selected date.\n */\n readonly value?: Date;\n};\n\nconst defaultFormat = (date: Date): string => {\n const year = date.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, \"0\");\n const day = String(date.getDate()).padStart(2, \"0\");\n\n return `${year}-${month}-${day}`;\n};\n\n/**\n * Compact date input. Shows the currently-selected date; Enter / Space opens\n * the inline `Calendar` for navigation. Enter inside the calendar commits the\n * date and closes the picker.\n */\nexport default function DatePicker({\n accentColor = \"blue\",\n autoFocus = false,\n defaultValue,\n formatValue = defaultFormat,\n isDisabled = false,\n maxDate,\n minDate,\n onChange,\n placeholder = \"Pick a date\",\n value,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const [isOpen, setIsOpen] = useState(false);\n const [internal, setInternal] = useState(defaultValue);\n const current = value ?? internal;\n\n useInput(\n useCallback(\n (input, key) => {\n if (!isFocused || isOpen) {\n return;\n }\n\n if (key.return || input === \" \") {\n setIsOpen(true);\n }\n },\n [isFocused, isOpen],\n ),\n { isActive: !isDisabled && isFocused && !isOpen },\n );\n\n // While the Calendar is open we still want Esc to dismiss without\n // committing — the Calendar itself doesn't handle Escape.\n useInput(\n useCallback((_input, key) => {\n if (key.escape) {\n setIsOpen(false);\n }\n }, []),\n { isActive: !isDisabled && isFocused && isOpen },\n );\n\n const handleCalendarSubmit = useCallback(\n (date: Date) => {\n if (value === undefined) {\n setInternal(date);\n }\n\n setIsOpen(false);\n onChange?.(date);\n },\n [value, onChange],\n );\n\n return (\n <Box flexDirection=\"column\">\n <Box borderColor={isFocused ? accentColor : undefined} borderStyle=\"round\" paddingX={1}>\n <Text color={accentColor}>📅 </Text>\n {current === undefined ? <Text dimColor>{placeholder}</Text> : <Text bold>{formatValue(current)}</Text>}\n {isFocused && !isOpen\n ? (\n <Box marginLeft={1}>\n <Text dimColor>(Enter to open)</Text>\n </Box>\n )\n : undefined}\n </Box>\n {isOpen\n ? (\n <Box marginTop={1}>\n <Calendar accentColor={accentColor} autoFocus defaultValue={current} maxDate={maxDate} minDate={minDate} onSubmit={handleCalendarSubmit} />\n </Box>\n )\n : undefined}\n </Box>\n );\n}\n\nexport { DatePicker };\nexport type { Props as DatePickerProps };\n",
|
|
21
|
+
"path": "registry/ui/date-picker.tsx",
|
|
22
|
+
"target": "components/ui/date-picker.tsx",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "definition-list",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Definition List",
|
|
6
|
+
"description": "Definition List component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type DefinitionItem = {\n readonly description: ReactNode;\n readonly key?: string;\n readonly term: ReactNode;\n};\n\nexport type Props = {\n /**\n * Spacing between term and description columns.\n * @default 2\n */\n readonly columnGap?: number;\n\n /**\n * Entries to render.\n */\n readonly items: ReadonlyArray<DefinitionItem>;\n\n /**\n * Inline items display on the same row; stacked items display with the term above the description.\n * @default \"inline\"\n */\n readonly layout?: \"inline\" | \"stacked\";\n\n /**\n * Color applied to the term.\n * @default \"cyan\"\n */\n readonly termColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Minimum width used to align inline terms.\n */\n readonly termWidth?: number;\n};\n\n/**\n * Key/value style definition list. Equivalent to the HTML `<dl>` element.\n */\nexport default function DefinitionList({ columnGap = 2, items, layout = \"inline\", termColor = \"cyan\", termWidth }: Props): ReactElement {\n if (layout === \"stacked\") {\n return (\n <Box flexDirection=\"column\" gap={1}>\n {items.map((item, index) => (\n // eslint-disable-next-line react-x/no-array-index-key -- items expose an optional stable `key`; index is the documented fallback for keyless entries\n <Box flexDirection=\"column\" key={item.key ?? index}>\n <Text bold color={termColor}>\n {item.term}\n </Text>\n <Text>{item.description}</Text>\n </Box>\n ))}\n </Box>\n );\n }\n\n return (\n <Box flexDirection=\"column\">\n {items.map((item, index) => (\n // eslint-disable-next-line react-x/no-array-index-key -- items expose an optional stable `key`; index is the documented fallback for keyless entries\n <Box gap={columnGap} key={item.key ?? index}>\n <Box flexShrink={0} width={termWidth}>\n <Text color={termColor}>{item.term}</Text>\n </Box>\n <Box flexGrow={1} flexShrink={1}>\n <Text>{item.description}</Text>\n </Box>\n </Box>\n ))}\n </Box>\n );\n}\n\nexport { DefinitionList };\nexport type { Props as DefinitionListProps };\n",
|
|
19
|
+
"path": "registry/ui/definition-list.tsx",
|
|
20
|
+
"target": "components/ui/definition-list.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "dialog",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Dialog",
|
|
6
|
+
"description": "Dialog component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"https://visulima.com/r/scroll.json"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "import Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useTerminalPalette from \"@visulima/tui/hooks/use-terminal-palette\";\nimport useWindowSize from \"@visulima/tui/hooks/use-window-size\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useRef } from \"react\";\n\nimport type { ScrollViewRef } from \"@/components/ui/scroll\";\nimport { ScrollView } from \"@/components/ui/scroll\";\n\nexport type Props = {\n /**\n * Background color for the dialog. Defaults to the detected terminal\n * background color, falling back to `\"black\"`.\n */\n readonly backgroundColor?: string;\n\n /**\n * Border color.\n * @default \"cyan\"\n */\n readonly borderColor?: string;\n\n /**\n * Border style.\n * @default \"round\"\n */\n readonly borderStyle?: \"arrow\" | \"bold\" | \"classic\" | \"double\" | \"doubleSingle\" | \"round\" | \"single\" | \"singleDouble\";\n\n /**\n * Dialog content.\n */\n readonly children: ReactNode;\n\n /**\n * Override terminal columns. Useful in renderToString or testing contexts\n * where useWindowSize is unavailable.\n */\n readonly columns?: number;\n\n /**\n * Footer content rendered below the scrollable area.\n * Useful for hints like \"Press ? to close\".\n */\n readonly footer?: ReactNode;\n\n /**\n * Maximum height as a fraction of terminal rows (0-1) or absolute rows.\n * Values <= 1 are treated as fractions; values > 1 as absolute row counts.\n * @default 0.8\n */\n readonly maxHeight?: number;\n\n /**\n * Padding inside the dialog border.\n * @default 2\n */\n readonly paddingX?: number;\n\n /**\n * Vertical padding inside the dialog border.\n * @default 1\n */\n readonly paddingY?: number;\n\n /**\n * Override terminal rows. Useful in renderToString or testing contexts\n * where useWindowSize is unavailable.\n */\n readonly rows?: number;\n\n /**\n * Scrollbar visual style.\n * @default \"block\"\n */\n readonly scrollbarStyle?:\n | \"arrow\"\n | \"block\"\n | \"bold\"\n | \"classic\"\n | \"dots\"\n | \"double\"\n | \"doubleSingle\"\n | \"line\"\n | \"round\"\n | \"single\"\n | \"singleDouble\"\n | \"thick\";\n\n /**\n * Ref to the internal ScrollView for programmatic scrolling.\n */\n readonly scrollRef?: React.Ref<ScrollViewRef>;\n\n /**\n * Title rendered at the top of the dialog. If a string is provided,\n * it is rendered as bold cyan text.\n */\n readonly title?: ReactNode;\n\n /**\n * Whether the dialog is visible. When `false`, nothing is rendered.\n * @default true\n */\n readonly visible?: boolean;\n\n /**\n * Dialog width in columns.\n * @default 60\n */\n readonly width?: number;\n};\n\n/**\n * A centered overlay dialog with automatic scrolling and scrollbar when\n * content exceeds the available terminal height.\n *\n * Uses the terminal's detected background color by default so the\n * dialog blends with the user's theme.\n *\n * ```tsx\n * <Dialog title=\"Help\" footer={<Text dimColor>Press Esc to close</Text>}>\n * <Text>Dialog content here</Text>\n * </Dialog>\n * ```\n */\nconst Dialog = ({\n backgroundColor: backgroundColorProp,\n borderColor = \"cyan\",\n borderStyle = \"round\",\n children,\n columns: columnsProp,\n footer,\n maxHeight: maxHeightProp = 0.8,\n paddingX = 2,\n paddingY = 1,\n rows: rowsProp,\n scrollbarStyle: scrollbarStyleProp = \"block\",\n scrollRef: externalScrollRef,\n title,\n visible = true,\n width = 60,\n}: Props): ReactElement | null => {\n // Hooks must be called unconditionally (React rules)\n const windowSize = useWindowSize();\n const { isLoading: paletteLoading, palette } = useTerminalPalette();\n const internalScrollRef = useRef<ScrollViewRef>(null);\n const scrollViewRef = externalScrollRef ?? internalScrollRef;\n\n if (!visible) {\n return null;\n }\n\n const cols = columnsProp ?? (windowSize.columns || 80);\n const termRows = rowsProp ?? (windowSize.rows || 24);\n\n const bg = backgroundColorProp ?? (paletteLoading ? \"black\" : palette?.background ?? \"black\");\n\n const resolvedMaxHeight = maxHeightProp <= 1 ? Math.floor(termRows * maxHeightProp) : Math.min(maxHeightProp, termRows - 2);\n\n const titleElement\n = typeof title === \"string\"\n ? (\n <Box marginBottom={1}>\n <Text bold color={borderColor}>\n {title}\n </Text>\n </Box>\n )\n : title ?? null\n ;\n\n return (\n <Box alignItems=\"center\" height={termRows} justifyContent=\"center\" position=\"absolute\" width={cols}>\n <Box\n backgroundColor={bg}\n borderBackgroundColor={bg}\n borderColor={borderColor}\n borderStyle={borderStyle}\n flexDirection=\"column\"\n maxHeight={resolvedMaxHeight}\n opaque\n paddingY={paddingY}\n width={Math.min(width, cols - 4)}\n >\n {/* Title — fixed above scroll area */}\n {titleElement != null && (\n <Box flexShrink={0} paddingX={paddingX}>\n {titleElement}\n </Box>\n )}\n\n {/* Scrollable content with built-in scrollbar */}\n <ScrollView\n flexGrow={1}\n flexShrink={1}\n paddingX={paddingX}\n ref={scrollViewRef}\n scrollbar\n scrollbarColor={borderColor}\n scrollbarStyle={scrollbarStyleProp}\n >\n {children}\n </ScrollView>\n\n {/* Footer — fixed below scroll area */}\n {footer != null && (\n <Box alignItems=\"center\" flexDirection=\"column\" flexShrink={0} marginTop={1} paddingX={paddingX}>\n {footer}\n </Box>\n )}\n </Box>\n </Box>\n );\n};\n\nexport default Dialog;\n\nexport { Dialog };\nexport type { Props as DialogProps };\n",
|
|
19
|
+
"path": "registry/ui/dialog.tsx",
|
|
20
|
+
"target": "components/ui/dialog.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "diff-view",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Diff View",
|
|
6
|
+
"description": "Diff View component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui",
|
|
12
|
+
"diff",
|
|
13
|
+
"shiki"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-use-before-define, consistent-return, default-case, no-void, react-x/no-array-index-key, react-you-might-not-need-an-effect/no-adjust-state-on-prop-change, react/function-component-definition, sonarjs/prefer-read-only-props */\n\n/**\n * Diff viewer component for Ink.\n *\n * Displays file differences in unified or split (side-by-side) mode\n * with colored additions/deletions and optional inline character-level diffs.\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport { getCachedTokens, getHighlighter, isLanguageSupported, renderTokenLine, resolveLanguage } from \"@visulima/tui/highlighter\";\nimport useWindowSize from \"@visulima/tui/hooks/use-window-size\";\nimport { createPatch, diffChars, diffWords, parsePatch } from \"diff\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport type { ThemedToken } from \"shiki\";\n\nexport type DiffViewMode = \"split\" | \"unified\";\nexport type InlineDiffMode = \"chars\" | \"words\";\n\nexport type Props = {\n /**\n * Number of unchanged context lines around changes.\n * @default 3\n */\n readonly context?: number;\n\n /**\n * Pre-computed unified diff string. If provided, `oldText`/`newText` are ignored.\n */\n readonly diff?: string;\n\n /**\n * Enable character-level inline diff highlighting within changed lines.\n * @default true\n */\n readonly inlineDiff?: boolean;\n\n /**\n * Granularity of inline diff highlighting.\n * - `\"chars\"` — character-level (best for code)\n * - `\"words\"` — word-level (best for prose)\n * @default \"chars\"\n */\n readonly inlineDiffMode?: InlineDiffMode;\n\n /**\n * Programming language for syntax highlighting within diff lines.\n * When provided, diff content is highlighted with Shiki.\n */\n readonly language?: string;\n\n /**\n * Display mode: unified (interleaved) or split (side-by-side).\n * @default \"unified\"\n */\n readonly mode?: DiffViewMode;\n\n /**\n * Label for the new version.\n */\n readonly newLabel?: string;\n\n /**\n * Modified text (\"new\" / right side).\n */\n readonly newText?: string;\n\n /**\n * Label for the old version.\n */\n readonly oldLabel?: string;\n\n /**\n * Original text (\"old\" / left side).\n */\n readonly oldText?: string;\n\n /**\n * Whether to display line numbers.\n * @default true\n */\n readonly showLineNumbers?: boolean;\n\n /**\n * Shiki theme for syntax highlighting.\n * @default \"github-dark-default\"\n */\n readonly theme?: string;\n};\n\ntype DiffLine = {\n content: string;\n inlineDiff?: PrecomputedInlineDiff;\n newLineNum?: number;\n oldLineNum?: number;\n pairedIndex?: number;\n type: \"add\" | \"context\" | \"del\";\n};\n\n/**\n * Render a line's content using Shiki syntax tokens if available,\n * falling back to plain text with the given color.\n */\nconst renderHighlightedContent = (\n content: string,\n highlightedLines: ThemedToken[][] | null,\n lineNumber: number | undefined,\n fallbackColor?: string,\n): ReactNode => {\n if (!highlightedLines || lineNumber === undefined || lineNumber < 1 || lineNumber > highlightedLines.length) {\n return fallbackColor ? <Text color={fallbackColor}>{content}</Text> : <Text dimColor>{content}</Text>;\n }\n\n const tokens = highlightedLines[lineNumber - 1];\n\n if (!tokens || tokens.length === 0) {\n return fallbackColor ? <Text color={fallbackColor}>{content}</Text> : <Text dimColor>{content}</Text>;\n }\n\n return renderTokenLine(tokens);\n};\n\n/**\n * Hook that asynchronously highlights text with Shiki when a language is provided.\n * Returns ThemedToken[][] (one array per line) or null if not ready.\n */\nconst useHighlightedLines = (text: string, language: string | undefined, theme: string): ThemedToken[][] | null => {\n const [tokens, setTokens] = useState<ThemedToken[][] | null>(null);\n const cancelledRef = useRef(false);\n\n useEffect(() => {\n cancelledRef.current = false;\n\n if (!language) {\n setTokens(null); // eslint-disable-line react-x/set-state-in-effect\n\n return;\n }\n\n const lang = resolveLanguage(language);\n\n if (!isLanguageSupported(lang)) {\n setTokens(null); // eslint-disable-line react-x/set-state-in-effect\n\n return;\n }\n\n void (async () => {\n try {\n const highlighter = await getHighlighter([lang], theme);\n const result = getCachedTokens(highlighter, text, lang, theme);\n\n if (!cancelledRef.current) {\n setTokens(result.tokens);\n }\n } catch {\n if (!cancelledRef.current) {\n setTokens(null);\n }\n }\n })();\n\n return () => {\n cancelledRef.current = true;\n };\n }, [text, language, theme]);\n\n return tokens;\n};\n\n/**\n * Parse a unified diff string into structured lines with optional precomputed inline diffs.\n */\nconst parseDiffLines = (diffString: string, enableInlineDiff: boolean, inlineDiffMode: InlineDiffMode = \"chars\"): { header: string; lines: DiffLine[] }[] => {\n const patches = parsePatch(diffString);\n const hunks: { header: string; lines: DiffLine[] }[] = [];\n\n for (const patch of patches) {\n for (const hunk of patch.hunks) {\n const header = `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;\n const lines: DiffLine[] = [];\n let oldLine = hunk.oldStart;\n let newLine = hunk.newStart;\n\n for (const line of hunk.lines) {\n // Skip \"\\" markers\n if (line.startsWith(\"\\\\\")) {\n continue;\n }\n\n if (line.startsWith(\"+\")) {\n lines.push({ content: line.slice(1), newLineNum: newLine++, type: \"add\" });\n } else if (line.startsWith(\"-\")) {\n lines.push({ content: line.slice(1), oldLineNum: oldLine++, type: \"del\" });\n } else {\n lines.push({ content: line.slice(1), newLineNum: newLine++, oldLineNum: oldLine++, type: \"context\" });\n }\n }\n\n // Precompute inline diffs for paired lines\n if (enableInlineDiff) {\n const pairs = findInlinePairs(lines);\n\n for (const [delIndex, addIndex] of pairs) {\n if (lines[delIndex]!.type === \"del\" && lines[addIndex]!.type === \"add\") {\n const computed = computeInlineDiff(lines[delIndex]!.content, lines[addIndex]!.content, inlineDiffMode);\n\n lines[delIndex]!.inlineDiff = computed ?? undefined;\n lines[delIndex]!.pairedIndex = addIndex;\n lines[addIndex]!.inlineDiff = computed ?? undefined;\n lines[addIndex]!.pairedIndex = delIndex;\n }\n }\n }\n\n hunks.push({ header, lines });\n }\n }\n\n return hunks;\n};\n\ntype InlineDiffPart = {\n highlight: boolean;\n value: string;\n};\n\ntype PrecomputedInlineDiff = {\n addParts: InlineDiffPart[];\n delParts: InlineDiffPart[];\n};\n\n// Skip character-level diff for lines exceeding this length (O(n*m) would be too slow)\nconst INLINE_DIFF_MAX_LINE_LENGTH = 500;\n\n/**\n * Precompute inline diff between paired del/add lines (character or word level).\n */\nconst computeInlineDiff = (oldContent: string, newContent: string, mode: InlineDiffMode = \"chars\"): PrecomputedInlineDiff | null => {\n if (oldContent.length > INLINE_DIFF_MAX_LINE_LENGTH || newContent.length > INLINE_DIFF_MAX_LINE_LENGTH) {\n return null;\n }\n\n const parts = mode === \"words\" ? diffWords(oldContent, newContent) : diffChars(oldContent, newContent);\n const delParts: InlineDiffPart[] = [];\n const addParts: InlineDiffPart[] = [];\n\n for (const part of parts) {\n if (part.removed) {\n delParts.push({ highlight: true, value: part.value });\n } else if (part.added) {\n addParts.push({ highlight: true, value: part.value });\n } else {\n delParts.push({ highlight: false, value: part.value });\n addParts.push({ highlight: false, value: part.value });\n }\n }\n\n return { addParts, delParts };\n};\n\n/**\n * Render precomputed inline diff parts.\n */\nconst renderInlineParts = (parts: InlineDiffPart[], color: string, bgColor: string): ReactNode => (\n <>\n {parts.map((part, index) => {\n if (part.highlight) {\n return (\n <Text backgroundColor={bgColor} color=\"white\" key={index}>\n {part.value}\n </Text>\n );\n }\n\n return (\n <Text color={color} key={index}>\n {part.value}\n </Text>\n );\n })}\n </>\n);\n\n/**\n * Find paired del/add line blocks for inline diff.\n * Handles consecutive del blocks followed by consecutive add blocks.\n */\nconst findInlinePairs = (lines: DiffLine[]): Map<number, number> => {\n const pairs = new Map<number, number>();\n let index = 0;\n\n while (index < lines.length) {\n // Find a block of consecutive deletions\n const delStart = index;\n\n while (index < lines.length && lines[index]!.type === \"del\") {\n index++;\n }\n\n const delEnd = index;\n\n // Find a block of consecutive additions right after\n const addStart = index;\n\n while (index < lines.length && lines[index]!.type === \"add\") {\n index++;\n }\n\n const addEnd = index;\n\n // Pair them up (1:1, shorter block determines pair count)\n const delCount = delEnd - delStart;\n const addCount = addEnd - addStart;\n const pairCount = Math.min(delCount, addCount);\n\n for (let p = 0; p < pairCount; p++) {\n pairs.set(delStart + p, addStart + p);\n pairs.set(addStart + p, delStart + p);\n }\n\n // Skip non-del/add lines\n if (index === delStart) {\n index++;\n }\n }\n\n return pairs;\n};\n\n/**\n * Render unified diff view.\n */\nfunction UnifiedView({\n hunks,\n newHighlighted,\n oldHighlighted,\n showLineNumbers,\n}: {\n hunks: { header: string; lines: DiffLine[] }[];\n newHighlighted: ThemedToken[][] | null;\n oldHighlighted: ThemedToken[][] | null;\n showLineNumbers: boolean;\n}): ReactElement {\n return (\n <Box flexDirection=\"column\">\n {hunks.map((hunk, hunkIndex) => (\n <Box flexDirection=\"column\" key={hunkIndex}>\n {hunkIndex > 0\n ? (\n <Box marginBottom={0} marginTop={0}>\n <Text dimColor>···</Text>\n </Box>\n )\n : undefined}\n <Text color=\"cyan\" dimColor>\n {hunk.header}\n </Text>\n {hunk.lines.map((line, lineIndex) => {\n let lineContent: ReactNode;\n let prefix: string;\n let color: string | undefined;\n\n switch (line.type) {\n case \"add\": {\n prefix = \"+\";\n color = \"green\";\n lineContent = line.inlineDiff\n ? renderInlineParts(line.inlineDiff.addParts, \"green\", \"green\")\n : renderHighlightedContent(line.content, newHighlighted, line.newLineNum, \"green\");\n break;\n }\n\n case \"del\": {\n prefix = \"-\";\n color = \"red\";\n lineContent = line.inlineDiff\n ? renderInlineParts(line.inlineDiff.delParts, \"red\", \"red\")\n : renderHighlightedContent(line.content, oldHighlighted, line.oldLineNum, \"red\");\n break;\n }\n\n default: {\n prefix = \" \";\n color = undefined;\n lineContent = renderHighlightedContent(line.content, oldHighlighted, line.oldLineNum);\n break;\n }\n }\n\n return (\n <Box key={lineIndex}>\n {showLineNumbers\n ? (\n <Text color={color} dimColor={line.type === \"context\"}>\n {String(line.oldLineNum ?? \"\").padStart(4)}\n{\" \"}\n{String(line.newLineNum ?? \"\").padStart(4)}\n{\" \"}\n </Text>\n )\n : undefined}\n <Text color={color}>{prefix}</Text>\n {lineContent}\n </Box>\n );\n })}\n </Box>\n ))}\n </Box>\n );\n}\n\n/**\n * Render split (side-by-side) diff view.\n */\nfunction SplitView({\n hunks,\n newHighlighted,\n oldHighlighted,\n showLineNumbers,\n width,\n}: {\n hunks: { header: string; lines: DiffLine[] }[];\n newHighlighted: ThemedToken[][] | null;\n oldHighlighted: ThemedToken[][] | null;\n showLineNumbers: boolean;\n width: number;\n}): ReactElement {\n const halfWidth = Math.floor(width / 2) - 1;\n\n return (\n <Box flexDirection=\"column\">\n {hunks.map((hunk, hunkIndex) => {\n const separator\n = hunkIndex > 0\n ? (\n <Box marginBottom={0} marginTop={0}>\n <Text dimColor>···</Text>\n </Box>\n )\n : undefined;\n\n // Build left (old) and right (new) lines using precomputed inline diffs\n const leftLines: { content: ReactNode; lineNum?: number }[] = [];\n const rightLines: { content: ReactNode; lineNum?: number }[] = [];\n\n for (let lineIndex = 0; lineIndex < hunk.lines.length; lineIndex++) {\n const line = hunk.lines[lineIndex]!;\n\n switch (line.type) {\n case \"add\": {\n // Only reach here for unpaired adds (paired adds handled by their del)\n if (line.pairedIndex === undefined) {\n leftLines.push({ content: <Text dimColor /> });\n rightLines.push({\n content: renderHighlightedContent(line.content, newHighlighted, line.newLineNum, \"green\"),\n lineNum: line.newLineNum,\n });\n }\n\n break;\n }\n case \"context\": {\n leftLines.push({ content: renderHighlightedContent(line.content, oldHighlighted, line.oldLineNum), lineNum: line.oldLineNum });\n rightLines.push({ content: renderHighlightedContent(line.content, newHighlighted, line.newLineNum), lineNum: line.newLineNum });\n\n break;\n }\n case \"del\": {\n if (line.pairedIndex === undefined) {\n leftLines.push({\n content: renderHighlightedContent(line.content, oldHighlighted, line.oldLineNum, \"red\"),\n lineNum: line.oldLineNum,\n });\n rightLines.push({ content: <Text dimColor /> });\n } else {\n const pairedLine = hunk.lines[line.pairedIndex]!;\n\n leftLines.push({\n content: line.inlineDiff\n ? renderInlineParts(line.inlineDiff.delParts, \"red\", \"red\")\n : renderHighlightedContent(line.content, oldHighlighted, line.oldLineNum, \"red\"),\n lineNum: line.oldLineNum,\n });\n rightLines.push({\n content: pairedLine.inlineDiff\n ? renderInlineParts(pairedLine.inlineDiff.addParts, \"green\", \"green\")\n : renderHighlightedContent(pairedLine.content, newHighlighted, pairedLine.newLineNum, \"green\"),\n lineNum: pairedLine.newLineNum,\n });\n }\n\n break;\n }\n // No default\n }\n }\n\n const rowCount = Math.max(leftLines.length, rightLines.length);\n\n return (\n <Box flexDirection=\"column\" key={hunkIndex}>\n {separator}\n <Text color=\"cyan\" dimColor>\n {hunk.header}\n </Text>\n {Array.from({ length: rowCount }, (_, rowIndex) => {\n const left = leftLines[rowIndex];\n const right = rightLines[rowIndex];\n\n return (\n <Box flexDirection=\"row\" key={rowIndex}>\n <Box width={halfWidth}>\n {showLineNumbers\n ? (\n<Text dimColor>\n{String(left?.lineNum ?? \"\").padStart(4)}\n{\" \"}\n</Text>\n )\n : undefined}\n {left?.content}\n </Box>\n <Text dimColor>│</Text>\n <Box width={halfWidth}>\n {showLineNumbers\n ? (\n<Text dimColor>\n{String(right?.lineNum ?? \"\").padStart(4)}\n{\" \"}\n</Text>\n )\n : undefined}\n {right?.content}\n </Box>\n </Box>\n );\n })}\n </Box>\n );\n })}\n </Box>\n );\n}\n\n/**\n * Display file differences with colored additions/deletions.\n *\n * ```tsx\n * <DiffView oldText=\"hello\" newText=\"hello world\" />\n * <DiffView diff={unifiedDiffString} mode=\"split\" />\n * ```\n */\nexport default function DiffView({\n context = 3,\n diff: diffProp,\n inlineDiff = true,\n inlineDiffMode = \"chars\",\n language,\n mode = \"unified\",\n newLabel = \"new\",\n newText = \"\",\n oldLabel = \"old\",\n oldText = \"\",\n showLineNumbers = true,\n theme = \"github-dark-default\",\n}: Props): ReactElement {\n const { columns } = useWindowSize();\n\n // Syntax highlighting for old and new text\n const oldHighlighted = useHighlightedLines(oldText, language, theme);\n const newHighlighted = useHighlightedLines(newText, language, theme);\n\n const diffString = useMemo(() => {\n if (diffProp) {\n return diffProp;\n }\n\n return createPatch(\"file\", oldText, newText, oldLabel, newLabel, { context });\n }, [diffProp, oldText, newText, oldLabel, newLabel, context]);\n\n const hunks = useMemo(() => parseDiffLines(diffString, inlineDiff, inlineDiffMode), [diffString, inlineDiff, inlineDiffMode]);\n\n if (hunks.every((h) => h.lines.length === 0)) {\n return (\n <Box>\n <Text dimColor>No differences found.</Text>\n </Box>\n );\n }\n\n // File labels\n const labels = (\n <Box flexDirection=\"column\">\n <Text color=\"red\" dimColor>\n ---\n{\" \"}\n{oldLabel}\n </Text>\n <Text color=\"green\" dimColor>\n +++\n{\" \"}\n{newLabel}\n </Text>\n </Box>\n );\n\n if (mode === \"split\") {\n return (\n <Box flexDirection=\"column\">\n {labels}\n <SplitView\n hunks={hunks}\n newHighlighted={newHighlighted}\n oldHighlighted={oldHighlighted}\n showLineNumbers={showLineNumbers}\n width={columns ?? 80}\n />\n </Box>\n );\n }\n\n return (\n <Box flexDirection=\"column\">\n {labels}\n <UnifiedView hunks={hunks} newHighlighted={newHighlighted} oldHighlighted={oldHighlighted} showLineNumbers={showLineNumbers} />\n </Box>\n );\n}\n\nexport { DiffView };\nexport type { Props as DiffViewProps };\n",
|
|
19
|
+
"path": "registry/ui/diff-view.tsx",
|
|
20
|
+
"target": "components/ui/diff-view.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "digits",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Digits",
|
|
6
|
+
"description": "Digits component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\n// A compact three-row \"seven-segment\" font. Every glyph is three columns wide\n// so digits align into a stable readout.\nconst FONT: Record<string, readonly [string, string, string]> = {\n \" \": [\" \", \" \", \" \"],\n 0: [\" ▄ \", \"█ █\", \"▀▄▀\"],\n 1: [\" ▄ \", \" █ \", \" ▪ \"],\n 2: [\"▄▄ \", \" ▄▀\", \"▀▄▄\"],\n 3: [\"▄▄ \", \" ▄▀\", \"▄▄▀\"],\n 4: [\" \", \"█▄█\", \" █\"],\n 5: [\"▄▄▄\", \"█▄ \", \"▄▄▀\"],\n 6: [\" ▄ \", \"█▄ \", \"▀▄▀\"],\n 7: [\"▄▄▄\", \" █\", \" █ \"],\n 8: [\" ▄ \", \"▄▀▄\", \"▀▄▀\"],\n 9: [\" ▄ \", \"▀▄█\", \" ▄▀\"],\n \"-\": [\" \", \" ─ \", \" \"],\n \".\": [\" \", \" \", \" ▪ \"],\n \":\": [\" \", \" ▪ \", \" ▪ \"],\n};\n\nconst FALLBACK: readonly [string, string, string] = [\" \", \" ? \", \" \"];\n\nexport type Props = {\n /**\n * Color of the digits.\n * @default \"cyan\"\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Number of spaces between glyphs.\n * @default 1\n */\n readonly gap?: number;\n\n /**\n * The value to render. Numbers are stringified; unsupported characters\n * render as `?`.\n */\n readonly value: number | string;\n};\n\n/**\n * A large seven-segment-style numeric readout. Supports digits, space, minus,\n * dot and colon — enough for counters, clocks and timers.\n */\nexport default function Digits({ color = \"cyan\", gap = 1, value }: Props): ReactElement {\n const text = String(value);\n const spacer = \" \".repeat(Math.max(0, gap));\n\n const characters: string[] = [];\n\n for (const char of text) {\n characters.push(char);\n }\n\n const lines = [0, 1, 2].map((rowIndex) => characters.map((char) => (FONT[char] ?? FALLBACK)[rowIndex]).join(spacer));\n\n return (\n <Box flexDirection=\"column\">\n {lines.map((line, index) => (\n // eslint-disable-next-line react-x/no-array-index-key -- row index (0..2) is stable\n <Text color={color} key={index}>\n {line}\n </Text>\n ))}\n </Box>\n );\n}\n\nexport { Digits };\nexport type { Props as DigitsProps };\n",
|
|
19
|
+
"path": "registry/ui/digits.tsx",
|
|
20
|
+
"target": "components/ui/digits.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "directory-tree",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Directory Tree",
|
|
6
|
+
"description": "Directory Tree component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type DirectoryNode = {\n readonly children?: ReadonlyArray<DirectoryNode>;\n readonly name: string;\n readonly type: \"directory\" | \"file\";\n};\n\nexport type Props = {\n /**\n * Accent color for the focused row.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus for keyboard navigation.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Start with every directory expanded.\n * @default false\n */\n readonly defaultExpanded?: boolean;\n\n /**\n * Color used for directory names.\n * @default \"cyan\"\n */\n readonly directoryColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * The tree to render.\n */\n readonly nodes: ReadonlyArray<DirectoryNode>;\n\n /**\n * Fires with the path segments when Enter is pressed on a file.\n */\n readonly onSelect?: (path: ReadonlyArray<string>) => void;\n};\n\ntype FlatEntry = {\n readonly depth: number;\n readonly id: string;\n readonly node: DirectoryNode;\n readonly path: ReadonlyArray<string>;\n};\n\nfunction wrap(value: number, size: number): number {\n return size === 0 ? 0 : ((value % size) + size) % size;\n}\n\nfunction glyphFor(isDirectory: boolean, isOpen: boolean): string {\n if (!isDirectory) {\n return \" 📄\";\n }\n\n return isOpen ? \"▾ 📁\" : \"▸ 📁\";\n}\n\nconst flatten = (nodes: ReadonlyArray<DirectoryNode>, expanded: ReadonlySet<string>, depth: number, parentId: string, parentPath: ReadonlyArray<string>, out: FlatEntry[]): void => {\n for (const node of nodes) {\n const id = `${parentId}/${node.name}`;\n const path = [...parentPath, node.name];\n\n out.push({ depth, id, node, path });\n\n if (node.type === \"directory\" && node.children !== undefined && expanded.has(id)) {\n flatten(node.children, expanded, depth + 1, id, path, out);\n }\n }\n};\n\nconst collectExpandable = (nodes: ReadonlyArray<DirectoryNode>, parentId: string, out: Set<string>): void => {\n for (const node of nodes) {\n const id = `${parentId}/${node.name}`;\n\n if (node.type === \"directory\" && node.children !== undefined) {\n out.add(id);\n collectExpandable(node.children, id, out);\n }\n }\n};\n\n/**\n * A read-only filesystem tree. Directories show a ▸/▾ toggle and a folder\n * glyph; files show a document glyph. Arrow keys navigate, →/← and Space\n * expand/collapse directories, and Enter fires `onSelect` with a file's path.\n */\nexport default function DirectoryTree({\n accentColor = \"blue\",\n autoFocus = false,\n defaultExpanded = false,\n directoryColor = \"cyan\",\n nodes,\n onSelect,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus });\n\n const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => {\n if (!defaultExpanded) {\n return new Set();\n }\n\n const set = new Set<string>();\n\n collectExpandable(nodes, \"\", set);\n\n return set;\n });\n const [cursor, setCursor] = useState(0);\n\n const rows = useMemo(() => {\n const out: FlatEntry[] = [];\n\n flatten(nodes, expanded, 0, \"\", [], out);\n\n return out;\n }, [expanded, nodes]);\n\n const toggle = useCallback((id: string, force?: boolean) => {\n setExpanded((current) => {\n const next = new Set(current);\n const shouldExpand = force ?? !next.has(id);\n\n if (shouldExpand) {\n next.add(id);\n } else {\n next.delete(id);\n }\n\n return next;\n });\n }, []);\n\n const inputHandler = useCallback(\n (input: string, key: { downArrow: boolean; leftArrow: boolean; return: boolean; rightArrow: boolean; upArrow: boolean }) => {\n const row = rows[cursor];\n\n if (key.upArrow) {\n setCursor((index) => wrap(index - 1, rows.length));\n\n return;\n }\n\n if (key.downArrow) {\n setCursor((index) => wrap(index + 1, rows.length));\n\n return;\n }\n\n if (row === undefined) {\n return;\n }\n\n // A directory (matches the folder glyph) is never \"selected\" on Enter,\n // even when it has no children; only nodes with children can expand.\n const isDirectory = row.node.type === \"directory\";\n const isExpandable = isDirectory && row.node.children !== undefined;\n\n if (isExpandable && (input === \" \" || key.rightArrow || key.leftArrow)) {\n let force: boolean | undefined;\n\n if (key.rightArrow) {\n force = true;\n } else if (key.leftArrow) {\n force = false;\n }\n\n toggle(row.id, force);\n\n return;\n }\n\n if (key.return && !isDirectory) {\n onSelect?.(row.path);\n }\n },\n [cursor, onSelect, rows, toggle],\n );\n\n useInput(inputHandler, { isActive: isFocused });\n\n return (\n <Box flexDirection=\"column\">\n {rows.map((row, index) => {\n const isActive = isFocused && index === cursor;\n const isDirectory = row.node.type === \"directory\";\n const isOpen = expanded.has(row.id);\n const glyph = glyphFor(isDirectory, isOpen);\n\n return (\n <Box key={row.id}>\n <Text color={isActive ? accentColor : undefined}>{isActive ? \"❯\" : \" \"}</Text>\n <Text>{\" \".repeat(row.depth)}</Text>\n <Text color={isDirectory ? directoryColor : undefined}>\n {glyph}\n {\" \"}\n {row.node.name}\n </Text>\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { DirectoryTree };\nexport type { Props as DirectoryTreeProps };\n",
|
|
19
|
+
"path": "registry/ui/directory-tree.tsx",
|
|
20
|
+
"target": "components/ui/directory-tree.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "divider",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Divider",
|
|
6
|
+
"description": "Divider component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useWindowSize from \"@visulima/tui/hooks/use-window-size\";\nimport type { ReactElement } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Custom character to use instead of the default line character.\n */\n readonly character?: string;\n\n /**\n * Color of the divider line.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Render the divider with dim color.\n */\n readonly dimColor?: boolean;\n\n /**\n * Optional label rendered in the middle of a horizontal divider.\n */\n readonly label?: string;\n\n /**\n * Padding around the label.\n * @default 1\n */\n readonly labelPadding?: number;\n\n /**\n * Total length of the divider. When omitted, horizontal dividers stretch to\n * fill the available space and vertical dividers default to one row.\n */\n readonly length?: number;\n\n /**\n * Orientation of the divider.\n * @default \"horizontal\"\n */\n readonly orientation?: \"horizontal\" | \"vertical\";\n};\n\n/**\n * Horizontal or vertical divider line, optionally with a centered label.\n */\nexport default function Divider({ character, color, dimColor = false, label, labelPadding = 1, length, orientation = \"horizontal\" }: Props): ReactElement {\n const { columns } = useWindowSize();\n const char = character ?? (orientation === \"horizontal\" ? \"─\" : \"│\");\n\n if (orientation === \"vertical\") {\n const rows = length ?? 1;\n\n return (\n <Box flexDirection=\"column\" height={rows}>\n {Array.from({ length: rows }, (_, index) => (\n <Text color={color} dimColor={dimColor} key={`divider-row-${index}`}>\n {char}\n </Text>\n ))}\n </Box>\n );\n }\n\n // Repeat long enough to fill the widest reasonable viewport; Yoga + Text\n // `truncate` wrapping will clip the excess. Cap the allocation so a\n // multi-thousand-column terminal (or a pathological `length` prop) can't\n // blow up the string size.\n const MAX_FILL = 500;\n const fillLength = Math.min(MAX_FILL, length ?? Math.max(columns, 1));\n\n if (label === undefined) {\n return (\n <Box flexGrow={length === undefined ? 1 : 0} width={length}>\n <Text color={color} dimColor={dimColor} wrap=\"truncate\">\n {char.repeat(fillLength)}\n </Text>\n </Box>\n );\n }\n\n const padding = \" \".repeat(labelPadding);\n\n return (\n <Box flexGrow={length === undefined ? 1 : 0} width={length}>\n <Box flexGrow={1} flexShrink={1}>\n <Text color={color} dimColor={dimColor} wrap=\"truncate\">\n {char.repeat(fillLength)}\n </Text>\n </Box>\n <Box flexShrink={0}>\n <Text color={color} dimColor={dimColor}>\n {padding}\n {label}\n {padding}\n </Text>\n </Box>\n <Box flexGrow={1} flexShrink={1}>\n <Text color={color} dimColor={dimColor} wrap=\"truncate\">\n {char.repeat(fillLength)}\n </Text>\n </Box>\n </Box>\n );\n}\n\nexport { Divider };\nexport type { Props as DividerProps };\n",
|
|
19
|
+
"path": "registry/ui/divider.tsx",
|
|
20
|
+
"target": "components/ui/divider.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "drawer",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Drawer",
|
|
6
|
+
"description": "Drawer component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCallback } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Accent color for the border and title.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Drawer body.\n */\n readonly children: ReactNode;\n\n /**\n * Whether the drawer is open. When false, nothing is rendered.\n */\n readonly isOpen: boolean;\n\n /**\n * Fires when Escape is pressed while open.\n */\n readonly onClose?: () => void;\n\n /**\n * Edge the drawer is anchored to. Left/right drawers take a fixed `size`\n * as width; top/bottom take it as height.\n * @default \"right\"\n */\n readonly side?: \"bottom\" | \"left\" | \"right\" | \"top\";\n\n /**\n * Fixed extent (width for left/right, height for top/bottom) in cells.\n * @default 32\n */\n readonly size?: number;\n\n /**\n * Optional title shown in the drawer header.\n */\n readonly title?: string;\n};\n\n/**\n * A slide-over panel anchored to a terminal edge. Rendered as a bordered box\n * sized along one axis; Escape fires `onClose`, and nothing renders while\n * closed. Terminals have no true overlay layer, so place the drawer where it\n * should appear in your layout.\n */\nexport default function Drawer({ accentColor = \"blue\", children, isOpen, onClose, side = \"right\", size = 32, title }: Props): ReactElement | null {\n const inputHandler = useCallback(\n (_input: string, key: { escape: boolean }) => {\n if (key.escape) {\n onClose?.();\n }\n },\n [onClose],\n );\n\n useInput(inputHandler, { isActive: isOpen });\n\n if (!isOpen) {\n return null;\n }\n\n const horizontal = side === \"left\" || side === \"right\";\n\n return (\n <Box\n borderColor={accentColor}\n borderStyle=\"round\"\n flexDirection=\"column\"\n height={horizontal ? undefined : size}\n paddingX={1}\n width={horizontal ? size : undefined}\n >\n {title === undefined\n ? undefined\n : (\n <Box justifyContent=\"space-between\">\n <Text bold color={accentColor}>\n {title}\n </Text>\n <Text dimColor>esc</Text>\n </Box>\n )}\n <Box flexDirection=\"column\" marginTop={title === undefined ? 0 : 1}>\n {children}\n </Box>\n </Box>\n );\n}\n\nexport { Drawer };\nexport type { Props as DrawerProps };\n",
|
|
19
|
+
"path": "registry/ui/drawer.tsx",
|
|
20
|
+
"target": "components/ui/drawer.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "file-change",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "File Change",
|
|
6
|
+
"description": "File Change component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type FileChangeStatus = \"added\" | \"deleted\" | \"modified\" | \"renamed\";\n\nconst STATUS_META: Record<FileChangeStatus, { readonly color: LiteralUnion<AnsiColors, string>; readonly mark: string }> = {\n added: { color: \"green\", mark: \"A\" },\n deleted: { color: \"red\", mark: \"D\" },\n modified: { color: \"yellow\", mark: \"M\" },\n renamed: { color: \"blue\", mark: \"R\" },\n};\n\nexport type Props = {\n /**\n * Number of added lines.\n * @default 0\n */\n readonly additions?: number;\n\n /**\n * Total width of the +/- change bar in cells.\n * @default 10\n */\n readonly barWidth?: number;\n\n /**\n * Number of removed lines.\n * @default 0\n */\n readonly deletions?: number;\n\n /**\n * The file path.\n */\n readonly path: string;\n\n /**\n * Change status, shown as a colored one-letter badge.\n * @default \"modified\"\n */\n readonly status?: FileChangeStatus;\n};\n\n/**\n * A single git-style diff-stat row: a status badge, the path, the numeric\n * add/remove counts, and a proportional ▰/▱ change bar.\n */\nexport default function FileChange({ additions = 0, barWidth = 10, deletions = 0, path, status = \"modified\" }: Props): ReactElement {\n const meta = STATUS_META[status];\n const total = additions + deletions;\n const plusCells = total === 0 ? 0 : Math.round((additions / total) * barWidth);\n const minusCells = total === 0 ? 0 : Math.min(barWidth - plusCells, Math.round((deletions / total) * barWidth));\n\n return (\n <Box gap={1}>\n <Text bold color={meta.color}>\n {meta.mark}\n </Text>\n <Text>{path}</Text>\n <Text color=\"green\">{`+${additions}`}</Text>\n <Text color=\"red\">{`-${deletions}`}</Text>\n <Box>\n <Text color=\"green\">{\"▰\".repeat(plusCells)}</Text>\n <Text color=\"red\">{\"▰\".repeat(minusCells)}</Text>\n <Text dimColor>{\"▱\".repeat(Math.max(0, barWidth - plusCells - minusCells))}</Text>\n </Box>\n </Box>\n );\n}\n\nexport { FileChange };\nexport type { Props as FileChangeProps };\n",
|
|
19
|
+
"path": "registry/ui/file-change.tsx",
|
|
20
|
+
"target": "components/ui/file-change.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "file-picker",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "File Picker",
|
|
6
|
+
"description": "File Picker component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui",
|
|
12
|
+
"node:fs",
|
|
13
|
+
"node:path"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"https://visulima.com/r/spinner.json"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"content": "import Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useState } from \"react\";\n\nimport Spinner from \"@/components/ui/spinner\";\nimport type { FileEntry, FilePickerFilter } from \"./types\";\nimport useFileSystem from \"./use-file-system\";\n\nexport type Props = {\n /**\n * Color for the focused item highlight.\n * @default \"cyan\"\n */\n readonly accentColor?: string;\n\n /**\n * Color for directory entries.\n * @default \"blue\"\n */\n readonly directoryColor?: string;\n\n /**\n * Color for file entries.\n * @default \"white\"\n */\n readonly fileColor?: string;\n\n /**\n * File filter options.\n */\n readonly filter?: FilePickerFilter;\n\n /**\n * Starting directory path.\n * @default process.cwd()\n */\n readonly initialPath?: string;\n\n /**\n * Whether keyboard navigation is active.\n * @default true\n */\n readonly isFocused?: boolean;\n\n /**\n * Maximum number of visible items (viewport height).\n * @default 10\n */\n readonly limit?: number;\n\n /**\n * Called when escape is pressed.\n */\n readonly onCancel?: () => void;\n\n /**\n * Called when a file is selected (enter on a file entry).\n */\n readonly onSelect?: (entry: FileEntry) => void;\n\n /**\n * Show file permissions column.\n * @default false\n */\n readonly showPermissions?: boolean;\n\n /**\n * Show file size column.\n * @default false\n */\n readonly showSize?: boolean;\n};\n\nfunction formatSize(bytes: number): string {\n if (bytes < 1024) {\n return `${bytes}B`;\n }\n\n if (bytes < 1024 * 1024) {\n return `${(bytes / 1024).toFixed(1)}K`;\n }\n\n if (bytes < 1024 * 1024 * 1024) {\n return `${(bytes / (1024 * 1024)).toFixed(1)}M`;\n }\n\n return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;\n}\n\n/**\n * A file system browser component for picking files from the terminal.\n *\n * ```tsx\n * <FilePicker\n * onSelect={(entry) => console.log(\"Selected:\", entry.path)}\n * filter={{ extensions: [\".ts\", \".tsx\"] }}\n * />\n * ```\n */\n// Stable defaults hoisted to module scope — using fresh `{}` / `process.cwd()`\n// as default props creates a new reference on every render, which triggers\n// re-render loops in children that memoize on reference equality.\nconst EMPTY_FILTER: NonNullable<Props[\"filter\"]> = {};\nconst getDefaultInitialPath = (): string => process.cwd();\n\nconst FilePicker = ({\n accentColor = \"cyan\",\n directoryColor = \"blue\",\n fileColor = \"white\",\n filter = EMPTY_FILTER,\n initialPath,\n isFocused = true,\n limit = 10,\n onCancel,\n onSelect,\n showPermissions = false,\n showSize = false,\n}: Props): ReactElement => {\n const resolvedInitialPath = initialPath ?? getDefaultInitialPath();\n const fs = useFileSystem({ filter, initialPath: resolvedInitialPath });\n const [focusedIndex, setFocusedIndex] = useState(0);\n\n // Clamp focused index to valid range\n const maxIndex = Math.max(0, fs.entries.length - 1);\n const safeIndex = Math.min(focusedIndex, maxIndex);\n\n useInput(\n useCallback(\n (_input: string, key) => {\n if (key.upArrow) {\n setFocusedIndex((i) => Math.max(0, i - 1));\n } else if (key.downArrow) {\n setFocusedIndex((i) => Math.min(maxIndex, i + 1));\n } else if (key.return) {\n const entry = fs.entries[safeIndex];\n\n if (entry) {\n if (entry.isDirectory) {\n fs.navigateTo(entry.path);\n setFocusedIndex(0);\n } else {\n onSelect?.(entry);\n }\n }\n } else if (key.backspace || key.leftArrow) {\n fs.goUp();\n setFocusedIndex(0);\n } else if (key.escape) {\n onCancel?.();\n } else if (_input === \".\") {\n fs.refresh();\n } else if (key.pageUp) {\n setFocusedIndex((i) => Math.max(0, i - limit));\n } else if (key.pageDown) {\n setFocusedIndex((i) => Math.min(maxIndex, i + limit));\n } else if (key.home) {\n setFocusedIndex(0);\n } else if (key.end) {\n setFocusedIndex(maxIndex);\n }\n },\n [maxIndex, safeIndex, fs, limit, onSelect, onCancel],\n ),\n { isActive: isFocused },\n );\n\n // Viewport window\n const halfLimit = Math.floor(limit / 2);\n let windowStart = Math.max(0, safeIndex - halfLimit);\n const windowEnd = Math.min(fs.entries.length, windowStart + limit);\n\n windowStart = Math.max(0, windowEnd - limit);\n\n const visibleEntries = fs.entries.slice(windowStart, windowEnd);\n\n return (\n <Box flexDirection=\"column\">\n <Text bold dimColor>\n {fs.currentPath}\n </Text>\n\n {fs.isLoading && (\n <Box>\n <Spinner />\n <Text> Loading...</Text>\n </Box>\n )}\n\n {fs.error && (\n <Text color=\"red\">\n Error:\n {fs.error}\n </Text>\n )}\n\n {!fs.isLoading && !fs.error && fs.entries.length === 0 && <Text dimColor>(empty directory)</Text>}\n\n {visibleEntries.map((entry, viewIndex) => {\n const absoluteIndex = windowStart + viewIndex;\n const isFocusedEntry = absoluteIndex === safeIndex;\n const icon = entry.isDirectory ? \"\\uD83D\\uDCC1 \" : \"\\uD83D\\uDCC4 \";\n const nameColor = entry.isDirectory ? directoryColor : fileColor;\n\n return (\n <Box key={entry.name}>\n <Text color={isFocusedEntry ? accentColor : undefined}>{isFocusedEntry ? \"\\u25B8 \" : \" \"}</Text>\n <Text>{icon}</Text>\n <Text bold={entry.isDirectory} color={isFocusedEntry ? accentColor : nameColor}>\n {entry.name}\n {entry.isDirectory ? \"/\" : \"\"}\n </Text>\n {showSize && !entry.isDirectory && (\n <Text dimColor>\n {\" \"}\n {formatSize(entry.size)}\n </Text>\n )}\n {showPermissions && (\n <Text dimColor>\n {\" \"}\n {entry.permissions}\n </Text>\n )}\n </Box>\n );\n })}\n\n {fs.entries.length > limit && (\n <Text dimColor>\n {safeIndex + 1}\n/\n{fs.entries.length}\n </Text>\n )}\n </Box>\n );\n};\n\nexport default FilePicker;\n",
|
|
21
|
+
"path": "registry/ui/file-picker/file-picker.tsx",
|
|
22
|
+
"target": "components/ui/file-picker/file-picker.tsx",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"content": "// fallow-ignore-file unused-type -- public component API barrel; re-exports consumed by library users, not internally.\n// fallow-ignore-file unused-export -- public component API barrel; re-exports consumed by library users, not internally.\nexport type { Props as FilePickerProps } from \"./file-picker\";\nexport { default as FilePicker } from \"./file-picker\";\nexport type { FileEntry, FilePickerFilter } from \"./types\";\n",
|
|
27
|
+
"path": "registry/ui/file-picker/index.ts",
|
|
28
|
+
"target": "components/ui/file-picker/index.ts",
|
|
29
|
+
"type": "registry:ui"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"content": "/**\n * A single entry (file or directory) returned by the file system hook.\n */\nexport type FileEntry = {\n readonly isDirectory: boolean;\n readonly isHidden: boolean;\n readonly name: string;\n readonly path: string;\n readonly permissions: string;\n readonly size: number;\n};\n\n/**\n * Filter options for the file picker.\n */\nexport type FilePickerFilter = {\n /**\n * Allowed file extensions (e.g. `[\".ts\", \".tsx\"]`). Empty or omitted means all.\n */\n readonly extensions?: ReadonlyArray<string>;\n\n /**\n * Whether to include directories in the list.\n * @default true\n */\n readonly showDirectories?: boolean;\n\n /**\n * Whether to include regular files in the list.\n * @default true\n */\n readonly showFiles?: boolean;\n\n /**\n * Whether to include hidden files (names starting with `.`).\n * @default false\n */\n readonly showHidden?: boolean;\n};\n",
|
|
33
|
+
"path": "registry/ui/file-picker/types.ts",
|
|
34
|
+
"target": "components/ui/file-picker/types.ts",
|
|
35
|
+
"type": "registry:ui"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"content": "import { readdir, stat } from \"node:fs/promises\";\nimport { dirname, extname, join, resolve } from \"node:path\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport type { FileEntry, FilePickerFilter } from \"./types\";\n\ntype UseFileSystemOptions = {\n readonly filter: FilePickerFilter;\n readonly initialPath: string;\n};\n\ntype UseFileSystemResult = {\n readonly currentPath: string;\n readonly entries: ReadonlyArray<FileEntry>;\n readonly error: string | undefined;\n readonly goUp: () => void;\n readonly isLoading: boolean;\n readonly navigateTo: (path: string) => void;\n readonly refresh: () => void;\n};\n\nfunction formatPermissions(mode: number): string {\n const rwx = (m: number): string =>\n // eslint-disable-next-line no-bitwise\n `${m & 4 ? \"r\" : \"-\"}${m & 2 ? \"w\" : \"-\"}${m & 1 ? \"x\" : \"-\"}`;\n\n // eslint-disable-next-line no-bitwise\n return `${rwx((mode >> 6) & 7)}${rwx((mode >> 3) & 7)}${rwx(mode & 7)}`;\n}\n\nfunction matchesFilter(entry: FileEntry, filter: FilePickerFilter): boolean {\n if (!(filter.showHidden ?? false) && entry.isHidden) {\n return false;\n }\n\n if (!(filter.showDirectories ?? true) && entry.isDirectory) {\n return false;\n }\n\n if (!(filter.showFiles ?? true) && !entry.isDirectory) {\n return false;\n }\n\n if (filter.extensions && filter.extensions.length > 0 && !entry.isDirectory) {\n const extension = extname(entry.name);\n\n if (!filter.extensions.includes(extension)) {\n return false;\n }\n }\n\n return true;\n}\n\nexport default function useFileSystem(options: UseFileSystemOptions): UseFileSystemResult {\n const { filter, initialPath } = options;\n\n const [currentPath, setCurrentPath] = useState(() => resolve(initialPath));\n const [entries, setEntries] = useState<ReadonlyArray<FileEntry>>([]);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<string | undefined>();\n const [refreshKey, setRefreshKey] = useState(0);\n\n const filterRef = useRef(filter);\n\n filterRef.current = filter;\n\n useEffect(() => {\n let cancelled = false;\n\n const loadDirectory = async (): Promise<void> => {\n setIsLoading(true);\n setError(undefined);\n\n try {\n const directoryEntries = await readdir(currentPath, { withFileTypes: true });\n\n // Gather stats for all entries in parallel — sequential stat() calls\n // serialize filesystem latency unnecessarily.\n const statsResults = await Promise.all(\n directoryEntries.map(async (entry) => {\n try {\n return await stat(join(currentPath, entry.name));\n } catch {\n return undefined;\n }\n }),\n );\n\n const fileEntries: FileEntry[] = directoryEntries.map((entry, index) => {\n const entryPath = join(currentPath, entry.name);\n const isDirectory = entry.isDirectory();\n const isHidden = entry.name.startsWith(\".\");\n const stats = statsResults[index];\n const size = stats?.size ?? 0;\n const permissions = stats ? formatPermissions(stats.mode) : \"---------\";\n\n return {\n isDirectory,\n isHidden,\n name: entry.name,\n path: entryPath,\n permissions,\n size,\n };\n });\n\n if (cancelled) {\n return;\n }\n\n // Filter entries\n const filtered = fileEntries.filter((entry) => matchesFilter(entry, filterRef.current));\n\n // Sort: directories first, then alphabetical (case-insensitive)\n filtered.sort((a, b) => {\n if (a.isDirectory !== b.isDirectory) {\n return a.isDirectory ? -1 : 1;\n }\n\n return a.name.localeCompare(b.name, undefined, { sensitivity: \"base\" });\n });\n\n setEntries(filtered);\n } catch (error_: unknown) {\n if (!cancelled) {\n setError(error_ instanceof Error ? error_.message : String(error_));\n setEntries([]);\n }\n } finally {\n if (!cancelled) {\n setIsLoading(false);\n }\n }\n };\n\n loadDirectory().catch(() => undefined);\n\n return () => {\n cancelled = true;\n };\n }, [currentPath, refreshKey]);\n\n const navigateTo = useCallback((path: string) => {\n setCurrentPath(resolve(path));\n }, []);\n\n const goUp = useCallback(() => {\n setCurrentPath((previous) => {\n const parent = dirname(previous);\n\n // Don't go above root\n return parent === previous ? previous : parent;\n });\n }, []);\n\n const refresh = useCallback(() => {\n setRefreshKey((k) => k + 1);\n }, []);\n\n return { currentPath, entries, error, goUp, isLoading, navigateTo, refresh };\n}\n",
|
|
39
|
+
"path": "registry/ui/file-picker/use-file-system.ts",
|
|
40
|
+
"target": "components/ui/file-picker/use-file-system.ts",
|
|
41
|
+
"type": "registry:ui"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
package/dist/r/form.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "form",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Form",
|
|
6
|
+
"description": "Form component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "import type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type FormFieldProps = {\n /**\n * The input element (TextInput, Checkbox, Select, …).\n */\n readonly children: ReactNode;\n\n /**\n * Optional helper text rendered beneath the input.\n */\n readonly description?: string;\n\n /**\n * Validation error — dimmed red below the input.\n */\n readonly error?: string;\n\n /**\n * When true, the field's label and description are dim.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Label rendered above the input.\n */\n readonly label: string;\n\n /**\n * Mark the field as required (adds a trailing `*`).\n */\n readonly required?: boolean;\n};\n\n/**\n * Standard form field wrapper: label + input + helper/error.\n */\nexport const FormField = ({ children, description, error, isDisabled = false, label, required = false }: FormFieldProps): ReactElement => (\n <Box flexDirection=\"column\" marginBottom={1}>\n <Box>\n <Text bold dimColor={isDisabled}>\n {label}\n </Text>\n {required ? <Text color=\"red\"> *</Text> : undefined}\n </Box>\n <Box marginTop={0}>{children}</Box>\n {/* eslint-disable-next-line sonarjs/no-nested-conditional -- minimal three-way render: error → description → nothing */}\n {error === undefined ? description === undefined ? undefined : <Text dimColor>{description}</Text> : <Text color=\"red\">{error}</Text>}\n </Box>\n);\n\nexport type Props = {\n /**\n * Accent color for the form chrome.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Render a border around the form.\n * @default true\n */\n readonly bordered?: boolean;\n\n /**\n * Fields composed via `<FormField />`.\n */\n readonly children: ReactNode;\n\n /**\n * Optional description rendered under the title.\n */\n readonly description?: string;\n\n /**\n * Optional footer rendered below the fields (buttons, hints, summary).\n */\n readonly footer?: ReactNode;\n\n /**\n * Optional heading rendered at the top.\n */\n readonly title?: string;\n\n /**\n * Fixed width.\n */\n readonly width?: number;\n};\n\ntype FormComponent = ((props: Props) => ReactElement) & { Field: typeof FormField };\n\n/**\n * Stateless form layout. Pair with the `useForm` hook for state management\n * and `FormField` for consistent label/error styling.\n */\nconst Form: FormComponent = Object.assign(\n ({ accentColor = \"blue\", bordered = true, children, description, footer, title, width }: Props): ReactElement => {\n const content = (\n <Box flexDirection=\"column\">\n {title === undefined\n ? undefined\n : (\n <Box marginBottom={1}>\n <Text bold color={accentColor}>\n {title}\n </Text>\n </Box>\n )}\n {description === undefined\n ? undefined\n : (\n <Box marginBottom={1}>\n <Text dimColor>{description}</Text>\n </Box>\n )}\n <Box flexDirection=\"column\">{children}</Box>\n {footer === undefined ? undefined : <Box marginTop={1}>{footer}</Box>}\n </Box>\n );\n\n if (!bordered) {\n return (\n <Box flexDirection=\"column\" width={width}>\n {content}\n </Box>\n );\n }\n\n return (\n <Box borderColor={accentColor} borderStyle=\"round\" flexDirection=\"column\" paddingX={1} paddingY={1} width={width}>\n {content}\n </Box>\n );\n },\n { Field: FormField },\n);\n\nexport default Form;\n\nexport { Form };\nexport type { Props as FormProps };\n",
|
|
19
|
+
"path": "registry/ui/form.tsx",
|
|
20
|
+
"target": "components/ui/form.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|