@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.
- package/README.md +63 -0
- package/components/content/info-panel-primitives.tsx +297 -0
- package/components/diagrams/diagram-utils.tsx +908 -0
- package/components/hooks/use-click-outside.ts +27 -0
- package/components/hooks/use-dropdown-max-height.ts +20 -0
- package/components/hooks/use-navigation-history.ts +94 -0
- package/components/lib/ai-tools.tsx +44 -0
- package/components/lib/cn.ts +6 -0
- package/components/lib/form-colors.ts +32 -0
- package/components/lib/theme-engine.ts +97 -0
- package/components/lib/toolr-brand.tsx +31 -0
- package/components/sections/ai-tools-paths/index.ts +37 -0
- package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
- package/components/sections/ai-tools-paths/types.ts +111 -0
- package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
- package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
- package/components/sections/captured-issues/index.ts +38 -0
- package/components/sections/captured-issues/types.ts +113 -0
- package/components/sections/captured-issues/use-captured-issues.ts +111 -0
- package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
- package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
- package/components/sections/golden-snapshots/index.ts +145 -0
- package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
- package/components/sections/golden-snapshots/status-overview.tsx +305 -0
- package/components/sections/golden-snapshots/types.ts +288 -0
- package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
- package/components/sections/golden-snapshots/version-manager.tsx +186 -0
- package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
- package/components/sections/prompt-editor/index.ts +121 -0
- package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
- package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
- package/components/sections/prompt-editor/types.ts +101 -0
- package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
- package/components/sections/report-bug/error-logger.ts +392 -0
- package/components/sections/report-bug/index.ts +59 -0
- package/components/sections/report-bug/issue-reporter-api.ts +83 -0
- package/components/sections/report-bug/report-bug-form.tsx +282 -0
- package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
- package/components/sections/report-bug/use-report-bug.ts +170 -0
- package/components/sections/snapshot-browser/index.ts +53 -0
- package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
- package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
- package/components/sections/snapshot-browser/types.ts +106 -0
- package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
- package/components/sections/snippets-editor/index.ts +31 -0
- package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
- package/components/sections/snippets-editor/types.ts +48 -0
- package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
- package/components/ui/action-dialog.tsx +309 -0
- package/components/ui/ai-action-button.tsx +137 -0
- package/components/ui/ai-execution-action-buttons.tsx +106 -0
- package/components/ui/badge.tsx +67 -0
- package/components/ui/bottom-panel-header.tsx +240 -0
- package/components/ui/breadcrumb.tsx +168 -0
- package/components/ui/checkbox.tsx +102 -0
- package/components/ui/collapsible-section.tsx +100 -0
- package/components/ui/confirm-badge.tsx +71 -0
- package/components/ui/detail-section.tsx +67 -0
- package/components/ui/detail-view-wrapper.tsx +55 -0
- package/components/ui/editor-placeholder-card.tsx +197 -0
- package/components/ui/editor-toolbar.tsx +123 -0
- package/components/ui/execution-details-panel.tsx +93 -0
- package/components/ui/extension-list-card.tsx +105 -0
- package/components/ui/file-structure-section.tsx +373 -0
- package/components/ui/file-tree.tsx +171 -0
- package/components/ui/files-panel.tsx +251 -0
- package/components/ui/filter-dropdown.tsx +173 -0
- package/components/ui/form-actions.tsx +127 -0
- package/components/ui/frontmatter-form-header.tsx +80 -0
- package/components/ui/icon-button.tsx +388 -0
- package/components/ui/input.tsx +211 -0
- package/components/ui/label.tsx +159 -0
- package/components/ui/layout-tab-bar.tsx +289 -0
- package/components/ui/modal.tsx +194 -0
- package/components/ui/nav-card.tsx +81 -0
- package/components/ui/navigation-bar.tsx +285 -0
- package/components/ui/number-input.tsx +165 -0
- package/components/ui/registry-browser.tsx +261 -0
- package/components/ui/registry-card.tsx +710 -0
- package/components/ui/registry-detail.tsx +224 -0
- package/components/ui/resizable-textarea.tsx +290 -0
- package/components/ui/scope-badge.tsx +67 -0
- package/components/ui/segmented-toggle.tsx +133 -0
- package/components/ui/select.tsx +172 -0
- package/components/ui/selection-grid.tsx +313 -0
- package/components/ui/setting-row.tsx +97 -0
- package/components/ui/snapshot-card.tsx +107 -0
- package/components/ui/snippets-panel.tsx +161 -0
- package/components/ui/sort-dropdown.tsx +109 -0
- package/components/ui/status-card.tsx +96 -0
- package/components/ui/tab-bar.tsx +340 -0
- package/components/ui/toggle.tsx +142 -0
- package/components/ui/tooltip.tsx +326 -0
- package/dist/content.d.ts +110 -0
- package/dist/content.js +195 -0
- package/dist/diagrams.d.ts +371 -0
- package/dist/diagrams.js +702 -0
- package/dist/index.d.ts +2714 -0
- package/dist/index.js +11220 -0
- package/dist/preset.d.ts +24 -0
- package/dist/preset.js +17 -0
- package/dist/tokens/tokens/primitives.css +45 -0
- package/dist/tokens/tokens/semantic.css +46 -0
- package/dist/tokens/tokens/theme.css +11 -0
- package/dist/tokens/tokens/tokens.json +65 -0
- package/index.ts +123 -0
- package/package.json +63 -0
- package/tailwind-preset.ts +22 -0
- package/tokens/primitives.css +45 -0
- package/tokens/semantic.css +46 -0
- package/tokens/theme.css +11 -0
- 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
|
+
}
|