@toolr/ui-design 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +63 -0
  2. package/components/content/info-panel-primitives.tsx +297 -0
  3. package/components/diagrams/diagram-utils.tsx +908 -0
  4. package/components/hooks/use-click-outside.ts +27 -0
  5. package/components/hooks/use-dropdown-max-height.ts +20 -0
  6. package/components/hooks/use-navigation-history.ts +94 -0
  7. package/components/lib/ai-tools.tsx +44 -0
  8. package/components/lib/cn.ts +6 -0
  9. package/components/lib/form-colors.ts +32 -0
  10. package/components/lib/theme-engine.ts +97 -0
  11. package/components/lib/toolr-brand.tsx +31 -0
  12. package/components/sections/ai-tools-paths/index.ts +37 -0
  13. package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
  14. package/components/sections/ai-tools-paths/types.ts +111 -0
  15. package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
  16. package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
  17. package/components/sections/captured-issues/index.ts +38 -0
  18. package/components/sections/captured-issues/types.ts +113 -0
  19. package/components/sections/captured-issues/use-captured-issues.ts +111 -0
  20. package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
  21. package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
  22. package/components/sections/golden-snapshots/index.ts +145 -0
  23. package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
  24. package/components/sections/golden-snapshots/status-overview.tsx +305 -0
  25. package/components/sections/golden-snapshots/types.ts +288 -0
  26. package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
  27. package/components/sections/golden-snapshots/version-manager.tsx +186 -0
  28. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
  29. package/components/sections/prompt-editor/index.ts +121 -0
  30. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
  31. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
  32. package/components/sections/prompt-editor/types.ts +101 -0
  33. package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
  34. package/components/sections/report-bug/error-logger.ts +392 -0
  35. package/components/sections/report-bug/index.ts +59 -0
  36. package/components/sections/report-bug/issue-reporter-api.ts +83 -0
  37. package/components/sections/report-bug/report-bug-form.tsx +282 -0
  38. package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
  39. package/components/sections/report-bug/use-report-bug.ts +170 -0
  40. package/components/sections/snapshot-browser/index.ts +53 -0
  41. package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
  42. package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
  43. package/components/sections/snapshot-browser/types.ts +106 -0
  44. package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
  45. package/components/sections/snippets-editor/index.ts +31 -0
  46. package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
  47. package/components/sections/snippets-editor/types.ts +48 -0
  48. package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
  49. package/components/ui/action-dialog.tsx +309 -0
  50. package/components/ui/ai-action-button.tsx +137 -0
  51. package/components/ui/ai-execution-action-buttons.tsx +106 -0
  52. package/components/ui/badge.tsx +67 -0
  53. package/components/ui/bottom-panel-header.tsx +240 -0
  54. package/components/ui/breadcrumb.tsx +168 -0
  55. package/components/ui/checkbox.tsx +102 -0
  56. package/components/ui/collapsible-section.tsx +100 -0
  57. package/components/ui/confirm-badge.tsx +71 -0
  58. package/components/ui/detail-section.tsx +67 -0
  59. package/components/ui/detail-view-wrapper.tsx +55 -0
  60. package/components/ui/editor-placeholder-card.tsx +197 -0
  61. package/components/ui/editor-toolbar.tsx +123 -0
  62. package/components/ui/execution-details-panel.tsx +93 -0
  63. package/components/ui/extension-list-card.tsx +105 -0
  64. package/components/ui/file-structure-section.tsx +373 -0
  65. package/components/ui/file-tree.tsx +171 -0
  66. package/components/ui/files-panel.tsx +251 -0
  67. package/components/ui/filter-dropdown.tsx +173 -0
  68. package/components/ui/form-actions.tsx +127 -0
  69. package/components/ui/frontmatter-form-header.tsx +80 -0
  70. package/components/ui/icon-button.tsx +388 -0
  71. package/components/ui/input.tsx +211 -0
  72. package/components/ui/label.tsx +159 -0
  73. package/components/ui/layout-tab-bar.tsx +289 -0
  74. package/components/ui/modal.tsx +194 -0
  75. package/components/ui/nav-card.tsx +81 -0
  76. package/components/ui/navigation-bar.tsx +285 -0
  77. package/components/ui/number-input.tsx +165 -0
  78. package/components/ui/registry-browser.tsx +261 -0
  79. package/components/ui/registry-card.tsx +710 -0
  80. package/components/ui/registry-detail.tsx +224 -0
  81. package/components/ui/resizable-textarea.tsx +290 -0
  82. package/components/ui/scope-badge.tsx +67 -0
  83. package/components/ui/segmented-toggle.tsx +133 -0
  84. package/components/ui/select.tsx +172 -0
  85. package/components/ui/selection-grid.tsx +313 -0
  86. package/components/ui/setting-row.tsx +97 -0
  87. package/components/ui/snapshot-card.tsx +107 -0
  88. package/components/ui/snippets-panel.tsx +161 -0
  89. package/components/ui/sort-dropdown.tsx +109 -0
  90. package/components/ui/status-card.tsx +96 -0
  91. package/components/ui/tab-bar.tsx +340 -0
  92. package/components/ui/toggle.tsx +142 -0
  93. package/components/ui/tooltip.tsx +326 -0
  94. package/dist/content.d.ts +110 -0
  95. package/dist/content.js +195 -0
  96. package/dist/diagrams.d.ts +371 -0
  97. package/dist/diagrams.js +702 -0
  98. package/dist/index.d.ts +2714 -0
  99. package/dist/index.js +11220 -0
  100. package/dist/preset.d.ts +24 -0
  101. package/dist/preset.js +17 -0
  102. package/dist/tokens/tokens/primitives.css +45 -0
  103. package/dist/tokens/tokens/semantic.css +46 -0
  104. package/dist/tokens/tokens/theme.css +11 -0
  105. package/dist/tokens/tokens/tokens.json +65 -0
  106. package/index.ts +123 -0
  107. package/package.json +63 -0
  108. package/tailwind-preset.ts +22 -0
  109. package/tokens/primitives.css +45 -0
  110. package/tokens/semantic.css +46 -0
  111. package/tokens/theme.css +11 -0
  112. package/tokens/tokens.json +65 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Snapshot Browser — Section barrel export
