@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
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
const PROXY_HEADER = 'x-dev-editor-target'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Middleware that intercepts asset requests originating from the proxied
|
|
7
|
+
* iframe and rewrites them to go through the proxy API route.
|
|
8
|
+
*
|
|
9
|
+
* Matches /_next/ paths (static assets, images) and common asset paths
|
|
10
|
+
* (fonts, images, icons, media) that target apps may serve. This ensures
|
|
11
|
+
* icon font files, images, and other resources load correctly through
|
|
12
|
+
* the proxy even when CSS url() references aren't rewritten.
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT: We do NOT match page-level paths (e.g. /works, /about).
|
|
15
|
+
* Those are handled by the proxy's HTML URL rewriting. Matching page
|
|
16
|
+
* paths pollutes Next.js HMR route tree and causes reload loops.
|
|
17
|
+
* The referer/fetch-dest check below ensures only iframe-originated
|
|
18
|
+
* requests are proxied.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Asset file extensions that should be proxied from the iframe
|
|
22
|
+
const ASSET_EXT_RE =
|
|
23
|
+
/\.(woff2?|ttf|eot|otf|svg|png|jpe?g|gif|webp|avif|ico|mp4|webm|css|js|json|map)(\?|$)/i
|
|
24
|
+
|
|
25
|
+
export function proxy(request: NextRequest) {
|
|
26
|
+
const targetUrl = request.cookies.get(PROXY_HEADER)?.value
|
|
27
|
+
if (!targetUrl) return NextResponse.next()
|
|
28
|
+
|
|
29
|
+
const { pathname } = request.nextUrl
|
|
30
|
+
|
|
31
|
+
// Never intercept our own editor API routes
|
|
32
|
+
if (pathname.startsWith('/api/')) return NextResponse.next()
|
|
33
|
+
|
|
34
|
+
// Check if this request originates from the proxied iframe.
|
|
35
|
+
// After the navigation blocker calls history.replaceState, the referer
|
|
36
|
+
// no longer contains /api/proxy. For dynamically loaded chunks, the
|
|
37
|
+
// navigation blocker adds ?_devproxy=1 to /_next/ URLs as a proxy marker.
|
|
38
|
+
// NOTE: Do NOT use "_dp" — Next.js uses ?_dp=1 internally for CSS preloading.
|
|
39
|
+
const referer = request.headers.get('referer') || ''
|
|
40
|
+
const isFromProxy = referer.includes('/api/proxy')
|
|
41
|
+
const fetchDest = request.headers.get('sec-fetch-dest') || ''
|
|
42
|
+
const hasDynamicProxyMarker = request.nextUrl.searchParams.has('_devproxy')
|
|
43
|
+
|
|
44
|
+
if (!isFromProxy && fetchDest !== 'iframe' && !hasDynamicProxyMarker) {
|
|
45
|
+
return NextResponse.next()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Only proxy requests that look like assets (have a file extension).
|
|
49
|
+
// This prevents page-level paths from being proxied.
|
|
50
|
+
if (!pathname.startsWith('/_next/') && !ASSET_EXT_RE.test(pathname)) {
|
|
51
|
+
return NextResponse.next()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Rewrite to proxy route (rewrite is transparent — doesn't change the
|
|
55
|
+
// client-visible URL and doesn't confuse HMR since these are asset paths,
|
|
56
|
+
// not page routes).
|
|
57
|
+
const proxyUrl = new URL(`/api/proxy${pathname}`, request.url)
|
|
58
|
+
proxyUrl.searchParams.set(PROXY_HEADER, targetUrl)
|
|
59
|
+
|
|
60
|
+
// Preserve original query params (strip internal markers)
|
|
61
|
+
request.nextUrl.searchParams.forEach((value, key) => {
|
|
62
|
+
if (key !== PROXY_HEADER && key !== '_devproxy') {
|
|
63
|
+
proxyUrl.searchParams.set(key, value)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return NextResponse.rewrite(proxyUrl)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const config = {
|
|
71
|
+
matcher: [
|
|
72
|
+
// /_next/ asset paths (static files, images, fonts)
|
|
73
|
+
'/_next/static/:path*',
|
|
74
|
+
'/_next/image',
|
|
75
|
+
// Common asset directories that target apps may serve
|
|
76
|
+
// (fonts, icons, images, media, static files)
|
|
77
|
+
'/fonts/:path*',
|
|
78
|
+
'/webfonts/:path*',
|
|
79
|
+
'/assets/:path*',
|
|
80
|
+
'/images/:path*',
|
|
81
|
+
'/icons/:path*',
|
|
82
|
+
'/media/:path*',
|
|
83
|
+
'/static/:path*',
|
|
84
|
+
'/public/:path*',
|
|
85
|
+
'/avatars/:path*',
|
|
86
|
+
'/uploads/:path*',
|
|
87
|
+
'/files/:path*',
|
|
88
|
+
'/content/:path*',
|
|
89
|
+
'/img/:path*',
|
|
90
|
+
'/pics/:path*',
|
|
91
|
+
],
|
|
92
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as pty from 'node-pty'
|
|
2
|
+
|
|
3
|
+
const TERMINAL_PORT = Number(process.env.TERMINAL_PORT) || 4001
|
|
4
|
+
|
|
5
|
+
interface WsData {
|
|
6
|
+
pty: pty.IPty
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
Bun.serve<WsData>({
|
|
10
|
+
port: TERMINAL_PORT,
|
|
11
|
+
|
|
12
|
+
fetch(req, server) {
|
|
13
|
+
const url = new URL(req.url)
|
|
14
|
+
|
|
15
|
+
if (url.pathname === '/ws') {
|
|
16
|
+
const ok = server.upgrade(req)
|
|
17
|
+
if (!ok) return new Response('WebSocket upgrade failed', { status: 400 })
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (url.pathname === '/health') {
|
|
22
|
+
return new Response('ok', {
|
|
23
|
+
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// CORS preflight
|
|
28
|
+
if (req.method === 'OPTIONS') {
|
|
29
|
+
return new Response(null, {
|
|
30
|
+
status: 204,
|
|
31
|
+
headers: {
|
|
32
|
+
'Access-Control-Allow-Origin': '*',
|
|
33
|
+
'Access-Control-Allow-Methods': 'GET',
|
|
34
|
+
'Access-Control-Allow-Headers': '*',
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return new Response('Not found', { status: 404 })
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
websocket: {
|
|
43
|
+
open(ws) {
|
|
44
|
+
const shell = process.env.SHELL || '/bin/bash'
|
|
45
|
+
const term = pty.spawn(shell, [], {
|
|
46
|
+
name: 'xterm-256color',
|
|
47
|
+
cols: 80,
|
|
48
|
+
rows: 24,
|
|
49
|
+
cwd: process.env.HOME || '/',
|
|
50
|
+
env: process.env as Record<string, string>,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
ws.data = { pty: term }
|
|
54
|
+
|
|
55
|
+
term.onData((data: string) => {
|
|
56
|
+
try {
|
|
57
|
+
ws.send(data)
|
|
58
|
+
} catch {
|
|
59
|
+
// WebSocket already closed
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
term.onExit(() => {
|
|
64
|
+
try {
|
|
65
|
+
ws.close()
|
|
66
|
+
} catch {
|
|
67
|
+
// Already closed
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
message(ws, message) {
|
|
73
|
+
const { pty: term } = ws.data
|
|
74
|
+
if (typeof message === 'string') {
|
|
75
|
+
// Resize messages prefixed with \x01
|
|
76
|
+
if (message.startsWith('\x01')) {
|
|
77
|
+
try {
|
|
78
|
+
const { cols, rows } = JSON.parse(message.slice(1))
|
|
79
|
+
if (cols && rows) {
|
|
80
|
+
term.resize(cols, rows)
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Invalid resize message, ignore
|
|
84
|
+
}
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
term.write(message)
|
|
88
|
+
} else {
|
|
89
|
+
// Binary data
|
|
90
|
+
term.write(Buffer.from(message).toString())
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
close(ws) {
|
|
95
|
+
if (ws.data?.pty) {
|
|
96
|
+
ws.data.pty.kill()
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
console.log(
|
|
103
|
+
`Terminal WebSocket server running on ws://localhost:${TERMINAL_PORT}/ws`,
|
|
104
|
+
)
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { StateCreator } from 'zustand'
|
|
2
|
+
import type {
|
|
3
|
+
StyleChange,
|
|
4
|
+
ElementSnapshot,
|
|
5
|
+
UndoRedoAction,
|
|
6
|
+
} from '@/types/changelog'
|
|
7
|
+
import { LOCAL_STORAGE_KEYS } from '@/lib/constants'
|
|
8
|
+
import { generateId } from '@/lib/utils'
|
|
9
|
+
|
|
10
|
+
const MAX_UNDO_STACK = 50
|
|
11
|
+
|
|
12
|
+
export interface ChangeSlice {
|
|
13
|
+
styleChanges: StyleChange[]
|
|
14
|
+
elementSnapshots: Record<string, ElementSnapshot>
|
|
15
|
+
undoStack: UndoRedoAction[]
|
|
16
|
+
redoStack: UndoRedoAction[]
|
|
17
|
+
|
|
18
|
+
addStyleChange: (change: StyleChange) => void
|
|
19
|
+
removeStyleChange: (id: string) => void
|
|
20
|
+
clearAllChanges: () => void
|
|
21
|
+
getChangeCount: () => number
|
|
22
|
+
saveElementSnapshot: (snapshot: ElementSnapshot) => void
|
|
23
|
+
updateAllSnapshotsScope: (scope: 'all' | 'breakpoint-only') => void
|
|
24
|
+
persistChanges: (urlKey: string) => void
|
|
25
|
+
loadPersistedChanges: (urlKey: string) => void
|
|
26
|
+
pushUndo: (action: UndoRedoAction) => void
|
|
27
|
+
popUndo: () => UndoRedoAction | null
|
|
28
|
+
popRedo: () => UndoRedoAction | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const createChangeSlice: StateCreator<
|
|
32
|
+
ChangeSlice,
|
|
33
|
+
[],
|
|
34
|
+
[],
|
|
35
|
+
ChangeSlice
|
|
36
|
+
> = (set, get) => ({
|
|
37
|
+
styleChanges: [],
|
|
38
|
+
elementSnapshots: {},
|
|
39
|
+
undoStack: [],
|
|
40
|
+
redoStack: [],
|
|
41
|
+
|
|
42
|
+
addStyleChange: (change) => {
|
|
43
|
+
set((state) => {
|
|
44
|
+
const existing = state.styleChanges.findIndex(
|
|
45
|
+
(c) =>
|
|
46
|
+
c.elementSelector === change.elementSelector &&
|
|
47
|
+
c.property === change.property,
|
|
48
|
+
)
|
|
49
|
+
if (existing >= 0) {
|
|
50
|
+
const trueOriginal = state.styleChanges[existing].originalValue
|
|
51
|
+
// Auto-remove: value returned to original — no net change
|
|
52
|
+
if (change.newValue === trueOriginal) {
|
|
53
|
+
const updated = state.styleChanges.filter((_, i) => i !== existing)
|
|
54
|
+
const stillHasChanges = updated.some(
|
|
55
|
+
(c) => c.elementSelector === change.elementSelector,
|
|
56
|
+
)
|
|
57
|
+
if (!stillHasChanges) {
|
|
58
|
+
const { [change.elementSelector]: _, ...rest } =
|
|
59
|
+
state.elementSnapshots
|
|
60
|
+
return { styleChanges: updated, elementSnapshots: rest }
|
|
61
|
+
}
|
|
62
|
+
return { styleChanges: updated }
|
|
63
|
+
}
|
|
64
|
+
const updated = [...state.styleChanges]
|
|
65
|
+
updated[existing] = { ...change, originalValue: trueOriginal }
|
|
66
|
+
return { styleChanges: updated }
|
|
67
|
+
}
|
|
68
|
+
return { styleChanges: [...state.styleChanges, change] }
|
|
69
|
+
})
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
removeStyleChange: (id) => {
|
|
73
|
+
set((state) => {
|
|
74
|
+
const removed = state.styleChanges.find((c) => c.id === id)
|
|
75
|
+
const updated = state.styleChanges.filter((c) => c.id !== id)
|
|
76
|
+
// Clean up snapshot if no more changes for that element
|
|
77
|
+
if (removed) {
|
|
78
|
+
const stillHasChanges = updated.some(
|
|
79
|
+
(c) => c.elementSelector === removed.elementSelector,
|
|
80
|
+
)
|
|
81
|
+
if (!stillHasChanges) {
|
|
82
|
+
const { [removed.elementSelector]: _, ...rest } =
|
|
83
|
+
state.elementSnapshots
|
|
84
|
+
return { styleChanges: updated, elementSnapshots: rest }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { styleChanges: updated }
|
|
88
|
+
})
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
clearAllChanges: () => {
|
|
92
|
+
set({
|
|
93
|
+
styleChanges: [],
|
|
94
|
+
elementSnapshots: {},
|
|
95
|
+
undoStack: [],
|
|
96
|
+
redoStack: [],
|
|
97
|
+
})
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
getChangeCount: () => {
|
|
101
|
+
return get().styleChanges.length
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
saveElementSnapshot: (snapshot) => {
|
|
105
|
+
set((state) => {
|
|
106
|
+
// Only save if we don't already have a snapshot for this selector
|
|
107
|
+
if (state.elementSnapshots[snapshot.selectorPath]) return state
|
|
108
|
+
return {
|
|
109
|
+
elementSnapshots: {
|
|
110
|
+
...state.elementSnapshots,
|
|
111
|
+
[snapshot.selectorPath]: snapshot,
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
updateAllSnapshotsScope: (scope) => {
|
|
118
|
+
set((state) => {
|
|
119
|
+
const updated: Record<string, ElementSnapshot> = {}
|
|
120
|
+
for (const [key, snap] of Object.entries(state.elementSnapshots)) {
|
|
121
|
+
updated[key] = { ...snap, changeScope: scope }
|
|
122
|
+
}
|
|
123
|
+
return { elementSnapshots: updated }
|
|
124
|
+
})
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
persistChanges: (urlKey) => {
|
|
128
|
+
const { styleChanges, elementSnapshots } = get()
|
|
129
|
+
try {
|
|
130
|
+
localStorage.setItem(
|
|
131
|
+
LOCAL_STORAGE_KEYS.CHANGES_PREFIX + urlKey,
|
|
132
|
+
JSON.stringify({ style: styleChanges, snapshots: elementSnapshots }),
|
|
133
|
+
)
|
|
134
|
+
} catch {}
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
loadPersistedChanges: (urlKey) => {
|
|
138
|
+
try {
|
|
139
|
+
const stored = localStorage.getItem(
|
|
140
|
+
LOCAL_STORAGE_KEYS.CHANGES_PREFIX + urlKey,
|
|
141
|
+
)
|
|
142
|
+
if (stored) {
|
|
143
|
+
const parsed = JSON.parse(stored)
|
|
144
|
+
set({
|
|
145
|
+
styleChanges: parsed.style || [],
|
|
146
|
+
elementSnapshots: parsed.snapshots || {},
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
pushUndo: (action) => {
|
|
153
|
+
set((state) => ({
|
|
154
|
+
undoStack: [...state.undoStack.slice(-(MAX_UNDO_STACK - 1)), action],
|
|
155
|
+
redoStack: [],
|
|
156
|
+
}))
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
popUndo: () => {
|
|
160
|
+
const { undoStack } = get()
|
|
161
|
+
if (undoStack.length === 0) return null
|
|
162
|
+
const action = undoStack[undoStack.length - 1]
|
|
163
|
+
|
|
164
|
+
set((state) => {
|
|
165
|
+
const newUndo = state.undoStack.slice(0, -1)
|
|
166
|
+
const newRedo = [...state.redoStack, action]
|
|
167
|
+
|
|
168
|
+
let newChanges = state.styleChanges
|
|
169
|
+
let newSnapshots = state.elementSnapshots
|
|
170
|
+
|
|
171
|
+
if (action.wasNewChange) {
|
|
172
|
+
newChanges = newChanges.filter(
|
|
173
|
+
(c) =>
|
|
174
|
+
!(
|
|
175
|
+
c.elementSelector === action.elementSelector &&
|
|
176
|
+
c.property === action.property
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
const stillHas = newChanges.some(
|
|
180
|
+
(c) => c.elementSelector === action.elementSelector,
|
|
181
|
+
)
|
|
182
|
+
if (!stillHas) {
|
|
183
|
+
const { [action.elementSelector]: _, ...rest } = newSnapshots
|
|
184
|
+
newSnapshots = rest
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
const exists = newChanges.some(
|
|
188
|
+
(c) =>
|
|
189
|
+
c.elementSelector === action.elementSelector &&
|
|
190
|
+
c.property === action.property,
|
|
191
|
+
)
|
|
192
|
+
if (exists) {
|
|
193
|
+
newChanges = newChanges.map((c) =>
|
|
194
|
+
c.elementSelector === action.elementSelector &&
|
|
195
|
+
c.property === action.property
|
|
196
|
+
? { ...c, newValue: action.beforeValue }
|
|
197
|
+
: c,
|
|
198
|
+
)
|
|
199
|
+
} else {
|
|
200
|
+
// Change was auto-removed (value matched original) — re-create it
|
|
201
|
+
newChanges = [
|
|
202
|
+
...newChanges,
|
|
203
|
+
{
|
|
204
|
+
id: generateId(),
|
|
205
|
+
elementSelector: action.elementSelector,
|
|
206
|
+
property: action.property,
|
|
207
|
+
originalValue: action.afterValue,
|
|
208
|
+
newValue: action.beforeValue,
|
|
209
|
+
breakpoint: action.breakpoint,
|
|
210
|
+
timestamp: Date.now(),
|
|
211
|
+
changeScope: action.changeScope,
|
|
212
|
+
},
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
undoStack: newUndo,
|
|
219
|
+
redoStack: newRedo,
|
|
220
|
+
styleChanges: newChanges,
|
|
221
|
+
elementSnapshots: newSnapshots,
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
return action
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
popRedo: () => {
|
|
229
|
+
const { redoStack } = get()
|
|
230
|
+
if (redoStack.length === 0) return null
|
|
231
|
+
const action = redoStack[redoStack.length - 1]
|
|
232
|
+
|
|
233
|
+
set((state) => {
|
|
234
|
+
const newRedo = state.redoStack.slice(0, -1)
|
|
235
|
+
const newUndo = [...state.undoStack, action]
|
|
236
|
+
|
|
237
|
+
let newChanges = state.styleChanges
|
|
238
|
+
const idx = newChanges.findIndex(
|
|
239
|
+
(c) =>
|
|
240
|
+
c.elementSelector === action.elementSelector &&
|
|
241
|
+
c.property === action.property,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
let newSnapshots = state.elementSnapshots
|
|
245
|
+
|
|
246
|
+
if (idx >= 0) {
|
|
247
|
+
const trueOriginal = newChanges[idx].originalValue
|
|
248
|
+
// Auto-remove: redo restores value to original — no net change
|
|
249
|
+
if (action.afterValue === trueOriginal) {
|
|
250
|
+
newChanges = newChanges.filter((_, i) => i !== idx)
|
|
251
|
+
const stillHas = newChanges.some(
|
|
252
|
+
(c) => c.elementSelector === action.elementSelector,
|
|
253
|
+
)
|
|
254
|
+
if (!stillHas) {
|
|
255
|
+
const { [action.elementSelector]: _, ...rest } = newSnapshots
|
|
256
|
+
newSnapshots = rest
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
newChanges = [...newChanges]
|
|
260
|
+
newChanges[idx] = { ...newChanges[idx], newValue: action.afterValue }
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
newChanges = [
|
|
264
|
+
...newChanges,
|
|
265
|
+
{
|
|
266
|
+
id: generateId(),
|
|
267
|
+
elementSelector: action.elementSelector,
|
|
268
|
+
property: action.property,
|
|
269
|
+
originalValue: action.beforeValue,
|
|
270
|
+
newValue: action.afterValue,
|
|
271
|
+
breakpoint: action.breakpoint,
|
|
272
|
+
timestamp: Date.now(),
|
|
273
|
+
changeScope: action.changeScope,
|
|
274
|
+
},
|
|
275
|
+
]
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
undoStack: newUndo,
|
|
280
|
+
redoStack: newRedo,
|
|
281
|
+
styleChanges: newChanges,
|
|
282
|
+
elementSnapshots: newSnapshots,
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
return action
|
|
287
|
+
},
|
|
288
|
+
})
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { StateCreator } from 'zustand'
|
|
2
|
+
import type {
|
|
3
|
+
ClaudeStatus,
|
|
4
|
+
ClaudeError,
|
|
5
|
+
ParsedDiff,
|
|
6
|
+
ProjectScanResult,
|
|
7
|
+
ClaudeScanResponse,
|
|
8
|
+
} from '@/types/claude'
|
|
9
|
+
import { LOCAL_STORAGE_KEYS } from '@/lib/constants'
|
|
10
|
+
|
|
11
|
+
export interface ClaudeSlice {
|
|
12
|
+
claudeStatus: ClaudeStatus
|
|
13
|
+
projectRoot: string | null
|
|
14
|
+
portRoots: Record<string, string>
|
|
15
|
+
projectScans: Record<string, ProjectScanResult>
|
|
16
|
+
cliAvailable: boolean | null
|
|
17
|
+
sessionId: string | null
|
|
18
|
+
parsedDiffs: ParsedDiff[]
|
|
19
|
+
claudeError: ClaudeError | null
|
|
20
|
+
|
|
21
|
+
// Project scan state
|
|
22
|
+
componentFileMap: Record<string, string> | null
|
|
23
|
+
scanStatus: 'idle' | 'scanning' | 'complete' | 'error'
|
|
24
|
+
scanError: string | null
|
|
25
|
+
scannedProjectName: string | null
|
|
26
|
+
|
|
27
|
+
// AI scan state (smart prompt generation)
|
|
28
|
+
aiScanStatus: 'idle' | 'scanning' | 'complete' | 'error'
|
|
29
|
+
aiScanResult: ClaudeScanResponse | null
|
|
30
|
+
aiScanError: string | null
|
|
31
|
+
|
|
32
|
+
// Client-side directory handle (File System Access API, non-serializable)
|
|
33
|
+
directoryHandle: FileSystemDirectoryHandle | null
|
|
34
|
+
|
|
35
|
+
setClaudeStatus: (status: ClaudeStatus) => void
|
|
36
|
+
setProjectRoot: (url: string, path: string | null) => void
|
|
37
|
+
getProjectRootForUrl: (url: string | null) => string | null
|
|
38
|
+
setProjectScan: (url: string, scan: ProjectScanResult) => void
|
|
39
|
+
getProjectScanForUrl: (url: string | null) => ProjectScanResult | null
|
|
40
|
+
setCliAvailable: (available: boolean) => void
|
|
41
|
+
setSessionId: (id: string | null) => void
|
|
42
|
+
setParsedDiffs: (diffs: ParsedDiff[]) => void
|
|
43
|
+
setClaudeError: (error: ClaudeError | null) => void
|
|
44
|
+
resetClaude: () => void
|
|
45
|
+
loadPersistedClaude: () => void
|
|
46
|
+
|
|
47
|
+
// Project scan actions
|
|
48
|
+
setComponentFileMap: (map: Record<string, string> | null) => void
|
|
49
|
+
setScanStatus: (status: 'idle' | 'scanning' | 'complete' | 'error') => void
|
|
50
|
+
setScanError: (error: string | null) => void
|
|
51
|
+
setScannedProjectName: (name: string | null) => void
|
|
52
|
+
clearScan: () => void
|
|
53
|
+
|
|
54
|
+
// AI scan actions
|
|
55
|
+
setAiScanStatus: (status: 'idle' | 'scanning' | 'complete' | 'error') => void
|
|
56
|
+
setAiScanResult: (result: ClaudeScanResponse | null) => void
|
|
57
|
+
setAiScanError: (error: string | null) => void
|
|
58
|
+
resetAiScan: () => void
|
|
59
|
+
|
|
60
|
+
// Client-side directory handle actions
|
|
61
|
+
setDirectoryHandle: (handle: FileSystemDirectoryHandle | null) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function persistPortRoots(portRoots: Record<string, string>) {
|
|
65
|
+
try {
|
|
66
|
+
localStorage.setItem(
|
|
67
|
+
LOCAL_STORAGE_KEYS.CLAUDE_PORT_ROOTS,
|
|
68
|
+
JSON.stringify(portRoots),
|
|
69
|
+
)
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function persistProjectScans(scans: Record<string, ProjectScanResult>) {
|
|
74
|
+
try {
|
|
75
|
+
localStorage.setItem(
|
|
76
|
+
LOCAL_STORAGE_KEYS.CLAUDE_PROJECT_SCANS,
|
|
77
|
+
JSON.stringify(scans),
|
|
78
|
+
)
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const createClaudeSlice: StateCreator<
|
|
83
|
+
ClaudeSlice,
|
|
84
|
+
[],
|
|
85
|
+
[],
|
|
86
|
+
ClaudeSlice
|
|
87
|
+
> = (set, get) => ({
|
|
88
|
+
claudeStatus: 'idle',
|
|
89
|
+
projectRoot: null,
|
|
90
|
+
portRoots: {},
|
|
91
|
+
projectScans: {},
|
|
92
|
+
cliAvailable: null,
|
|
93
|
+
sessionId: null,
|
|
94
|
+
parsedDiffs: [],
|
|
95
|
+
claudeError: null,
|
|
96
|
+
|
|
97
|
+
componentFileMap: null,
|
|
98
|
+
scanStatus: 'idle',
|
|
99
|
+
scanError: null,
|
|
100
|
+
scannedProjectName: null,
|
|
101
|
+
|
|
102
|
+
aiScanStatus: 'idle',
|
|
103
|
+
aiScanResult: null,
|
|
104
|
+
aiScanError: null,
|
|
105
|
+
|
|
106
|
+
directoryHandle: null,
|
|
107
|
+
|
|
108
|
+
setClaudeStatus: (status) => set({ claudeStatus: status }),
|
|
109
|
+
|
|
110
|
+
setProjectRoot: (url, path) => {
|
|
111
|
+
const portRoots = { ...get().portRoots }
|
|
112
|
+
if (path) {
|
|
113
|
+
portRoots[url] = path
|
|
114
|
+
} else {
|
|
115
|
+
delete portRoots[url]
|
|
116
|
+
}
|
|
117
|
+
set({ portRoots, projectRoot: path })
|
|
118
|
+
persistPortRoots(portRoots)
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
getProjectRootForUrl: (url) => {
|
|
122
|
+
if (!url) return null
|
|
123
|
+
return get().portRoots[url] ?? null
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
setProjectScan: (url, scan) => {
|
|
127
|
+
const projectScans = { ...get().projectScans, [url]: scan }
|
|
128
|
+
set({ projectScans })
|
|
129
|
+
persistProjectScans(projectScans)
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
getProjectScanForUrl: (url) => {
|
|
133
|
+
if (!url) return null
|
|
134
|
+
return get().projectScans[url] ?? null
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
setCliAvailable: (available) => {
|
|
138
|
+
set({ cliAvailable: available })
|
|
139
|
+
try {
|
|
140
|
+
localStorage.setItem(
|
|
141
|
+
LOCAL_STORAGE_KEYS.CLAUDE_CLI_AVAILABLE,
|
|
142
|
+
JSON.stringify(available),
|
|
143
|
+
)
|
|
144
|
+
} catch {}
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
setSessionId: (id) => set({ sessionId: id }),
|
|
148
|
+
setParsedDiffs: (diffs) => set({ parsedDiffs: diffs }),
|
|
149
|
+
setClaudeError: (error) => set({ claudeError: error }),
|
|
150
|
+
|
|
151
|
+
resetClaude: () => {
|
|
152
|
+
set({
|
|
153
|
+
claudeStatus: 'idle',
|
|
154
|
+
sessionId: null,
|
|
155
|
+
parsedDiffs: [],
|
|
156
|
+
claudeError: null,
|
|
157
|
+
aiScanStatus: 'idle',
|
|
158
|
+
aiScanResult: null,
|
|
159
|
+
aiScanError: null,
|
|
160
|
+
})
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
setComponentFileMap: (map) => set({ componentFileMap: map }),
|
|
164
|
+
setScanStatus: (status) => set({ scanStatus: status }),
|
|
165
|
+
setScanError: (error) => set({ scanError: error }),
|
|
166
|
+
setScannedProjectName: (name) => set({ scannedProjectName: name }),
|
|
167
|
+
clearScan: () =>
|
|
168
|
+
set({
|
|
169
|
+
componentFileMap: null,
|
|
170
|
+
scanStatus: 'idle',
|
|
171
|
+
scanError: null,
|
|
172
|
+
scannedProjectName: null,
|
|
173
|
+
}),
|
|
174
|
+
|
|
175
|
+
setAiScanStatus: (status) => set({ aiScanStatus: status }),
|
|
176
|
+
setAiScanResult: (result) => set({ aiScanResult: result }),
|
|
177
|
+
setAiScanError: (error) => set({ aiScanError: error }),
|
|
178
|
+
resetAiScan: () =>
|
|
179
|
+
set({
|
|
180
|
+
aiScanStatus: 'idle',
|
|
181
|
+
aiScanResult: null,
|
|
182
|
+
aiScanError: null,
|
|
183
|
+
}),
|
|
184
|
+
|
|
185
|
+
setDirectoryHandle: (handle) => set({ directoryHandle: handle }),
|
|
186
|
+
|
|
187
|
+
loadPersistedClaude: () => {
|
|
188
|
+
try {
|
|
189
|
+
// Load port roots map
|
|
190
|
+
const portRootsJson = localStorage.getItem(
|
|
191
|
+
LOCAL_STORAGE_KEYS.CLAUDE_PORT_ROOTS,
|
|
192
|
+
)
|
|
193
|
+
if (portRootsJson) {
|
|
194
|
+
const portRoots = JSON.parse(portRootsJson) as Record<string, string>
|
|
195
|
+
set({ portRoots })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Migrate old single-key value: just remove it (no way to know which port it was for)
|
|
199
|
+
const oldRoot = localStorage.getItem(
|
|
200
|
+
LOCAL_STORAGE_KEYS.CLAUDE_PROJECT_ROOT,
|
|
201
|
+
)
|
|
202
|
+
if (oldRoot) {
|
|
203
|
+
localStorage.removeItem(LOCAL_STORAGE_KEYS.CLAUDE_PROJECT_ROOT)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Load project scans
|
|
207
|
+
const scansJson = localStorage.getItem(
|
|
208
|
+
LOCAL_STORAGE_KEYS.CLAUDE_PROJECT_SCANS,
|
|
209
|
+
)
|
|
210
|
+
if (scansJson) {
|
|
211
|
+
const projectScans = JSON.parse(scansJson) as Record<
|
|
212
|
+
string,
|
|
213
|
+
ProjectScanResult
|
|
214
|
+
>
|
|
215
|
+
set({ projectScans })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const cli = localStorage.getItem(LOCAL_STORAGE_KEYS.CLAUDE_CLI_AVAILABLE)
|
|
219
|
+
if (cli) set({ cliAvailable: JSON.parse(cli) })
|
|
220
|
+
} catch {}
|
|
221
|
+
},
|
|
222
|
+
})
|