@toolr/ui-design 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +63 -0
  2. package/components/content/info-panel-primitives.tsx +297 -0
  3. package/components/diagrams/diagram-utils.tsx +908 -0
  4. package/components/hooks/use-click-outside.ts +27 -0
  5. package/components/hooks/use-dropdown-max-height.ts +20 -0
  6. package/components/hooks/use-navigation-history.ts +94 -0
  7. package/components/lib/ai-tools.tsx +44 -0
  8. package/components/lib/cn.ts +6 -0
  9. package/components/lib/form-colors.ts +32 -0
  10. package/components/lib/theme-engine.ts +97 -0
  11. package/components/lib/toolr-brand.tsx +31 -0
  12. package/components/sections/ai-tools-paths/index.ts +37 -0
  13. package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
  14. package/components/sections/ai-tools-paths/types.ts +111 -0
  15. package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
  16. package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
  17. package/components/sections/captured-issues/index.ts +38 -0
  18. package/components/sections/captured-issues/types.ts +113 -0
  19. package/components/sections/captured-issues/use-captured-issues.ts +111 -0
  20. package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
  21. package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
  22. package/components/sections/golden-snapshots/index.ts +145 -0
  23. package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
  24. package/components/sections/golden-snapshots/status-overview.tsx +305 -0
  25. package/components/sections/golden-snapshots/types.ts +288 -0
  26. package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
  27. package/components/sections/golden-snapshots/version-manager.tsx +186 -0
  28. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
  29. package/components/sections/prompt-editor/index.ts +121 -0
  30. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
  31. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
  32. package/components/sections/prompt-editor/types.ts +101 -0
  33. package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
  34. package/components/sections/report-bug/error-logger.ts +392 -0
  35. package/components/sections/report-bug/index.ts +59 -0
  36. package/components/sections/report-bug/issue-reporter-api.ts +83 -0
  37. package/components/sections/report-bug/report-bug-form.tsx +282 -0
  38. package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
  39. package/components/sections/report-bug/use-report-bug.ts +170 -0
  40. package/components/sections/snapshot-browser/index.ts +53 -0
  41. package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
  42. package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
  43. package/components/sections/snapshot-browser/types.ts +106 -0
  44. package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
  45. package/components/sections/snippets-editor/index.ts +31 -0
  46. package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
  47. package/components/sections/snippets-editor/types.ts +48 -0
  48. package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
  49. package/components/ui/action-dialog.tsx +309 -0
  50. package/components/ui/ai-action-button.tsx +137 -0
  51. package/components/ui/ai-execution-action-buttons.tsx +106 -0
  52. package/components/ui/badge.tsx +67 -0
  53. package/components/ui/bottom-panel-header.tsx +240 -0
  54. package/components/ui/breadcrumb.tsx +168 -0
  55. package/components/ui/checkbox.tsx +102 -0
  56. package/components/ui/collapsible-section.tsx +100 -0
  57. package/components/ui/confirm-badge.tsx +71 -0
  58. package/components/ui/detail-section.tsx +67 -0
  59. package/components/ui/detail-view-wrapper.tsx +55 -0
  60. package/components/ui/editor-placeholder-card.tsx +197 -0
  61. package/components/ui/editor-toolbar.tsx +123 -0
  62. package/components/ui/execution-details-panel.tsx +93 -0
  63. package/components/ui/extension-list-card.tsx +105 -0
  64. package/components/ui/file-structure-section.tsx +373 -0
  65. package/components/ui/file-tree.tsx +171 -0
  66. package/components/ui/files-panel.tsx +251 -0
  67. package/components/ui/filter-dropdown.tsx +173 -0
  68. package/components/ui/form-actions.tsx +127 -0
  69. package/components/ui/frontmatter-form-header.tsx +80 -0
  70. package/components/ui/icon-button.tsx +388 -0
  71. package/components/ui/input.tsx +211 -0
  72. package/components/ui/label.tsx +159 -0
  73. package/components/ui/layout-tab-bar.tsx +289 -0
  74. package/components/ui/modal.tsx +194 -0
  75. package/components/ui/nav-card.tsx +81 -0
  76. package/components/ui/navigation-bar.tsx +285 -0
  77. package/components/ui/number-input.tsx +165 -0
  78. package/components/ui/registry-browser.tsx +261 -0
  79. package/components/ui/registry-card.tsx +710 -0
  80. package/components/ui/registry-detail.tsx +224 -0
  81. package/components/ui/resizable-textarea.tsx +290 -0
  82. package/components/ui/scope-badge.tsx +67 -0
  83. package/components/ui/segmented-toggle.tsx +133 -0
  84. package/components/ui/select.tsx +172 -0
  85. package/components/ui/selection-grid.tsx +313 -0
  86. package/components/ui/setting-row.tsx +97 -0
  87. package/components/ui/snapshot-card.tsx +107 -0
  88. package/components/ui/snippets-panel.tsx +161 -0
  89. package/components/ui/sort-dropdown.tsx +109 -0
  90. package/components/ui/status-card.tsx +96 -0
  91. package/components/ui/tab-bar.tsx +340 -0
  92. package/components/ui/toggle.tsx +142 -0
  93. package/components/ui/tooltip.tsx +326 -0
  94. package/dist/content.d.ts +110 -0
  95. package/dist/content.js +195 -0
  96. package/dist/diagrams.d.ts +371 -0
  97. package/dist/diagrams.js +702 -0
  98. package/dist/index.d.ts +2714 -0
  99. package/dist/index.js +11220 -0
  100. package/dist/preset.d.ts +24 -0
  101. package/dist/preset.js +17 -0
  102. package/dist/tokens/tokens/primitives.css +45 -0
  103. package/dist/tokens/tokens/semantic.css +46 -0
  104. package/dist/tokens/tokens/theme.css +11 -0
  105. package/dist/tokens/tokens/tokens.json +65 -0
  106. package/index.ts +123 -0
  107. package/package.json +63 -0
  108. package/tailwind-preset.ts +22 -0
  109. package/tokens/primitives.css +45 -0
  110. package/tokens/semantic.css +46 -0
  111. package/tokens/theme.css +11 -0
  112. package/tokens/tokens.json +65 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * ExecutionDetailsPanel - Shows AI execution configuration before running actions