3
+ *
4
+ * This section provides a complete, reusable snapshot management feature
5
+ * with a hierarchical tree browser, search, and delete capabilities.
6
+ *
7
+ * File structure:
8
+ * - snapshot-browser-panel.tsx — Main panel component (drop-in usage)
9
+ * - snapshot-tree.tsx — Hierarchical tree sub-component
10
+ * - use-snapshot-browser.ts — Tree state & search hook
11
+ * - types.ts — Data types and API interface
12
+ *
13
+ * Quick start for consuming apps:
14
+ * import { SnapshotBrowserPanel } from '@toolr/ui-design'
15
+ *
16
+ * <SnapshotBrowserPanel
17
+ * api={{ deleteSnapshot, clearAllSnapshots }}
18
+ * scopes={scopes}
19
+ * snapshotLimit={20}
20
+ * onSnapshotLimitChange={setLimit}
21
+ * onClearAll={handleClearAll}
22
+ * />
23
+ *
24
+ * Data hierarchy:
25
+ * SnapshotScope → SnapshotCategory → SnapshotItem → SnapshotEntry
26
+ *
27
+ * Example scope structure:
28
+ * { id: 'settings', name: 'Settings (Prompts)', categories: [
29
+ * { id: 'verifier', name: 'Verifier Prompts', items: [
30
+ * { id: 'system-prompt', name: 'System Prompt', snapshots: [
31
+ * { id: 'snap-1', content: '...', savedAt: '2026-02-25T10:00:00Z' }
32
+ * ]}
33
+ * ]}
34
+ * ]}
35
+ */
36
+
37
+ // Main panel component
38
+ export { SnapshotBrowserPanel, type SnapshotBrowserPanelProps } from './snapshot-browser-panel.tsx'
39
+
40
+ // Tree sub-component (standalone usage)
41
+ export { SnapshotTree, type SnapshotTreeProps } from './snapshot-tree.tsx'
42
+
43
+ // Hook for custom UIs
44
+ export { useSnapshotBrowser, type UseSnapshotBrowserOptions, type UseSnapshotBrowserReturn } from './use-snapshot-browser.ts'
45
+
46
+ // Data types and API interface
47
+ export type {
48
+ SnapshotEntry,
49
+ SnapshotItem,
50
+ SnapshotCategory,
51
+ SnapshotScope,
52
+ SnapshotBrowserApi,
53
+ } from './types.ts'
@@ -0,0 +1,147 @@
1
+ /**
2
+ * SnapshotBrowserPanel — Self-contained snapshot management panel
3
+ *
4
+ * Part of: Sections > Snapshot Browser
5
+ *
6
+ * This is the main "drop in and it works" component. Replicate the configr
7
+ * app's Settings > General > Snapshots page as a reusable panel. Provide
8
+ * scopes data, an API adapter, and snapshot limit controls.
9
+ *
10
+ * Usage:
11
+ * <SnapshotBrowserPanel
12
+ * api={snapshotApi}
13
+ * scopes={scopes}
14
+ * snapshotLimit={20}
15
+ * onSnapshotLimitChange={setLimit}
16
+ * onClearAll={handleClearAll}
17
+ * />
18
+ *
19
+ * AI agent notes:
20
+ * - This component manages tree expansion state internally via useSnapshotBrowser hook
21
+ * - It renders: snapshot limit selector, hierarchical tree browser, help section
22
+ * - The api prop bridges to the app backend (Tauri commands, REST, etc.)
23
+ * - Consuming apps provide scopes data and handle persistence
24
+ * - Uses ui-design components (Select, IconButton, Input) for consistency
25
+ */
26
+
27
+ import { Trash2, HelpCircle } from 'lucide-react'
28
+ import { cn } from '../../lib/cn.ts'
29
+ import { Select } from '../../ui/select.tsx'
30
+ import { IconButton } from '../../ui/icon-button.tsx'
31
+ import { SnapshotTree } from './snapshot-tree.tsx'
32
+ import { useSnapshotBrowser } from './use-snapshot-browser.ts'
33
+ import type { SnapshotScope, SnapshotBrowserApi } from './types.ts'
34
+
35
+ const SNAPSHOT_LIMIT_OPTIONS = [5, 10, 15, 20, 25, 30, 40, 50].map((n) => ({
36
+ value: n,
37
+ label: String(n),
38
+ }))
39
+
40
+ export interface SnapshotBrowserPanelProps {
41
+ api: SnapshotBrowserApi
42
+ scopes: SnapshotScope[]
43
+ snapshotLimit: number
44
+ onSnapshotLimitChange: (limit: number) => void
45
+ onClearAll?: () => void
46
+ className?: string
47
+ }
48
+
49
+ export function SnapshotBrowserPanel({
50
+ api,
51
+ scopes,
52
+ snapshotLimit,
53
+ onSnapshotLimitChange,
54
+ onClearAll,
55
+ className,
56
+ }: SnapshotBrowserPanelProps) {
57
+ const {
58
+ searchQuery,
59
+ setSearchQuery,
60
+ expandedPaths,
61
+ toggleExpand,
62
+ expandAll,
63
+ collapseAll,
64
+ allExpanded,
65
+ totalSnapshotCount,
66
+ allExpandablePaths,
67
+ deleteSnapshot,
68
+ deletingSnapshotId,
69
+ } = useSnapshotBrowser({ scopes, api })
70
+
71
+ return (
72
+ <div className={cn('space-y-6', className)}>
73
+ {/* Snapshot Limit */}
74
+ <div className="bg-[#181825] border border-[#313244] rounded-lg p-4">
75
+ <div className="flex items-center justify-between">
76
+ <div>
77
+ <label className="text-[#cdd6f4]">Snapshot Limit</label>
78
+ <p className="text-sm text-[#6c7086]">
79
+ Maximum number of snapshots to keep per item (1-50)
80
+ </p>
81
+ </div>
82
+ <Select
83
+ value={snapshotLimit}
84
+ options={SNAPSHOT_LIMIT_OPTIONS}
85
+ onChange={(v) => onSnapshotLimitChange(v)}
86
+ />
87
+ </div>
88
+ </div>
89
+
90
+ {/* Snapshot Browser */}
91
+ <div className="bg-[#181825] border border-[#313244] rounded-lg p-4">
92
+ <div className="flex items-center justify-between mb-3">
93
+ <div>
94
+ <label className="text-[#cdd6f4]">Browse Snapshots</label>
95
+ <p className="text-sm text-[#6c7086]">
96
+ {totalSnapshotCount === 0
97
+ ? 'No snapshots saved yet'
98
+ : `${totalSnapshotCount} snapshot${totalSnapshotCount === 1 ? '' : 's'} stored`}
99
+ </p>
100
+ </div>
101
+ <IconButton
102
+ icon={<Trash2 className="w-4 h-4" />}
103
+ onClick={() => onClearAll?.()}
104
+ disabled={totalSnapshotCount === 0 || !onClearAll}
105
+ size="sm"
106
+ color="red"
107
+ tooltip={{
108
+ title: 'Clear all snapshots',
109
+ description: 'Delete all saved snapshots',
110
+ }}
111
+ />
112
+ </div>
113
+ <SnapshotTree
114
+ scopes={scopes}
115
+ searchQuery={searchQuery}
116
+ onSearchChange={setSearchQuery}
117
+ expandedPaths={expandedPaths}
118
+ onToggleExpand={toggleExpand}
119
+ onExpandAll={expandAll}
120
+ onCollapseAll={collapseAll}
121
+ allExpanded={allExpanded}
122
+ allExpandablePaths={allExpandablePaths}
123
+ onDeleteSnapshot={deleteSnapshot}
124
+ deletingSnapshotId={deletingSnapshotId}
125
+ />
126
+ </div>
127
+
128
+ {/* Help */}
129
+ <div className="bg-[#181825]/50 border border-[#313244] rounded-lg p-4">
130
+ <div className="flex items-start gap-3">
131
+ <HelpCircle className="w-4 h-4 text-[#6c7086] mt-0.5 shrink-0" />
132
+ <div className="text-sm text-[#6c7086] space-y-2">
133
+ <p>
134
+ <strong className="text-[#a6adc8]">How snapshots work:</strong>
135
+ </p>
136
+ <ul className="list-disc list-inside space-y-1 ml-1">
137
+ <li>Click &ldquo;Save Snapshot&rdquo; in any editor to save the current version</li>
138
+ <li>When resetting content, you can choose from saved snapshots or the file default</li>
139
+ <li>Snapshots are stored locally and persist across sessions</li>
140
+ <li>When the limit is reached, the oldest snapshot is removed</li>
141
+ </ul>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ )
147
+ }
@@ -0,0 +1,451 @@
1
+ /**
2
+ * SnapshotTree — Hierarchical tree browser for viewing and managing snapshots
3
+ *
4
+ * Part of: Sections > Snapshot Browser
5
+ *
6
+ * Renders a 4-level tree: Scope > Category > Item > Snapshot
7
+ * with search filtering, expand/collapse all, and delete actions.
8
+ *
9
+ * Ported from configr's SnapshotBrowser component, decoupled from Zustand stores.
10
+ */
11
+
12
+ import { useMemo } from 'react'
13
+ import {
14
+ ChevronRight,
15
+ ChevronDown,
16
+ Search,
17
+ X,
18
+ ChevronsUpDown,
19
+ ChevronsDownUp,
20
+ Trash2,
21
+ History,
22
+ Clock,
23
+ } from 'lucide-react'
24
+ import { cn } from '../../lib/cn.ts'
25
+ import { Input } from '../../ui/input.tsx'
26
+ import { IconButton } from '../../ui/icon-button.tsx'
27
+ import type { SnapshotScope, SnapshotCategory, SnapshotItem, SnapshotEntry } from './types.ts'
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function formatRelativeTime(isoString: string): string {
34
+ const date = new Date(isoString)
35
+ const now = new Date()
36
+ const diffMs = now.getTime() - date.getTime()
37
+ const diffMins = Math.floor(diffMs / 60000)
38
+ const diffHours = Math.floor(diffMs / 3600000)
39
+ const diffDays = Math.floor(diffMs / 86400000)
40
+
41
+ if (diffMins < 1) return 'Just now'
42
+ if (diffMins < 60) return `${diffMins}m ago`
43
+ if (diffHours < 24) return `${diffHours}h ago`
44
+ if (diffDays < 7) return `${diffDays}d ago`
45
+
46
+ return date.toLocaleDateString(undefined, {
47
+ month: 'short',
48
+ day: 'numeric',
49
+ hour: 'numeric',
50
+ minute: '2-digit',
51
+ })
52
+ }
53
+
54
+ function formatFullDate(isoString: string): string {
55
+ const date = new Date(isoString)
56
+ return date.toLocaleString(undefined, {
57
+ month: 'short',
58
+ day: 'numeric',
59
+ year: 'numeric',
60
+ hour: 'numeric',
61
+ minute: '2-digit',
62
+ })
63
+ }
64
+
65
+ function getContentPreview(content: string, maxLength = 40): string {
66
+ const firstNonEmptyLine = content.split('\n').find((line) => line.trim()) ?? ''
67
+ const cleaned = firstNonEmptyLine.replace(/^[#/*\-\s]+/, '').trim()
68
+ if (cleaned.length <= maxLength) return cleaned
69
+ return cleaned.slice(0, maxLength - 3) + '...'
70
+ }
71
+
72
+ function countScopeSnapshots(scope: SnapshotScope): number {
73
+ let count = 0
74
+ for (const cat of scope.categories) {
75
+ for (const item of cat.items) {
76
+ count += item.snapshots.length
77
+ }
78
+ }
79
+ return count
80
+ }
81
+
82
+ function countCategorySnapshots(category: SnapshotCategory): number {
83
+ let count = 0
84
+ for (const item of category.items) {
85
+ count += item.snapshots.length
86
+ }
87
+ return count
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Category icon mapping
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const CATEGORY_ICON_COLORS: Record<string, string> = {
95
+ verifier: 'text-teal-400',
96
+ simulator: 'text-purple-400',
97
+ console: 'text-blue-400',
98
+ skills: 'text-yellow-400',
99
+ hooks: 'text-green-400',
100
+ agents: 'text-violet-400',
101
+ 'project-memory': 'text-indigo-400',
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Search filtering
106
+ // ---------------------------------------------------------------------------
107
+
108
+ function filterScopes(scopes: SnapshotScope[], query: string): SnapshotScope[] {
109
+ if (!query.trim()) return scopes
110
+
111
+ const lowerQuery = query.toLowerCase()
112
+
113
+ return scopes
114
+ .map((scope) => {
115
+ if (scope.name.toLowerCase().includes(lowerQuery)) return scope
116
+
117
+ const filteredCategories = scope.categories
118
+ .map((cat) => {
119
+ if (cat.name.toLowerCase().includes(lowerQuery)) return cat
120
+
121
+ const filteredItems = cat.items
122
+ .map((item) => {
123
+ if (item.name.toLowerCase().includes(lowerQuery)) return item
124
+
125
+ const filteredSnapshots = item.snapshots.filter((snap) => {
126
+ const label = snap.label || getContentPreview(snap.content)
127
+ return label.toLowerCase().includes(lowerQuery)
128
+ })
129
+
130
+ if (filteredSnapshots.length > 0) {
131
+ return { ...item, snapshots: filteredSnapshots }
132
+ }
133
+ return null
134
+ })
135
+ .filter((item): item is SnapshotItem => item !== null)
136
+
137
+ if (filteredItems.length > 0) {
138
+ return { ...cat, items: filteredItems }
139
+ }
140
+ return null
141
+ })
142
+ .filter((cat): cat is SnapshotCategory => cat !== null)
143
+
144
+ if (filteredCategories.length > 0) {
145
+ return { ...scope, categories: filteredCategories }
146
+ }
147
+ return null
148
+ })
149
+ .filter((scope): scope is SnapshotScope => scope !== null)
150
+ }
151
+
152
+ function highlightMatch(text: string, query: string) {
153
+ if (!query) return text
154
+ const lowerText = text.toLowerCase()
155
+ const lowerQuery = query.toLowerCase()
156
+ const index = lowerText.indexOf(lowerQuery)
157
+ if (index === -1) return text
158
+ return (
159
+ <>
160
+ {text.slice(0, index)}
161
+ <span className="bg-yellow-500/30 text-yellow-200">{text.slice(index, index + query.length)}</span>
162
+ {text.slice(index + query.length)}
163
+ </>
164
+ )
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Tree node components
169
+ // ---------------------------------------------------------------------------
170
+
171
+ interface SnapshotEntryRowProps {
172
+ entry: SnapshotEntry
173
+ depth: number
174
+ scopeId: string
175
+ categoryId: string
176
+ itemId: string
177
+ onDelete: (scopeId: string, categoryId: string, itemId: string, snapshotId: string) => void
178
+ deletingSnapshotId: string | null
179
+ searchQuery: string
180
+ }
181
+
182
+ function SnapshotEntryRow({
183
+ entry,
184
+ depth,
185
+ scopeId,
186
+ categoryId,
187
+ itemId,
188
+ onDelete,
189
+ deletingSnapshotId,
190
+ searchQuery,
191
+ }: SnapshotEntryRowProps) {
192
+ const displayName = entry.label || getContentPreview(entry.content) || 'Snapshot'
193
+ const isDeleting = deletingSnapshotId === entry.id
194
+
195
+ return (
196
+ <div
197
+ className="flex items-center gap-2 px-2 py-1.5 text-sm rounded-md group transition-colors hover:bg-[#313244]/50 text-[#a6adc8]"
198
+ style={{ paddingLeft: `${depth * 16 + 8}px` }}
199
+ >
200
+ <Clock className="w-3 h-3 shrink-0 text-[#6c7086]" />
201
+ <span className="text-xs flex-1 truncate">
202
+ {searchQuery ? highlightMatch(displayName, searchQuery) : displayName}
203
+ </span>
204
+ <span className="text-[10px] text-[#6c7086] shrink-0" title={formatFullDate(entry.savedAt)}>
205
+ {formatRelativeTime(entry.savedAt)}
206
+ </span>
207
+ <IconButton
208
+ icon={<Trash2 className="w-3.5 h-3.5" />}
209
+ onClick={(e) => {
210
+ e?.stopPropagation()
211
+ onDelete(scopeId, categoryId, itemId, entry.id)
212
+ }}
213
+ size="sm"
214
+ color="red"
215
+ className={cn('transition-opacity', isDeleting ? 'opacity-50' : 'opacity-0 group-hover:opacity-100')}
216
+ disabled={isDeleting}
217
+ tooltip={{ title: 'Delete', description: 'Delete this snapshot' }}
218
+ />
219
+ </div>
220
+ )
221
+ }
222
+
223
+ interface ExpandableNodeProps {
224
+ id: string
225
+ path: string
226
+ label: string
227
+ icon?: React.ReactNode
228
+ snapshotCount: number
229
+ depth: number
230
+ isExpanded: boolean
231
+ onToggle: (path: string) => void
232
+ searchQuery: string
233
+ children: React.ReactNode
234
+ }
235
+
236
+ function ExpandableNode({
237
+ path,
238
+ label,
239
+ icon,
240
+ snapshotCount,
241
+ depth,
242
+ isExpanded,
243
+ onToggle,
244
+ searchQuery,
245
+ children,
246
+ }: ExpandableNodeProps) {
247
+ return (
248
+ <div>
249
+ <button
250
+ onClick={() => onToggle(path)}
251
+ className="w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors cursor-pointer text-[#a6adc8] hover:bg-[#313244]/50 hover:text-[#cdd6f4]"
252
+ style={{ paddingLeft: `${depth * 16 + 8}px` }}
253
+ >
254
+ <span className="w-4 h-4 flex items-center justify-center shrink-0">
255
+ {isExpanded ? (
256
+ <ChevronDown className="w-3.5 h-3.5" />
257
+ ) : (
258
+ <ChevronRight className="w-3.5 h-3.5" />
259
+ )}
260
+ </span>
261
+
262
+ {icon && <span className="shrink-0">{icon}</span>}
263
+
264
+ <span className="truncate flex-1 text-left">
265
+ {searchQuery ? highlightMatch(label, searchQuery) : label}
266
+ </span>
267
+
268
+ {snapshotCount > 0 && (
269
+ <span className="text-xs text-[#6c7086] bg-[#313244] px-1.5 py-0.5 rounded shrink-0">
270
+ {snapshotCount}
271
+ </span>
272
+ )}
273
+ </button>
274
+
275
+ {isExpanded && children}
276
+ </div>
277
+ )
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Main SnapshotTree component
282
+ // ---------------------------------------------------------------------------
283
+
284
+ export interface SnapshotTreeProps {
285
+ scopes: SnapshotScope[]
286
+ searchQuery: string
287
+ onSearchChange: (query: string) => void
288
+ expandedPaths: Set<string>
289
+ onToggleExpand: (path: string) => void
290
+ onExpandAll: () => void
291
+ onCollapseAll: () => void
292
+ allExpanded: boolean
293
+ allExpandablePaths: string[]
294
+ onDeleteSnapshot: (scopeId: string, categoryId: string, itemId: string, snapshotId: string) => void
295
+ deletingSnapshotId: string | null
296
+ className?: string
297
+ }
298
+
299
+ export function SnapshotTree({
300
+ scopes,
301
+ searchQuery,
302
+ onSearchChange,
303
+ expandedPaths,
304
+ onToggleExpand,
305
+ onExpandAll,
306
+ onCollapseAll,
307
+ allExpanded,
308
+ allExpandablePaths,
309
+ onDeleteSnapshot,
310
+ deletingSnapshotId,
311
+ className,
312
+ }: SnapshotTreeProps) {
313
+ const filteredScopes = useMemo(() => filterScopes(scopes, searchQuery), [scopes, searchQuery])
314
+
315
+ const totalCount = useMemo(() => {
316
+ let count = 0
317
+ for (const scope of scopes) {
318
+ count += countScopeSnapshots(scope)
319
+ }
320
+ return count
321
+ }, [scopes])
322
+
323
+ if (totalCount === 0) {
324
+ return (
325
+ <div className={cn('text-sm text-[#6c7086] py-8 text-center', className)}>
326
+ No snapshots saved yet.
327
+ <br />
328
+ <span className="text-xs">Use the camera button in editors to save snapshots.</span>
329
+ </div>
330
+ )
331
+ }
332
+
333
+ return (
334
+ <div className={cn('flex flex-col gap-3', className)}>
335
+ {/* Search and controls */}
336
+ <div className="flex items-center gap-2">
337
+ <div className="relative flex-1">
338
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#6c7086] pointer-events-none" />
339
+ <Input
340
+ placeholder="Search snapshots..."
341
+ value={searchQuery}
342
+ onChange={onSearchChange}
343
+ variant="outline"
344
+ size="sm"
345
+ autoComplete="off"
346
+ autoCorrect="off"
347
+ autoCapitalize="off"
348
+ spellCheck={false}
349
+ className="pl-8 pr-7"
350
+ />
351
+ {searchQuery && (
352
+ <IconButton
353
+ icon={<X className="w-3.5 h-3.5" />}
354
+ onClick={() => onSearchChange('')}
355
+ color="neutral"
356
+ size="xss"
357
+ className="absolute right-2 top-1/2 -translate-y-1/2"
358
+ tooltip={{ title: 'Clear', description: 'Clear search' }}
359
+ />
360
+ )}
361
+ </div>
362
+
363
+ {allExpandablePaths.length > 0 && (
364
+ <IconButton
365
+ icon={allExpanded ? <ChevronsDownUp className="w-3.5 h-3.5" /> : <ChevronsUpDown className="w-3.5 h-3.5" />}
366
+ onClick={allExpanded ? onCollapseAll : onExpandAll}
367
+ size="sm"
368
+ color="neutral"
369
+ tooltip={{
370
+ title: allExpanded ? 'Collapse All' : 'Expand All',
371
+ description: allExpanded ? 'Collapse all sections' : 'Expand all sections',
372
+ }}
373
+ />
374
+ )}
375
+ </div>
376
+
377
+ {/* Tree */}
378
+ <div className="bg-[#181825] border border-[#313244] rounded-lg p-2 min-h-[200px] max-h-[60vh] overflow-y-auto">
379
+ {filteredScopes.length === 0 && searchQuery ? (
380
+ <p className="text-xs text-[#6c7086] text-center py-4">
381
+ No snapshots match &ldquo;{searchQuery}&rdquo;
382
+ </p>
383
+ ) : (
384
+ filteredScopes.map((scope) => (
385
+ <ExpandableNode
386
+ key={scope.id}
387
+ id={scope.id}
388
+ path={scope.id}
389
+ label={scope.name}
390
+ snapshotCount={countScopeSnapshots(scope)}
391
+ depth={0}
392
+ isExpanded={expandedPaths.has(scope.id)}
393
+ onToggle={onToggleExpand}
394
+ searchQuery={searchQuery}
395
+ >
396
+ {scope.categories.map((category) => (
397
+ <ExpandableNode
398
+ key={category.id}
399
+ id={category.id}
400
+ path={`${scope.id}/${category.id}`}
401
+ label={category.name}
402
+ icon={
403
+ category.icon ? (
404
+ <History className={cn('w-3.5 h-3.5', CATEGORY_ICON_COLORS[category.icon] || 'text-[#6c7086]')} />
405
+ ) : undefined
406
+ }
407
+ snapshotCount={countCategorySnapshots(category)}
408
+ depth={1}
409
+ isExpanded={expandedPaths.has(`${scope.id}/${category.id}`)}
410
+ onToggle={onToggleExpand}
411
+ searchQuery={searchQuery}
412
+ >
413
+ {category.items.map((item) => (
414
+ <ExpandableNode
415
+ key={item.id}
416
+ id={item.id}
417
+ path={`${scope.id}/${category.id}/${item.id}`}
418
+ label={item.name}
419
+ icon={<History className="w-3.5 h-3.5 text-[#6c7086]" />}
420
+ snapshotCount={item.snapshots.length}
421
+ depth={2}
422
+ isExpanded={expandedPaths.has(`${scope.id}/${category.id}/${item.id}`)}
423
+ onToggle={onToggleExpand}
424
+ searchQuery={searchQuery}
425
+ >
426
+ {[...item.snapshots]
427
+ .sort((a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime())
428
+ .map((entry) => (
429
+ <SnapshotEntryRow
430
+ key={entry.id}
431
+ entry={entry}
432
+ depth={3}
433
+ scopeId={scope.id}
434
+ categoryId={category.id}
435
+ itemId={item.id}
436
+ onDelete={onDeleteSnapshot}
437
+ deletingSnapshotId={deletingSnapshotId}
438
+ searchQuery={searchQuery}
439
+ />
440
+ ))}
441
+ </ExpandableNode>
442
+ ))}
443
+ </ExpandableNode>
444
+ ))}
445
+ </ExpandableNode>
446
+ ))
447
+ )}
448
+ </div>
449
+ </div>
450
+ )
451
+ }