@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,171 @@
|
|
|
1
|
+
import { FileCode, Folder, ChevronRight, ChevronDown } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
export interface FileTreeNode {
|
|
4
|
+
name: string
|
|
5
|
+
type: 'file' | 'directory'
|
|
6
|
+
children?: FileTreeNode[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FileTreeProps {
|
|
10
|
+
nodes: FileTreeNode[]
|
|
11
|
+
rootName?: string
|
|
12
|
+
selectedPath: string | null
|
|
13
|
+
onSelectFile: (path: string) => void
|
|
14
|
+
prefix?: string
|
|
15
|
+
expandedPaths: Set<string>
|
|
16
|
+
onTogglePath: (path: string) => void
|
|
17
|
+
accentColor?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ACCENT_SELECTED: Record<string, string> = {
|
|
21
|
+
blue: 'bg-blue-400/20 text-neutral-200',
|
|
22
|
+
purple: 'bg-purple-400/20 text-neutral-200',
|
|
23
|
+
orange: 'bg-orange-400/20 text-neutral-200',
|
|
24
|
+
green: 'bg-green-400/20 text-neutral-200',
|
|
25
|
+
pink: 'bg-pink-400/20 text-neutral-200',
|
|
26
|
+
amber: 'bg-amber-400/20 text-neutral-200',
|
|
27
|
+
emerald: 'bg-emerald-400/20 text-neutral-200',
|
|
28
|
+
teal: 'bg-teal-400/20 text-neutral-200',
|
|
29
|
+
sky: 'bg-sky-400/20 text-neutral-200',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ACCENT_ICON: Record<string, string> = {
|
|
33
|
+
blue: 'text-blue-400',
|
|
34
|
+
purple: 'text-purple-400',
|
|
35
|
+
orange: 'text-orange-400',
|
|
36
|
+
green: 'text-green-400',
|
|
37
|
+
pink: 'text-pink-400',
|
|
38
|
+
amber: 'text-amber-400',
|
|
39
|
+
emerald: 'text-emerald-400',
|
|
40
|
+
teal: 'text-teal-400',
|
|
41
|
+
sky: 'text-sky-400',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nodeHasFiles(node: FileTreeNode): boolean {
|
|
45
|
+
if (node.type === 'file') return true
|
|
46
|
+
return !!node.children?.some(nodeHasFiles)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Collect all directory paths that would be rendered in the tree */
|
|
50
|
+
export function collectDirPaths(nodes: FileTreeNode[], rootName?: string, prefix = ''): Set<string> {
|
|
51
|
+
const paths = new Set<string>()
|
|
52
|
+
function walk(children: FileTreeNode[], pathPrefix: string) {
|
|
53
|
+
for (const node of children.filter(nodeHasFiles)) {
|
|
54
|
+
if (node.type === 'directory') {
|
|
55
|
+
const path = pathPrefix ? `${pathPrefix}/${node.name}` : node.name
|
|
56
|
+
paths.add(path)
|
|
57
|
+
if (node.children) walk(node.children, path)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (rootName) {
|
|
62
|
+
paths.add(rootName)
|
|
63
|
+
walk(nodes, rootName)
|
|
64
|
+
} else {
|
|
65
|
+
walk(nodes, prefix)
|
|
66
|
+
}
|
|
67
|
+
return paths
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function FileTree({ nodes, rootName, selectedPath, onSelectFile, prefix = '', expandedPaths, onTogglePath, accentColor = 'blue' }: FileTreeProps) {
|
|
71
|
+
if (rootName) {
|
|
72
|
+
const rootNode: FileTreeNode = { name: rootName, type: 'directory', children: nodes }
|
|
73
|
+
return (
|
|
74
|
+
<ul className="space-y-0.5">
|
|
75
|
+
<FileTreeNodeItem
|
|
76
|
+
node={rootNode}
|
|
77
|
+
path={rootName}
|
|
78
|
+
selectedPath={selectedPath}
|
|
79
|
+
onSelectFile={onSelectFile}
|
|
80
|
+
expandedPaths={expandedPaths}
|
|
81
|
+
onTogglePath={onTogglePath}
|
|
82
|
+
accentColor={accentColor}
|
|
83
|
+
/>
|
|
84
|
+
</ul>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<ul className="space-y-0.5">
|
|
90
|
+
{nodes.filter(nodeHasFiles).map((node) => {
|
|
91
|
+
const fullPath = prefix ? `${prefix}/${node.name}` : node.name
|
|
92
|
+
return (
|
|
93
|
+
<FileTreeNodeItem
|
|
94
|
+
key={node.name}
|
|
95
|
+
node={node}
|
|
96
|
+
path={fullPath}
|
|
97
|
+
selectedPath={selectedPath}
|
|
98
|
+
onSelectFile={onSelectFile}
|
|
99
|
+
expandedPaths={expandedPaths}
|
|
100
|
+
onTogglePath={onTogglePath}
|
|
101
|
+
accentColor={accentColor}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
104
|
+
})}
|
|
105
|
+
</ul>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface FileTreeNodeItemProps {
|
|
110
|
+
node: FileTreeNode
|
|
111
|
+
path: string
|
|
112
|
+
selectedPath: string | null
|
|
113
|
+
onSelectFile: (path: string) => void
|
|
114
|
+
expandedPaths: Set<string>
|
|
115
|
+
onTogglePath: (path: string) => void
|
|
116
|
+
accentColor: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function FileTreeNodeItem({ node, path, selectedPath, onSelectFile, expandedPaths, onTogglePath, accentColor }: FileTreeNodeItemProps) {
|
|
120
|
+
const isDir = node.type === 'directory'
|
|
121
|
+
const isSelected = !isDir && selectedPath === path
|
|
122
|
+
const expanded = isDir && expandedPaths.has(path)
|
|
123
|
+
const base = 'flex items-center gap-1.5 py-0.5 px-1 rounded text-xs transition-colors overflow-hidden whitespace-nowrap'
|
|
124
|
+
const selectedClass = ACCENT_SELECTED[accentColor] ?? ACCENT_SELECTED.blue
|
|
125
|
+
const iconColorClass = ACCENT_ICON[accentColor] ?? ACCENT_ICON.blue
|
|
126
|
+
const rowClass = isSelected
|
|
127
|
+
? `${base} ${selectedClass}`
|
|
128
|
+
: isDir
|
|
129
|
+
? `${base} cursor-pointer hover:text-neutral-200 text-neutral-400`
|
|
130
|
+
: `${base} cursor-pointer hover:bg-neutral-700/50 hover:text-neutral-200 text-neutral-400`
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<li>
|
|
134
|
+
<button
|
|
135
|
+
onClick={isDir ? () => onTogglePath(path) : () => onSelectFile(path)}
|
|
136
|
+
className={rowClass}
|
|
137
|
+
>
|
|
138
|
+
{isDir ? (
|
|
139
|
+
expanded ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronRight className="w-3 h-3 shrink-0" />
|
|
140
|
+
) : (
|
|
141
|
+
<span className="w-3" />
|
|
142
|
+
)}
|
|
143
|
+
{isDir ? (
|
|
144
|
+
<Folder className={`w-3.5 h-3.5 shrink-0 ${iconColorClass}`} />
|
|
145
|
+
) : (
|
|
146
|
+
<FileCode className={`w-3.5 h-3.5 shrink-0 ${isSelected ? iconColorClass : 'text-neutral-500'}`} />
|
|
147
|
+
)}
|
|
148
|
+
<span className="truncate">{node.name}</span>
|
|
149
|
+
</button>
|
|
150
|
+
{isDir && expanded && node.children && (
|
|
151
|
+
<ul className="ml-4 space-y-0.5">
|
|
152
|
+
{node.children.filter(nodeHasFiles).map((child) => {
|
|
153
|
+
const childPath = `${path}/${child.name}`
|
|
154
|
+
return (
|
|
155
|
+
<FileTreeNodeItem
|
|
156
|
+
key={child.name}
|
|
157
|
+
node={child}
|
|
158
|
+
path={childPath}
|
|
159
|
+
selectedPath={selectedPath}
|
|
160
|
+
onSelectFile={onSelectFile}
|
|
161
|
+
expandedPaths={expandedPaths}
|
|
162
|
+
onTogglePath={onTogglePath}
|
|
163
|
+
accentColor={accentColor}
|
|
164
|
+
/>
|
|
165
|
+
)
|
|
166
|
+
})}
|
|
167
|
+
</ul>
|
|
168
|
+
)}
|
|
169
|
+
</li>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/** File explorer panel with tree view, folder collapse/expand, search, and file selection. */
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from 'react'
|
|
4
|
+
import { Folder, FolderOpen, File, FileCode, FileText, FileJson, Image, ChevronRight, Search, MoreVertical } from 'lucide-react'
|
|
5
|
+
import type { LucideIcon } from 'lucide-react'
|
|
6
|
+
import type { IconName } from './icon-button.tsx'
|
|
7
|
+
import { cn } from '../lib/cn.ts'
|
|
8
|
+
|
|
9
|
+
const iconSubset: Partial<Record<IconName, LucideIcon>> = {
|
|
10
|
+
folder: Folder,
|
|
11
|
+
file: File,
|
|
12
|
+
code: FileCode,
|
|
13
|
+
image: Image,
|
|
14
|
+
search: Search,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const EXTENSION_ICON_MAP: Record<string, LucideIcon> = {
|
|
18
|
+
ts: FileCode,
|
|
19
|
+
tsx: FileCode,
|
|
20
|
+
js: FileCode,
|
|
21
|
+
jsx: FileCode,
|
|
22
|
+
json: FileJson,
|
|
23
|
+
md: FileText,
|
|
24
|
+
mdx: FileText,
|
|
25
|
+
txt: FileText,
|
|
26
|
+
png: Image,
|
|
27
|
+
jpg: Image,
|
|
28
|
+
jpeg: Image,
|
|
29
|
+
svg: Image,
|
|
30
|
+
gif: Image,
|
|
31
|
+
webp: Image,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FileEntry {
|
|
35
|
+
path: string
|
|
36
|
+
name: string
|
|
37
|
+
type: 'file' | 'folder'
|
|
38
|
+
icon?: IconName
|
|
39
|
+
color?: string
|
|
40
|
+
badge?: string
|
|
41
|
+
children?: FileEntry[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FilesPanelProps {
|
|
45
|
+
files: FileEntry[]
|
|
46
|
+
selectedPath?: string
|
|
47
|
+
onSelect?: (path: string) => void
|
|
48
|
+
onAction?: (action: string, path: string) => void
|
|
49
|
+
showSearch?: boolean
|
|
50
|
+
className?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function collectAllFolderPaths(entries: FileEntry[]): Set<string> {
|
|
54
|
+
const paths = new Set<string>()
|
|
55
|
+
function walk(items: FileEntry[]) {
|
|
56
|
+
for (const entry of items) {
|
|
57
|
+
if (entry.type === 'folder') {
|
|
58
|
+
paths.add(entry.path)
|
|
59
|
+
if (entry.children) walk(entry.children)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
walk(entries)
|
|
64
|
+
return paths
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function countFiles(entries: FileEntry[]): number {
|
|
68
|
+
let count = 0
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (entry.type === 'file') count++
|
|
71
|
+
if (entry.children) count += countFiles(entry.children)
|
|
72
|
+
}
|
|
73
|
+
return count
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getFileIcon(entry: FileEntry): LucideIcon {
|
|
77
|
+
if (entry.icon && iconSubset[entry.icon]) return iconSubset[entry.icon]!
|
|
78
|
+
if (entry.type === 'folder') return Folder
|
|
79
|
+
const ext = entry.name.split('.').pop()?.toLowerCase()
|
|
80
|
+
if (ext && EXTENSION_ICON_MAP[ext]) return EXTENSION_ICON_MAP[ext]
|
|
81
|
+
return File
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getFolderIcon(expanded: boolean, entry: FileEntry): LucideIcon {
|
|
85
|
+
if (entry.icon && iconSubset[entry.icon]) return iconSubset[entry.icon]!
|
|
86
|
+
return expanded ? FolderOpen : Folder
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function filterTree(entries: FileEntry[], query: string): FileEntry[] {
|
|
90
|
+
const q = query.toLowerCase()
|
|
91
|
+
const result: FileEntry[] = []
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (entry.type === 'file') {
|
|
94
|
+
if (entry.name.toLowerCase().includes(q)) result.push(entry)
|
|
95
|
+
} else {
|
|
96
|
+
const filteredChildren = entry.children ? filterTree(entry.children, query) : []
|
|
97
|
+
if (entry.name.toLowerCase().includes(q) || filteredChildren.length > 0) {
|
|
98
|
+
result.push({ ...entry, children: filteredChildren.length > 0 ? filteredChildren : entry.children })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return result
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface FileNodeProps {
|
|
106
|
+
entry: FileEntry
|
|
107
|
+
depth: number
|
|
108
|
+
selectedPath?: string
|
|
109
|
+
expandedPaths: Set<string>
|
|
110
|
+
onToggleExpand: (path: string) => void
|
|
111
|
+
onSelect?: (path: string) => void
|
|
112
|
+
onAction?: (action: string, path: string) => void
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function FileNode({ entry, depth, selectedPath, expandedPaths, onToggleExpand, onSelect, onAction }: FileNodeProps) {
|
|
116
|
+
const isFolder = entry.type === 'folder'
|
|
117
|
+
const isExpanded = isFolder && expandedPaths.has(entry.path)
|
|
118
|
+
const isSelected = !isFolder && selectedPath === entry.path
|
|
119
|
+
const Icon = isFolder ? getFolderIcon(isExpanded, entry) : getFileIcon(entry)
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<li>
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={isFolder ? () => onToggleExpand(entry.path) : () => onSelect?.(entry.path)}
|
|
126
|
+
className={cn(
|
|
127
|
+
'group flex items-center gap-1.5 w-full py-1 px-2 rounded text-xs transition-colors cursor-pointer',
|
|
128
|
+
isSelected
|
|
129
|
+
? 'bg-blue-400/15 text-blue-400'
|
|
130
|
+
: 'text-neutral-400 hover:bg-neutral-700/40 hover:text-neutral-200',
|
|
131
|
+
)}
|
|
132
|
+
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
133
|
+
>
|
|
134
|
+
{isFolder ? (
|
|
135
|
+
<ChevronRight
|
|
136
|
+
className={cn('w-3 h-3 shrink-0 transition-transform', isExpanded && 'rotate-90')}
|
|
137
|
+
/>
|
|
138
|
+
) : (
|
|
139
|
+
<span className="w-3 shrink-0" />
|
|
140
|
+
)}
|
|
141
|
+
<Icon
|
|
142
|
+
className="w-3.5 h-3.5 shrink-0"
|
|
143
|
+
style={entry.color ? { color: entry.color } : undefined}
|
|
144
|
+
/>
|
|
145
|
+
<span className="truncate">{entry.name}</span>
|
|
146
|
+
{entry.badge && (
|
|
147
|
+
<span className="ml-auto shrink-0 px-1.5 py-0.5 text-[9px] rounded bg-neutral-700 text-neutral-500">
|
|
148
|
+
{entry.badge}
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
151
|
+
{onAction && (
|
|
152
|
+
<span
|
|
153
|
+
role="button"
|
|
154
|
+
tabIndex={-1}
|
|
155
|
+
className="ml-auto shrink-0 opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-neutral-700 text-neutral-500 hover:text-neutral-200 transition-all"
|
|
156
|
+
onClick={(e) => { e.stopPropagation(); onAction('menu', entry.path) }}
|
|
157
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onAction('menu', entry.path) } }}
|
|
158
|
+
>
|
|
159
|
+
<MoreVertical className="w-3 h-3" />
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
</button>
|
|
163
|
+
{isFolder && isExpanded && entry.children && (
|
|
164
|
+
<ul>
|
|
165
|
+
{entry.children.map((child) => (
|
|
166
|
+
<FileNode
|
|
167
|
+
key={child.path}
|
|
168
|
+
entry={child}
|
|
169
|
+
depth={depth + 1}
|
|
170
|
+
selectedPath={selectedPath}
|
|
171
|
+
expandedPaths={expandedPaths}
|
|
172
|
+
onToggleExpand={onToggleExpand}
|
|
173
|
+
onSelect={onSelect}
|
|
174
|
+
onAction={onAction}
|
|
175
|
+
/>
|
|
176
|
+
))}
|
|
177
|
+
</ul>
|
|
178
|
+
)}
|
|
179
|
+
</li>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function FilesPanel({
|
|
184
|
+
files,
|
|
185
|
+
selectedPath,
|
|
186
|
+
onSelect,
|
|
187
|
+
onAction,
|
|
188
|
+
showSearch = false,
|
|
189
|
+
className,
|
|
190
|
+
}: FilesPanelProps) {
|
|
191
|
+
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => collectAllFolderPaths(files))
|
|
192
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
193
|
+
|
|
194
|
+
const fileCount = useMemo(() => countFiles(files), [files])
|
|
195
|
+
|
|
196
|
+
const displayedFiles = useMemo(
|
|
197
|
+
() => searchQuery ? filterTree(files, searchQuery) : files,
|
|
198
|
+
[files, searchQuery],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
function handleToggleExpand(path: string) {
|
|
202
|
+
setExpandedPaths((prev: Set<string>) => {
|
|
203
|
+
const next = new Set(prev)
|
|
204
|
+
if (next.has(path)) next.delete(path)
|
|
205
|
+
else next.add(path)
|
|
206
|
+
return next
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div className={cn('flex flex-col bg-neutral-800 rounded-lg overflow-hidden', className)}>
|
|
212
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-700">
|
|
213
|
+
<span className="text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Files</span>
|
|
214
|
+
<span className="text-[10px] text-neutral-500">{fileCount} files</span>
|
|
215
|
+
</div>
|
|
216
|
+
{showSearch && (
|
|
217
|
+
<div className="px-2 py-2 border-b border-neutral-700">
|
|
218
|
+
<div className="flex items-center gap-1.5 px-2 py-1 bg-black border border-neutral-700 rounded text-xs">
|
|
219
|
+
<Search className="w-3 h-3 text-neutral-500 shrink-0" />
|
|
220
|
+
<input
|
|
221
|
+
type="text"
|
|
222
|
+
placeholder="Search files..."
|
|
223
|
+
value={searchQuery}
|
|
224
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
225
|
+
className="flex-1 bg-transparent text-neutral-200 placeholder-neutral-500 outline-none text-xs"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
<div className="flex-1 overflow-y-auto py-1">
|
|
231
|
+
<ul>
|
|
232
|
+
{displayedFiles.map((entry: FileEntry) => (
|
|
233
|
+
<FileNode
|
|
234
|
+
key={entry.path}
|
|
235
|
+
entry={entry}
|
|
236
|
+
depth={0}
|
|
237
|
+
selectedPath={selectedPath}
|
|
238
|
+
expandedPaths={expandedPaths}
|
|
239
|
+
onToggleExpand={handleToggleExpand}
|
|
240
|
+
onSelect={onSelect}
|
|
241
|
+
onAction={onAction}
|
|
242
|
+
/>
|
|
243
|
+
))}
|
|
244
|
+
</ul>
|
|
245
|
+
{displayedFiles.length === 0 && (
|
|
246
|
+
<p className="text-[11px] text-neutral-500 text-center py-4">No files found</p>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
+
import { ChevronDown, Check, X, Search, Filter } from 'lucide-react'
|
|
3
|
+
import { useClickOutside } from '../hooks/use-click-outside.ts'
|
|
4
|
+
import { useDropdownMaxHeight } from '../hooks/use-dropdown-max-height.ts'
|
|
5
|
+
import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
|
|
6
|
+
|
|
7
|
+
const SEARCH_THRESHOLD = 20
|
|
8
|
+
|
|
9
|
+
const VARIANT_CLASSES = {
|
|
10
|
+
filled: { bg: 'bg-neutral-800', hoverBg: 'hover:bg-neutral-700' },
|
|
11
|
+
outline: { bg: 'bg-transparent', hoverBg: 'hover:bg-neutral-800' },
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FilterDropdownProps {
|
|
15
|
+
value: string
|
|
16
|
+
onChange: (value: string) => void
|
|
17
|
+
options: { value: string; label: string }[]
|
|
18
|
+
allLabel: string
|
|
19
|
+
labelExtra?: ReactNode
|
|
20
|
+
clearable?: boolean
|
|
21
|
+
variant?: 'filled' | 'outline'
|
|
22
|
+
color?: FormColor
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function FilterDropdown({
|
|
26
|
+
value,
|
|
27
|
+
onChange,
|
|
28
|
+
options,
|
|
29
|
+
allLabel,
|
|
30
|
+
labelExtra,
|
|
31
|
+
clearable = true,
|
|
32
|
+
variant = 'outline',
|
|
33
|
+
color = 'blue',
|
|
34
|
+
}: FilterDropdownProps) {
|
|
35
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
36
|
+
const [search, setSearch] = useState('')
|
|
37
|
+
const [highlightIdx, setHighlightIdx] = useState(-1)
|
|
38
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
39
|
+
const menuRef = useDropdownMaxHeight<HTMLDivElement>(isOpen)
|
|
40
|
+
const searchRef = useRef<HTMLInputElement>(null)
|
|
41
|
+
const isActive = value !== 'all'
|
|
42
|
+
const showSearch = options.length > SEARCH_THRESHOLD
|
|
43
|
+
const v = VARIANT_CLASSES[variant]
|
|
44
|
+
|
|
45
|
+
useClickOutside(ref, isOpen, () => setIsOpen(false))
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!isOpen) { setSearch(''); setHighlightIdx(-1) }
|
|
49
|
+
else if (showSearch) requestAnimationFrame(() => searchRef.current?.focus())
|
|
50
|
+
}, [isOpen, showSearch])
|
|
51
|
+
|
|
52
|
+
useEffect(() => { setHighlightIdx(-1) }, [search])
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (highlightIdx >= 0 && menuRef.current) {
|
|
56
|
+
menuRef.current.querySelector<HTMLElement>(`[data-idx="${highlightIdx}"]`)?.scrollIntoView({ block: 'nearest' })
|
|
57
|
+
}
|
|
58
|
+
}, [highlightIdx, menuRef])
|
|
59
|
+
|
|
60
|
+
const selectedLabel = isActive ? (options.find((o) => o.value === value)?.label || value) : allLabel
|
|
61
|
+
const filtered = showSearch && search
|
|
62
|
+
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
|
|
63
|
+
: options
|
|
64
|
+
|
|
65
|
+
const hasAllOption = clearable && !search
|
|
66
|
+
const itemCount = filtered.length + (hasAllOption ? 1 : 0)
|
|
67
|
+
|
|
68
|
+
const handleSelect = (val: string) => { onChange(val); setIsOpen(false) }
|
|
69
|
+
|
|
70
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
71
|
+
if (!isOpen) return
|
|
72
|
+
if (e.key === 'ArrowDown') {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
setHighlightIdx((i) => Math.min(i + 1, itemCount - 1))
|
|
75
|
+
} else if (e.key === 'ArrowUp') {
|
|
76
|
+
e.preventDefault()
|
|
77
|
+
setHighlightIdx((i) => Math.max(i - 1, 0))
|
|
78
|
+
} else if (e.key === 'Enter' && highlightIdx >= 0) {
|
|
79
|
+
e.preventDefault()
|
|
80
|
+
const offset = hasAllOption ? 1 : 0
|
|
81
|
+
handleSelect(hasAllOption && highlightIdx === 0 ? 'all' : filtered[highlightIdx - offset].value)
|
|
82
|
+
} else if (e.key === 'Escape') {
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
setIsOpen(false)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="relative flex items-center" ref={ref} onKeyDown={handleKeyDown}>
|
|
90
|
+
<button
|
|
91
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
92
|
+
className={`flex items-center gap-1.5 h-7 px-2 rounded-md border ${v.bg} text-xs transition-colors cursor-pointer ${
|
|
93
|
+
isActive
|
|
94
|
+
? `${clearable ? 'rounded-r-none border-r-0' : ''} ${FORM_COLORS[color].border} text-neutral-200 ${FORM_COLORS[color].hover}`
|
|
95
|
+
: isOpen
|
|
96
|
+
? `${FORM_COLORS[color].border} text-neutral-200`
|
|
97
|
+
: `${FORM_COLORS[color].border} text-neutral-400 ${FORM_COLORS[color].hover} hover:text-neutral-200`
|
|
98
|
+
}`}
|
|
99
|
+
>
|
|
100
|
+
<Filter className={`w-3 h-3 ${isActive ? FORM_COLORS[color].accent : ''}`} />
|
|
101
|
+
{labelExtra}
|
|
102
|
+
<span className="whitespace-nowrap">{selectedLabel}</span>
|
|
103
|
+
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
104
|
+
</button>
|
|
105
|
+
{isActive && clearable && (
|
|
106
|
+
<button
|
|
107
|
+
onClick={() => onChange('all')}
|
|
108
|
+
className={`flex items-center justify-center h-7 px-1.5 rounded-r-md border border-l-0 ${FORM_COLORS[color].border} ${v.bg} text-neutral-400 ${FORM_COLORS[color].hover} hover:text-neutral-200 transition-colors cursor-pointer`}
|
|
109
|
+
>
|
|
110
|
+
<X className="w-3 h-3" />
|
|
111
|
+
</button>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{isOpen && (
|
|
115
|
+
<div ref={menuRef} className={`absolute right-0 top-full z-50 mt-1 min-w-[140px] whitespace-nowrap ${v.bg} border ${FORM_COLORS[color].border} rounded-lg shadow-xl overflow-hidden`}>
|
|
116
|
+
{showSearch && (
|
|
117
|
+
<div className={`sticky top-0 p-1.5 ${v.bg} border-b ${FORM_COLORS[color].border} z-10`}>
|
|
118
|
+
<div className="relative">
|
|
119
|
+
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-neutral-500" />
|
|
120
|
+
<input
|
|
121
|
+
ref={searchRef}
|
|
122
|
+
type="text"
|
|
123
|
+
value={search}
|
|
124
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
125
|
+
onKeyDown={handleKeyDown}
|
|
126
|
+
placeholder="Search..."
|
|
127
|
+
className={`w-full pl-6 pr-2 py-1 text-xs bg-neutral-800 border border-neutral-600 rounded text-neutral-200 placeholder-neutral-500 outline-none ${FORM_COLORS[color].focus}`}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
{hasAllOption && (
|
|
133
|
+
<button
|
|
134
|
+
data-idx={0}
|
|
135
|
+
onClick={() => handleSelect('all')}
|
|
136
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer ${
|
|
137
|
+
highlightIdx === 0
|
|
138
|
+
? 'bg-neutral-600 text-neutral-200'
|
|
139
|
+
: !isActive ? `${FORM_COLORS[color].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
|
|
140
|
+
}`}
|
|
141
|
+
>
|
|
142
|
+
<Check className={`w-3 h-3 shrink-0 ${!isActive ? FORM_COLORS[color].accent : 'invisible'}`} />
|
|
143
|
+
<span>All</span>
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
146
|
+
{filtered.map((opt, i) => {
|
|
147
|
+
const idx = i + (hasAllOption ? 1 : 0)
|
|
148
|
+
const isHighlighted = highlightIdx === idx
|
|
149
|
+
const isSelected = value === opt.value
|
|
150
|
+
return (
|
|
151
|
+
<button
|
|
152
|
+
key={opt.value}
|
|
153
|
+
data-idx={idx}
|
|
154
|
+
onClick={() => handleSelect(opt.value)}
|
|
155
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer ${
|
|
156
|
+
isHighlighted
|
|
157
|
+
? 'bg-neutral-600 text-neutral-200'
|
|
158
|
+
: isSelected ? `${FORM_COLORS[color].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
|
|
159
|
+
}`}
|
|
160
|
+
>
|
|
161
|
+
<Check className={`w-3 h-3 shrink-0 ${isSelected ? FORM_COLORS[color].accent : 'invisible'}`} />
|
|
162
|
+
<span>{opt.label}</span>
|
|
163
|
+
</button>
|
|
164
|
+
)
|
|
165
|
+
})}
|
|
166
|
+
{showSearch && search && filtered.length === 0 && (
|
|
167
|
+
<div className="px-3 py-2 text-xs text-neutral-500">No matches</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|