@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,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot Browser — Shared type definitions
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Snapshot Browser
|
|
5
|
+
*
|
|
6
|
+
* These types define the CONTRACT between the UI layer (this package) and
|
|
7
|
+
* the Rust/Tauri backend that each app must implement. Every type here has
|
|
8
|
+
* a 1:1 mapping to a Rust struct returned by Tauri commands.
|
|
9
|
+
*
|
|
10
|
+
* IMPORTANT FOR AI AGENTS:
|
|
11
|
+
* When adding a new field here, the corresponding Rust struct must also be
|
|
12
|
+
* updated (and vice versa). The serde rename attributes in Rust use camelCase
|
|
13
|
+
* to match these TypeScript interfaces.
|
|
14
|
+
*
|
|
15
|
+
* Data hierarchy:
|
|
16
|
+
* SnapshotScope → SnapshotCategory → SnapshotItem → SnapshotEntry
|
|
17
|
+
*
|
|
18
|
+
* Example:
|
|
19
|
+
* Scope: "Settings (Prompts)"
|
|
20
|
+
* Category: "Verifier Prompts"
|
|
21
|
+
* Item: "system-prompt"
|
|
22
|
+
* Entry: { content: "You are a...", savedAt: "2026-02-25T10:00:00Z" }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Snapshot data hierarchy
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** A single snapshot — one saved version of content */
|
|
30
|
+
export interface SnapshotEntry {
|
|
31
|
+
id: string
|
|
32
|
+
content: string
|
|
33
|
+
savedAt: string
|
|
34
|
+
label?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** An item containing snapshots (e.g. a specific prompt or file) */
|
|
38
|
+
export interface SnapshotItem {
|
|
39
|
+
id: string
|
|
40
|
+
name: string
|
|
41
|
+
snapshots: SnapshotEntry[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A category containing items (e.g. "Verifier Prompts", "Skills") */
|
|
45
|
+
export interface SnapshotCategory {
|
|
46
|
+
id: string
|
|
47
|
+
name: string
|
|
48
|
+
icon?: string
|
|
49
|
+
items: SnapshotItem[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A top-level scope containing categories (e.g. "Settings", "Extensions") */
|
|
53
|
+
export interface SnapshotScope {
|
|
54
|
+
id: string
|
|
55
|
+
name: string
|
|
56
|
+
categories: SnapshotCategory[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// API adapter interface
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* SnapshotBrowserApi — The callback interface that each app must implement.
|
|
65
|
+
*
|
|
66
|
+
* This is the bridge between the shared UI and the app-specific backend.
|
|
67
|
+
* Each function maps to a Tauri command (or any other backend).
|
|
68
|
+
*
|
|
69
|
+
* ┌─────────────────────────────────────────────────────────────────────┐
|
|
70
|
+
* │ RUST BACKEND IMPLEMENTATION GUIDE │
|
|
71
|
+
* │ │
|
|
72
|
+
* │ Each method below corresponds to a Tauri #[tauri::command]. │
|
|
73
|
+
* │ The Rust reference implementation lives in: │
|
|
74
|
+
* │ configr/main/src-tauri/src/commands/snapshots/ │
|
|
75
|
+
* │ │
|
|
76
|
+
* │ REQUIRED Tauri commands: │
|
|
77
|
+
* │ │
|
|
78
|
+
* │ delete_snapshot(scope, category, item, snapshot) → void │
|
|
79
|
+
* │ Deletes a single snapshot entry by its composite key. │
|
|
80
|
+
* │ The four IDs form a unique path to the snapshot. │
|
|
81
|
+
* │ │
|
|
82
|
+
* │ clear_all_snapshots() → void │
|
|
83
|
+
* │ Removes all snapshots across all scopes. │
|
|
84
|
+
* │ Should clear the backing store completely. │
|
|
85
|
+
* │ │
|
|
86
|
+
* │ STORAGE STRUCTURE (Rust backend must maintain): │
|
|
87
|
+
* │ Snapshots are stored as JSON in the app data directory: │
|
|
88
|
+
* │ {app_data_dir}/ │
|
|
89
|
+
* │ └── snapshots/ │
|
|
90
|
+
* │ └── {scope}/ │
|
|
91
|
+
* │ └── {category}/ │
|
|
92
|
+
* │ └── {item}.json — SnapshotEntry[] serialized │
|
|
93
|
+
* │ │
|
|
94
|
+
* │ KEY BEHAVIORS the Rust backend must implement: │
|
|
95
|
+
* │ 1. Pruning: when snapshot limit is reached, remove oldest entry │
|
|
96
|
+
* │ 2. Atomic writes: use temp file + rename for crash safety │
|
|
97
|
+
* │ 3. IDs should be stable (UUID or timestamp-based) │
|
|
98
|
+
* └─────────────────────────────────────────────────────────────────────┘
|
|
99
|
+
*/
|
|
100
|
+
export interface SnapshotBrowserApi {
|
|
101
|
+
/** Delete a single snapshot by its composite key */
|
|
102
|
+
deleteSnapshot: (scopeId: string, categoryId: string, itemId: string, snapshotId: string) => Promise<void>
|
|
103
|
+
|
|
104
|
+
/** Clear all snapshots across all scopes */
|
|
105
|
+
clearAllSnapshots: () => Promise<void>
|
|
106
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSnapshotBrowser — State management hook for the snapshot tree browser
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Snapshot Browser
|
|
5
|
+
*
|
|
6
|
+
* Manages:
|
|
7
|
+
* - Tree expansion state (expanded scopes, categories, items)
|
|
8
|
+
* - Search filter with auto-expand on search
|
|
9
|
+
* - Expand all / collapse all
|
|
10
|
+
* - Total snapshot count computation
|
|
11
|
+
* - Delete operations with loading state
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
|
|
15
|
+
import type { SnapshotScope, SnapshotBrowserApi } from './types.ts'
|
|
16
|
+
|
|
17
|
+
export interface UseSnapshotBrowserOptions {
|
|
18
|
+
scopes: SnapshotScope[]
|
|
19
|
+
api: SnapshotBrowserApi
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseSnapshotBrowserReturn {
|
|
23
|
+
searchQuery: string
|
|
24
|
+
setSearchQuery: (query: string) => void
|
|
25
|
+
expandedPaths: Set<string>
|
|
26
|
+
toggleExpand: (path: string) => void
|
|
27
|
+
expandAll: () => void
|
|
28
|
+
collapseAll: () => void
|
|
29
|
+
allExpanded: boolean
|
|
30
|
+
totalSnapshotCount: number
|
|
31
|
+
allExpandablePaths: string[]
|
|
32
|
+
deleteSnapshot: (scopeId: string, categoryId: string, itemId: string, snapshotId: string) => Promise<void>
|
|
33
|
+
deletingSnapshotId: string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function collectExpandablePaths(scopes: SnapshotScope[]): string[] {
|
|
37
|
+
const paths: string[] = []
|
|
38
|
+
for (const scope of scopes) {
|
|
39
|
+
paths.push(scope.id)
|
|
40
|
+
for (const category of scope.categories) {
|
|
41
|
+
paths.push(`${scope.id}/${category.id}`)
|
|
42
|
+
for (const item of category.items) {
|
|
43
|
+
if (item.snapshots.length > 0) {
|
|
44
|
+
paths.push(`${scope.id}/${category.id}/${item.id}`)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return paths
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function countSnapshots(scopes: SnapshotScope[]): number {
|
|
53
|
+
let count = 0
|
|
54
|
+
for (const scope of scopes) {
|
|
55
|
+
for (const category of scope.categories) {
|
|
56
|
+
for (const item of category.items) {
|
|
57
|
+
count += item.snapshots.length
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return count
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useSnapshotBrowser({ scopes, api }: UseSnapshotBrowserOptions): UseSnapshotBrowserReturn {
|
|
65
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
66
|
+
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set())
|
|
67
|
+
const [deletingSnapshotId, setDeletingSnapshotId] = useState<string | null>(null)
|
|
68
|
+
const prevSearchRef = useRef('')
|
|
69
|
+
|
|
70
|
+
const allExpandablePaths = useMemo(() => collectExpandablePaths(scopes), [scopes])
|
|
71
|
+
const totalSnapshotCount = useMemo(() => countSnapshots(scopes), [scopes])
|
|
72
|
+
|
|
73
|
+
const allExpanded = allExpandablePaths.length > 0 && allExpandablePaths.every((p: string) => expandedPaths.has(p))
|
|
74
|
+
|
|
75
|
+
// Auto-expand all when search query is entered
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (searchQuery.trim() && !prevSearchRef.current.trim()) {
|
|
78
|
+
setExpandedPaths(new Set(allExpandablePaths))
|
|
79
|
+
}
|
|
80
|
+
prevSearchRef.current = searchQuery
|
|
81
|
+
}, [searchQuery, allExpandablePaths])
|
|
82
|
+
|
|
83
|
+
const toggleExpand = useCallback((path: string) => {
|
|
84
|
+
setExpandedPaths((prev: Set<string>) => {
|
|
85
|
+
const next = new Set(prev)
|
|
86
|
+
if (next.has(path)) {
|
|
87
|
+
next.delete(path)
|
|
88
|
+
} else {
|
|
89
|
+
next.add(path)
|
|
90
|
+
}
|
|
91
|
+
return next
|
|
92
|
+
})
|
|
93
|
+
}, [])
|
|
94
|
+
|
|
95
|
+
const expandAll = useCallback(() => {
|
|
96
|
+
setExpandedPaths(new Set(allExpandablePaths))
|
|
97
|
+
}, [allExpandablePaths])
|
|
98
|
+
|
|
99
|
+
const collapseAll = useCallback(() => {
|
|
100
|
+
setExpandedPaths(new Set())
|
|
101
|
+
}, [])
|
|
102
|
+
|
|
103
|
+
const deleteSnapshot = useCallback(async (scopeId: string, categoryId: string, itemId: string, snapshotId: string) => {
|
|
104
|
+
setDeletingSnapshotId(snapshotId)
|
|
105
|
+
try {
|
|
106
|
+
await api.deleteSnapshot(scopeId, categoryId, itemId, snapshotId)
|
|
107
|
+
} finally {
|
|
108
|
+
setDeletingSnapshotId(null)
|
|
109
|
+
}
|
|
110
|
+
}, [api])
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
searchQuery,
|
|
114
|
+
setSearchQuery,
|
|
115
|
+
expandedPaths,
|
|
116
|
+
toggleExpand,
|
|
117
|
+
expandAll,
|
|
118
|
+
collapseAll,
|
|
119
|
+
allExpanded,
|
|
120
|
+
totalSnapshotCount,
|
|
121
|
+
allExpandablePaths,
|
|
122
|
+
deleteSnapshot,
|
|
123
|
+
deletingSnapshotId,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snippets Editor — Section barrel export
|
|
3
|
+
*
|
|
4
|
+
* This section provides a complete, reusable snippets CRUD editor.
|
|
5
|
+
* Consuming apps provide data and an API adapter; the component handles
|
|
6
|
+
* the full UI including search, validation, and two-column layout.
|
|
7
|
+
*
|
|
8
|
+
* File structure:
|
|
9
|
+
* - snippets-editor.tsx — Main two-column editor component (drop-in usage)
|
|
10
|
+
* - use-snippets-editor.ts — Form state & CRUD hook (used by editor, also standalone)
|
|
11
|
+
* - types.ts — Shared types and validation regex
|
|
12
|
+
*
|
|
13
|
+
* Quick start:
|
|
14
|
+
* import { SnippetsEditor } from '@toolr/ui-design'
|
|
15
|
+
*
|
|
16
|
+
* <SnippetsEditor
|
|
17
|
+
* api={snippetsApi}
|
|
18
|
+
* snippets={currentSnippets}
|
|
19
|
+
* title="Skills Snippets"
|
|
20
|
+
* description="Define snippets to reuse in skills prompts with {{SNIPPET_NAME}} syntax."
|
|
21
|
+
* />
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Main editor component
|
|
25
|
+
export { SnippetsEditor, type SnippetsEditorProps } from './snippets-editor.tsx'
|
|
26
|
+
|
|
27
|
+
// Hook for custom UIs
|
|
28
|
+
export { useSnippetsEditor, type UseSnippetsEditorOptions, type UseSnippetsEditorReturn } from './use-snippets-editor.ts'
|
|
29
|
+
|
|
30
|
+
// Types
|
|
31
|
+
export { SNIPPET_NAME_REGEX, type SnippetData, type SnippetsEditorApi } from './types.ts'
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SnippetsEditor — Two-column CRUD editor for prompt snippets
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Snippets Editor
|
|
5
|
+
*
|
|
6
|
+
* Replicates the configr "Settings > Extensions > [type] > Snippets" page
|
|
7
|
+
* as a reusable, self-contained component. Left column shows the snippet
|
|
8
|
+
* list with search; right column shows the editor form.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* <SnippetsEditor
|
|
12
|
+
* api={snippetsApi}
|
|
13
|
+
* snippets={currentSnippets}
|
|
14
|
+
* title="Skills Snippets"
|
|
15
|
+
* description="Define snippets to reuse in skills prompts with {{SNIPPET_NAME}} syntax."
|
|
16
|
+
* />
|
|
17
|
+
*
|
|
18
|
+
* AI agent notes:
|
|
19
|
+
* - Uses a resizable divider between left sidebar and right editor
|
|
20
|
+
* - The component is self-contained — no Zustand, no Tauri dependencies
|
|
21
|
+
* - Consuming apps provide data + API callbacks; this component handles UI
|
|
22
|
+
* - Dark theme styling matches configr's Catppuccin-inspired palette
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { useCallback, useRef, useState } from 'react'
|
|
26
|
+
import { Plus, X, Braces, Trash2, RotateCcw, Save } from 'lucide-react'
|
|
27
|
+
import { cn } from '../../lib/cn.ts'
|
|
28
|
+
import { Input } from '../../ui/input.tsx'
|
|
29
|
+
import { ResizableTextarea } from '../../ui/resizable-textarea.tsx'
|
|
30
|
+
import { IconButton } from '../../ui/icon-button.tsx'
|
|
31
|
+
import { useSnippetsEditor } from './use-snippets-editor.ts'
|
|
32
|
+
import type { SnippetData, SnippetsEditorApi } from './types.ts'
|
|
33
|
+
|
|
34
|
+
export interface SnippetsEditorProps {
|
|
35
|
+
api: SnippetsEditorApi
|
|
36
|
+
snippets: SnippetData[]
|
|
37
|
+
/** Section title, e.g. "Skills Snippets" */
|
|
38
|
+
title?: string
|
|
39
|
+
/** Section description, e.g. "Define snippets to reuse in skills prompts..." */
|
|
40
|
+
description?: string
|
|
41
|
+
className?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MIN_SIDEBAR = 200
|
|
45
|
+
const MAX_SIDEBAR = 350
|
|
46
|
+
const DEFAULT_SIDEBAR = 260
|
|
47
|
+
|
|
48
|
+
export function SnippetsEditor({
|
|
49
|
+
api,
|
|
50
|
+
snippets,
|
|
51
|
+
title = 'Snippets',
|
|
52
|
+
description = 'Define reusable snippets for prompts using {{SNIPPET_NAME}} syntax.',
|
|
53
|
+
className,
|
|
54
|
+
}: SnippetsEditorProps) {
|
|
55
|
+
const {
|
|
56
|
+
selectedName,
|
|
57
|
+
selectSnippet,
|
|
58
|
+
searchQuery,
|
|
59
|
+
setSearchQuery,
|
|
60
|
+
filteredSnippets,
|
|
61
|
+
formData,
|
|
62
|
+
setFormField,
|
|
63
|
+
formError,
|
|
64
|
+
isEditing,
|
|
65
|
+
isAdding,
|
|
66
|
+
startAdd,
|
|
67
|
+
cancelForm,
|
|
68
|
+
save,
|
|
69
|
+
remove,
|
|
70
|
+
resetForm,
|
|
71
|
+
isSaving,
|
|
72
|
+
} = useSnippetsEditor({ api, snippets })
|
|
73
|
+
|
|
74
|
+
// Resizable sidebar
|
|
75
|
+
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR)
|
|
76
|
+
const dragRef = useRef<{ startX: number; startW: number } | null>(null)
|
|
77
|
+
|
|
78
|
+
const onDividerMouseDown = useCallback((e: React.MouseEvent) => {
|
|
79
|
+
e.preventDefault()
|
|
80
|
+
dragRef.current = { startX: e.clientX, startW: sidebarWidth }
|
|
81
|
+
const onMove = (ev: MouseEvent) => {
|
|
82
|
+
if (!dragRef.current) return
|
|
83
|
+
const newW = Math.min(MAX_SIDEBAR, Math.max(MIN_SIDEBAR, dragRef.current.startW + ev.clientX - dragRef.current.startX))
|
|
84
|
+
setSidebarWidth(newW)
|
|
85
|
+
}
|
|
86
|
+
const onUp = () => {
|
|
87
|
+
dragRef.current = null
|
|
88
|
+
document.removeEventListener('mousemove', onMove)
|
|
89
|
+
document.removeEventListener('mouseup', onUp)
|
|
90
|
+
}
|
|
91
|
+
document.addEventListener('mousemove', onMove)
|
|
92
|
+
document.addEventListener('mouseup', onUp)
|
|
93
|
+
}, [sidebarWidth])
|
|
94
|
+
|
|
95
|
+
const hasSelection = isEditing || isAdding
|
|
96
|
+
const nameHasError = formError !== null && (
|
|
97
|
+
formError.includes('name') || formError.includes('Name') || formError.includes('uppercase') || formError.includes('exists')
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className={cn('flex flex-col bg-[#181825] border border-[#313244] rounded-lg overflow-hidden', className)}>
|
|
102
|
+
{/* Header */}
|
|
103
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-[#313244] bg-purple-500/5">
|
|
104
|
+
<div className="flex items-center gap-2">
|
|
105
|
+
<Braces className="w-4 h-4 text-purple-400" />
|
|
106
|
+
<h3 className="text-sm font-medium text-[#cdd6f4]">{title}</h3>
|
|
107
|
+
<span className="px-2 py-0.5 text-xs rounded-full bg-[#313244] text-[#a6adc8]">
|
|
108
|
+
{snippets.length}
|
|
109
|
+
</span>
|
|
110
|
+
</div>
|
|
111
|
+
<p className="text-xs text-[#6c7086] hidden sm:block">{description}</p>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Body: two columns */}
|
|
115
|
+
<div className="flex flex-1 min-h-[400px]">
|
|
116
|
+
{/* Left: Snippet list */}
|
|
117
|
+
<div className="flex flex-col border-r border-[#313244]" style={{ width: sidebarWidth, minWidth: MIN_SIDEBAR }}>
|
|
118
|
+
{/* Search + Add */}
|
|
119
|
+
<div className="flex items-center gap-1.5 p-2 border-b border-[#313244]">
|
|
120
|
+
<div className="flex-1">
|
|
121
|
+
<Input
|
|
122
|
+
type="search"
|
|
123
|
+
value={searchQuery}
|
|
124
|
+
onChange={setSearchQuery}
|
|
125
|
+
placeholder="Search snippets..."
|
|
126
|
+
size="xs"
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
<IconButton
|
|
130
|
+
icon={<Plus className="w-3.5 h-3.5" />}
|
|
131
|
+
onClick={startAdd}
|
|
132
|
+
size="xs"
|
|
133
|
+
color="blue"
|
|
134
|
+
tooltip={{ title: 'Add Snippet', description: 'Create a new snippet' }}
|
|
135
|
+
disabled={isAdding}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Snippet list */}
|
|
140
|
+
<div className="flex-1 overflow-y-auto">
|
|
141
|
+
{filteredSnippets.length === 0 && !isAdding && (
|
|
142
|
+
<div className="text-center py-10 px-4">
|
|
143
|
+
<Braces className="w-8 h-8 mx-auto text-purple-400/40 mb-3" />
|
|
144
|
+
<p className="text-xs text-[#6c7086] mb-1">
|
|
145
|
+
{searchQuery ? 'No matching snippets' : 'No snippets defined'}
|
|
146
|
+
</p>
|
|
147
|
+
<p className="text-[10px] text-[#45475a]">
|
|
148
|
+
{searchQuery ? 'Try a different search term' : 'Click + to add your first snippet'}
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
{filteredSnippets.map((snippet) => (
|
|
153
|
+
<SnippetListItem
|
|
154
|
+
key={snippet.name}
|
|
155
|
+
snippet={snippet}
|
|
156
|
+
selected={selectedName === snippet.name}
|
|
157
|
+
onSelect={() => selectSnippet(snippet.name)}
|
|
158
|
+
onDelete={() => remove(snippet.name)}
|
|
159
|
+
/>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{/* Resizable divider */}
|
|
165
|
+
<div
|
|
166
|
+
className="w-1 cursor-col-resize bg-transparent hover:bg-blue-500/30 transition-colors flex-shrink-0"
|
|
167
|
+
onMouseDown={onDividerMouseDown}
|
|
168
|
+
/>
|
|
169
|
+
|
|
170
|
+
{/* Right: Editor */}
|
|
171
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
172
|
+
{hasSelection ? (
|
|
173
|
+
<SnippetForm
|
|
174
|
+
formData={formData}
|
|
175
|
+
setFormField={setFormField}
|
|
176
|
+
formError={formError}
|
|
177
|
+
nameHasError={nameHasError}
|
|
178
|
+
isEditing={isEditing}
|
|
179
|
+
isSaving={isSaving}
|
|
180
|
+
onSave={save}
|
|
181
|
+
onReset={resetForm}
|
|
182
|
+
onDelete={isEditing && selectedName ? () => remove(selectedName) : undefined}
|
|
183
|
+
onCancel={isAdding ? cancelForm : undefined}
|
|
184
|
+
/>
|
|
185
|
+
) : (
|
|
186
|
+
<div className="flex-1 flex items-center justify-center p-6">
|
|
187
|
+
<div className="text-center max-w-xs">
|
|
188
|
+
<Braces className="w-10 h-10 mx-auto text-purple-400/30 mb-4" />
|
|
189
|
+
<p className="text-sm text-[#6c7086] mb-2">Select a snippet to edit</p>
|
|
190
|
+
<p className="text-xs text-[#45475a] leading-relaxed">
|
|
191
|
+
Choose a snippet from the list, or click{' '}
|
|
192
|
+
<span className="text-blue-400">+</span> to create a new one.
|
|
193
|
+
Reference snippets in prompts with{' '}
|
|
194
|
+
<span className="font-mono text-purple-400">{'{{SNIPPET_NAME}}'}</span> syntax.
|
|
195
|
+
</p>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Snippet list item
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
interface SnippetListItemProps {
|
|
210
|
+
snippet: SnippetData
|
|
211
|
+
selected: boolean
|
|
212
|
+
onSelect: () => void
|
|
213
|
+
onDelete: () => void
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function SnippetListItem({ snippet, selected, onSelect, onDelete }: SnippetListItemProps) {
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
className={cn(
|
|
220
|
+
'group flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors border-l-2',
|
|
221
|
+
selected
|
|
222
|
+
? 'bg-[#1e1e2e] border-l-[#89b4fa]'
|
|
223
|
+
: 'border-l-transparent hover:bg-[#1e1e2e]/50',
|
|
224
|
+
)}
|
|
225
|
+
onClick={onSelect}
|
|
226
|
+
>
|
|
227
|
+
<div className="flex-1 min-w-0">
|
|
228
|
+
<p className="text-xs font-mono font-medium text-[#cdd6f4] truncate">
|
|
229
|
+
{snippet.name}
|
|
230
|
+
</p>
|
|
231
|
+
<p className="text-[10px] text-[#6c7086] truncate mt-0.5">
|
|
232
|
+
{snippet.description}
|
|
233
|
+
</p>
|
|
234
|
+
{snippet.value && (
|
|
235
|
+
<p className="text-[10px] text-[#45475a] truncate mt-0.5 font-mono">
|
|
236
|
+
{snippet.value.slice(0, 80)}{snippet.value.length > 80 ? '...' : ''}
|
|
237
|
+
</p>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
|
243
|
+
className="opacity-0 group-hover:opacity-100 mt-0.5 p-0.5 rounded text-[#6c7086] hover:text-red-400 hover:bg-red-500/10 transition-all"
|
|
244
|
+
>
|
|
245
|
+
<X className="w-3 h-3" />
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Snippet editor form
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
interface SnippetFormProps {
|
|
256
|
+
formData: { name: string; description: string; value: string }
|
|
257
|
+
setFormField: (field: 'name' | 'description' | 'value', value: string) => void
|
|
258
|
+
formError: string | null
|
|
259
|
+
nameHasError: boolean
|
|
260
|
+
isEditing: boolean
|
|
261
|
+
isSaving: boolean
|
|
262
|
+
onSave: () => void
|
|
263
|
+
onReset: () => void
|
|
264
|
+
onDelete?: () => void
|
|
265
|
+
onCancel?: () => void
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function SnippetForm({
|
|
269
|
+
formData,
|
|
270
|
+
setFormField,
|
|
271
|
+
formError,
|
|
272
|
+
nameHasError,
|
|
273
|
+
isEditing,
|
|
274
|
+
isSaving,
|
|
275
|
+
onSave,
|
|
276
|
+
onReset,
|
|
277
|
+
onDelete,
|
|
278
|
+
onCancel,
|
|
279
|
+
}: SnippetFormProps) {
|
|
280
|
+
return (
|
|
281
|
+
<div className="flex-1 flex flex-col">
|
|
282
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
283
|
+
{/* Name */}
|
|
284
|
+
<div>
|
|
285
|
+
<label className="block text-xs text-[#6c7086] mb-1.5">
|
|
286
|
+
Snippet Name <span className="text-red-400">*</span>
|
|
287
|
+
</label>
|
|
288
|
+
<Input
|
|
289
|
+
value={formData.name}
|
|
290
|
+
onChange={(val: string) => setFormField('name', val)}
|
|
291
|
+
placeholder="MY_SNIPPET"
|
|
292
|
+
error={nameHasError}
|
|
293
|
+
autoFocus={!isEditing}
|
|
294
|
+
/>
|
|
295
|
+
<p className="mt-1 text-[10px] text-[#45475a]">
|
|
296
|
+
Use in prompts as <span className="font-mono text-purple-400">{'{{' + (formData.name || 'NAME') + '}}'}</span>
|
|
297
|
+
</p>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* Description */}
|
|
301
|
+
<div>
|
|
302
|
+
<label className="block text-xs text-[#6c7086] mb-1.5">
|
|
303
|
+
Description <span className="text-red-400">*</span>
|
|
304
|
+
</label>
|
|
305
|
+
<Input
|
|
306
|
+
value={formData.description}
|
|
307
|
+
onChange={(val: string) => setFormField('description', val)}
|
|
308
|
+
placeholder="Describe what this snippet is used for"
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
{/* Value */}
|
|
313
|
+
<div>
|
|
314
|
+
<label className="block text-xs text-[#6c7086] mb-1.5">Value</label>
|
|
315
|
+
<ResizableTextarea
|
|
316
|
+
mode="code"
|
|
317
|
+
language="markdown"
|
|
318
|
+
value={formData.value}
|
|
319
|
+
onChange={(val) => setFormField('value', val)}
|
|
320
|
+
minHeight={160}
|
|
321
|
+
/>
|
|
322
|
+
<p className="mt-1 text-[10px] text-[#45475a]">
|
|
323
|
+
Can be a single value, multi-line text, or an entire document
|
|
324
|
+
</p>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
{/* Error */}
|
|
328
|
+
{formError && (
|
|
329
|
+
<div className="px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg">
|
|
330
|
+
<p className="text-xs text-red-400">{formError}</p>
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
{/* Footer actions */}
|
|
336
|
+
<div className="flex items-center justify-between gap-2 border-t border-[#313244] px-4 py-3">
|
|
337
|
+
<div>
|
|
338
|
+
{onDelete && (
|
|
339
|
+
<IconButton
|
|
340
|
+
icon={<Trash2 className="w-3.5 h-3.5" />}
|
|
341
|
+
onClick={onDelete}
|
|
342
|
+
size="sm"
|
|
343
|
+
color="red"
|
|
344
|
+
disabled={isSaving}
|
|
345
|
+
tooltip={{ title: 'Delete', description: 'Remove this snippet' }}
|
|
346
|
+
/>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
<div className="flex items-center gap-2">
|
|
350
|
+
{onCancel && (
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
onClick={onCancel}
|
|
354
|
+
disabled={isSaving}
|
|
355
|
+
className="rounded-md border border-[#313244] bg-transparent px-3 py-1.5 text-xs text-[#a6adc8] transition-colors hover:bg-[#313244] hover:text-[#cdd6f4] disabled:opacity-50"
|
|
356
|
+
>
|
|
357
|
+
Cancel
|
|
358
|
+
</button>
|
|
359
|
+
)}
|
|
360
|
+
<IconButton
|
|
361
|
+
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
|
362
|
+
onClick={onReset}
|
|
363
|
+
size="sm"
|
|
364
|
+
color="neutral"
|
|
365
|
+
disabled={isSaving}
|
|
366
|
+
tooltip={{ title: 'Reset', description: 'Revert to saved values' }}
|
|
367
|
+
/>
|
|
368
|
+
<button
|
|
369
|
+
type="button"
|
|
370
|
+
onClick={onSave}
|
|
371
|
+
disabled={isSaving}
|
|
372
|
+
className="flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-xs text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
373
|
+
>
|
|
374
|
+
<Save className="w-3 h-3" />
|
|
375
|
+
{isEditing ? 'Save' : 'Add'}
|
|
376
|
+
</button>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
)
|
|
381
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snippets Editor — Shared type definitions
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Snippets Editor
|
|
5
|
+
*
|
|
6
|
+
* Snippets are reusable text blocks that can be referenced in prompts
|
|
7
|
+
* using the {{SNIPPET_NAME}} syntax. Each snippet has a name (uppercase
|
|
8
|
+
* with underscores), a description, and a value that gets substituted
|
|
9
|
+
* at render time.
|
|
10
|
+
*
|
|
11
|
+
* Example usage in a prompt:
|
|
12
|
+
* "Follow the coding standards defined in {{CODE_STYLE}}"
|
|
13
|
+
*
|
|
14
|
+
* The consuming app is responsible for the actual template substitution.
|
|
15
|
+
* This UI only manages CRUD operations via the provided API.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A single snippet definition */
|
|
19
|
+
export interface SnippetData {
|
|
20
|
+
/** Snippet name — must match SNIPPET_NAME_REGEX (e.g., PROJECT_CONTEXT) */
|
|
21
|
+
name: string
|
|
22
|
+
/** Human-readable description of what this snippet contains */
|
|
23
|
+
description: string
|
|
24
|
+
/** The actual snippet content that gets substituted for {{NAME}} */
|
|
25
|
+
value: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validation regex for snippet names.
|
|
30
|
+
* Must start with an uppercase letter, followed by uppercase letters, digits, or underscores.
|
|
31
|
+
* Examples: MY_SNIPPET, CODE_STYLE, ERROR_HANDLING_V2
|
|
32
|
+
*/
|
|
33
|
+
export const SNIPPET_NAME_REGEX = /^[A-Z][A-Z0-9_]*$/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* SnippetsEditorApi — The callback interface that consuming apps must implement.
|
|
37
|
+
*
|
|
38
|
+
* Each method maps to a backend operation (Tauri command, API call, etc.).
|
|
39
|
+
* The UI calls these methods and awaits their completion before updating state.
|
|
40
|
+
*/
|
|
41
|
+
export interface SnippetsEditorApi {
|
|
42
|
+
/** Add a new snippet. Rejects if a snippet with the same name already exists. */
|
|
43
|
+
addSnippet: (snippet: SnippetData) => Promise<void>
|
|
44
|
+
/** Update an existing snippet. originalName is used to find the snippet to replace. */
|
|
45
|
+
updateSnippet: (originalName: string, snippet: SnippetData) => Promise<void>
|
|
46
|
+
/** Remove a snippet by name. */
|
|
47
|
+
removeSnippet: (name: string) => Promise<void>
|
|
48
|
+
}
|