3
+ *
4
+ * Displays tool, permissions, output format, CLI flags, and change handling.
5
+ * Optional "Allow direct edits" toggle with warning message.
6
+ * Used inside ActionDialog as the mandatory execution details section.
7
+ */
8
+
9
+ import { AlertTriangle, Info } from 'lucide-react'
10
+ import { Checkbox } from './checkbox.tsx'
11
+ import { cn } from '../lib/cn.ts'
12
+
13
+ export interface ExecutionDetailRow {
14
+ label: string
15
+ value: string
16
+ mono?: boolean
17
+ }
18
+
19
+ export interface ExecutionDetailsPanelProps {
20
+ details: ExecutionDetailRow[]
21
+ /** Show the "Allow direct edits" toggle */
22
+ allowDirectEdits?: boolean
23
+ onAllowDirectEditsChange?: (value: boolean) => void
24
+ /** Warning message shown below the toggle */
25
+ warningMessage?: string
26
+ className?: string
27
+ }
28
+
29
+ export function ExecutionDetailsPanel({
30
+ details,
31
+ allowDirectEdits,
32
+ onAllowDirectEditsChange,
33
+ warningMessage,
34
+ className,
35
+ }: ExecutionDetailsPanelProps) {
36
+ const showToggle = onAllowDirectEditsChange !== undefined
37
+
38
+ return (
39
+ <div className={cn('space-y-3', className)}>
40
+ {/* Header */}
41
+ <div className="flex items-center gap-2">
42
+ <Info className="w-4 h-4 text-neutral-500" />
43
+ <span className="font-medium text-neutral-400 text-sm">Execution Details</span>
44
+ </div>
45
+
46
+ {/* Direct edits toggle */}
47
+ {showToggle && (
48
+ <div
49
+ className="flex items-start gap-2 cursor-pointer"
50
+ onClick={() => onAllowDirectEditsChange!(!allowDirectEdits)}
51
+ >
52
+ <div onClick={(e) => e.stopPropagation()}>
53
+ <Checkbox
54
+ checked={allowDirectEdits ?? false}
55
+ onChange={onAllowDirectEditsChange!}
56
+ className="mt-0.5"
57
+ />
58
+ </div>
59
+ <div>
60
+ <span className="text-neutral-300 text-sm">Allow direct file edits</span>
61
+ <p className="text-neutral-500 text-xs mt-0.5">
62
+ {allowDirectEdits
63
+ ? 'AI will modify files directly. Changes saved immediately.'
64
+ : 'Changes will be shown in editor for review.'}
65
+ </p>
66
+ </div>
67
+ </div>
68
+ )}
69
+
70
+ {/* Warning */}
71
+ {warningMessage && (
72
+ <div className="rounded border border-red-500/50 bg-red-500/10 p-2">
73
+ <div className="flex items-start gap-2">
74
+ <AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
75
+ <p className="text-red-300 text-xs">{warningMessage}</p>
76
+ </div>
77
+ </div>
78
+ )}
79
+
80
+ {/* Detail rows */}
81
+ {details.length > 0 && (
82
+ <div className="space-y-2">
83
+ {details.map((row) => (
84
+ <div key={row.label} className="flex items-start gap-3">
85
+ <span className="text-neutral-500 text-xs w-24 shrink-0">{row.label}:</span>
86
+ <span className={cn('text-neutral-300 text-xs', row.mono && 'font-mono')}>{row.value}</span>
87
+ </div>
88
+ ))}
89
+ </div>
90
+ )}
91
+ </div>
92
+ )
93
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * ExtensionListCard — Standardized card for extension list view items.
3
+ *
4
+ * Used across apps for skill, command, hook, agent, plugin, MCP server,
5
+ * Gemini extension, and marketplace cards.
6
+ *
7
+ * Features:
8
+ * - Colored left border for type indication
9
+ * - Icon, title, badges, description, actions slots
10
+ * - Inactive/dimmed state for disabled items
11
+ * - Hover-revealed action buttons (300ms delay)
12
+ * - Optional metadata row (static or hover-aware)
13
+ */
14
+
15
+ import { memo, useState, useRef, useCallback, useEffect, type ReactNode, type ElementType } from 'react'
16
+ import { cn } from '../lib/cn.ts'
17
+
18
+ export interface ExtensionListCardProps {
19
+ /** Lucide icon component */
20
+ icon: ElementType
21
+ /** Tailwind color class for icon (e.g. 'text-teal-400') */
22
+ iconColor: string
23
+ /** Tailwind color class for left border (e.g. 'border-l-teal-400') */
24
+ borderColor: string
25
+ /** Card title */
26
+ title: string
27
+ /** Custom title class (defaults to 'text-white') */
28
+ titleClassName?: string
29
+ /** Badges rendered after the title */
30
+ badges?: ReactNode
31
+ /** Description text or node (can be string or ReactNode for custom formatting) */
32
+ description?: ReactNode
33
+ /** Action buttons (shown on hover) */
34
+ actions?: ReactNode
35
+ /** Metadata row at bottom. Can be a render function receiving hover state. */
36
+ metadata?: ReactNode | ((isHovered: boolean) => ReactNode)
37
+ /** Whether card is inactive/dimmed */
38
+ isInactive?: boolean
39
+ /** Test ID for E2E testing */
40
+ testId?: string
41
+ }
42
+
43
+ export const ExtensionListCard = memo(function ExtensionListCard({
44
+ icon: Icon,
45
+ iconColor,
46
+ borderColor,
47
+ title,
48
+ titleClassName = 'text-white',
49
+ badges,
50
+ description,
51
+ actions,
52
+ metadata,
53
+ isInactive,
54
+ testId,
55
+ }: ExtensionListCardProps) {
56
+ const [isHovered, setIsHovered] = useState(false)
57
+ const hoverTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
58
+ useEffect(() => () => clearTimeout(hoverTimerRef.current), [])
59
+ const handleMouseEnter = useCallback(() => {
60
+ hoverTimerRef.current = setTimeout(() => setIsHovered(true), 300)
61
+ }, [])
62
+ const handleMouseLeave = useCallback(() => {
63
+ clearTimeout(hoverTimerRef.current)
64
+ setIsHovered(false)
65
+ }, [])
66
+
67
+ return (
68
+ <div
69
+ className={cn(
70
+ 'relative flex flex-col p-3 bg-neutral-800/50 rounded-lg border border-neutral-700/50 border-l-4 hover:bg-neutral-800 transition-colors',
71
+ borderColor,
72
+ isInactive && 'opacity-60',
73
+ isHovered ? 'h-auto z-10' : 'h-full',
74
+ )}
75
+ data-testid={testId}
76
+ onMouseEnter={handleMouseEnter}
77
+ onMouseLeave={handleMouseLeave}
78
+ >
79
+ <div className="flex items-start justify-between gap-2">
80
+ <div className="flex items-start gap-3 min-w-0 flex-1">
81
+ <Icon className={cn('w-5 h-5 shrink-0', iconColor)} />
82
+ <div className="min-w-0 flex-1">
83
+ <div className="flex items-center gap-2 flex-wrap">
84
+ <span className={cn('text-sm font-medium', titleClassName)}>{title}</span>
85
+ {badges}
86
+ </div>
87
+ {description && (
88
+ <div className={cn('text-xs text-neutral-500 mt-1', !isHovered && 'line-clamp-2')}>{description}</div>
89
+ )}
90
+ </div>
91
+ </div>
92
+ {actions && (
93
+ <div className={cn('items-center gap-1.5 shrink-0', isHovered ? 'flex' : 'hidden')}>
94
+ {actions}
95
+ </div>
96
+ )}
97
+ </div>
98
+ {metadata != null && (
99
+ <div className="flex items-center justify-between mt-2 ml-8 mr-2 text-xs text-neutral-500">
100
+ {typeof metadata === 'function' ? metadata(isHovered) : metadata}
101
+ </div>
102
+ )}
103
+ </div>
104
+ )
105
+ })
@@ -0,0 +1,373 @@
1
+ import { useState, useEffect, useCallback, useRef, useMemo, type ReactNode } from 'react'
2
+ import { FileCode, FolderTree, Loader2, AlertCircle, AlignLeft, Code2, Type } from 'lucide-react'
3
+ import { CollapseButton } from './icon-button.tsx'
4
+ import { SegmentedToggle } from './segmented-toggle.tsx'
5
+ import { FileTree, collectDirPaths, type FileTreeNode } from './file-tree.tsx'
6
+
7
+ export type PreviewMode = 'format' | 'language' | 'plain'
8
+ export type AccentColor = 'blue' | 'purple' | 'orange' | 'green' | 'pink' | 'amber' | 'emerald' | 'teal' | 'sky'
9
+
10
+ const ACCENT_ICON: Record<AccentColor, string> = {
11
+ blue: 'text-blue-400', purple: 'text-purple-400', orange: 'text-orange-400',
12
+ green: 'text-green-400', pink: 'text-pink-400', amber: 'text-amber-400',
13
+ emerald: 'text-emerald-400', teal: 'text-teal-400', sky: 'text-sky-400',
14
+ }
15
+
16
+ export interface FileStructureSectionProps {
17
+ files: FileTreeNode[] | null
18
+ rootName: string
19
+ isLoading?: boolean
20
+ error?: string | null
21
+ onFetchContent: (relativePath: string) => Promise<string>
22
+ /** Enable the Format option in the mode toggle (renders markdown). */
23
+ format?: boolean
24
+ /** Enable the Language option in the mode toggle (syntax highlighting). */
25
+ language?: boolean
26
+ /**
27
+ * Default active mode when multiple options are enabled.
28
+ * Only relevant when both `format` and `language` are true.
29
+ * Defaults to `'format'` when both are set.
30
+ */
31
+ default?: PreviewMode
32
+ /** Accent color applied to icons, selected state, and the mode toggle. Defaults to 'blue'. */
33
+ accentColor?: AccentColor
34
+ /** Custom renderer called when mode is 'language'. Receives the resolved language as third arg. */
35
+ renderPreview?: (content: string, filePath: string, language: string) => ReactNode
36
+ }
37
+
38
+ export function getLanguageFromPath(filePath: string): string {
39
+ const ext = filePath.split('.').pop()?.toLowerCase() || ''
40
+ const map: Record<string, string> = {
41
+ js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
42
+ json: 'json', md: 'markdown', yml: 'yaml', yaml: 'yaml',
43
+ sh: 'shell', bash: 'shell',
44
+ rs: 'rust', py: 'python', rb: 'ruby', go: 'go',
45
+ html: 'html', css: 'css', scss: 'scss',
46
+ toml: 'ini', xml: 'xml', sql: 'sql',
47
+ }
48
+ return map[ext] || 'plaintext'
49
+ }
50
+
51
+ function renderMarkdownContent(content: string) {
52
+ const lines = content.split('\n')
53
+ const nodes: ReactNode[] = []
54
+ let i = 0
55
+
56
+ // Frontmatter block
57
+ if (lines[0] === '---') {
58
+ const fmLines: string[] = []
59
+ i = 1
60
+ while (i < lines.length && lines[i] !== '---') { fmLines.push(lines[i]); i++ }
61
+ i++ // skip closing ---
62
+ nodes.push(
63
+ <div key="fm" className="mb-3 font-mono text-[11px] text-neutral-500 border-l-2 border-neutral-700 pl-2 py-0.5">
64
+ <div className="text-neutral-600">---</div>
65
+ {fmLines.map((l, j) => <div key={j}>{l}</div>)}
66
+ <div className="text-neutral-600">---</div>
67
+ </div>
68
+ )
69
+ }
70
+
71
+ while (i < lines.length) {
72
+ const line = lines[i]
73
+ if (line.startsWith('```')) {
74
+ const codeLines: string[] = []
75
+ i++
76
+ while (i < lines.length && !lines[i].startsWith('```')) { codeLines.push(lines[i]); i++ }
77
+ nodes.push(
78
+ <pre key={i} className="mb-2 p-2 bg-black/30 rounded text-[11px] font-mono text-neutral-300 overflow-x-auto">
79
+ {codeLines.join('\n')}
80
+ </pre>
81
+ )
82
+ } else if (line.startsWith('### ')) {
83
+ nodes.push(<h3 key={i} className="text-[11px] font-semibold text-neutral-300 mt-2 mb-0.5">{line.slice(4)}</h3>)
84
+ } else if (line.startsWith('## ')) {
85
+ nodes.push(<h2 key={i} className="text-xs font-semibold text-neutral-200 mt-2.5 mb-1">{line.slice(3)}</h2>)
86
+ } else if (line.startsWith('# ')) {
87
+ nodes.push(<h1 key={i} className="text-sm font-semibold text-neutral-100 mb-1.5">{line.slice(2)}</h1>)
88
+ } else if (line === '' || line === '\r') {
89
+ nodes.push(<div key={i} className="h-1.5" />)
90
+ } else {
91
+ nodes.push(<p key={i} className="text-[11px] text-neutral-400 leading-relaxed">{line}</p>)
92
+ }
93
+ i++
94
+ }
95
+
96
+ return <div className="p-3">{nodes}</div>
97
+ }
98
+
99
+ const FORMAT_OPTION = { value: 'format' as const, icon: <AlignLeft className="w-3 h-3" />, tooltip: { description: 'Render as markdown' } }
100
+ const LANGUAGE_OPTION = { value: 'language' as const, icon: <Code2 className="w-3 h-3" />, tooltip: { description: 'Syntax highlighting' } }
101
+ const PLAIN_OPTION = { value: 'plain' as const, icon: <Type className="w-3 h-3" />, tooltip: { description: 'Plain text' } }
102
+
103
+ export function FileStructureSection({
104
+ files,
105
+ rootName,
106
+ isLoading,
107
+ error,
108
+ onFetchContent,
109
+ format,
110
+ language,
111
+ default: defaultMode,
112
+ accentColor = 'blue',
113
+ renderPreview,
114
+ }: FileStructureSectionProps) {
115
+ const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null)
116
+ const [fileContent, setFileContent] = useState<string | null>(null)
117
+ const [fetchedFilePath, setFetchedFilePath] = useState<string | null>(null)
118
+ const [fileError, setFileError] = useState<string | null>(null)
119
+
120
+ // Resolve initial mode from props
121
+ function resolveInitialMode(): PreviewMode {
122
+ if (defaultMode === 'format' && format) return 'format'
123
+ if (defaultMode === 'language' && language) return 'language'
124
+ if (defaultMode === 'plain') return 'plain'
125
+ if (format) return 'format'
126
+ if (language) return 'language'
127
+ return 'plain'
128
+ }
129
+ const [mode, setMode] = useState<PreviewMode>(resolveInitialMode)
130
+
131
+ // When format/language props change, fall back if current mode is no longer available
132
+ useEffect(() => {
133
+ setMode((prev: PreviewMode) => {
134
+ if (prev === 'format' && !format) return language ? 'language' : 'plain'
135
+ if (prev === 'language' && !language) return format ? 'format' : 'plain'
136
+ return prev
137
+ })
138
+ }, [format, language])
139
+
140
+ // Build toggle options based on enabled features
141
+ const toggleOptions = [
142
+ ...(format ? [FORMAT_OPTION] : []),
143
+ ...(language ? [LANGUAGE_OPTION] : []),
144
+ PLAIN_OPTION,
145
+ ]
146
+ const showToggle = !!(format || language)
147
+
148
+ const allDirPaths = useMemo(() => {
149
+ if (!files) return new Set<string>()
150
+ return collectDirPaths(files, rootName)
151
+ }, [files, rootName])
152
+ const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set<string>())
153
+ useEffect(() => { setExpandedPaths(new Set(allDirPaths)) }, [allDirPaths])
154
+ const togglePath = useCallback((path: string) => {
155
+ setExpandedPaths((prev: Set<string>) => {
156
+ const next = new Set(prev)
157
+ if (next.has(path)) next.delete(path)
158
+ else next.add(path)
159
+ return next
160
+ })
161
+ }, [])
162
+ const allCollapsed = expandedPaths.size === 0
163
+
164
+ const [treeHeight, setTreeHeight] = useState<number | null>(null)
165
+ const sectionRef = useRef<HTMLDivElement>(null)
166
+ const resizing = useRef(false)
167
+ const startY = useRef(0)
168
+ const startHeight = useRef(0)
169
+
170
+ useEffect(() => {
171
+ if (treeHeight !== null || !sectionRef.current) return
172
+ const el = sectionRef.current
173
+ const scrollParent = el.closest('.overflow-y-auto') as HTMLElement | null
174
+ if (!scrollParent) return
175
+
176
+ const observer = new ResizeObserver(() => {
177
+ if (scrollParent.clientHeight === 0) return
178
+ const containerRect = scrollParent.getBoundingClientRect()
179
+ const sectionRect = el.getBoundingClientRect()
180
+ const contentAbove = (sectionRect.top - containerRect.top) + scrollParent.scrollTop
181
+ const remaining = scrollParent.clientHeight - contentAbove - 60
182
+ const maxHeight = Math.min(scrollParent.clientHeight - 100, window.innerHeight * 0.6)
183
+ setTreeHeight(Math.max(250, Math.min(remaining, maxHeight)))
184
+ observer.disconnect()
185
+ })
186
+ observer.observe(scrollParent)
187
+ return () => observer.disconnect()
188
+ }, [treeHeight, files])
189
+
190
+ const effectiveHeight = treeHeight ?? 250
191
+
192
+ const handleResizeStart = useCallback((e: React.MouseEvent) => {
193
+ e.preventDefault()
194
+ resizing.current = true
195
+ startY.current = e.clientY
196
+ startHeight.current = effectiveHeight
197
+
198
+ const onMouseMove = (ev: MouseEvent) => {
199
+ if (!resizing.current) return
200
+ setTreeHeight(Math.max(150, startHeight.current + (ev.clientY - startY.current)))
201
+ }
202
+ const onMouseUp = () => {
203
+ resizing.current = false
204
+ document.removeEventListener('mousemove', onMouseMove)
205
+ document.removeEventListener('mouseup', onMouseUp)
206
+ }
207
+ document.addEventListener('mousemove', onMouseMove, { passive: true })
208
+ document.addEventListener('mouseup', onMouseUp, { passive: true })
209
+ }, [effectiveHeight])
210
+
211
+ const firstFilePath = useMemo(() => {
212
+ if (!files) return null
213
+ const findFirst = (nodes: FileTreeNode[], prefix = ''): string | null => {
214
+ for (const node of nodes) {
215
+ const path = prefix ? `${prefix}/${node.name}` : node.name
216
+ if (node.type === 'file') return path
217
+ if (node.children) {
218
+ const found = findFirst(node.children, path)
219
+ if (found) return found
220
+ }
221
+ }
222
+ return null
223
+ }
224
+ return findFirst(files, rootName)
225
+ }, [files, rootName])
226
+
227
+ const effectiveFilePath = selectedFilePath ?? firstFilePath
228
+ const fileIsLoading = effectiveFilePath != null && effectiveFilePath !== fetchedFilePath
229
+
230
+ useEffect(() => {
231
+ if (!effectiveFilePath) return
232
+
233
+ const relativePath = effectiveFilePath.startsWith(`${rootName}/`)
234
+ ? effectiveFilePath.slice(rootName.length + 1)
235
+ : effectiveFilePath
236
+
237
+ let cancelled = false
238
+
239
+ onFetchContent(relativePath)
240
+ .then((text) => {
241
+ if (!cancelled) {
242
+ setFileContent(text)
243
+ setFetchedFilePath(effectiveFilePath)
244
+ setFileError(null)
245
+ }
246
+ })
247
+ .catch((err) => {
248
+ if (!cancelled) {
249
+ setFileError(err instanceof Error ? err.message : 'Failed to load file')
250
+ setFetchedFilePath(effectiveFilePath)
251
+ }
252
+ })
253
+
254
+ return () => { cancelled = true }
255
+ }, [effectiveFilePath, rootName, onFetchContent])
256
+
257
+ const handleSelectFile = useCallback((filePath: string) => {
258
+ setSelectedFilePath(filePath)
259
+ setFileContent(null)
260
+ setFileError(null)
261
+ }, [])
262
+
263
+ if (isLoading) {
264
+ return (
265
+ <div>
266
+ <h3 className="text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">File Structure</h3>
267
+ <div className="flex items-center gap-2 text-xs text-neutral-500 py-4">
268
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
269
+ Loading file tree...
270
+ </div>
271
+ </div>
272
+ )
273
+ }
274
+
275
+ if (!files || files.length === 0) {
276
+ return null
277
+ }
278
+
279
+ if (error) {
280
+ return (
281
+ <div>
282
+ <h3 className="text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">File Structure</h3>
283
+ <div className="flex items-center gap-2 text-xs text-red-400 py-4">
284
+ <AlertCircle className="w-3.5 h-3.5 shrink-0" />
285
+ {error}
286
+ </div>
287
+ </div>
288
+ )
289
+ }
290
+
291
+ const selectedFileName = effectiveFilePath?.split('/').pop() || ''
292
+ const resolvedLanguage = (mode === 'language' && effectiveFilePath)
293
+ ? getLanguageFromPath(effectiveFilePath)
294
+ : 'plaintext'
295
+
296
+ function renderContent(content: string, filePath: string) {
297
+ if (mode === 'format') return renderMarkdownContent(content)
298
+ if (mode === 'language' && renderPreview) return renderPreview(content, filePath, resolvedLanguage)
299
+ return (
300
+ <pre className="p-3 text-xs font-mono text-white leading-relaxed whitespace-pre-wrap">
301
+ <code>{content}</code>
302
+ </pre>
303
+ )
304
+ }
305
+
306
+ return (
307
+ <div ref={sectionRef}>
308
+ <h3 className="text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2">File Structure</h3>
309
+ <div className="flex gap-3" style={{ height: `${effectiveHeight}px` }}>
310
+ {/* Tree panel */}
311
+ <div className={`flex flex-col bg-neutral-900 border border-neutral-700 rounded-lg overflow-hidden ${effectiveFilePath ? 'w-1/3 shrink-0' : 'flex-1'}`}>
312
+ <div className="flex items-center px-3 py-2 border-b border-neutral-700 shrink-0 gap-2 min-w-0">
313
+ <FolderTree className={`w-3.5 h-3.5 shrink-0 ${ACCENT_ICON[accentColor]}`} />
314
+ <span className="text-xs text-neutral-200 truncate flex-1">Files</span>
315
+ <CollapseButton
316
+ collapsed={allCollapsed}
317
+ onToggle={() => setExpandedPaths(allCollapsed ? new Set(allDirPaths) : new Set())}
318
+ />
319
+ </div>
320
+ <div className="flex-1 overflow-y-auto p-3">
321
+ <FileTree
322
+ nodes={files}
323
+ rootName={rootName}
324
+ selectedPath={effectiveFilePath}
325
+ onSelectFile={handleSelectFile}
326
+ expandedPaths={expandedPaths}
327
+ onTogglePath={togglePath}
328
+ accentColor={accentColor}
329
+ />
330
+ </div>
331
+ </div>
332
+
333
+ {/* Preview panel */}
334
+ {effectiveFilePath && (
335
+ <div className="flex-1 flex flex-col bg-neutral-900 border border-neutral-700 rounded-lg overflow-hidden">
336
+ <div className="flex items-center px-3 py-2 border-b border-neutral-700 shrink-0 gap-2 min-w-0">
337
+ <FileCode className={`w-3.5 h-3.5 shrink-0 ${ACCENT_ICON[accentColor]}`} />
338
+ <span className="text-xs text-neutral-200 truncate flex-1">{selectedFileName}</span>
339
+ {showToggle && (
340
+ <SegmentedToggle
341
+ options={toggleOptions}
342
+ value={mode}
343
+ onChange={setMode}
344
+ accentColor={accentColor}
345
+ size="xss"
346
+ />
347
+ )}
348
+ </div>
349
+ <div className="flex-1 overflow-auto">
350
+ {fileIsLoading ? (
351
+ <div className="flex items-center gap-2 text-xs text-neutral-500 p-3">
352
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
353
+ Loading...
354
+ </div>
355
+ ) : fileError ? (
356
+ <p className="text-xs text-red-400 p-3">{fileError}</p>
357
+ ) : fileContent !== null ? (
358
+ renderContent(fileContent, effectiveFilePath)
359
+ ) : null}
360
+ </div>
361
+ </div>
362
+ )}
363
+ </div>
364
+ {/* Resize handle */}
365
+ <div
366
+ onMouseDown={handleResizeStart}
367
+ className="h-4 -mt-1.5 cursor-grab active:cursor-grabbing flex items-center justify-center group"
368
+ >
369
+ <div className="w-10 h-1 rounded-full bg-neutral-700 group-hover:bg-neutral-500 group-hover:w-14 group-hover:h-1.5 transition-all" />
370
+ </div>
371
+ </div>
372
+ )
373
+ }