@antigenic-oss/paint 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/LICENSE +178 -0
- package/NOTICE +4 -0
- package/README.md +180 -0
- package/bin/paint.js +266 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +19 -0
- package/package.json +81 -0
- package/postcss.config.mjs +8 -0
- package/public/dev-editor-inspector.js +1872 -0
- package/src/app/api/claude/analyze/route.ts +319 -0
- package/src/app/api/claude/apply/route.ts +185 -0
- package/src/app/api/claude/pick-folder/route.ts +64 -0
- package/src/app/api/claude/scan/route.ts +221 -0
- package/src/app/api/claude/status/route.ts +55 -0
- package/src/app/api/project/scan/route.ts +634 -0
- package/src/app/api/project-scan/css-variables/route.ts +238 -0
- package/src/app/api/project-scan/route.ts +40 -0
- package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
- package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
- package/src/app/docs/DocsClient.tsx +322 -0
- package/src/app/docs/layout.tsx +7 -0
- package/src/app/docs/page.tsx +855 -0
- package/src/app/globals.css +176 -0
- package/src/app/layout.tsx +19 -0
- package/src/app/page.tsx +46 -0
- package/src/bridge/api-handlers.ts +885 -0
- package/src/bridge/proxy-handler.ts +329 -0
- package/src/bridge/server.ts +113 -0
- package/src/components/BreakpointTabs.tsx +72 -0
- package/src/components/ChangeSummaryModal.tsx +267 -0
- package/src/components/ConnectModal.tsx +994 -0
- package/src/components/Editor.tsx +90 -0
- package/src/components/PageSelector.tsx +208 -0
- package/src/components/PreviewFrame.tsx +299 -0
- package/src/components/ProjectFolderBanner.tsx +91 -0
- package/src/components/ResponsiveToolbar.tsx +222 -0
- package/src/components/TargetSelector.tsx +243 -0
- package/src/components/TopBar.tsx +315 -0
- package/src/components/common/CollapsibleSection.tsx +36 -0
- package/src/components/common/ColorPicker.tsx +920 -0
- package/src/components/common/EditablePre.tsx +136 -0
- package/src/components/common/ErrorBoundary.tsx +65 -0
- package/src/components/common/ResizablePanel.tsx +83 -0
- package/src/components/common/ScanAnimation.tsx +76 -0
- package/src/components/common/ToastContainer.tsx +97 -0
- package/src/components/common/UnitInput.tsx +77 -0
- package/src/components/common/VariableColorPicker.tsx +622 -0
- package/src/components/left-panel/AddElementPanel.tsx +237 -0
- package/src/components/left-panel/ComponentsPanel.tsx +609 -0
- package/src/components/left-panel/IconSidebar.tsx +99 -0
- package/src/components/left-panel/LayerNode.tsx +874 -0
- package/src/components/left-panel/LayerSearch.tsx +23 -0
- package/src/components/left-panel/LayersPanel.tsx +52 -0
- package/src/components/left-panel/LeftPanel.tsx +122 -0
- package/src/components/left-panel/PagesPanel.tsx +114 -0
- package/src/components/left-panel/icons.tsx +162 -0
- package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
- package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
- package/src/components/right-panel/ElementLogBox.tsx +248 -0
- package/src/components/right-panel/PanelTabs.tsx +83 -0
- package/src/components/right-panel/RightPanel.tsx +41 -0
- package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
- package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
- package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
- package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
- package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
- package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
- package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
- package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
- package/src/components/right-panel/claude/DiffCard.tsx +130 -0
- package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
- package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
- package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
- package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
- package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
- package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
- package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
- package/src/components/right-panel/design/BorderSection.tsx +161 -0
- package/src/components/right-panel/design/CSSRawView.tsx +412 -0
- package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
- package/src/components/right-panel/design/DesignPanel.tsx +275 -0
- package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
- package/src/components/right-panel/design/GradientEditor.tsx +726 -0
- package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
- package/src/components/right-panel/design/PositionSection.tsx +865 -0
- package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
- package/src/components/right-panel/design/SVGSection.tsx +361 -0
- package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
- package/src/components/right-panel/design/SizeSection.tsx +183 -0
- package/src/components/right-panel/design/TextSection.tsx +719 -0
- package/src/components/right-panel/design/icons.tsx +948 -0
- package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
- package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
- package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
- package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
- package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
- package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
- package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
- package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
- package/src/hooks/useBridge.ts +95 -0
- package/src/hooks/useChangeTracker.ts +563 -0
- package/src/hooks/useClaudeAPI.ts +118 -0
- package/src/hooks/useDOMTree.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.ts +76 -0
- package/src/hooks/usePostMessage.ts +589 -0
- package/src/hooks/useProjectScan.ts +204 -0
- package/src/hooks/useResizable.ts +20 -0
- package/src/hooks/useSelectedElement.ts +51 -0
- package/src/hooks/useTargetUrl.ts +81 -0
- package/src/inspector/DOMTraverser.ts +71 -0
- package/src/inspector/ElementSelector.ts +23 -0
- package/src/inspector/HoverHighlighter.ts +54 -0
- package/src/inspector/SelectionHighlighter.ts +27 -0
- package/src/inspector/StyleExtractor.ts +19 -0
- package/src/inspector/inspector.ts +17 -0
- package/src/inspector/messaging.ts +30 -0
- package/src/lib/apiBase.ts +15 -0
- package/src/lib/classifyElement.ts +430 -0
- package/src/lib/claude-bin.ts +197 -0
- package/src/lib/claude-stream.ts +158 -0
- package/src/lib/clientProjectScanner.ts +344 -0
- package/src/lib/componentMatcher.ts +156 -0
- package/src/lib/constants.ts +573 -0
- package/src/lib/cssVariableUtils.ts +409 -0
- package/src/lib/diffParser.ts +206 -0
- package/src/lib/folderPicker.ts +84 -0
- package/src/lib/gradientParser.ts +160 -0
- package/src/lib/projectScanner.ts +355 -0
- package/src/lib/promptBuilder.ts +402 -0
- package/src/lib/shadowParser.ts +124 -0
- package/src/lib/tailwindClassParser.ts +248 -0
- package/src/lib/textShadowUtils.ts +106 -0
- package/src/lib/utils.ts +299 -0
- package/src/lib/validatePath.ts +40 -0
- package/src/proxy.ts +92 -0
- package/src/server/terminal-server.ts +104 -0
- package/src/store/changeSlice.ts +288 -0
- package/src/store/claudeSlice.ts +222 -0
- package/src/store/componentSlice.ts +90 -0
- package/src/store/consoleSlice.ts +51 -0
- package/src/store/cssVariableSlice.ts +94 -0
- package/src/store/elementSlice.ts +78 -0
- package/src/store/index.ts +35 -0
- package/src/store/terminalSlice.ts +30 -0
- package/src/store/treeSlice.ts +69 -0
- package/src/store/uiSlice.ts +327 -0
- package/src/types/changelog.ts +49 -0
- package/src/types/claude.ts +131 -0
- package/src/types/component.ts +49 -0
- package/src/types/cssVariables.ts +18 -0
- package/src/types/element.ts +21 -0
- package/src/types/file-system-access.d.ts +27 -0
- package/src/types/gradient.ts +12 -0
- package/src/types/messages.ts +392 -0
- package/src/types/shadow.ts +8 -0
- package/src/types/tree.ts +9 -0
- package/tsconfig.json +42 -0
- package/tsconfig.server.json +12 -0
|
@@ -0,0 +1,1474 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState, useCallback, useRef } from 'react'
|
|
4
|
+
import { useEditorStore } from '@/store'
|
|
5
|
+
import { useChangeTracker } from '@/hooks/useChangeTracker'
|
|
6
|
+
import {
|
|
7
|
+
buildInstructionsFooter,
|
|
8
|
+
getBreakpointDeviceInfo,
|
|
9
|
+
} from '@/lib/constants'
|
|
10
|
+
import { inferSourcePath } from '@/lib/classifyElement'
|
|
11
|
+
import { camelToKebab } from '@/lib/utils'
|
|
12
|
+
import { consumeClaudeStream, formatStderrLine } from '@/lib/claude-stream'
|
|
13
|
+
import { EditablePre } from '@/components/common/EditablePre'
|
|
14
|
+
import { AiScanResultPanel } from './AiScanResultPanel'
|
|
15
|
+
import type {
|
|
16
|
+
StyleChange,
|
|
17
|
+
ElementSnapshot,
|
|
18
|
+
Breakpoint,
|
|
19
|
+
} from '@/types/changelog'
|
|
20
|
+
import type { FileMap, ClaudeScanResponse } from '@/types/claude'
|
|
21
|
+
|
|
22
|
+
type BreakpointGroupKey = 'all' | 'desktop-only' | 'tablet-only' | 'mobile-only'
|
|
23
|
+
|
|
24
|
+
const GROUP_ORDER: BreakpointGroupKey[] = [
|
|
25
|
+
'all',
|
|
26
|
+
'desktop-only',
|
|
27
|
+
'tablet-only',
|
|
28
|
+
'mobile-only',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const GROUP_META: Record<BreakpointGroupKey, { label: string }> = {
|
|
32
|
+
all: { label: 'All' },
|
|
33
|
+
'desktop-only': { label: 'Desktop Only' },
|
|
34
|
+
'tablet-only': { label: 'Tablet Only' },
|
|
35
|
+
'mobile-only': { label: 'Mobile Only' },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getGroupKey(change: StyleChange): BreakpointGroupKey {
|
|
39
|
+
const scope = change.changeScope ?? 'all'
|
|
40
|
+
if (scope === 'all') return 'all'
|
|
41
|
+
return `${change.breakpoint}-only` as BreakpointGroupKey
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function truncateText(text: string, maxLen: number): string {
|
|
45
|
+
if (!text) return '(empty)'
|
|
46
|
+
if (text.length <= maxLen) return `"${text}"`
|
|
47
|
+
return `"${text.substring(0, maxLen)}..."`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Extract component name from c- prefixed class (e.g. "c-header" → "Header", "c-nav-bar" → "Nav Bar") */
|
|
51
|
+
function getComponentName(className: string | null | undefined): string | null {
|
|
52
|
+
if (!className) return null
|
|
53
|
+
const match = className
|
|
54
|
+
.split(/\s+/)
|
|
55
|
+
.find((cls) => cls.startsWith('c-') && cls.length > 2)
|
|
56
|
+
if (!match) return null
|
|
57
|
+
return match
|
|
58
|
+
.substring(2)
|
|
59
|
+
.split('-')
|
|
60
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
61
|
+
.join(' ')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildComponentCreationLog(extraction: StyleChange): string {
|
|
65
|
+
const lines: string[] = []
|
|
66
|
+
try {
|
|
67
|
+
const data = JSON.parse(extraction.newValue) as {
|
|
68
|
+
name: string
|
|
69
|
+
variants: Array<{ groupName: string; options: string[] }>
|
|
70
|
+
}
|
|
71
|
+
const kebabName = data.name
|
|
72
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
73
|
+
.replace(/\s+/g, '-')
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
|
|
76
|
+
lines.push('=== COMPONENT EXTRACTION ===')
|
|
77
|
+
lines.push('')
|
|
78
|
+
lines.push(`### ${data.name} Component`)
|
|
79
|
+
lines.push(`- Selector: \`${extraction.elementSelector}\``)
|
|
80
|
+
lines.push(`- Suggested file: \`src/components/${kebabName}.tsx\``)
|
|
81
|
+
if (data.variants.length > 0) {
|
|
82
|
+
lines.push('- Suggested props:')
|
|
83
|
+
for (const v of data.variants) {
|
|
84
|
+
lines.push(` - ${v.groupName.toLowerCase()}: ${v.options.join(' | ')}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
lines.push('')
|
|
88
|
+
lines.push('## Instructions for Claude Code')
|
|
89
|
+
lines.push(
|
|
90
|
+
`Extract the element at selector \`${extraction.elementSelector}\` into a`,
|
|
91
|
+
)
|
|
92
|
+
lines.push(`reusable React component named \`${data.name}\`.`)
|
|
93
|
+
if (data.variants.length > 0) {
|
|
94
|
+
lines.push('Accept the following props for variant control:')
|
|
95
|
+
for (const v of data.variants) {
|
|
96
|
+
lines.push(` - ${v.groupName.toLowerCase()}: ${v.options.join(' | ')}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
lines.push('=== END COMPONENT EXTRACTION ===')
|
|
100
|
+
} catch {
|
|
101
|
+
lines.push(`Create component from ${extraction.elementSelector}`)
|
|
102
|
+
}
|
|
103
|
+
return lines.join('\n')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function CopyIcon({ size = 14 }: { size?: number }) {
|
|
107
|
+
return (
|
|
108
|
+
<svg
|
|
109
|
+
width={size}
|
|
110
|
+
height={size}
|
|
111
|
+
viewBox="0 0 24 24"
|
|
112
|
+
fill="none"
|
|
113
|
+
stroke="currentColor"
|
|
114
|
+
strokeWidth={2}
|
|
115
|
+
strokeLinecap="round"
|
|
116
|
+
strokeLinejoin="round"
|
|
117
|
+
>
|
|
118
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
119
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
120
|
+
</svg>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function CheckIcon({ size = 14 }: { size?: number }) {
|
|
125
|
+
return (
|
|
126
|
+
<svg
|
|
127
|
+
width={size}
|
|
128
|
+
height={size}
|
|
129
|
+
viewBox="0 0 24 24"
|
|
130
|
+
fill="none"
|
|
131
|
+
stroke="currentColor"
|
|
132
|
+
strokeWidth={2}
|
|
133
|
+
strokeLinecap="round"
|
|
134
|
+
strokeLinejoin="round"
|
|
135
|
+
>
|
|
136
|
+
<polyline points="20 6 9 17 4 12" />
|
|
137
|
+
</svg>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildSingleElementLog(
|
|
142
|
+
snapshot: ElementSnapshot,
|
|
143
|
+
changes: StyleChange[],
|
|
144
|
+
fileMap?: FileMap | null,
|
|
145
|
+
projectRoot?: string | null,
|
|
146
|
+
framework?: string | null,
|
|
147
|
+
cssStrategy?: string[] | null,
|
|
148
|
+
): string {
|
|
149
|
+
const lines: string[] = []
|
|
150
|
+
const isMobileApp = framework === 'flutter' || framework === 'react-native'
|
|
151
|
+
|
|
152
|
+
const attrParts: string[] = []
|
|
153
|
+
if (snapshot.elementId) attrParts.push(`id="${snapshot.elementId}"`)
|
|
154
|
+
if (snapshot.className) attrParts.push(`class="${snapshot.className}"`)
|
|
155
|
+
const tag = `<${snapshot.tagName}${attrParts.length ? ' ' + attrParts.join(' ') : ''}>`
|
|
156
|
+
|
|
157
|
+
const changeBp = (changes[0]?.breakpoint || 'mobile') as Breakpoint
|
|
158
|
+
const { deviceName, range } = getBreakpointDeviceInfo(changeBp)
|
|
159
|
+
|
|
160
|
+
const compName = getComponentName(snapshot.className)
|
|
161
|
+
if (compName) {
|
|
162
|
+
lines.push('COMPONENT NAME')
|
|
163
|
+
lines.push(compName)
|
|
164
|
+
lines.push('')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
lines.push('CHANGES')
|
|
168
|
+
for (const c of changes) {
|
|
169
|
+
if (c.property === '__element_inserted__') {
|
|
170
|
+
lines.push(` element inserted (${snapshot.tagName})`)
|
|
171
|
+
} else if (c.property === '__element_moved__') {
|
|
172
|
+
lines.push(` element moved → ${c.newValue}`)
|
|
173
|
+
} else if (c.property === '__element_deleted__') {
|
|
174
|
+
lines.push(` element deleted (was display: ${c.originalValue})`)
|
|
175
|
+
} else if (c.property === '__text_content__') {
|
|
176
|
+
lines.push(` text content: "${c.originalValue}" → "${c.newValue}"`)
|
|
177
|
+
} else {
|
|
178
|
+
const cInfo = getBreakpointDeviceInfo(c.breakpoint)
|
|
179
|
+
lines.push(
|
|
180
|
+
` ${camelToKebab(c.property)}: "${c.originalValue}" → "${c.newValue}" [${cInfo.deviceName} ${cInfo.range}]`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
lines.push('')
|
|
185
|
+
|
|
186
|
+
lines.push('PAGE NAME')
|
|
187
|
+
lines.push(snapshot.pagePath || '/')
|
|
188
|
+
lines.push('')
|
|
189
|
+
|
|
190
|
+
lines.push('ELEMENT')
|
|
191
|
+
lines.push(tag)
|
|
192
|
+
lines.push('')
|
|
193
|
+
|
|
194
|
+
if (!isMobileApp) {
|
|
195
|
+
lines.push('DEVICE')
|
|
196
|
+
lines.push(`Device Name: ${deviceName}`)
|
|
197
|
+
lines.push(`Breakpoint: ${range}`)
|
|
198
|
+
lines.push('')
|
|
199
|
+
lines.push('APPLIES TO')
|
|
200
|
+
lines.push(
|
|
201
|
+
snapshot.changeScope === 'all'
|
|
202
|
+
? 'All breakpoints'
|
|
203
|
+
: `${deviceName} (${range})`,
|
|
204
|
+
)
|
|
205
|
+
lines.push('')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const attrEntries = Object.entries(snapshot.attributes)
|
|
209
|
+
if (attrEntries.length > 0) {
|
|
210
|
+
lines.push('ATTRIBUTES')
|
|
211
|
+
for (const [key, value] of attrEntries) {
|
|
212
|
+
lines.push(` ${key}: ${value}`)
|
|
213
|
+
}
|
|
214
|
+
lines.push('')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (snapshot.innerText) {
|
|
218
|
+
lines.push('INNER TEXT')
|
|
219
|
+
lines.push(snapshot.innerText)
|
|
220
|
+
lines.push('')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
lines.push(
|
|
224
|
+
buildInstructionsFooter(changes.length, 1, { framework, cssStrategy }),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return lines.join('\n').trim()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildElementSection(
|
|
231
|
+
snapshot: ElementSnapshot,
|
|
232
|
+
changes: StyleChange[],
|
|
233
|
+
fileMap?: FileMap | null,
|
|
234
|
+
projectRoot?: string | null,
|
|
235
|
+
framework?: string | null,
|
|
236
|
+
cssStrategy?: string[] | null,
|
|
237
|
+
): string {
|
|
238
|
+
const lines: string[] = []
|
|
239
|
+
const isMobileApp = framework === 'flutter' || framework === 'react-native'
|
|
240
|
+
|
|
241
|
+
const attrParts: string[] = []
|
|
242
|
+
if (snapshot.elementId) attrParts.push(`id="${snapshot.elementId}"`)
|
|
243
|
+
if (snapshot.className) attrParts.push(`class="${snapshot.className}"`)
|
|
244
|
+
const tag = `<${snapshot.tagName}${attrParts.length ? ' ' + attrParts.join(' ') : ''}>`
|
|
245
|
+
|
|
246
|
+
const changeBp = (changes[0]?.breakpoint || 'mobile') as Breakpoint
|
|
247
|
+
const { deviceName: elDevice, range: elRange } =
|
|
248
|
+
getBreakpointDeviceInfo(changeBp)
|
|
249
|
+
|
|
250
|
+
const compName = getComponentName(snapshot.className)
|
|
251
|
+
if (compName) {
|
|
252
|
+
lines.push('COMPONENT NAME')
|
|
253
|
+
lines.push(compName)
|
|
254
|
+
lines.push('')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
lines.push('CHANGES')
|
|
258
|
+
for (const c of changes) {
|
|
259
|
+
if (c.property === '__element_inserted__') {
|
|
260
|
+
lines.push(` element inserted (${snapshot.tagName})`)
|
|
261
|
+
} else if (c.property === '__element_moved__') {
|
|
262
|
+
lines.push(` element moved → ${c.newValue}`)
|
|
263
|
+
} else if (c.property === '__element_deleted__') {
|
|
264
|
+
lines.push(` element deleted (was display: ${c.originalValue})`)
|
|
265
|
+
} else if (c.property === '__text_content__') {
|
|
266
|
+
lines.push(` text content: "${c.originalValue}" → "${c.newValue}"`)
|
|
267
|
+
} else {
|
|
268
|
+
const cInfo = getBreakpointDeviceInfo(c.breakpoint)
|
|
269
|
+
lines.push(
|
|
270
|
+
` ${camelToKebab(c.property)}: "${c.originalValue}" → "${c.newValue}" [${cInfo.deviceName} ${cInfo.range}]`,
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
lines.push('')
|
|
275
|
+
|
|
276
|
+
lines.push('PAGE NAME')
|
|
277
|
+
lines.push(snapshot.pagePath || '/')
|
|
278
|
+
lines.push('')
|
|
279
|
+
|
|
280
|
+
lines.push('ELEMENT')
|
|
281
|
+
lines.push(tag)
|
|
282
|
+
lines.push('')
|
|
283
|
+
|
|
284
|
+
if (!isMobileApp) {
|
|
285
|
+
lines.push('APPLIES TO')
|
|
286
|
+
lines.push(
|
|
287
|
+
snapshot.changeScope === 'all'
|
|
288
|
+
? 'All breakpoints'
|
|
289
|
+
: `${elDevice} (${elRange})`,
|
|
290
|
+
)
|
|
291
|
+
lines.push('')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const attrEntries = Object.entries(snapshot.attributes)
|
|
295
|
+
if (attrEntries.length > 0) {
|
|
296
|
+
lines.push('ATTRIBUTES')
|
|
297
|
+
for (const [key, value] of attrEntries) {
|
|
298
|
+
lines.push(` ${key}: ${value}`)
|
|
299
|
+
}
|
|
300
|
+
lines.push('')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (snapshot.innerText) {
|
|
304
|
+
lines.push('INNER TEXT')
|
|
305
|
+
lines.push(snapshot.innerText)
|
|
306
|
+
lines.push('')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return lines.join('\n')
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function buildGroupLog(opts: {
|
|
313
|
+
groupLabel: string
|
|
314
|
+
elements: Array<{ snapshot: ElementSnapshot; changes: StyleChange[] }>
|
|
315
|
+
targetUrl: string | null
|
|
316
|
+
pagePath: string
|
|
317
|
+
breakpoint: Breakpoint
|
|
318
|
+
fileMap?: FileMap | null
|
|
319
|
+
projectRoot?: string | null
|
|
320
|
+
framework?: string | null
|
|
321
|
+
cssStrategy?: string[] | null
|
|
322
|
+
}): string {
|
|
323
|
+
const lines: string[] = []
|
|
324
|
+
const isMobileApp =
|
|
325
|
+
opts.framework === 'flutter' || opts.framework === 'react-native'
|
|
326
|
+
const totalChanges = opts.elements.reduce(
|
|
327
|
+
(sum, g) => sum + g.changes.length,
|
|
328
|
+
0,
|
|
329
|
+
)
|
|
330
|
+
const { deviceName, range } = getBreakpointDeviceInfo(opts.breakpoint)
|
|
331
|
+
|
|
332
|
+
lines.push('=== DEV EDITOR CHANGELOG ===')
|
|
333
|
+
lines.push(`Scope: ${opts.groupLabel}`)
|
|
334
|
+
if (opts.targetUrl) {
|
|
335
|
+
lines.push(`Project URL: ${opts.targetUrl}`)
|
|
336
|
+
lines.push(`Page: ${opts.pagePath || '/'}`)
|
|
337
|
+
if (!isMobileApp) {
|
|
338
|
+
lines.push(`Device Name: ${deviceName}`)
|
|
339
|
+
lines.push(`Breakpoint: ${range}`)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
lines.push(`Generated: ${new Date().toISOString()}`)
|
|
343
|
+
lines.push('')
|
|
344
|
+
|
|
345
|
+
for (let i = 0; i < opts.elements.length; i++) {
|
|
346
|
+
const { snapshot, changes } = opts.elements[i]
|
|
347
|
+
lines.push(
|
|
348
|
+
buildElementSection(
|
|
349
|
+
snapshot,
|
|
350
|
+
changes,
|
|
351
|
+
opts.fileMap,
|
|
352
|
+
opts.projectRoot,
|
|
353
|
+
opts.framework,
|
|
354
|
+
opts.cssStrategy,
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
if (i < opts.elements.length - 1) {
|
|
358
|
+
lines.push('')
|
|
359
|
+
lines.push('---')
|
|
360
|
+
lines.push('')
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
lines.push('')
|
|
365
|
+
lines.push(
|
|
366
|
+
buildInstructionsFooter(totalChanges, opts.elements.length, {
|
|
367
|
+
framework: opts.framework,
|
|
368
|
+
cssStrategy: opts.cssStrategy,
|
|
369
|
+
}),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
return lines.join('\n').trim()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function useCopy() {
|
|
376
|
+
const [copied, setCopied] = useState(false)
|
|
377
|
+
const copy = useCallback(async (text: string) => {
|
|
378
|
+
try {
|
|
379
|
+
await navigator.clipboard.writeText(text)
|
|
380
|
+
} catch {
|
|
381
|
+
const textarea = document.createElement('textarea')
|
|
382
|
+
textarea.value = text
|
|
383
|
+
textarea.style.position = 'fixed'
|
|
384
|
+
textarea.style.opacity = '0'
|
|
385
|
+
document.body.appendChild(textarea)
|
|
386
|
+
textarea.select()
|
|
387
|
+
document.execCommand('copy')
|
|
388
|
+
document.body.removeChild(textarea)
|
|
389
|
+
}
|
|
390
|
+
setCopied(true)
|
|
391
|
+
setTimeout(() => setCopied(false), 2000)
|
|
392
|
+
}, [])
|
|
393
|
+
return { copied, copy }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function CopyButton({ text, size = 11 }: { text: string; size?: number }) {
|
|
397
|
+
const { copied, copy } = useCopy()
|
|
398
|
+
return (
|
|
399
|
+
<button
|
|
400
|
+
onClick={(e) => {
|
|
401
|
+
e.stopPropagation()
|
|
402
|
+
copy(text)
|
|
403
|
+
}}
|
|
404
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors flex-shrink-0"
|
|
405
|
+
style={{
|
|
406
|
+
color: copied ? 'var(--success)' : 'var(--text-muted)',
|
|
407
|
+
background: 'transparent',
|
|
408
|
+
}}
|
|
409
|
+
title="Copy to clipboard"
|
|
410
|
+
>
|
|
411
|
+
{copied ? <CheckIcon size={size} /> : <CopyIcon size={size} />}
|
|
412
|
+
{copied ? 'Copied' : 'Copy'}
|
|
413
|
+
</button>
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function ClearIcon({ size = 14 }: { size?: number }) {
|
|
418
|
+
return (
|
|
419
|
+
<svg
|
|
420
|
+
width={size}
|
|
421
|
+
height={size}
|
|
422
|
+
viewBox="0 0 24 24"
|
|
423
|
+
fill="none"
|
|
424
|
+
stroke="currentColor"
|
|
425
|
+
strokeWidth={2}
|
|
426
|
+
strokeLinecap="round"
|
|
427
|
+
strokeLinejoin="round"
|
|
428
|
+
>
|
|
429
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
430
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
431
|
+
</svg>
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function ElementAccordion({
|
|
436
|
+
snapshot,
|
|
437
|
+
changes,
|
|
438
|
+
onRevert,
|
|
439
|
+
liveStyles,
|
|
440
|
+
fileMap,
|
|
441
|
+
projectRoot,
|
|
442
|
+
framework,
|
|
443
|
+
cssStrategy,
|
|
444
|
+
}: {
|
|
445
|
+
snapshot: ElementSnapshot
|
|
446
|
+
changes: StyleChange[]
|
|
447
|
+
onRevert: (id: string, selector: string, property: string) => void
|
|
448
|
+
/** When provided (current element), use these as display values instead of change.newValue. */
|
|
449
|
+
liveStyles?: Record<string, string>
|
|
450
|
+
fileMap?: FileMap | null
|
|
451
|
+
projectRoot?: string | null
|
|
452
|
+
framework?: string | null
|
|
453
|
+
cssStrategy?: string[] | null
|
|
454
|
+
}) {
|
|
455
|
+
const [open, setOpen] = useState(false)
|
|
456
|
+
const editedTextRef = useRef<string | null>(null)
|
|
457
|
+
|
|
458
|
+
const logText = useMemo(
|
|
459
|
+
() =>
|
|
460
|
+
buildSingleElementLog(
|
|
461
|
+
snapshot,
|
|
462
|
+
changes,
|
|
463
|
+
fileMap,
|
|
464
|
+
projectRoot,
|
|
465
|
+
framework,
|
|
466
|
+
cssStrategy,
|
|
467
|
+
),
|
|
468
|
+
[snapshot, changes, fileMap, projectRoot, framework, cssStrategy],
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
const copyText = editedTextRef.current ?? logText
|
|
472
|
+
|
|
473
|
+
const handleTextChange = useCallback(
|
|
474
|
+
(edited: string) => {
|
|
475
|
+
editedTextRef.current = edited === logText ? null : edited
|
|
476
|
+
},
|
|
477
|
+
[logText],
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
const sourcePath = inferSourcePath({
|
|
481
|
+
tagName: snapshot.tagName,
|
|
482
|
+
className: snapshot.className,
|
|
483
|
+
id: snapshot.elementId,
|
|
484
|
+
selectorPath: snapshot.selectorPath,
|
|
485
|
+
pagePath: snapshot.pagePath,
|
|
486
|
+
fileMap,
|
|
487
|
+
sourceInfo: snapshot.sourceInfo,
|
|
488
|
+
projectRoot,
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
const compName = getComponentName(snapshot.className)
|
|
492
|
+
const label = compName
|
|
493
|
+
? compName
|
|
494
|
+
: snapshot.elementId
|
|
495
|
+
? `${snapshot.tagName}#${snapshot.elementId}`
|
|
496
|
+
: snapshot.tagName
|
|
497
|
+
|
|
498
|
+
return (
|
|
499
|
+
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
|
500
|
+
{/* Accordion header */}
|
|
501
|
+
<div
|
|
502
|
+
onClick={() => setOpen(!open)}
|
|
503
|
+
className="flex items-center w-full px-3 py-2 text-xs hover:bg-[var(--bg-hover)] transition-colors cursor-pointer"
|
|
504
|
+
role="button"
|
|
505
|
+
tabIndex={0}
|
|
506
|
+
onKeyDown={(e) => {
|
|
507
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
508
|
+
e.preventDefault()
|
|
509
|
+
setOpen(!open)
|
|
510
|
+
}
|
|
511
|
+
}}
|
|
512
|
+
>
|
|
513
|
+
<span
|
|
514
|
+
className="mr-2 text-[10px] transition-transform"
|
|
515
|
+
style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)' }}
|
|
516
|
+
>
|
|
517
|
+
▼
|
|
518
|
+
</span>
|
|
519
|
+
<span
|
|
520
|
+
className="flex-1 text-left truncate"
|
|
521
|
+
style={{ color: 'var(--text-secondary)' }}
|
|
522
|
+
>
|
|
523
|
+
<span style={{ color: compName ? '#4ade80' : 'var(--accent)' }}>
|
|
524
|
+
{label}
|
|
525
|
+
</span>
|
|
526
|
+
{compName && (
|
|
527
|
+
<span style={{ color: 'var(--text-muted)' }}>
|
|
528
|
+
{' '}
|
|
529
|
+
({snapshot.tagName})
|
|
530
|
+
</span>
|
|
531
|
+
)}
|
|
532
|
+
<span style={{ color: 'var(--text-muted)' }}>
|
|
533
|
+
{' '}
|
|
534
|
+
· {sourcePath} · {changes.length} change
|
|
535
|
+
{changes.length !== 1 ? 's' : ''}
|
|
536
|
+
</span>
|
|
537
|
+
</span>
|
|
538
|
+
<CopyButton text={copyText} />
|
|
539
|
+
<button
|
|
540
|
+
onClick={(e) => {
|
|
541
|
+
e.stopPropagation()
|
|
542
|
+
for (const c of changes) {
|
|
543
|
+
onRevert(c.id, c.elementSelector, c.property)
|
|
544
|
+
}
|
|
545
|
+
}}
|
|
546
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors flex-shrink-0 hover:bg-[var(--bg-hover)]"
|
|
547
|
+
style={{ color: 'var(--text-muted)' }}
|
|
548
|
+
title="Clear all changes for this element"
|
|
549
|
+
>
|
|
550
|
+
<ClearIcon size={11} />
|
|
551
|
+
Clear
|
|
552
|
+
</button>
|
|
553
|
+
</div>
|
|
554
|
+
|
|
555
|
+
{/* Accordion body */}
|
|
556
|
+
{open && (
|
|
557
|
+
<div className="px-3 pb-3">
|
|
558
|
+
<EditablePre
|
|
559
|
+
text={logText}
|
|
560
|
+
onTextChange={handleTextChange}
|
|
561
|
+
className="text-[11px] font-mono whitespace-pre-wrap break-words leading-relaxed mb-2"
|
|
562
|
+
style={{ color: 'var(--text-muted)' }}
|
|
563
|
+
/>
|
|
564
|
+
|
|
565
|
+
{/* Per-change undo buttons */}
|
|
566
|
+
<div className="space-y-1">
|
|
567
|
+
{changes.map((change) => {
|
|
568
|
+
const displayVal =
|
|
569
|
+
liveStyles &&
|
|
570
|
+
change.property !== '__text_content__' &&
|
|
571
|
+
change.property !== '__element_deleted__' &&
|
|
572
|
+
change.property !== '__element_inserted__' &&
|
|
573
|
+
change.property !== '__element_moved__'
|
|
574
|
+
? (liveStyles[change.property] ?? change.newValue)
|
|
575
|
+
: change.newValue
|
|
576
|
+
return (
|
|
577
|
+
<div
|
|
578
|
+
key={change.id}
|
|
579
|
+
className="flex items-center justify-between text-xs"
|
|
580
|
+
>
|
|
581
|
+
<span
|
|
582
|
+
className="truncate"
|
|
583
|
+
style={{ color: 'var(--text-muted)' }}
|
|
584
|
+
>
|
|
585
|
+
{change.property === '__element_inserted__' ? (
|
|
586
|
+
<span style={{ color: 'var(--accent)' }}>
|
|
587
|
+
element inserted
|
|
588
|
+
</span>
|
|
589
|
+
) : change.property === '__element_moved__' ? (
|
|
590
|
+
<span style={{ color: '#fbbf24' }}>element moved</span>
|
|
591
|
+
) : change.property === '__element_deleted__' ? (
|
|
592
|
+
<span style={{ color: 'var(--error)' }}>
|
|
593
|
+
element deleted
|
|
594
|
+
</span>
|
|
595
|
+
) : change.property === '__text_content__' ? (
|
|
596
|
+
<>
|
|
597
|
+
text:{' '}
|
|
598
|
+
<span
|
|
599
|
+
style={{
|
|
600
|
+
color: 'var(--text-muted)',
|
|
601
|
+
textDecoration: 'line-through',
|
|
602
|
+
}}
|
|
603
|
+
>
|
|
604
|
+
{truncateText(change.originalValue, 20)}
|
|
605
|
+
</span>
|
|
606
|
+
{' → '}
|
|
607
|
+
<span style={{ color: 'var(--success)' }}>
|
|
608
|
+
{truncateText(change.newValue, 20)}
|
|
609
|
+
</span>
|
|
610
|
+
</>
|
|
611
|
+
) : (
|
|
612
|
+
<>
|
|
613
|
+
{camelToKebab(change.property)}:{' '}
|
|
614
|
+
<span style={{ color: 'var(--success)' }}>
|
|
615
|
+
{displayVal}
|
|
616
|
+
</span>
|
|
617
|
+
</>
|
|
618
|
+
)}
|
|
619
|
+
</span>
|
|
620
|
+
<button
|
|
621
|
+
onClick={() =>
|
|
622
|
+
onRevert(
|
|
623
|
+
change.id,
|
|
624
|
+
change.elementSelector,
|
|
625
|
+
change.property,
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
className="px-1.5 py-0.5 text-[10px] rounded flex-shrink-0 ml-2"
|
|
629
|
+
style={{
|
|
630
|
+
background: 'var(--bg-tertiary)',
|
|
631
|
+
color: 'var(--text-secondary)',
|
|
632
|
+
border: '1px solid var(--border)',
|
|
633
|
+
}}
|
|
634
|
+
>
|
|
635
|
+
Undo
|
|
636
|
+
</button>
|
|
637
|
+
</div>
|
|
638
|
+
)
|
|
639
|
+
})}
|
|
640
|
+
</div>
|
|
641
|
+
</div>
|
|
642
|
+
)}
|
|
643
|
+
</div>
|
|
644
|
+
)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
interface BreakpointGroupData {
|
|
648
|
+
key: BreakpointGroupKey
|
|
649
|
+
label: string
|
|
650
|
+
elements: Array<{
|
|
651
|
+
selector: string
|
|
652
|
+
snapshot: ElementSnapshot
|
|
653
|
+
changes: StyleChange[]
|
|
654
|
+
}>
|
|
655
|
+
allChanges: StyleChange[]
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function BreakpointGroupAccordion({
|
|
659
|
+
group,
|
|
660
|
+
targetUrl,
|
|
661
|
+
pagePath,
|
|
662
|
+
breakpoint,
|
|
663
|
+
onRevert,
|
|
664
|
+
isActiveBreakpoint,
|
|
665
|
+
selectorPath,
|
|
666
|
+
computedStyles,
|
|
667
|
+
fileMap,
|
|
668
|
+
projectRoot,
|
|
669
|
+
framework,
|
|
670
|
+
cssStrategy,
|
|
671
|
+
}: {
|
|
672
|
+
group: BreakpointGroupData
|
|
673
|
+
targetUrl: string | null
|
|
674
|
+
pagePath: string
|
|
675
|
+
breakpoint: Breakpoint
|
|
676
|
+
onRevert: (id: string, selector: string, property: string) => void
|
|
677
|
+
isActiveBreakpoint: boolean
|
|
678
|
+
selectorPath?: string | null
|
|
679
|
+
computedStyles?: Record<string, string>
|
|
680
|
+
fileMap?: FileMap | null
|
|
681
|
+
projectRoot?: string | null
|
|
682
|
+
framework?: string | null
|
|
683
|
+
cssStrategy?: string[] | null
|
|
684
|
+
}) {
|
|
685
|
+
const [open, setOpen] = useState(false)
|
|
686
|
+
const [showConfirm, setShowConfirm] = useState(false)
|
|
687
|
+
|
|
688
|
+
const groupLogText = useMemo(
|
|
689
|
+
() =>
|
|
690
|
+
buildGroupLog({
|
|
691
|
+
groupLabel: group.label,
|
|
692
|
+
elements: group.elements,
|
|
693
|
+
targetUrl,
|
|
694
|
+
pagePath,
|
|
695
|
+
breakpoint,
|
|
696
|
+
fileMap,
|
|
697
|
+
projectRoot,
|
|
698
|
+
framework,
|
|
699
|
+
cssStrategy,
|
|
700
|
+
}),
|
|
701
|
+
[
|
|
702
|
+
group,
|
|
703
|
+
targetUrl,
|
|
704
|
+
pagePath,
|
|
705
|
+
breakpoint,
|
|
706
|
+
fileMap,
|
|
707
|
+
projectRoot,
|
|
708
|
+
framework,
|
|
709
|
+
cssStrategy,
|
|
710
|
+
],
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
const totalChanges = group.allChanges.length
|
|
714
|
+
const isEmpty = group.elements.length === 0
|
|
715
|
+
|
|
716
|
+
const handleClearGroup = useCallback(() => {
|
|
717
|
+
for (const c of group.allChanges) {
|
|
718
|
+
onRevert(c.id, c.elementSelector, c.property)
|
|
719
|
+
}
|
|
720
|
+
setShowConfirm(false)
|
|
721
|
+
}, [group.allChanges, onRevert])
|
|
722
|
+
|
|
723
|
+
return (
|
|
724
|
+
<div style={{ borderBottom: '2px solid var(--border)' }}>
|
|
725
|
+
{/* Group header */}
|
|
726
|
+
<div
|
|
727
|
+
onClick={() => setOpen(!open)}
|
|
728
|
+
className="flex items-center w-full px-3 py-2 text-xs hover:bg-[var(--bg-hover)] transition-colors cursor-pointer"
|
|
729
|
+
style={{ background: 'rgba(42,42,42,0.5)' }}
|
|
730
|
+
role="button"
|
|
731
|
+
tabIndex={0}
|
|
732
|
+
onKeyDown={(e) => {
|
|
733
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
734
|
+
e.preventDefault()
|
|
735
|
+
setOpen(!open)
|
|
736
|
+
}
|
|
737
|
+
}}
|
|
738
|
+
>
|
|
739
|
+
<span
|
|
740
|
+
className="mr-2 text-[10px] transition-transform"
|
|
741
|
+
style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)' }}
|
|
742
|
+
>
|
|
743
|
+
▼
|
|
744
|
+
</span>
|
|
745
|
+
<span
|
|
746
|
+
className="flex-1 text-left font-medium"
|
|
747
|
+
style={{ color: 'var(--text-primary)' }}
|
|
748
|
+
>
|
|
749
|
+
{group.label}
|
|
750
|
+
<span
|
|
751
|
+
className="font-normal ml-2"
|
|
752
|
+
style={{ color: 'var(--text-muted)' }}
|
|
753
|
+
>
|
|
754
|
+
{totalChanges} change{totalChanges !== 1 ? 's' : ''}
|
|
755
|
+
</span>
|
|
756
|
+
</span>
|
|
757
|
+
{!isEmpty && (
|
|
758
|
+
<>
|
|
759
|
+
<CopyButton text={groupLogText} size={11} />
|
|
760
|
+
{showConfirm ? (
|
|
761
|
+
<span
|
|
762
|
+
className="flex items-center gap-1 flex-shrink-0"
|
|
763
|
+
onClick={(e) => e.stopPropagation()}
|
|
764
|
+
>
|
|
765
|
+
<button
|
|
766
|
+
onClick={handleClearGroup}
|
|
767
|
+
className="px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors"
|
|
768
|
+
style={{ background: 'var(--error)', color: '#fff' }}
|
|
769
|
+
>
|
|
770
|
+
Confirm
|
|
771
|
+
</button>
|
|
772
|
+
<button
|
|
773
|
+
onClick={() => setShowConfirm(false)}
|
|
774
|
+
className="px-1.5 py-0.5 rounded text-[10px] transition-colors"
|
|
775
|
+
style={{ color: 'var(--text-muted)' }}
|
|
776
|
+
>
|
|
777
|
+
Cancel
|
|
778
|
+
</button>
|
|
779
|
+
</span>
|
|
780
|
+
) : (
|
|
781
|
+
<button
|
|
782
|
+
onClick={(e) => {
|
|
783
|
+
e.stopPropagation()
|
|
784
|
+
setShowConfirm(true)
|
|
785
|
+
}}
|
|
786
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors flex-shrink-0 hover:bg-[var(--bg-hover)]"
|
|
787
|
+
style={{ color: 'var(--text-muted)' }}
|
|
788
|
+
title={`Clear all ${group.label.toLowerCase()} changes`}
|
|
789
|
+
>
|
|
790
|
+
<ClearIcon size={11} />
|
|
791
|
+
Clear
|
|
792
|
+
</button>
|
|
793
|
+
)}
|
|
794
|
+
</>
|
|
795
|
+
)}
|
|
796
|
+
</div>
|
|
797
|
+
|
|
798
|
+
{/* Group body: scope toggle + element accordions or empty state */}
|
|
799
|
+
{open && (
|
|
800
|
+
<div>
|
|
801
|
+
{isActiveBreakpoint && <ChangeScopeToggle />}
|
|
802
|
+
{isEmpty ? (
|
|
803
|
+
<div
|
|
804
|
+
className="px-3 py-3 text-[11px]"
|
|
805
|
+
style={{ color: 'var(--text-muted)' }}
|
|
806
|
+
>
|
|
807
|
+
No changes
|
|
808
|
+
</div>
|
|
809
|
+
) : (
|
|
810
|
+
group.elements.map(({ selector, snapshot, changes }) => (
|
|
811
|
+
<ElementAccordion
|
|
812
|
+
key={selector}
|
|
813
|
+
snapshot={snapshot}
|
|
814
|
+
changes={changes}
|
|
815
|
+
onRevert={onRevert}
|
|
816
|
+
liveStyles={
|
|
817
|
+
selector === selectorPath ? computedStyles : undefined
|
|
818
|
+
}
|
|
819
|
+
fileMap={fileMap}
|
|
820
|
+
projectRoot={projectRoot}
|
|
821
|
+
framework={framework}
|
|
822
|
+
cssStrategy={cssStrategy}
|
|
823
|
+
/>
|
|
824
|
+
))
|
|
825
|
+
)}
|
|
826
|
+
</div>
|
|
827
|
+
)}
|
|
828
|
+
</div>
|
|
829
|
+
)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function ChangeScopeToggle() {
|
|
833
|
+
const changeScope = useEditorStore((s) => s.changeScope)
|
|
834
|
+
const setChangeScope = useEditorStore((s) => s.setChangeScope)
|
|
835
|
+
const updateAllSnapshotsScope = useEditorStore(
|
|
836
|
+
(s) => s.updateAllSnapshotsScope,
|
|
837
|
+
)
|
|
838
|
+
const activeBreakpoint = useEditorStore((s) => s.activeBreakpoint)
|
|
839
|
+
|
|
840
|
+
const handleScopeChange = useCallback(
|
|
841
|
+
(scope: 'all' | 'breakpoint-only') => {
|
|
842
|
+
setChangeScope(scope)
|
|
843
|
+
updateAllSnapshotsScope(scope)
|
|
844
|
+
},
|
|
845
|
+
[setChangeScope, updateAllSnapshotsScope],
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
return (
|
|
849
|
+
<div
|
|
850
|
+
className="flex items-center justify-between px-3 py-1.5"
|
|
851
|
+
style={{ borderBottom: '1px solid var(--border)' }}
|
|
852
|
+
>
|
|
853
|
+
<span className="text-[11px]" style={{ color: 'var(--text-muted)' }}>
|
|
854
|
+
Apply to
|
|
855
|
+
</span>
|
|
856
|
+
<div
|
|
857
|
+
className="flex items-center gap-0.5 rounded p-0.5"
|
|
858
|
+
style={{ background: 'var(--bg-tertiary)' }}
|
|
859
|
+
>
|
|
860
|
+
<button
|
|
861
|
+
onClick={() => handleScopeChange('all')}
|
|
862
|
+
className="px-2 py-0.5 text-[11px] rounded transition-colors"
|
|
863
|
+
style={{
|
|
864
|
+
background:
|
|
865
|
+
changeScope === 'all'
|
|
866
|
+
? 'var(--accent-bg, rgba(74,158,255,0.15))'
|
|
867
|
+
: 'transparent',
|
|
868
|
+
color:
|
|
869
|
+
changeScope === 'all' ? 'var(--accent)' : 'var(--text-muted)',
|
|
870
|
+
}}
|
|
871
|
+
>
|
|
872
|
+
All
|
|
873
|
+
</button>
|
|
874
|
+
<button
|
|
875
|
+
onClick={() => handleScopeChange('breakpoint-only')}
|
|
876
|
+
className="px-2 py-0.5 text-[11px] rounded transition-colors capitalize"
|
|
877
|
+
style={{
|
|
878
|
+
background:
|
|
879
|
+
changeScope === 'breakpoint-only'
|
|
880
|
+
? 'var(--accent-bg, rgba(74,158,255,0.15))'
|
|
881
|
+
: 'transparent',
|
|
882
|
+
color:
|
|
883
|
+
changeScope === 'breakpoint-only'
|
|
884
|
+
? 'var(--accent)'
|
|
885
|
+
: 'var(--text-muted)',
|
|
886
|
+
}}
|
|
887
|
+
>
|
|
888
|
+
{activeBreakpoint} only
|
|
889
|
+
</button>
|
|
890
|
+
</div>
|
|
891
|
+
</div>
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function ScanIcon({ size = 14 }: { size?: number }) {
|
|
896
|
+
return (
|
|
897
|
+
<svg
|
|
898
|
+
width={size}
|
|
899
|
+
height={size}
|
|
900
|
+
viewBox="0 0 24 24"
|
|
901
|
+
fill="none"
|
|
902
|
+
stroke="currentColor"
|
|
903
|
+
strokeWidth={2}
|
|
904
|
+
strokeLinecap="round"
|
|
905
|
+
strokeLinejoin="round"
|
|
906
|
+
>
|
|
907
|
+
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
|
908
|
+
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
|
909
|
+
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
|
910
|
+
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
|
911
|
+
<line x1="7" y1="12" x2="17" y2="12" />
|
|
912
|
+
</svg>
|
|
913
|
+
)
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function SpinnerIcon({ size = 14 }: { size?: number }) {
|
|
917
|
+
return (
|
|
918
|
+
<svg
|
|
919
|
+
width={size}
|
|
920
|
+
height={size}
|
|
921
|
+
viewBox="0 0 24 24"
|
|
922
|
+
fill="none"
|
|
923
|
+
stroke="currentColor"
|
|
924
|
+
strokeWidth={2}
|
|
925
|
+
strokeLinecap="round"
|
|
926
|
+
className="animate-spin"
|
|
927
|
+
>
|
|
928
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
929
|
+
</svg>
|
|
930
|
+
)
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function BottomActionBar({
|
|
934
|
+
copyAllText,
|
|
935
|
+
changeCount,
|
|
936
|
+
showClearConfirm,
|
|
937
|
+
onClearAll,
|
|
938
|
+
onShowClearConfirm,
|
|
939
|
+
onCancelClear,
|
|
940
|
+
onAiScan,
|
|
941
|
+
aiScanStatus,
|
|
942
|
+
}: {
|
|
943
|
+
copyAllText: string
|
|
944
|
+
changeCount: number
|
|
945
|
+
showClearConfirm: boolean
|
|
946
|
+
onClearAll: () => void
|
|
947
|
+
onShowClearConfirm: () => void
|
|
948
|
+
onCancelClear: () => void
|
|
949
|
+
onAiScan: () => void
|
|
950
|
+
aiScanStatus: 'idle' | 'scanning' | 'complete' | 'error'
|
|
951
|
+
}) {
|
|
952
|
+
const { copied, copy } = useCopy()
|
|
953
|
+
|
|
954
|
+
return (
|
|
955
|
+
<div
|
|
956
|
+
className="flex-shrink-0 px-3 py-3 flex flex-col gap-2"
|
|
957
|
+
style={{
|
|
958
|
+
borderTop: '1px solid var(--border)',
|
|
959
|
+
background:
|
|
960
|
+
'linear-gradient(to top, rgba(30,30,30,0.95), rgba(30,30,30,0.8))',
|
|
961
|
+
backdropFilter: 'blur(8px)',
|
|
962
|
+
}}
|
|
963
|
+
>
|
|
964
|
+
{/* Primary: Copy All Changes */}
|
|
965
|
+
<button
|
|
966
|
+
onClick={() => copy(copyAllText)}
|
|
967
|
+
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-md text-[12px] font-medium transition-all"
|
|
968
|
+
style={{
|
|
969
|
+
background: copied
|
|
970
|
+
? 'rgba(74, 222, 128, 0.15)'
|
|
971
|
+
: 'rgba(74, 158, 255, 0.12)',
|
|
972
|
+
color: copied ? 'var(--success)' : 'var(--accent)',
|
|
973
|
+
border: `1px solid ${copied ? 'rgba(74, 222, 128, 0.3)' : 'rgba(74, 158, 255, 0.25)'}`,
|
|
974
|
+
}}
|
|
975
|
+
>
|
|
976
|
+
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
|
|
977
|
+
{copied ? 'Copied to clipboard' : `Copy All Changes (${changeCount})`}
|
|
978
|
+
</button>
|
|
979
|
+
|
|
980
|
+
{/* AI Scan */}
|
|
981
|
+
<button
|
|
982
|
+
onClick={onAiScan}
|
|
983
|
+
disabled={aiScanStatus === 'scanning'}
|
|
984
|
+
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded-md text-[12px] font-medium transition-all disabled:opacity-60"
|
|
985
|
+
style={{
|
|
986
|
+
background:
|
|
987
|
+
aiScanStatus === 'scanning'
|
|
988
|
+
? 'rgba(168, 85, 247, 0.06)'
|
|
989
|
+
: 'rgba(168, 85, 247, 0.12)',
|
|
990
|
+
color: aiScanStatus === 'scanning' ? 'var(--text-muted)' : '#a855f7',
|
|
991
|
+
border: `1px solid ${aiScanStatus === 'scanning' ? 'var(--border)' : 'rgba(168, 85, 247, 0.25)'}`,
|
|
992
|
+
}}
|
|
993
|
+
>
|
|
994
|
+
{aiScanStatus === 'scanning' ? (
|
|
995
|
+
<SpinnerIcon size={14} />
|
|
996
|
+
) : (
|
|
997
|
+
<ScanIcon size={14} />
|
|
998
|
+
)}
|
|
999
|
+
{aiScanStatus === 'scanning' ? 'Scanning...' : 'AI Scan'}
|
|
1000
|
+
</button>
|
|
1001
|
+
|
|
1002
|
+
{/* Secondary: Clear */}
|
|
1003
|
+
{showClearConfirm ? (
|
|
1004
|
+
<div className="flex items-center gap-2">
|
|
1005
|
+
<button
|
|
1006
|
+
onClick={onClearAll}
|
|
1007
|
+
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-md text-[12px] font-medium transition-all"
|
|
1008
|
+
style={{
|
|
1009
|
+
background: 'rgba(248, 113, 113, 0.15)',
|
|
1010
|
+
color: 'var(--error)',
|
|
1011
|
+
border: '1px solid rgba(248, 113, 113, 0.3)',
|
|
1012
|
+
}}
|
|
1013
|
+
>
|
|
1014
|
+
<ClearIcon size={12} />
|
|
1015
|
+
Confirm Clear
|
|
1016
|
+
</button>
|
|
1017
|
+
<button
|
|
1018
|
+
onClick={onCancelClear}
|
|
1019
|
+
className="py-2 px-4 rounded-md text-[12px] transition-all"
|
|
1020
|
+
style={{
|
|
1021
|
+
color: 'var(--text-muted)',
|
|
1022
|
+
border: '1px solid var(--border)',
|
|
1023
|
+
}}
|
|
1024
|
+
>
|
|
1025
|
+
Cancel
|
|
1026
|
+
</button>
|
|
1027
|
+
</div>
|
|
1028
|
+
) : (
|
|
1029
|
+
<button
|
|
1030
|
+
onClick={onShowClearConfirm}
|
|
1031
|
+
className="w-full flex items-center justify-center gap-1.5 py-2 px-4 rounded-md text-[12px] transition-all"
|
|
1032
|
+
style={{
|
|
1033
|
+
color: 'var(--text-muted)',
|
|
1034
|
+
border: '1px solid var(--border)',
|
|
1035
|
+
}}
|
|
1036
|
+
title="Clear all changes"
|
|
1037
|
+
>
|
|
1038
|
+
<ClearIcon size={12} />
|
|
1039
|
+
Clear All Changes
|
|
1040
|
+
</button>
|
|
1041
|
+
)}
|
|
1042
|
+
</div>
|
|
1043
|
+
)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export function ChangesPanel() {
|
|
1047
|
+
const styleChanges = useEditorStore((s) => s.styleChanges)
|
|
1048
|
+
const elementSnapshots = useEditorStore((s) => s.elementSnapshots)
|
|
1049
|
+
const targetUrl = useEditorStore((s) => s.targetUrl)
|
|
1050
|
+
const activeBreakpoint = useEditorStore((s) => s.activeBreakpoint)
|
|
1051
|
+
const currentPagePath = useEditorStore((s) => s.currentPagePath)
|
|
1052
|
+
const selectorPath = useEditorStore((s) => s.selectorPath)
|
|
1053
|
+
const computedStyles = useEditorStore((s) => s.computedStyles)
|
|
1054
|
+
const getProjectScanForUrl = useEditorStore((s) => s.getProjectScanForUrl)
|
|
1055
|
+
const getProjectRootForUrl = useEditorStore((s) => s.getProjectRootForUrl)
|
|
1056
|
+
const aiScanStatus = useEditorStore((s) => s.aiScanStatus)
|
|
1057
|
+
const aiScanResult = useEditorStore((s) => s.aiScanResult)
|
|
1058
|
+
const aiScanError = useEditorStore((s) => s.aiScanError)
|
|
1059
|
+
const setAiScanStatus = useEditorStore((s) => s.setAiScanStatus)
|
|
1060
|
+
const setAiScanResult = useEditorStore((s) => s.setAiScanResult)
|
|
1061
|
+
const setAiScanError = useEditorStore((s) => s.setAiScanError)
|
|
1062
|
+
const resetAiScan = useEditorStore((s) => s.resetAiScan)
|
|
1063
|
+
const showToast = useEditorStore((s) => s.showToast)
|
|
1064
|
+
const setActiveRightTab = useEditorStore((s) => s.setActiveRightTab)
|
|
1065
|
+
const removeStyleChange = useEditorStore((s) => s.removeStyleChange)
|
|
1066
|
+
const removeCreatedComponent = useEditorStore((s) => s.removeCreatedComponent)
|
|
1067
|
+
const { revertChange } = useChangeTracker()
|
|
1068
|
+
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
|
1069
|
+
const setActiveLeftTab = useEditorStore((s) => s.setActiveLeftTab)
|
|
1070
|
+
const scanAbortRef = useRef<AbortController | null>(null)
|
|
1071
|
+
|
|
1072
|
+
const projectScan = useMemo(() => {
|
|
1073
|
+
return getProjectScanForUrl(targetUrl)
|
|
1074
|
+
}, [targetUrl, getProjectScanForUrl])
|
|
1075
|
+
|
|
1076
|
+
const fileMap = projectScan?.fileMap ?? null
|
|
1077
|
+
const framework = projectScan?.framework ?? null
|
|
1078
|
+
const cssStrategy = projectScan?.cssStrategy ?? null
|
|
1079
|
+
|
|
1080
|
+
const projectRoot = useMemo(() => {
|
|
1081
|
+
return getProjectRootForUrl(targetUrl)
|
|
1082
|
+
}, [targetUrl, getProjectRootForUrl])
|
|
1083
|
+
|
|
1084
|
+
// Separate component extractions from regular changes
|
|
1085
|
+
const { componentExtractions, regularChanges } = useMemo(() => {
|
|
1086
|
+
const extractions: StyleChange[] = []
|
|
1087
|
+
const regular: StyleChange[] = []
|
|
1088
|
+
for (const change of styleChanges) {
|
|
1089
|
+
if (change.property === '__component_creation__') {
|
|
1090
|
+
extractions.push(change)
|
|
1091
|
+
} else {
|
|
1092
|
+
regular.push(change)
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return { componentExtractions: extractions, regularChanges: regular }
|
|
1096
|
+
}, [styleChanges])
|
|
1097
|
+
|
|
1098
|
+
// Filter regular changes to current breakpoint
|
|
1099
|
+
const breakpointChanges = useMemo(() => {
|
|
1100
|
+
return regularChanges.filter((c) => c.breakpoint === activeBreakpoint)
|
|
1101
|
+
}, [regularChanges, activeBreakpoint])
|
|
1102
|
+
|
|
1103
|
+
// Group filtered changes by element selector
|
|
1104
|
+
const elementGroups = useMemo(() => {
|
|
1105
|
+
const elementMap = new Map<string, StyleChange[]>()
|
|
1106
|
+
for (const change of breakpointChanges) {
|
|
1107
|
+
const existing = elementMap.get(change.elementSelector) || []
|
|
1108
|
+
existing.push(change)
|
|
1109
|
+
elementMap.set(change.elementSelector, existing)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const elements: Array<{
|
|
1113
|
+
selector: string
|
|
1114
|
+
snapshot: ElementSnapshot
|
|
1115
|
+
changes: StyleChange[]
|
|
1116
|
+
}> = []
|
|
1117
|
+
for (const [selector, changes] of elementMap) {
|
|
1118
|
+
const snapshot = elementSnapshots[selector]
|
|
1119
|
+
if (snapshot) {
|
|
1120
|
+
elements.push({ selector, snapshot, changes })
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return elements
|
|
1124
|
+
}, [breakpointChanges, elementSnapshots])
|
|
1125
|
+
|
|
1126
|
+
// Build "Copy All" log text (includes component extractions)
|
|
1127
|
+
const copyAllText = useMemo(() => {
|
|
1128
|
+
const hasChanges = elementGroups.length > 0
|
|
1129
|
+
const hasExtractions = componentExtractions.length > 0
|
|
1130
|
+
if (!hasChanges && !hasExtractions) return ''
|
|
1131
|
+
|
|
1132
|
+
const parts: string[] = []
|
|
1133
|
+
|
|
1134
|
+
// Component extractions section
|
|
1135
|
+
if (hasExtractions) {
|
|
1136
|
+
for (const extraction of componentExtractions) {
|
|
1137
|
+
parts.push(buildComponentCreationLog(extraction))
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Style changes section
|
|
1142
|
+
if (hasChanges) {
|
|
1143
|
+
parts.push(
|
|
1144
|
+
buildGroupLog({
|
|
1145
|
+
groupLabel: 'All Changes',
|
|
1146
|
+
elements: elementGroups,
|
|
1147
|
+
targetUrl,
|
|
1148
|
+
pagePath: currentPagePath,
|
|
1149
|
+
breakpoint: activeBreakpoint,
|
|
1150
|
+
fileMap,
|
|
1151
|
+
projectRoot,
|
|
1152
|
+
framework,
|
|
1153
|
+
cssStrategy,
|
|
1154
|
+
}),
|
|
1155
|
+
)
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
return parts.join('\n\n')
|
|
1159
|
+
}, [
|
|
1160
|
+
elementGroups,
|
|
1161
|
+
componentExtractions,
|
|
1162
|
+
targetUrl,
|
|
1163
|
+
currentPagePath,
|
|
1164
|
+
activeBreakpoint,
|
|
1165
|
+
fileMap,
|
|
1166
|
+
projectRoot,
|
|
1167
|
+
framework,
|
|
1168
|
+
cssStrategy,
|
|
1169
|
+
])
|
|
1170
|
+
|
|
1171
|
+
const handleRevertExtraction = useCallback(
|
|
1172
|
+
(extraction: StyleChange) => {
|
|
1173
|
+
removeStyleChange(extraction.id)
|
|
1174
|
+
removeCreatedComponent(extraction.elementSelector)
|
|
1175
|
+
},
|
|
1176
|
+
[removeStyleChange, removeCreatedComponent],
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1179
|
+
const handleClearAll = useCallback(() => {
|
|
1180
|
+
// Revert component extractions
|
|
1181
|
+
for (const extraction of componentExtractions) {
|
|
1182
|
+
handleRevertExtraction(extraction)
|
|
1183
|
+
}
|
|
1184
|
+
// Revert style changes
|
|
1185
|
+
for (const { changes } of elementGroups) {
|
|
1186
|
+
for (const c of changes) {
|
|
1187
|
+
revertChange(c.id, c.elementSelector, c.property)
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
resetAiScan()
|
|
1191
|
+
setShowClearConfirm(false)
|
|
1192
|
+
}, [
|
|
1193
|
+
elementGroups,
|
|
1194
|
+
componentExtractions,
|
|
1195
|
+
revertChange,
|
|
1196
|
+
handleRevertExtraction,
|
|
1197
|
+
resetAiScan,
|
|
1198
|
+
])
|
|
1199
|
+
|
|
1200
|
+
const handleAiScan = useCallback(() => {
|
|
1201
|
+
if (!targetUrl || !projectRoot || breakpointChanges.length === 0) return
|
|
1202
|
+
|
|
1203
|
+
setAiScanStatus('scanning')
|
|
1204
|
+
setAiScanError(null)
|
|
1205
|
+
|
|
1206
|
+
// Auto-switch to Terminal tab so user sees progress
|
|
1207
|
+
setActiveLeftTab('terminal')
|
|
1208
|
+
|
|
1209
|
+
// Write header to terminal
|
|
1210
|
+
const write = useEditorStore.getState().writeToTerminal
|
|
1211
|
+
write?.('\r\n\x1b[1;35m AI Scan: Analyzing project...\x1b[0m\r\n')
|
|
1212
|
+
|
|
1213
|
+
// Abort any previous scan stream
|
|
1214
|
+
scanAbortRef.current?.abort()
|
|
1215
|
+
|
|
1216
|
+
const controller = consumeClaudeStream<ClaudeScanResponse>(
|
|
1217
|
+
'/api/claude/scan',
|
|
1218
|
+
{ changelog: copyAllText, projectRoot, projectScan },
|
|
1219
|
+
{
|
|
1220
|
+
onStderr: (line: string) => {
|
|
1221
|
+
const w = useEditorStore.getState().writeToTerminal
|
|
1222
|
+
const formatted = formatStderrLine(line)
|
|
1223
|
+
if (formatted) w?.(formatted + '\r\n')
|
|
1224
|
+
},
|
|
1225
|
+
onResult: (data: ClaudeScanResponse) => {
|
|
1226
|
+
setAiScanResult(data)
|
|
1227
|
+
setAiScanStatus('complete')
|
|
1228
|
+
showToast('success', 'AI Scan complete')
|
|
1229
|
+
const w = useEditorStore.getState().writeToTerminal
|
|
1230
|
+
w?.('\x1b[32m AI Scan complete.\x1b[0m\r\n')
|
|
1231
|
+
},
|
|
1232
|
+
onError: (err: { code: string; message: string }) => {
|
|
1233
|
+
setAiScanStatus('error')
|
|
1234
|
+
setAiScanError(err.message)
|
|
1235
|
+
showToast('error', err.message || 'AI Scan failed')
|
|
1236
|
+
const w = useEditorStore.getState().writeToTerminal
|
|
1237
|
+
w?.(`\x1b[31m Error: ${err.message}\x1b[0m\r\n`)
|
|
1238
|
+
},
|
|
1239
|
+
},
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
scanAbortRef.current = controller
|
|
1243
|
+
}, [
|
|
1244
|
+
targetUrl,
|
|
1245
|
+
projectRoot,
|
|
1246
|
+
breakpointChanges.length,
|
|
1247
|
+
copyAllText,
|
|
1248
|
+
projectScan,
|
|
1249
|
+
setAiScanStatus,
|
|
1250
|
+
setAiScanError,
|
|
1251
|
+
setAiScanResult,
|
|
1252
|
+
showToast,
|
|
1253
|
+
setActiveLeftTab,
|
|
1254
|
+
])
|
|
1255
|
+
|
|
1256
|
+
const handleSendToClaudeCode = useCallback(
|
|
1257
|
+
(prompt: string) => {
|
|
1258
|
+
// Store the edited prompt so ClaudeIntegrationPanel can pick it up
|
|
1259
|
+
setAiScanResult({ ...aiScanResult!, smartPrompt: prompt })
|
|
1260
|
+
setActiveRightTab('claude')
|
|
1261
|
+
},
|
|
1262
|
+
[aiScanResult, setAiScanResult, setActiveRightTab],
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
if (styleChanges.length === 0) {
|
|
1266
|
+
return (
|
|
1267
|
+
<div
|
|
1268
|
+
className="flex items-center justify-center h-full text-xs"
|
|
1269
|
+
style={{ color: 'var(--text-muted)' }}
|
|
1270
|
+
>
|
|
1271
|
+
No changes tracked yet
|
|
1272
|
+
</div>
|
|
1273
|
+
)
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (breakpointChanges.length === 0 && componentExtractions.length === 0) {
|
|
1277
|
+
return (
|
|
1278
|
+
<div className="flex flex-col h-full">
|
|
1279
|
+
<ChangeScopeToggle />
|
|
1280
|
+
<div
|
|
1281
|
+
className="flex items-center justify-center flex-1 text-xs"
|
|
1282
|
+
style={{ color: 'var(--text-muted)' }}
|
|
1283
|
+
>
|
|
1284
|
+
No changes on {activeBreakpoint}
|
|
1285
|
+
</div>
|
|
1286
|
+
</div>
|
|
1287
|
+
)
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
return (
|
|
1291
|
+
<div className="flex flex-col h-full">
|
|
1292
|
+
{/* Scope toggle */}
|
|
1293
|
+
<ChangeScopeToggle />
|
|
1294
|
+
|
|
1295
|
+
{/* Header */}
|
|
1296
|
+
<div
|
|
1297
|
+
className="flex items-center justify-between px-3 py-1.5 flex-shrink-0"
|
|
1298
|
+
style={{ borderBottom: '1px solid var(--border)' }}
|
|
1299
|
+
>
|
|
1300
|
+
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
1301
|
+
{breakpointChanges.length} change
|
|
1302
|
+
{breakpointChanges.length !== 1 ? 's' : ''} · {elementGroups.length}{' '}
|
|
1303
|
+
element{elementGroups.length !== 1 ? 's' : ''}
|
|
1304
|
+
</span>
|
|
1305
|
+
<span className="flex items-center gap-1">
|
|
1306
|
+
<CopyButton text={copyAllText} size={11} />
|
|
1307
|
+
{showClearConfirm ? (
|
|
1308
|
+
<span
|
|
1309
|
+
className="flex items-center gap-1"
|
|
1310
|
+
onClick={(e) => e.stopPropagation()}
|
|
1311
|
+
>
|
|
1312
|
+
<button
|
|
1313
|
+
onClick={handleClearAll}
|
|
1314
|
+
className="px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors"
|
|
1315
|
+
style={{ background: 'var(--error)', color: '#fff' }}
|
|
1316
|
+
>
|
|
1317
|
+
Confirm
|
|
1318
|
+
</button>
|
|
1319
|
+
<button
|
|
1320
|
+
onClick={() => setShowClearConfirm(false)}
|
|
1321
|
+
className="px-1.5 py-0.5 rounded text-[10px] transition-colors"
|
|
1322
|
+
style={{ color: 'var(--text-muted)' }}
|
|
1323
|
+
>
|
|
1324
|
+
Cancel
|
|
1325
|
+
</button>
|
|
1326
|
+
</span>
|
|
1327
|
+
) : (
|
|
1328
|
+
<button
|
|
1329
|
+
onClick={() => setShowClearConfirm(true)}
|
|
1330
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors flex-shrink-0 hover:bg-[var(--bg-hover)]"
|
|
1331
|
+
style={{ color: 'var(--text-muted)' }}
|
|
1332
|
+
title="Clear all changes"
|
|
1333
|
+
>
|
|
1334
|
+
<ClearIcon size={11} />
|
|
1335
|
+
Clear
|
|
1336
|
+
</button>
|
|
1337
|
+
)}
|
|
1338
|
+
</span>
|
|
1339
|
+
</div>
|
|
1340
|
+
|
|
1341
|
+
{/* Flat element list */}
|
|
1342
|
+
<div className="flex-1 overflow-y-auto">
|
|
1343
|
+
{/* Component extraction entries */}
|
|
1344
|
+
{componentExtractions.map((extraction) => {
|
|
1345
|
+
let name = 'Component'
|
|
1346
|
+
try {
|
|
1347
|
+
const data = JSON.parse(extraction.newValue)
|
|
1348
|
+
name = data.name || 'Component'
|
|
1349
|
+
} catch {
|
|
1350
|
+
/* use default */
|
|
1351
|
+
}
|
|
1352
|
+
return (
|
|
1353
|
+
<div
|
|
1354
|
+
key={extraction.id}
|
|
1355
|
+
className="flex items-center gap-2 px-3 py-2 text-xs"
|
|
1356
|
+
style={{
|
|
1357
|
+
borderBottom: '1px solid var(--border)',
|
|
1358
|
+
borderLeft: '2px solid var(--accent)',
|
|
1359
|
+
}}
|
|
1360
|
+
>
|
|
1361
|
+
<svg
|
|
1362
|
+
width="14"
|
|
1363
|
+
height="14"
|
|
1364
|
+
viewBox="0 0 16 16"
|
|
1365
|
+
fill="none"
|
|
1366
|
+
stroke="var(--accent)"
|
|
1367
|
+
strokeWidth="1.5"
|
|
1368
|
+
strokeLinecap="round"
|
|
1369
|
+
strokeLinejoin="round"
|
|
1370
|
+
className="flex-shrink-0"
|
|
1371
|
+
>
|
|
1372
|
+
<rect x="1" y="4" width="14" height="8" rx="1.5" />
|
|
1373
|
+
<path d="M4 4V2.5A1.5 1.5 0 0 1 5.5 1h5A1.5 1.5 0 0 1 12 2.5V4" />
|
|
1374
|
+
</svg>
|
|
1375
|
+
<div className="truncate flex-1">
|
|
1376
|
+
<div
|
|
1377
|
+
className="truncate font-medium"
|
|
1378
|
+
style={{ color: 'var(--accent)' }}
|
|
1379
|
+
>
|
|
1380
|
+
Create {name} component
|
|
1381
|
+
</div>
|
|
1382
|
+
<div
|
|
1383
|
+
className="truncate"
|
|
1384
|
+
style={{ color: 'var(--text-muted)', fontSize: '10px' }}
|
|
1385
|
+
>
|
|
1386
|
+
{extraction.elementSelector}
|
|
1387
|
+
</div>
|
|
1388
|
+
</div>
|
|
1389
|
+
<CopyButton
|
|
1390
|
+
text={buildComponentCreationLog(extraction)}
|
|
1391
|
+
size={11}
|
|
1392
|
+
/>
|
|
1393
|
+
<button
|
|
1394
|
+
onClick={() => handleRevertExtraction(extraction)}
|
|
1395
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors flex-shrink-0 hover:bg-[var(--bg-hover)]"
|
|
1396
|
+
style={{ color: 'var(--text-muted)' }}
|
|
1397
|
+
title="Remove component extraction"
|
|
1398
|
+
>
|
|
1399
|
+
<ClearIcon size={11} />
|
|
1400
|
+
Clear
|
|
1401
|
+
</button>
|
|
1402
|
+
</div>
|
|
1403
|
+
)
|
|
1404
|
+
})}
|
|
1405
|
+
|
|
1406
|
+
{elementGroups.map(({ selector, snapshot, changes }) => (
|
|
1407
|
+
<ElementAccordion
|
|
1408
|
+
key={selector}
|
|
1409
|
+
snapshot={snapshot}
|
|
1410
|
+
changes={changes}
|
|
1411
|
+
onRevert={revertChange}
|
|
1412
|
+
liveStyles={selector === selectorPath ? computedStyles : undefined}
|
|
1413
|
+
fileMap={fileMap}
|
|
1414
|
+
projectRoot={projectRoot}
|
|
1415
|
+
framework={framework}
|
|
1416
|
+
cssStrategy={cssStrategy}
|
|
1417
|
+
/>
|
|
1418
|
+
))}
|
|
1419
|
+
</div>
|
|
1420
|
+
|
|
1421
|
+
{/* AI Scan result panel */}
|
|
1422
|
+
{aiScanStatus === 'complete' && aiScanResult && (
|
|
1423
|
+
<AiScanResultPanel
|
|
1424
|
+
result={aiScanResult}
|
|
1425
|
+
onDismiss={resetAiScan}
|
|
1426
|
+
onSendToClaudeCode={handleSendToClaudeCode}
|
|
1427
|
+
/>
|
|
1428
|
+
)}
|
|
1429
|
+
|
|
1430
|
+
{/* AI Scan error */}
|
|
1431
|
+
{aiScanStatus === 'error' && (
|
|
1432
|
+
<div
|
|
1433
|
+
className="mx-3 mt-2 px-2 py-1.5 rounded text-[11px]"
|
|
1434
|
+
style={{
|
|
1435
|
+
background: 'rgba(248, 113, 113, 0.08)',
|
|
1436
|
+
border: '1px solid rgba(248, 113, 113, 0.25)',
|
|
1437
|
+
color: 'var(--error)',
|
|
1438
|
+
}}
|
|
1439
|
+
>
|
|
1440
|
+
{aiScanError?.includes('not authenticated') ||
|
|
1441
|
+
aiScanError?.includes('claude login') ? (
|
|
1442
|
+
<>
|
|
1443
|
+
<span className="font-medium">Claude CLI not authenticated.</span>{' '}
|
|
1444
|
+
Run{' '}
|
|
1445
|
+
<code
|
|
1446
|
+
className="px-1 py-0.5 rounded text-[10px]"
|
|
1447
|
+
style={{ background: 'rgba(248, 113, 113, 0.15)' }}
|
|
1448
|
+
>
|
|
1449
|
+
claude login
|
|
1450
|
+
</code>{' '}
|
|
1451
|
+
in your terminal, then try again.
|
|
1452
|
+
</>
|
|
1453
|
+
) : (
|
|
1454
|
+
aiScanError || 'AI Scan failed. Try again.'
|
|
1455
|
+
)}
|
|
1456
|
+
</div>
|
|
1457
|
+
)}
|
|
1458
|
+
|
|
1459
|
+
{/* Bottom action bar */}
|
|
1460
|
+
{breakpointChanges.length > 0 && (
|
|
1461
|
+
<BottomActionBar
|
|
1462
|
+
copyAllText={copyAllText}
|
|
1463
|
+
changeCount={breakpointChanges.length}
|
|
1464
|
+
showClearConfirm={showClearConfirm}
|
|
1465
|
+
onClearAll={handleClearAll}
|
|
1466
|
+
onShowClearConfirm={() => setShowClearConfirm(true)}
|
|
1467
|
+
onCancelClear={() => setShowClearConfirm(false)}
|
|
1468
|
+
onAiScan={handleAiScan}
|
|
1469
|
+
aiScanStatus={aiScanStatus}
|
|
1470
|
+
/>
|
|
1471
|
+
)}
|
|
1472
|
+
</div>
|
|
1473
|
+
)
|
|
1474
|
+
}
|