@toolr/ui-design 0.1.8 → 0.1.9
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/ai-manifest.json +35 -20
- package/components/composites/dashboard-list-item.tsx +172 -0
- package/components/composites/dashboard-panel.tsx +218 -0
- package/components/content/info-panel-primitives.tsx +9 -8
- package/components/diagrams/diagram-utils.tsx +2 -1
- package/components/hooks/use-dropdown-portal.ts +39 -0
- package/components/lib/accent-context.ts +10 -0
- package/components/lib/{ai-tools.tsx → coding-agents.tsx} +23 -8
- package/components/lib/custom-icons.tsx +37 -0
- package/components/lib/git-providers.tsx +39 -0
- package/components/lib/theme-engine.ts +59 -10
- package/components/lib/toolr-brand.tsx +23 -9
- package/components/sections/captured-issues/captured-issues-panel.tsx +17 -8
- package/components/sections/{ai-tools-paths/tools-paths-panel.tsx → coding-agent-paths/agent-paths-panel.tsx} +70 -62
- package/components/sections/coding-agent-paths/index.ts +37 -0
- package/components/sections/{ai-tools-paths → coding-agent-paths}/types.ts +28 -28
- package/components/sections/coding-agent-paths/use-agent-paths.ts +159 -0
- package/components/sections/golden-snapshots/file-diff-viewer.tsx +10 -9
- package/components/sections/golden-snapshots/golden-sync-panel.tsx +12 -3
- package/components/sections/golden-snapshots/snapshot-manager.tsx +9 -7
- package/components/sections/golden-snapshots/status-overview.tsx +8 -8
- package/components/sections/golden-snapshots/version-manager.tsx +6 -6
- package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +3 -3
- package/components/sections/prompt-editor/index.ts +1 -1
- package/components/sections/prompt-editor/simulator-prompt-editor.tsx +13 -5
- package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +18 -10
- package/components/sections/prompt-editor/types.ts +2 -2
- package/components/sections/report-bug/report-bug-form.tsx +12 -4
- package/components/sections/report-bug/screenshot-uploader.tsx +11 -3
- package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +12 -4
- package/components/sections/snapshot-browser/snapshot-tree.tsx +5 -4
- package/components/sections/snapshot-browser/types.ts +1 -1
- package/components/sections/snippets-editor/snippets-editor.tsx +16 -9
- package/components/settings/SettingsHeader.tsx +2 -2
- package/components/settings/SettingsPanel.tsx +11 -3
- package/components/settings/SettingsTreeNav.tsx +15 -9
- package/components/ui/action-dialog.tsx +24 -30
- package/components/ui/ai-action-button.tsx +10 -7
- package/components/ui/ai-execution-action-buttons.tsx +13 -5
- package/components/ui/badge.tsx +7 -4
- package/components/ui/bottom-panel-header.tsx +9 -5
- package/components/ui/breadcrumb.tsx +9 -1
- package/components/ui/{extension-list-card.tsx → capability-list-card.tsx} +13 -5
- package/components/ui/checkbox.tsx +6 -3
- package/components/ui/collapsible-section.tsx +38 -29
- package/components/ui/confirm-badge.tsx +7 -4
- package/components/ui/cookie-consent.tsx +13 -7
- package/components/ui/detail-section.tsx +24 -16
- package/components/ui/detail-view-wrapper.tsx +30 -22
- package/components/ui/editor-placeholder-card.tsx +28 -24
- package/components/ui/editor-toolbar.tsx +7 -4
- package/components/ui/execution-details-panel.tsx +10 -5
- package/components/ui/file-structure-section.tsx +3 -3
- package/components/ui/file-tree.tsx +3 -1
- package/components/ui/files-panel.tsx +147 -27
- package/components/ui/filter-dropdown.tsx +84 -74
- package/components/ui/form-actions.tsx +14 -6
- package/components/ui/frontmatter-form-header.tsx +10 -2
- package/components/ui/icon-button.tsx +22 -9
- package/components/ui/input.tsx +7 -4
- package/components/ui/label.tsx +5 -5
- package/components/ui/layout-tab-bar.tsx +7 -5
- package/components/ui/modal.tsx +18 -4
- package/components/ui/nav-card.tsx +6 -3
- package/components/ui/navigation-bar.tsx +37 -9
- package/components/ui/number-input.tsx +8 -4
- package/components/ui/project-explorer.tsx +666 -0
- package/components/ui/registry-browser.tsx +12 -1
- package/components/ui/registry-card.tsx +49 -42
- package/components/ui/registry-detail.tsx +34 -11
- package/components/ui/resizable-textarea.tsx +18 -11
- package/components/ui/scope-badge.tsx +18 -11
- package/components/ui/segmented-toggle.tsx +5 -2
- package/components/ui/select.tsx +12 -9
- package/components/ui/selection-grid.tsx +36 -37
- package/components/ui/setting-row.tsx +2 -2
- package/components/ui/settings-card.tsx +10 -3
- package/components/ui/settings-info-box.tsx +9 -5
- package/components/ui/settings-section-title.tsx +14 -2
- package/components/ui/snapshot-card.tsx +10 -2
- package/components/ui/snippets-panel.tsx +4 -2
- package/components/ui/sort-dropdown.tsx +39 -29
- package/components/ui/status-card.tsx +9 -1
- package/components/ui/tab-bar.tsx +12 -9
- package/components/ui/toggle.tsx +13 -7
- package/components/ui/tooltip.tsx +9 -1
- package/dist/content.js +8 -8
- package/dist/diagrams.d.ts +0 -1
- package/dist/index.d.ts +421 -182
- package/dist/index.js +2984 -1691
- package/dist/tokens/primitives.css +28 -6
- package/dist/tokens/semantic.css +15 -15
- package/dist/tokens/theme.css +23 -0
- package/index.ts +25 -11
- package/package.json +1 -1
- package/tokens/primitives.css +28 -6
- package/tokens/semantic.css +15 -15
- package/tokens/theme.css +23 -0
- package/components/sections/ai-tools-paths/index.ts +0 -37
- package/components/sections/ai-tools-paths/use-tools-paths.ts +0 -159
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import React, { type ReactNode, memo, useState, useCallback } from 'react'
|
|
2
|
+
import { IconButton } from './icon-button.tsx'
|
|
3
|
+
import { Input } from './input.tsx'
|
|
4
|
+
import { Tooltip } from './tooltip.tsx'
|
|
5
|
+
import { iconMap } from './icon-button.tsx'
|
|
6
|
+
import { useAccentColor } from '../lib/accent-context.ts'
|
|
7
|
+
import type { FormColor } from '../lib/form-colors.ts'
|
|
8
|
+
import { FolderOpen as FolderOpenIcon } from 'lucide-react'
|
|
9
|
+
|
|
10
|
+
const SearchIcon = iconMap['search']
|
|
11
|
+
const XIcon = iconMap['x']
|
|
12
|
+
const FolderIcon = iconMap['folder']
|
|
13
|
+
const ChevronRightIcon = iconMap['chevron-right']
|
|
14
|
+
const ChevronDownIcon = iconMap['chevron-down']
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface ExplorerNode {
|
|
21
|
+
name: string
|
|
22
|
+
path: string
|
|
23
|
+
children: ExplorerNode[]
|
|
24
|
+
project?: { id: string; name: string; path: string; [key: string]: unknown }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ExplorerTopItem {
|
|
28
|
+
id: string
|
|
29
|
+
icon: ReactNode
|
|
30
|
+
label: string
|
|
31
|
+
selected: boolean
|
|
32
|
+
selectedClass: string
|
|
33
|
+
onClick: () => void
|
|
34
|
+
actions?: ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ExplorerContextMenuInfo {
|
|
38
|
+
x: number
|
|
39
|
+
y: number
|
|
40
|
+
project: ExplorerNode['project'] | null
|
|
41
|
+
nodeName: string
|
|
42
|
+
nodePath: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ProjectExplorerProps {
|
|
46
|
+
tree: ExplorerNode[]
|
|
47
|
+
selectedProjectId: string | null
|
|
48
|
+
onSelectProject: (id: string) => void
|
|
49
|
+
|
|
50
|
+
topItems?: ExplorerTopItem[]
|
|
51
|
+
|
|
52
|
+
searchQuery: string
|
|
53
|
+
onSearchChange: (query: string) => void
|
|
54
|
+
|
|
55
|
+
collapsedPaths: string[]
|
|
56
|
+
onTogglePath: (path: string) => void
|
|
57
|
+
expandablePaths: string[]
|
|
58
|
+
onExpandAll: () => void
|
|
59
|
+
onCollapseAll: () => void
|
|
60
|
+
|
|
61
|
+
width: number
|
|
62
|
+
onWidthChange: (width: number) => void
|
|
63
|
+
collapsed: boolean
|
|
64
|
+
onToggleCollapsed: () => void
|
|
65
|
+
|
|
66
|
+
onAddProject: () => void
|
|
67
|
+
isAddingProject?: boolean
|
|
68
|
+
onScanProjects?: () => void
|
|
69
|
+
|
|
70
|
+
onMiddleClick?: (project: ExplorerNode['project']) => void
|
|
71
|
+
renderProjectActions?: (project: ExplorerNode['project']) => ReactNode
|
|
72
|
+
renderContextMenu?: (info: ExplorerContextMenuInfo, onClose: () => void) => ReactNode
|
|
73
|
+
isProjectHighlighted?: (project: ExplorerNode['project']) => boolean
|
|
74
|
+
|
|
75
|
+
accentColor?: FormColor
|
|
76
|
+
emptyMessage?: string
|
|
77
|
+
noMatchMessage?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Accent color class maps
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
const COLLAPSED_BUTTON: Record<string, string> = {
|
|
85
|
+
blue: 'text-blue-500', green: 'text-green-500', red: 'text-red-500', orange: 'text-orange-500',
|
|
86
|
+
cyan: 'text-cyan-500', yellow: 'text-yellow-500', purple: 'text-purple-500', indigo: 'text-indigo-500',
|
|
87
|
+
emerald: 'text-emerald-500', amber: 'text-amber-500', violet: 'text-violet-500', neutral: 'text-neutral-500',
|
|
88
|
+
sky: 'text-sky-500', pink: 'text-pink-500', teal: 'text-teal-500',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const RESIZE_HOVER: Record<string, string> = {
|
|
92
|
+
blue: 'hover:bg-blue-500', green: 'hover:bg-green-500', red: 'hover:bg-red-500', orange: 'hover:bg-orange-500',
|
|
93
|
+
cyan: 'hover:bg-cyan-500', yellow: 'hover:bg-yellow-500', purple: 'hover:bg-purple-500', indigo: 'hover:bg-indigo-500',
|
|
94
|
+
emerald: 'hover:bg-emerald-500', amber: 'hover:bg-amber-500', violet: 'hover:bg-violet-500', neutral: 'hover:bg-neutral-500',
|
|
95
|
+
sky: 'hover:bg-sky-500', pink: 'hover:bg-pink-500', teal: 'hover:bg-teal-500',
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const RESIZE_ACTIVE: Record<string, string> = {
|
|
99
|
+
blue: 'bg-blue-500', green: 'bg-green-500', red: 'bg-red-500', orange: 'bg-orange-500',
|
|
100
|
+
cyan: 'bg-cyan-500', yellow: 'bg-yellow-500', purple: 'bg-purple-500', indigo: 'bg-indigo-500',
|
|
101
|
+
emerald: 'bg-emerald-500', amber: 'bg-amber-500', violet: 'bg-violet-500', neutral: 'bg-neutral-500',
|
|
102
|
+
sky: 'bg-sky-500', pink: 'bg-pink-500', teal: 'bg-teal-500',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const RESIZE_IDLE_HOVER: Record<string, string> = {
|
|
106
|
+
blue: 'hover:bg-blue-500/50', green: 'hover:bg-green-500/50', red: 'hover:bg-red-500/50', orange: 'hover:bg-orange-500/50',
|
|
107
|
+
cyan: 'hover:bg-cyan-500/50', yellow: 'hover:bg-yellow-500/50', purple: 'hover:bg-purple-500/50', indigo: 'hover:bg-indigo-500/50',
|
|
108
|
+
emerald: 'hover:bg-emerald-500/50', amber: 'hover:bg-amber-500/50', violet: 'hover:bg-violet-500/50', neutral: 'hover:bg-neutral-500/50',
|
|
109
|
+
sky: 'hover:bg-sky-500/50', pink: 'hover:bg-pink-500/50', teal: 'hover:bg-teal-500/50',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const SELECTION: Record<string, string> = {
|
|
113
|
+
blue: 'bg-blue-500/20 text-white ring-1 ring-blue-500/30', green: 'bg-green-500/20 text-white ring-1 ring-green-500/30',
|
|
114
|
+
red: 'bg-red-500/20 text-white ring-1 ring-red-500/30', orange: 'bg-orange-500/20 text-white ring-1 ring-orange-500/30',
|
|
115
|
+
cyan: 'bg-cyan-500/20 text-white ring-1 ring-cyan-500/30', yellow: 'bg-yellow-500/20 text-white ring-1 ring-yellow-500/30',
|
|
116
|
+
purple: 'bg-purple-500/20 text-white ring-1 ring-purple-500/30', indigo: 'bg-indigo-500/20 text-white ring-1 ring-indigo-500/30',
|
|
117
|
+
emerald: 'bg-emerald-500/20 text-white ring-1 ring-emerald-500/30', amber: 'bg-amber-500/20 text-white ring-1 ring-amber-500/30',
|
|
118
|
+
violet: 'bg-violet-500/20 text-white ring-1 ring-violet-500/30', neutral: 'bg-neutral-500/20 text-white ring-1 ring-neutral-500/30',
|
|
119
|
+
sky: 'bg-sky-500/20 text-white ring-1 ring-sky-500/30', pink: 'bg-pink-500/20 text-white ring-1 ring-pink-500/30',
|
|
120
|
+
teal: 'bg-teal-500/20 text-white ring-1 ring-teal-500/30',
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const FOLDER_ACCENT: Record<string, string> = {
|
|
124
|
+
blue: 'text-blue-500', green: 'text-green-500', red: 'text-red-500', orange: 'text-orange-500',
|
|
125
|
+
cyan: 'text-cyan-500', yellow: 'text-yellow-500', purple: 'text-purple-500', indigo: 'text-indigo-500',
|
|
126
|
+
emerald: 'text-emerald-500', amber: 'text-amber-500', violet: 'text-violet-500', neutral: 'text-neutral-500',
|
|
127
|
+
sky: 'text-sky-500', pink: 'text-pink-500', teal: 'text-teal-500',
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const PANEL_BG = ''
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Tree utilities
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function findCommonPrefixLength(paths: string[][]): number {
|
|
137
|
+
if (paths.length === 0) return 0
|
|
138
|
+
const first = paths[0]
|
|
139
|
+
let len = 0
|
|
140
|
+
outer: for (let i = 0; i < first.length; i++) {
|
|
141
|
+
for (const p of paths) {
|
|
142
|
+
if (p[i] !== first[i]) break outer
|
|
143
|
+
}
|
|
144
|
+
len = i + 1
|
|
145
|
+
}
|
|
146
|
+
return len
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sortTreeNodes(nodes: ExplorerNode[]): ExplorerNode[] {
|
|
150
|
+
return nodes
|
|
151
|
+
.map((n) => ({ ...n, children: sortTreeNodes(n.children) }))
|
|
152
|
+
.sort((a, b) => {
|
|
153
|
+
const aFolder = a.children.length > 0 && !a.project
|
|
154
|
+
const bFolder = b.children.length > 0 && !b.project
|
|
155
|
+
if (aFolder && !bFolder) return -1
|
|
156
|
+
if (!aFolder && bFolder) return 1
|
|
157
|
+
return a.name.localeCompare(b.name)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function insertIntoTree<P extends { id: string; path: string }>(
|
|
162
|
+
root: ExplorerNode[],
|
|
163
|
+
project: P,
|
|
164
|
+
commonPrefixLength: number,
|
|
165
|
+
): void {
|
|
166
|
+
const parts = project.path.split('/').filter(Boolean)
|
|
167
|
+
const relevant = parts.slice(commonPrefixLength)
|
|
168
|
+
const prefixPath = parts.slice(0, commonPrefixLength).join('/')
|
|
169
|
+
let currentPath = prefixPath ? '/' + prefixPath : ''
|
|
170
|
+
let level = root
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < relevant.length; i++) {
|
|
173
|
+
const part = relevant[i]
|
|
174
|
+
currentPath = currentPath + '/' + part
|
|
175
|
+
const isLast = i === relevant.length - 1
|
|
176
|
+
|
|
177
|
+
let existing = level.find((n) => n.name === part)
|
|
178
|
+
if (!existing) {
|
|
179
|
+
existing = {
|
|
180
|
+
name: part,
|
|
181
|
+
path: currentPath,
|
|
182
|
+
children: [],
|
|
183
|
+
project: isLast ? (project as unknown as ExplorerNode['project']) : undefined,
|
|
184
|
+
}
|
|
185
|
+
level.push(existing)
|
|
186
|
+
}
|
|
187
|
+
if (isLast) {
|
|
188
|
+
existing.project = project as unknown as ExplorerNode['project']
|
|
189
|
+
}
|
|
190
|
+
level = existing.children
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function buildExplorerTree<P extends { id: string; path: string }>(
|
|
195
|
+
projects: P[],
|
|
196
|
+
isSpecial?: (p: P) => boolean,
|
|
197
|
+
): ExplorerNode[] {
|
|
198
|
+
const regular = isSpecial ? projects.filter((p) => !isSpecial(p)) : projects
|
|
199
|
+
const paths = regular.map((p) => p.path.split('/').filter(Boolean))
|
|
200
|
+
if (paths.length === 0) return []
|
|
201
|
+
|
|
202
|
+
const commonLen = findCommonPrefixLength(paths)
|
|
203
|
+
const root: ExplorerNode[] = []
|
|
204
|
+
for (const project of regular) {
|
|
205
|
+
insertIntoTree(root, project, commonLen)
|
|
206
|
+
}
|
|
207
|
+
return sortTreeNodes(root)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function filterExplorerTree(nodes: ExplorerNode[], query: string): ExplorerNode[] {
|
|
211
|
+
const lq = query.toLowerCase()
|
|
212
|
+
return nodes.reduce<ExplorerNode[]>((acc, node) => {
|
|
213
|
+
if (node.name.toLowerCase().includes(lq) || node.path.toLowerCase().includes(lq)) {
|
|
214
|
+
acc.push(node)
|
|
215
|
+
} else {
|
|
216
|
+
const filtered = filterExplorerTree(node.children, query)
|
|
217
|
+
if (filtered.length > 0) acc.push({ ...node, children: filtered })
|
|
218
|
+
}
|
|
219
|
+
return acc
|
|
220
|
+
}, [])
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function collectExplorerExpandablePaths(nodes: ExplorerNode[]): string[] {
|
|
224
|
+
const paths: string[] = []
|
|
225
|
+
for (const node of nodes) {
|
|
226
|
+
if (node.children.length > 0) {
|
|
227
|
+
paths.push(node.path)
|
|
228
|
+
paths.push(...collectExplorerExpandablePaths(node.children))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return paths
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function findPathToNode(nodes: ExplorerNode[], projectId: string): string[] | null {
|
|
235
|
+
for (const node of nodes) {
|
|
236
|
+
if (node.project?.id === projectId) return []
|
|
237
|
+
if (node.children.length === 0) continue
|
|
238
|
+
const child = findPathToNode(node.children, projectId)
|
|
239
|
+
if (child !== null) return [node.path, ...child]
|
|
240
|
+
}
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Internal: highlight match
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
function highlightMatch(text: string, query: string): ReactNode {
|
|
249
|
+
if (!query) return text
|
|
250
|
+
const idx = text.toLowerCase().indexOf(query.toLowerCase())
|
|
251
|
+
if (idx === -1) return text
|
|
252
|
+
return (
|
|
253
|
+
<>
|
|
254
|
+
{text.slice(0, idx)}
|
|
255
|
+
<span className="bg-yellow-500/30 text-yellow-200">{text.slice(idx, idx + query.length)}</span>
|
|
256
|
+
{text.slice(idx + query.length)}
|
|
257
|
+
</>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Internal: ExplorerTreeNodeRow
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
interface TreeNodeProps {
|
|
266
|
+
node: ExplorerNode
|
|
267
|
+
depth: number
|
|
268
|
+
selectedProjectId: string | null
|
|
269
|
+
collapsedPaths: string[]
|
|
270
|
+
onSelectProject: (id: string) => void
|
|
271
|
+
onToggle: (path: string) => void
|
|
272
|
+
onMiddleClick?: (project: ExplorerNode['project']) => void
|
|
273
|
+
onContextMenu?: (info: ExplorerContextMenuInfo) => void
|
|
274
|
+
renderProjectActions?: (project: ExplorerNode['project']) => ReactNode
|
|
275
|
+
isProjectHighlighted?: (project: ExplorerNode['project']) => boolean
|
|
276
|
+
searchQuery?: string
|
|
277
|
+
accentColor: string
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const ExplorerTreeNodeRow = memo(function ExplorerTreeNodeRow({
|
|
281
|
+
node, depth, selectedProjectId, collapsedPaths,
|
|
282
|
+
onSelectProject, onToggle, onMiddleClick, onContextMenu,
|
|
283
|
+
renderProjectActions, isProjectHighlighted, searchQuery, accentColor,
|
|
284
|
+
}: TreeNodeProps) {
|
|
285
|
+
const hasChildren = node.children.length > 0
|
|
286
|
+
const isProject = !!node.project
|
|
287
|
+
const isCollapsed = collapsedPaths.includes(node.path)
|
|
288
|
+
const isSelected = isProject && node.project?.id === selectedProjectId
|
|
289
|
+
|
|
290
|
+
const handleClick = useCallback(() => {
|
|
291
|
+
if (isProject && node.project) onSelectProject(node.project.id)
|
|
292
|
+
else if (hasChildren) onToggle(node.path)
|
|
293
|
+
}, [isProject, hasChildren, node, onSelectProject, onToggle])
|
|
294
|
+
|
|
295
|
+
const handleChevronClick = useCallback((e: React.MouseEvent) => {
|
|
296
|
+
e.stopPropagation()
|
|
297
|
+
onToggle(node.path)
|
|
298
|
+
}, [node.path, onToggle])
|
|
299
|
+
|
|
300
|
+
const handleAuxClick = useCallback((e: React.MouseEvent) => {
|
|
301
|
+
if (e.button !== 1 || !node.project || !onMiddleClick) return
|
|
302
|
+
e.preventDefault()
|
|
303
|
+
onMiddleClick(node.project)
|
|
304
|
+
}, [node.project, onMiddleClick])
|
|
305
|
+
|
|
306
|
+
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
|
307
|
+
if (!onContextMenu) return
|
|
308
|
+
e.preventDefault()
|
|
309
|
+
onContextMenu({
|
|
310
|
+
x: e.clientX, y: e.clientY,
|
|
311
|
+
project: node.project ?? null,
|
|
312
|
+
nodeName: node.name, nodePath: node.path,
|
|
313
|
+
})
|
|
314
|
+
}, [node, onContextMenu])
|
|
315
|
+
|
|
316
|
+
const displayName = searchQuery ? highlightMatch(node.name, searchQuery) : node.name
|
|
317
|
+
const selectionClass = SELECTION[accentColor] ?? SELECTION.blue
|
|
318
|
+
const CurrentFolderIcon = isCollapsed ? FolderIcon : FolderOpenIcon
|
|
319
|
+
const highlighted = isProject && (!isProjectHighlighted || isProjectHighlighted(node.project))
|
|
320
|
+
const folderColor = highlighted ? (FOLDER_ACCENT[accentColor] ?? FOLDER_ACCENT.blue) : 'text-neutral-500'
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<>
|
|
324
|
+
<div
|
|
325
|
+
role="button"
|
|
326
|
+
tabIndex={0}
|
|
327
|
+
onClick={handleClick}
|
|
328
|
+
onAuxClick={handleAuxClick}
|
|
329
|
+
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
|
|
330
|
+
onContextMenu={handleContextMenu}
|
|
331
|
+
className={`w-full relative flex items-center gap-1 py-1 rounded-lg text-left text-sm transition-colors cursor-pointer select-none ${
|
|
332
|
+
isSelected ? selectionClass : 'text-neutral-400 hover:text-white hover:bg-neutral-960/50'
|
|
333
|
+
}`}
|
|
334
|
+
style={{ paddingLeft: `${depth * 12 + 8}px`, paddingRight: '8px' }}
|
|
335
|
+
data-testid={isProject && node.project ? `explorer-item-${node.project.id}` : undefined}
|
|
336
|
+
>
|
|
337
|
+
{hasChildren ? (
|
|
338
|
+
<button
|
|
339
|
+
type="button"
|
|
340
|
+
className="w-[18px] h-[18px] flex items-center justify-center flex-shrink-0 rounded text-neutral-500 hover:text-neutral-300 transition-colors cursor-pointer"
|
|
341
|
+
onClick={handleChevronClick}
|
|
342
|
+
>
|
|
343
|
+
{isCollapsed ? <ChevronRightIcon className="w-4 h-4" /> : <ChevronDownIcon className="w-4 h-4" />}
|
|
344
|
+
</button>
|
|
345
|
+
) : (
|
|
346
|
+
<span className="w-[18px] flex-shrink-0" />
|
|
347
|
+
)}
|
|
348
|
+
<CurrentFolderIcon className={`w-4 h-4 flex-shrink-0 ${folderColor}`} />
|
|
349
|
+
<span className="truncate flex-1">{displayName}</span>
|
|
350
|
+
{isProject && Array.isArray((node.project as Record<string, unknown>)?.worktrees) &&
|
|
351
|
+
((node.project as Record<string, unknown>).worktrees as unknown[]).length > 0 ? (
|
|
352
|
+
<span className="text-xs text-violet-400/80 bg-violet-400/10 px-1 rounded ml-1 flex-shrink-0">
|
|
353
|
+
worktree
|
|
354
|
+
</span>
|
|
355
|
+
) : null}
|
|
356
|
+
{isProject && node.project && renderProjectActions?.(node.project)}
|
|
357
|
+
</div>
|
|
358
|
+
{hasChildren && !isCollapsed && (
|
|
359
|
+
<div>
|
|
360
|
+
{node.children.map((child) => (
|
|
361
|
+
<ExplorerTreeNodeRow
|
|
362
|
+
key={child.path}
|
|
363
|
+
node={child}
|
|
364
|
+
depth={depth + 1}
|
|
365
|
+
selectedProjectId={selectedProjectId}
|
|
366
|
+
collapsedPaths={collapsedPaths}
|
|
367
|
+
onSelectProject={onSelectProject}
|
|
368
|
+
onToggle={onToggle}
|
|
369
|
+
onMiddleClick={onMiddleClick}
|
|
370
|
+
onContextMenu={onContextMenu}
|
|
371
|
+
renderProjectActions={renderProjectActions}
|
|
372
|
+
isProjectHighlighted={isProjectHighlighted}
|
|
373
|
+
searchQuery={searchQuery}
|
|
374
|
+
accentColor={accentColor}
|
|
375
|
+
/>
|
|
376
|
+
))}
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
</>
|
|
380
|
+
)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Internal: TopItemsSection
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
function TopItemRow({ item }: { item: ExplorerTopItem }) {
|
|
388
|
+
return (
|
|
389
|
+
<div
|
|
390
|
+
role="button"
|
|
391
|
+
tabIndex={0}
|
|
392
|
+
onClick={item.onClick}
|
|
393
|
+
onKeyDown={(e) => e.key === 'Enter' && item.onClick()}
|
|
394
|
+
className={`flex items-center gap-1 py-1 rounded-lg text-sm cursor-pointer select-none transition-colors ${
|
|
395
|
+
item.selected ? item.selectedClass : 'text-neutral-300 hover:text-white hover:bg-neutral-960/50'
|
|
396
|
+
}`}
|
|
397
|
+
style={{ paddingLeft: '8px', paddingRight: '8px' }}
|
|
398
|
+
>
|
|
399
|
+
{item.icon}
|
|
400
|
+
<span className="truncate flex-1 ml-1">{item.label}</span>
|
|
401
|
+
{item.actions}
|
|
402
|
+
</div>
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function TopItemSpacer() {
|
|
407
|
+
return <div className="py-1" style={{ paddingLeft: '8px', paddingRight: '8px' }}> </div>
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function TopItemsSection({ items }: { items: ExplorerTopItem[] }) {
|
|
411
|
+
const row1 = items[0]
|
|
412
|
+
const row2 = items[1]
|
|
413
|
+
return (
|
|
414
|
+
<div className="flex-shrink-0 px-1">
|
|
415
|
+
{row1 ? <TopItemRow item={row1} /> : <TopItemSpacer />}
|
|
416
|
+
{row2 ? <TopItemRow item={row2} /> : <TopItemSpacer />}
|
|
417
|
+
<div className="mx-2 my-1 border-t border-neutral-800" />
|
|
418
|
+
</div>
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// Internal: CollapsedExplorer
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
function CollapsedExplorer({ onToggle, accentColor }: { onToggle: () => void; accentColor: string }) {
|
|
427
|
+
return (
|
|
428
|
+
<aside
|
|
429
|
+
aria-label="Project explorer"
|
|
430
|
+
className={`flex flex-col items-center py-3 px-1.5 ${PANEL_BG} border-r border-neutral-960 h-full w-[40px] flex-shrink-0`}
|
|
431
|
+
>
|
|
432
|
+
<Tooltip
|
|
433
|
+
content={{ title: 'Projects', description: 'Expand sidebar' }}
|
|
434
|
+
position="right"
|
|
435
|
+
align="center"
|
|
436
|
+
>
|
|
437
|
+
<button
|
|
438
|
+
onClick={onToggle}
|
|
439
|
+
className={`w-full py-2 flex flex-col items-center justify-center gap-0.5 transition-colors cursor-pointer ${COLLAPSED_BUTTON[accentColor] ?? COLLAPSED_BUTTON.blue}`}
|
|
440
|
+
>
|
|
441
|
+
<FolderIcon className="w-4 h-4" />
|
|
442
|
+
</button>
|
|
443
|
+
</Tooltip>
|
|
444
|
+
<div className="flex-1 flex items-center justify-center">
|
|
445
|
+
<span
|
|
446
|
+
className="text-sm font-semibold text-neutral-500 uppercase tracking-widest"
|
|
447
|
+
style={{ writingMode: 'vertical-rl', textOrientation: 'mixed', transform: 'rotate(180deg)' }}
|
|
448
|
+
>
|
|
449
|
+
Explorer
|
|
450
|
+
</span>
|
|
451
|
+
</div>
|
|
452
|
+
</aside>
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// Internal: ResizeHandle
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
function ResizeHandle({ width, onWidthChange, accentColor }: {
|
|
461
|
+
width: number; onWidthChange: (w: number) => void; accentColor: string
|
|
462
|
+
}) {
|
|
463
|
+
const [isResizing, setIsResizing] = useState(false)
|
|
464
|
+
|
|
465
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
466
|
+
e.preventDefault()
|
|
467
|
+
setIsResizing(true)
|
|
468
|
+
const startX = e.clientX
|
|
469
|
+
const startWidth = width
|
|
470
|
+
|
|
471
|
+
const onMove = (ev: MouseEvent) => onWidthChange(startWidth + (ev.clientX - startX))
|
|
472
|
+
const onUp = () => {
|
|
473
|
+
setIsResizing(false)
|
|
474
|
+
document.removeEventListener('mousemove', onMove)
|
|
475
|
+
document.removeEventListener('mouseup', onUp)
|
|
476
|
+
}
|
|
477
|
+
document.addEventListener('mousemove', onMove, { passive: true })
|
|
478
|
+
document.addEventListener('mouseup', onUp, { passive: true })
|
|
479
|
+
}, [width, onWidthChange])
|
|
480
|
+
|
|
481
|
+
const hover = RESIZE_HOVER[accentColor] ?? RESIZE_HOVER.blue
|
|
482
|
+
const active = RESIZE_ACTIVE[accentColor] ?? RESIZE_ACTIVE.blue
|
|
483
|
+
const idleHover = RESIZE_IDLE_HOVER[accentColor] ?? RESIZE_IDLE_HOVER.blue
|
|
484
|
+
|
|
485
|
+
return (
|
|
486
|
+
<div
|
|
487
|
+
onMouseDown={handleMouseDown}
|
|
488
|
+
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize ${hover} transition-colors z-10 ${
|
|
489
|
+
isResizing ? active : `bg-transparent ${idleHover}`
|
|
490
|
+
}`}
|
|
491
|
+
/>
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
// Internal: SearchInput
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
function SearchInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
500
|
+
return (
|
|
501
|
+
<div className="flex-shrink-0 px-3 py-2">
|
|
502
|
+
<div className="relative">
|
|
503
|
+
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
504
|
+
<Input
|
|
505
|
+
placeholder="Search projects..."
|
|
506
|
+
value={value}
|
|
507
|
+
onChange={onChange}
|
|
508
|
+
autoComplete="off"
|
|
509
|
+
autoCorrect="off"
|
|
510
|
+
autoCapitalize="off"
|
|
511
|
+
spellCheck={false}
|
|
512
|
+
data-form-type="other"
|
|
513
|
+
size="sm"
|
|
514
|
+
className="pl-8 pr-8"
|
|
515
|
+
style={{ height: '26px' }}
|
|
516
|
+
/>
|
|
517
|
+
{value && (
|
|
518
|
+
<button
|
|
519
|
+
type="button"
|
|
520
|
+
onClick={() => onChange('')}
|
|
521
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 w-[18px] h-[18px] flex items-center justify-center rounded-md text-neutral-400 hover:text-neutral-300 hover:bg-neutral-500/20 transition-colors"
|
|
522
|
+
>
|
|
523
|
+
<XIcon className="w-3 h-3" />
|
|
524
|
+
</button>
|
|
525
|
+
)}
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// ProjectExplorer
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
export function ProjectExplorer({
|
|
536
|
+
tree, selectedProjectId, onSelectProject,
|
|
537
|
+
topItems,
|
|
538
|
+
searchQuery, onSearchChange,
|
|
539
|
+
collapsedPaths, onTogglePath, expandablePaths, onExpandAll, onCollapseAll,
|
|
540
|
+
width, onWidthChange, collapsed, onToggleCollapsed,
|
|
541
|
+
onAddProject, isAddingProject, onScanProjects,
|
|
542
|
+
onMiddleClick, renderProjectActions, renderContextMenu, isProjectHighlighted,
|
|
543
|
+
accentColor: accentColorProp,
|
|
544
|
+
emptyMessage = 'No projects yet',
|
|
545
|
+
noMatchMessage = 'No matching projects',
|
|
546
|
+
}: ProjectExplorerProps) {
|
|
547
|
+
const ctxAccent = useAccentColor()
|
|
548
|
+
const accentColor = accentColorProp ?? ctxAccent ?? 'blue'
|
|
549
|
+
|
|
550
|
+
const [contextMenu, setContextMenu] = useState<ExplorerContextMenuInfo | null>(null)
|
|
551
|
+
|
|
552
|
+
const handleContextMenu = useCallback((info: ExplorerContextMenuInfo) => {
|
|
553
|
+
setContextMenu(info)
|
|
554
|
+
}, [])
|
|
555
|
+
|
|
556
|
+
const handleContextMenuClose = useCallback(() => {
|
|
557
|
+
setContextMenu(null)
|
|
558
|
+
}, [])
|
|
559
|
+
|
|
560
|
+
// Close context menu on any click
|
|
561
|
+
React.useEffect(() => {
|
|
562
|
+
if (!contextMenu) return
|
|
563
|
+
const close = () => setContextMenu(null)
|
|
564
|
+
document.addEventListener('click', close)
|
|
565
|
+
return () => document.removeEventListener('click', close)
|
|
566
|
+
}, [contextMenu])
|
|
567
|
+
|
|
568
|
+
const filteredTree = React.useMemo(() => {
|
|
569
|
+
if (!searchQuery.trim()) return tree
|
|
570
|
+
return filterExplorerTree(tree, searchQuery)
|
|
571
|
+
}, [tree, searchQuery])
|
|
572
|
+
|
|
573
|
+
const expandableSet = React.useMemo(() => new Set(expandablePaths), [expandablePaths])
|
|
574
|
+
const collapsedCount = collapsedPaths.filter((p) => expandableSet.has(p)).length
|
|
575
|
+
const allCollapsed = collapsedCount === expandablePaths.length && expandablePaths.length > 0
|
|
576
|
+
|
|
577
|
+
if (collapsed) {
|
|
578
|
+
return <CollapsedExplorer onToggle={onToggleCollapsed} accentColor={accentColor} />
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
<aside
|
|
583
|
+
aria-label="Project explorer"
|
|
584
|
+
className={`${PANEL_BG} flex flex-col relative overflow-hidden flex-shrink-0`}
|
|
585
|
+
style={{ width }}
|
|
586
|
+
>
|
|
587
|
+
<ResizeHandle width={width} onWidthChange={onWidthChange} accentColor={accentColor} />
|
|
588
|
+
|
|
589
|
+
{/* Header */}
|
|
590
|
+
<div className="flex items-center justify-between px-3 h-[47px] border-b border-neutral-960">
|
|
591
|
+
<span className="text-sm font-semibold text-neutral-500 uppercase tracking-wider">Explorer</span>
|
|
592
|
+
<div className="flex items-center gap-1">
|
|
593
|
+
<IconButton
|
|
594
|
+
icon={isAddingProject ? 'loader-2' : 'plus'}
|
|
595
|
+
onClick={onAddProject}
|
|
596
|
+
size="sm"
|
|
597
|
+
accentColor="cyan"
|
|
598
|
+
disabled={isAddingProject}
|
|
599
|
+
tooltip={{ title: 'Add project', description: 'Open a folder to add as a project' }}
|
|
600
|
+
/>
|
|
601
|
+
{onScanProjects && (
|
|
602
|
+
<IconButton
|
|
603
|
+
icon="folder-search"
|
|
604
|
+
onClick={onScanProjects}
|
|
605
|
+
size="sm"
|
|
606
|
+
accentColor="neutral"
|
|
607
|
+
tooltip={{ title: 'Scan for projects', description: 'Discover AI-configured projects on your machine' }}
|
|
608
|
+
/>
|
|
609
|
+
)}
|
|
610
|
+
{expandablePaths.length > 0 && (
|
|
611
|
+
<IconButton
|
|
612
|
+
icon={allCollapsed ? 'chevrons-up-down' : 'chevrons-down-up'}
|
|
613
|
+
onClick={allCollapsed ? onExpandAll : onCollapseAll}
|
|
614
|
+
size="sm"
|
|
615
|
+
accentColor="neutral"
|
|
616
|
+
tooltip={{
|
|
617
|
+
title: allCollapsed ? 'Expand all' : 'Collapse all',
|
|
618
|
+
description: allCollapsed ? 'Expand all folders' : 'Collapse all folders',
|
|
619
|
+
}}
|
|
620
|
+
/>
|
|
621
|
+
)}
|
|
622
|
+
<IconButton
|
|
623
|
+
icon="panel-left-close"
|
|
624
|
+
onClick={onToggleCollapsed}
|
|
625
|
+
size="sm"
|
|
626
|
+
accentColor="neutral"
|
|
627
|
+
tooltip={{ title: 'Collapse sidebar', description: 'Minimize to icon strip' }}
|
|
628
|
+
/>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
|
|
632
|
+
{topItems && topItems.length > 0 && <TopItemsSection items={topItems} />}
|
|
633
|
+
|
|
634
|
+
<SearchInput value={searchQuery} onChange={onSearchChange} />
|
|
635
|
+
|
|
636
|
+
{/* Tree */}
|
|
637
|
+
<nav className="flex-1 overflow-y-auto min-h-0 p-1">
|
|
638
|
+
{filteredTree.length === 0 && (
|
|
639
|
+
<div className="px-3 py-4 text-sm text-neutral-500 text-center">
|
|
640
|
+
{searchQuery ? noMatchMessage : emptyMessage}
|
|
641
|
+
</div>
|
|
642
|
+
)}
|
|
643
|
+
{filteredTree.map((node) => (
|
|
644
|
+
<ExplorerTreeNodeRow
|
|
645
|
+
key={node.path}
|
|
646
|
+
node={node}
|
|
647
|
+
depth={0}
|
|
648
|
+
selectedProjectId={selectedProjectId}
|
|
649
|
+
collapsedPaths={collapsedPaths}
|
|
650
|
+
onSelectProject={onSelectProject}
|
|
651
|
+
onToggle={onTogglePath}
|
|
652
|
+
onMiddleClick={onMiddleClick}
|
|
653
|
+
onContextMenu={renderContextMenu ? handleContextMenu : undefined}
|
|
654
|
+
renderProjectActions={renderProjectActions}
|
|
655
|
+
isProjectHighlighted={isProjectHighlighted}
|
|
656
|
+
searchQuery={searchQuery || undefined}
|
|
657
|
+
accentColor={accentColor}
|
|
658
|
+
/>
|
|
659
|
+
))}
|
|
660
|
+
</nav>
|
|
661
|
+
|
|
662
|
+
{/* Context menu */}
|
|
663
|
+
{contextMenu && renderContextMenu?.(contextMenu, handleContextMenuClose)}
|
|
664
|
+
</aside>
|
|
665
|
+
)
|
|
666
|
+
}
|