@aprovan/patchwork-editor 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/.turbo/turbo-build.log +16 -0
- package/LICENSE +373 -0
- package/dist/index.d.ts +331 -0
- package/dist/index.js +1597 -0
- package/package.json +45 -0
- package/src/components/CodeBlockExtension.tsx +190 -0
- package/src/components/CodePreview.tsx +344 -0
- package/src/components/MarkdownEditor.tsx +270 -0
- package/src/components/ServicesInspector.tsx +118 -0
- package/src/components/edit/EditHistory.tsx +89 -0
- package/src/components/edit/EditModal.tsx +236 -0
- package/src/components/edit/FileTree.tsx +144 -0
- package/src/components/edit/api.ts +100 -0
- package/src/components/edit/index.ts +6 -0
- package/src/components/edit/types.ts +53 -0
- package/src/components/edit/useEditSession.ts +164 -0
- package/src/components/index.ts +5 -0
- package/src/index.ts +72 -0
- package/src/lib/code-extractor.ts +210 -0
- package/src/lib/diff.ts +308 -0
- package/src/lib/index.ts +4 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/vfs.ts +106 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo, type ReactNode } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Code,
|
|
4
|
+
Eye,
|
|
5
|
+
AlertCircle,
|
|
6
|
+
Loader2,
|
|
7
|
+
Pencil,
|
|
8
|
+
X,
|
|
9
|
+
RotateCcw,
|
|
10
|
+
Send,
|
|
11
|
+
FolderTree,
|
|
12
|
+
FileCode,
|
|
13
|
+
} from 'lucide-react';
|
|
14
|
+
import { MarkdownEditor } from '../MarkdownEditor';
|
|
15
|
+
import { EditHistory } from './EditHistory';
|
|
16
|
+
import { FileTree } from './FileTree';
|
|
17
|
+
import { useEditSession, type UseEditSessionOptions } from './useEditSession';
|
|
18
|
+
import { getActiveContent, getFiles } from './types';
|
|
19
|
+
import { Bobbin, serializeChangesToYAML, type Change } from '@aprovan/bobbin';
|
|
20
|
+
|
|
21
|
+
// Simple hash for React key to force re-render on code changes
|
|
22
|
+
function hashCode(str: string): number {
|
|
23
|
+
let hash = 0;
|
|
24
|
+
for (let i = 0; i < str.length; i++) {
|
|
25
|
+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
26
|
+
}
|
|
27
|
+
return hash;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EditModalProps extends UseEditSessionOptions {
|
|
31
|
+
isOpen: boolean;
|
|
32
|
+
onClose: (finalCode: string, editCount: number) => void;
|
|
33
|
+
renderPreview: (code: string) => ReactNode;
|
|
34
|
+
renderLoading?: () => ReactNode;
|
|
35
|
+
renderError?: (error: string) => ReactNode;
|
|
36
|
+
previewError?: string | null;
|
|
37
|
+
previewLoading?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function EditModal({
|
|
41
|
+
isOpen,
|
|
42
|
+
onClose,
|
|
43
|
+
renderPreview,
|
|
44
|
+
renderLoading,
|
|
45
|
+
renderError,
|
|
46
|
+
previewError,
|
|
47
|
+
previewLoading,
|
|
48
|
+
...sessionOptions
|
|
49
|
+
}: EditModalProps) {
|
|
50
|
+
const [showPreview, setShowPreview] = useState(true);
|
|
51
|
+
const [showTree, setShowTree] = useState(false);
|
|
52
|
+
const [editInput, setEditInput] = useState('');
|
|
53
|
+
const [bobbinChanges, setBobbinChanges] = useState<Change[]>([]);
|
|
54
|
+
const [previewContainer, setPreviewContainer] = useState<HTMLDivElement | null>(null);
|
|
55
|
+
|
|
56
|
+
const session = useEditSession(sessionOptions);
|
|
57
|
+
const code = getActiveContent(session);
|
|
58
|
+
const files = useMemo(() => getFiles(session.project), [session.project]);
|
|
59
|
+
const hasChanges = code !== (session.originalProject.files.get(session.activeFile)?.content ?? '');
|
|
60
|
+
|
|
61
|
+
const handleBobbinChanges = useCallback((changes: Change[]) => {
|
|
62
|
+
setBobbinChanges(changes);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const handleSubmit = () => {
|
|
66
|
+
if ((!editInput.trim() && bobbinChanges.length === 0) || session.isApplying) return;
|
|
67
|
+
|
|
68
|
+
// Convert bobbin changes to YAML context
|
|
69
|
+
let promptWithContext = editInput;
|
|
70
|
+
|
|
71
|
+
if (bobbinChanges.length > 0) {
|
|
72
|
+
const bobbinYaml = serializeChangesToYAML(bobbinChanges, []);
|
|
73
|
+
promptWithContext = `${editInput}\n\n---\nVisual Changes (apply these styles/modifications):\n\`\`\`yaml\n${bobbinYaml}\n\`\`\``;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
session.submitEdit(promptWithContext);
|
|
77
|
+
setEditInput('');
|
|
78
|
+
setBobbinChanges([]);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const handleClose = () => {
|
|
82
|
+
const editCount = session.history.length;
|
|
83
|
+
const finalCode = code;
|
|
84
|
+
setEditInput('');
|
|
85
|
+
session.clearError();
|
|
86
|
+
onClose(finalCode, editCount);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (!isOpen) return null;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-8">
|
|
93
|
+
<div className="flex flex-col bg-background rounded-lg shadow-xl w-full h-full max-w-6xl max-h-[90vh] overflow-hidden">
|
|
94
|
+
<div className="flex items-center gap-2 px-4 py-3 bg-background border-b-2">
|
|
95
|
+
<Pencil className="h-4 w-4 text-primary" />
|
|
96
|
+
{session.isApplying && (
|
|
97
|
+
<span className="text-xs font-medium text-primary flex items-center gap-1 ml-2">
|
|
98
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
99
|
+
Applying edits...
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
<div className="ml-auto flex gap-2">
|
|
103
|
+
{hasChanges && (
|
|
104
|
+
<button
|
|
105
|
+
onClick={session.revert}
|
|
106
|
+
className="px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-primary/20 text-primary"
|
|
107
|
+
title="Revert to original"
|
|
108
|
+
>
|
|
109
|
+
<RotateCcw className="h-3 w-3" />
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
<button
|
|
113
|
+
onClick={() => setShowTree(!showTree)}
|
|
114
|
+
className={`px-2 py-1 text-xs rounded flex items-center gap-1 ${showTree ? 'bg-primary text-primary-foreground' : 'hover:bg-primary/20 text-primary'}`}
|
|
115
|
+
title={showTree ? 'Single file' : 'File tree'}
|
|
116
|
+
>
|
|
117
|
+
{showTree ? <FileCode className="h-3 w-3" /> : <FolderTree className="h-3 w-3" />}
|
|
118
|
+
</button>
|
|
119
|
+
<button
|
|
120
|
+
onClick={() => setShowPreview(!showPreview)}
|
|
121
|
+
className={`px-2 py-1 text-xs rounded flex items-center gap-1 ${showPreview ? 'bg-primary text-primary-foreground' : 'hover:bg-primary/20 text-primary'}`}
|
|
122
|
+
>
|
|
123
|
+
{showPreview ? <Eye className="h-3 w-3" /> : <Code className="h-3 w-3" />}
|
|
124
|
+
{showPreview ? 'Preview' : 'Code'}
|
|
125
|
+
</button>
|
|
126
|
+
<button
|
|
127
|
+
onClick={handleClose}
|
|
128
|
+
className="px-2 py-1 text-xs rounded flex items-center gap-1 bg-primary text-primary-foreground hover:bg-primary/90"
|
|
129
|
+
title="Exit edit mode"
|
|
130
|
+
>
|
|
131
|
+
<X className="h-3 w-3" />
|
|
132
|
+
Done
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className="flex-1 min-h-0 border-b-2 overflow-hidden flex">
|
|
138
|
+
{showTree && (
|
|
139
|
+
<FileTree
|
|
140
|
+
files={files}
|
|
141
|
+
activeFile={session.activeFile}
|
|
142
|
+
onSelectFile={session.setActiveFile}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
<div className="flex-1 overflow-auto">
|
|
146
|
+
{showPreview ? (
|
|
147
|
+
<div className="bg-white h-full relative" ref={setPreviewContainer}>
|
|
148
|
+
{previewError && renderError ? (
|
|
149
|
+
renderError(previewError)
|
|
150
|
+
) : previewError ? (
|
|
151
|
+
<div className="p-4 text-sm text-destructive flex items-center gap-2">
|
|
152
|
+
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
153
|
+
<span>{previewError}</span>
|
|
154
|
+
</div>
|
|
155
|
+
) : previewLoading && renderLoading ? (
|
|
156
|
+
renderLoading()
|
|
157
|
+
) : previewLoading ? (
|
|
158
|
+
<div className="p-4 flex items-center gap-2 text-muted-foreground">
|
|
159
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
160
|
+
<span className="text-sm">Rendering preview...</span>
|
|
161
|
+
</div>
|
|
162
|
+
) : (
|
|
163
|
+
<div className="p-4" key={hashCode(code)}>{renderPreview(code)}</div>
|
|
164
|
+
)}
|
|
165
|
+
{!renderLoading && !renderError && !previewLoading && <Bobbin
|
|
166
|
+
container={previewContainer}
|
|
167
|
+
pillContainer={previewContainer}
|
|
168
|
+
defaultActive={false}
|
|
169
|
+
showInspector
|
|
170
|
+
onChanges={handleBobbinChanges}
|
|
171
|
+
exclude={['.bobbin-pill', '[data-bobbin]']}
|
|
172
|
+
/>}
|
|
173
|
+
</div>
|
|
174
|
+
) : (
|
|
175
|
+
<div className="p-4 bg-muted/10 h-full overflow-auto">
|
|
176
|
+
<pre className="text-xs whitespace-pre-wrap break-words m-0">
|
|
177
|
+
<code>{code}</code>
|
|
178
|
+
</pre>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<EditHistory
|
|
185
|
+
entries={session.history}
|
|
186
|
+
streamingNotes={session.streamingNotes}
|
|
187
|
+
isStreaming={session.isApplying}
|
|
188
|
+
pendingPrompt={session.pendingPrompt}
|
|
189
|
+
className="h-48"
|
|
190
|
+
/>
|
|
191
|
+
|
|
192
|
+
{session.error && (
|
|
193
|
+
<div className="px-4 py-2 bg-destructive/10 text-destructive text-sm flex items-center gap-2 border-t-2 border-destructive">
|
|
194
|
+
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
195
|
+
{session.error}
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{bobbinChanges.length > 0 && (
|
|
200
|
+
<div className="px-4 py-2 bg-blue-50 text-blue-700 text-sm flex items-center gap-2 border-t">
|
|
201
|
+
<span>{bobbinChanges.length} visual change{bobbinChanges.length !== 1 ? 's' : ''}</span>
|
|
202
|
+
<button
|
|
203
|
+
onClick={() => setBobbinChanges([])}
|
|
204
|
+
className="text-xs underline hover:no-underline"
|
|
205
|
+
>
|
|
206
|
+
Clear
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
<div className="p-4 border-t-2 bg-primary/5 flex gap-2 items-end">
|
|
212
|
+
<div className="flex-1">
|
|
213
|
+
<MarkdownEditor
|
|
214
|
+
value={editInput}
|
|
215
|
+
onChange={setEditInput}
|
|
216
|
+
onSubmit={handleSubmit}
|
|
217
|
+
placeholder="Describe changes..."
|
|
218
|
+
disabled={session.isApplying}
|
|
219
|
+
/>
|
|
220
|
+
</div>
|
|
221
|
+
<button
|
|
222
|
+
onClick={handleSubmit}
|
|
223
|
+
disabled={(!editInput.trim() && bobbinChanges.length === 0) || session.isApplying}
|
|
224
|
+
className="px-3 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1 shrink-0"
|
|
225
|
+
>
|
|
226
|
+
{session.isApplying ? (
|
|
227
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
228
|
+
) : (
|
|
229
|
+
<Send className="h-4 w-4" />
|
|
230
|
+
)}
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { ChevronRight, ChevronDown, File, Folder } from 'lucide-react';
|
|
3
|
+
import type { VirtualFile } from '@aprovan/patchwork-compiler';
|
|
4
|
+
|
|
5
|
+
interface TreeNode {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
isDir: boolean;
|
|
9
|
+
children: TreeNode[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function buildTree(files: VirtualFile[]): TreeNode {
|
|
13
|
+
const root: TreeNode = { name: '', path: '', isDir: true, children: [] };
|
|
14
|
+
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const parts = file.path.split('/');
|
|
17
|
+
let current = root;
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < parts.length; i++) {
|
|
20
|
+
const part = parts[i]!;
|
|
21
|
+
const isLast = i === parts.length - 1;
|
|
22
|
+
const currentPath = parts.slice(0, i + 1).join('/');
|
|
23
|
+
|
|
24
|
+
let child = current.children.find(c => c.name === part);
|
|
25
|
+
if (!child) {
|
|
26
|
+
child = {
|
|
27
|
+
name: part,
|
|
28
|
+
path: currentPath,
|
|
29
|
+
isDir: !isLast,
|
|
30
|
+
children: [],
|
|
31
|
+
};
|
|
32
|
+
current.children.push(child);
|
|
33
|
+
}
|
|
34
|
+
current = child;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
root.children.sort((a, b) => {
|
|
39
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
40
|
+
return a.name.localeCompare(b.name);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return root;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface TreeNodeComponentProps {
|
|
47
|
+
node: TreeNode;
|
|
48
|
+
activeFile: string;
|
|
49
|
+
onSelect: (path: string) => void;
|
|
50
|
+
depth?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }: TreeNodeComponentProps) {
|
|
54
|
+
const [expanded, setExpanded] = useState(true);
|
|
55
|
+
|
|
56
|
+
if (!node.name) {
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
{node.children.map(child => (
|
|
60
|
+
<TreeNodeComponent
|
|
61
|
+
key={child.path}
|
|
62
|
+
node={child}
|
|
63
|
+
activeFile={activeFile}
|
|
64
|
+
onSelect={onSelect}
|
|
65
|
+
depth={depth}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const isActive = node.path === activeFile;
|
|
73
|
+
|
|
74
|
+
if (node.isDir) {
|
|
75
|
+
return (
|
|
76
|
+
<div>
|
|
77
|
+
<button
|
|
78
|
+
onClick={() => setExpanded(!expanded)}
|
|
79
|
+
className="flex items-center gap-1 w-full px-2 py-1 text-left text-sm hover:bg-muted/50 rounded"
|
|
80
|
+
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
81
|
+
>
|
|
82
|
+
{expanded ? (
|
|
83
|
+
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
84
|
+
) : (
|
|
85
|
+
<ChevronRight className="h-3 w-3 shrink-0" />
|
|
86
|
+
)}
|
|
87
|
+
<Folder className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
88
|
+
<span className="truncate">{node.name}</span>
|
|
89
|
+
</button>
|
|
90
|
+
{expanded && (
|
|
91
|
+
<div>
|
|
92
|
+
{node.children.map(child => (
|
|
93
|
+
<TreeNodeComponent
|
|
94
|
+
key={child.path}
|
|
95
|
+
node={child}
|
|
96
|
+
activeFile={activeFile}
|
|
97
|
+
onSelect={onSelect}
|
|
98
|
+
depth={depth + 1}
|
|
99
|
+
/>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => onSelect(node.path)}
|
|
110
|
+
className={`flex items-center gap-1 w-full px-2 py-1 text-left text-sm hover:bg-muted/50 rounded ${
|
|
111
|
+
isActive ? 'bg-primary/10 text-primary' : ''
|
|
112
|
+
}`}
|
|
113
|
+
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
|
114
|
+
>
|
|
115
|
+
<File className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
116
|
+
<span className="truncate">{node.name}</span>
|
|
117
|
+
</button>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface FileTreeProps {
|
|
122
|
+
files: VirtualFile[];
|
|
123
|
+
activeFile: string;
|
|
124
|
+
onSelectFile: (path: string) => void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
|
|
128
|
+
const tree = useMemo(() => buildTree(files), [files]);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="w-48 border-r bg-muted/30 overflow-auto text-foreground">
|
|
132
|
+
<div className="p-2 border-b text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
133
|
+
Files
|
|
134
|
+
</div>
|
|
135
|
+
<div className="p-1">
|
|
136
|
+
<TreeNodeComponent
|
|
137
|
+
node={tree}
|
|
138
|
+
activeFile={activeFile}
|
|
139
|
+
onSelect={onSelectFile}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { applyDiffs, hasDiffBlocks, parseEditResponse } from '../../lib/diff';
|
|
2
|
+
import type { EditRequest, EditResponse } from './types';
|
|
3
|
+
|
|
4
|
+
export interface EditApiOptions {
|
|
5
|
+
endpoint?: string;
|
|
6
|
+
onProgress?: (note: string) => void;
|
|
7
|
+
/** Automatically remove stray diff markers from output (default: true) */
|
|
8
|
+
sanitize?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function sendEditRequest(
|
|
12
|
+
request: EditRequest,
|
|
13
|
+
options: EditApiOptions = {},
|
|
14
|
+
): Promise<EditResponse> {
|
|
15
|
+
const { endpoint = '/api/edit', onProgress, sanitize = true } = options;
|
|
16
|
+
|
|
17
|
+
const response = await fetch(endpoint, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'Content-Type': 'application/json' },
|
|
20
|
+
body: JSON.stringify(request),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error('Edit request failed');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const text = await streamResponse(response, onProgress);
|
|
28
|
+
|
|
29
|
+
if (!hasDiffBlocks(text)) {
|
|
30
|
+
throw new Error('No valid diffs in response');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const parsed = parseEditResponse(text);
|
|
34
|
+
const result = applyDiffs(request.code, parsed.diffs, { sanitize });
|
|
35
|
+
|
|
36
|
+
if (result.applied === 0) {
|
|
37
|
+
// Provide detailed context about failed diffs for better error feedback
|
|
38
|
+
const failedDetails = result.failed
|
|
39
|
+
.map((f, i) => `[${i + 1}] "${f}"`)
|
|
40
|
+
.join('\n');
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Failed to apply ${parsed.diffs.length} diff(s). None of the SEARCH blocks matched the code.\n\nFailed searches:\n${failedDetails}\n\nThis usually means the code has changed or the SEARCH text doesn't match exactly.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Include warning in summary if markers were detected
|
|
47
|
+
let summary = parsed.summary || `Applied ${result.applied} change(s)`;
|
|
48
|
+
if (result.warning) {
|
|
49
|
+
summary = `⚠️ ${result.warning}\n\n${summary}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
newCode: result.code,
|
|
54
|
+
summary,
|
|
55
|
+
progressNotes: parsed.progressNotes,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function streamResponse(
|
|
60
|
+
response: Response,
|
|
61
|
+
onProgress?: (note: string) => void,
|
|
62
|
+
): Promise<string> {
|
|
63
|
+
const reader = response.body?.getReader();
|
|
64
|
+
if (!reader) {
|
|
65
|
+
return response.text();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const decoder = new TextDecoder();
|
|
69
|
+
let fullText = '';
|
|
70
|
+
const emittedNotes = new Set<string>();
|
|
71
|
+
|
|
72
|
+
let done = false;
|
|
73
|
+
while (!done) {
|
|
74
|
+
const result = await reader.read();
|
|
75
|
+
done = result.done;
|
|
76
|
+
if (result.value) {
|
|
77
|
+
fullText += decoder.decode(result.value, { stream: true });
|
|
78
|
+
|
|
79
|
+
if (onProgress) {
|
|
80
|
+
// Extract notes from code fence attributes as they stream in.
|
|
81
|
+
// Format: ```lang note="description" path="@/file.tsx"
|
|
82
|
+
// Match complete attribute to avoid emitting partial notes.
|
|
83
|
+
const noteAttrRegex = /```\w*\s+note="([^"]+)"/g;
|
|
84
|
+
let match;
|
|
85
|
+
while ((match = noteAttrRegex.exec(fullText)) !== null) {
|
|
86
|
+
const noteMatch = match[1];
|
|
87
|
+
if (noteMatch) {
|
|
88
|
+
const note = noteMatch.trim();
|
|
89
|
+
if (!emittedNotes.has(note)) {
|
|
90
|
+
emittedNotes.add(note);
|
|
91
|
+
onProgress(note);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return fullText;
|
|
100
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { VirtualProject, VirtualFile } from '@aprovan/patchwork-compiler';
|
|
2
|
+
|
|
3
|
+
export interface EditHistoryEntry {
|
|
4
|
+
prompt: string;
|
|
5
|
+
summary: string;
|
|
6
|
+
isRetry?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface EditSessionState {
|
|
10
|
+
project: VirtualProject;
|
|
11
|
+
originalProject: VirtualProject;
|
|
12
|
+
activeFile: string;
|
|
13
|
+
history: EditHistoryEntry[];
|
|
14
|
+
isApplying: boolean;
|
|
15
|
+
error: string | null;
|
|
16
|
+
streamingNotes: string[];
|
|
17
|
+
pendingPrompt: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EditSessionActions {
|
|
21
|
+
submitEdit: (prompt: string) => Promise<void>;
|
|
22
|
+
revert: () => void;
|
|
23
|
+
updateActiveFile: (content: string) => void;
|
|
24
|
+
setActiveFile: (path: string) => void;
|
|
25
|
+
clearError: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Convenience getters
|
|
29
|
+
export function getActiveContent(state: EditSessionState): string {
|
|
30
|
+
return state.project.files.get(state.activeFile)?.content ?? '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getFiles(project: VirtualProject): VirtualFile[] {
|
|
34
|
+
return Array.from(project.files.values());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EditRequest {
|
|
38
|
+
code: string;
|
|
39
|
+
prompt: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EditResponse {
|
|
43
|
+
newCode: string;
|
|
44
|
+
summary: string;
|
|
45
|
+
progressNotes: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CompileResult {
|
|
49
|
+
success: boolean;
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type CompileFn = (code: string) => Promise<CompileResult>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
2
|
+
import type { VirtualProject } from '@aprovan/patchwork-compiler';
|
|
3
|
+
import { createSingleFileProject } from '@aprovan/patchwork-compiler';
|
|
4
|
+
import { sendEditRequest } from './api';
|
|
5
|
+
import type {
|
|
6
|
+
EditHistoryEntry,
|
|
7
|
+
EditSessionState,
|
|
8
|
+
EditSessionActions,
|
|
9
|
+
CompileFn,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
export interface UseEditSessionOptions {
|
|
13
|
+
originalCode: string;
|
|
14
|
+
compile?: CompileFn;
|
|
15
|
+
apiEndpoint?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cloneProject(project: VirtualProject): VirtualProject {
|
|
19
|
+
return {
|
|
20
|
+
...project,
|
|
21
|
+
files: new Map(project.files),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useEditSession(
|
|
26
|
+
options: UseEditSessionOptions,
|
|
27
|
+
): EditSessionState & EditSessionActions {
|
|
28
|
+
const { originalCode, compile, apiEndpoint } = options;
|
|
29
|
+
|
|
30
|
+
const originalProject = useMemo(
|
|
31
|
+
() => createSingleFileProject(originalCode),
|
|
32
|
+
[originalCode],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const [project, setProject] = useState<VirtualProject>(originalProject);
|
|
36
|
+
const [activeFile, setActiveFile] = useState(originalProject.entry);
|
|
37
|
+
const [history, setHistory] = useState<EditHistoryEntry[]>([]);
|
|
38
|
+
const [isApplying, setIsApplying] = useState(false);
|
|
39
|
+
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
const [streamingNotes, setStreamingNotes] = useState<string[]>([]);
|
|
41
|
+
const [pendingPrompt, setPendingPrompt] = useState<string | null>(null);
|
|
42
|
+
|
|
43
|
+
const performEdit = useCallback(
|
|
44
|
+
async (
|
|
45
|
+
currentCode: string,
|
|
46
|
+
prompt: string,
|
|
47
|
+
isRetry = false,
|
|
48
|
+
): Promise<{ newCode: string; entries: EditHistoryEntry[] }> => {
|
|
49
|
+
const entries: EditHistoryEntry[] = [];
|
|
50
|
+
|
|
51
|
+
const response = await sendEditRequest(
|
|
52
|
+
{ code: currentCode, prompt },
|
|
53
|
+
{
|
|
54
|
+
endpoint: apiEndpoint,
|
|
55
|
+
onProgress: (note) => setStreamingNotes((prev) => [...prev, note]),
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
entries.push({
|
|
60
|
+
prompt: isRetry ? `Fix: ${prompt}` : prompt,
|
|
61
|
+
summary: response.summary,
|
|
62
|
+
isRetry,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (compile) {
|
|
66
|
+
const compileResult = await compile(response.newCode);
|
|
67
|
+
if (!compileResult.success && compileResult.error) {
|
|
68
|
+
setStreamingNotes([]);
|
|
69
|
+
const errorPrompt = `Compilation error: ${compileResult.error}\n\nPlease fix this error.`;
|
|
70
|
+
const retryResult = await performEdit(
|
|
71
|
+
response.newCode,
|
|
72
|
+
errorPrompt,
|
|
73
|
+
true,
|
|
74
|
+
);
|
|
75
|
+
return {
|
|
76
|
+
newCode: retryResult.newCode,
|
|
77
|
+
entries: [...entries, ...retryResult.entries],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { newCode: response.newCode, entries };
|
|
83
|
+
},
|
|
84
|
+
[compile, apiEndpoint],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const currentCode = useMemo(
|
|
88
|
+
() => project.files.get(activeFile)?.content ?? '',
|
|
89
|
+
[project, activeFile],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const submitEdit = useCallback(
|
|
93
|
+
async (prompt: string) => {
|
|
94
|
+
if (!prompt.trim() || isApplying) return;
|
|
95
|
+
|
|
96
|
+
setIsApplying(true);
|
|
97
|
+
setError(null);
|
|
98
|
+
setStreamingNotes([]);
|
|
99
|
+
setPendingPrompt(prompt);
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const result = await performEdit(currentCode, prompt);
|
|
103
|
+
setProject((prev) => {
|
|
104
|
+
const updated = cloneProject(prev);
|
|
105
|
+
const file = updated.files.get(activeFile);
|
|
106
|
+
if (file) {
|
|
107
|
+
updated.files.set(activeFile, { ...file, content: result.newCode });
|
|
108
|
+
}
|
|
109
|
+
return updated;
|
|
110
|
+
});
|
|
111
|
+
setHistory((prev) => [...prev, ...result.entries]);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
setError(err instanceof Error ? err.message : 'Edit failed');
|
|
114
|
+
} finally {
|
|
115
|
+
setIsApplying(false);
|
|
116
|
+
setStreamingNotes([]);
|
|
117
|
+
setPendingPrompt(null);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[currentCode, activeFile, isApplying, performEdit],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const revert = useCallback(() => {
|
|
124
|
+
setProject(originalProject);
|
|
125
|
+
setActiveFile(originalProject.entry);
|
|
126
|
+
setHistory([]);
|
|
127
|
+
setError(null);
|
|
128
|
+
setStreamingNotes([]);
|
|
129
|
+
}, [originalProject]);
|
|
130
|
+
|
|
131
|
+
const updateActiveFile = useCallback(
|
|
132
|
+
(content: string) => {
|
|
133
|
+
setProject((prev) => {
|
|
134
|
+
const updated = cloneProject(prev);
|
|
135
|
+
const file = updated.files.get(activeFile);
|
|
136
|
+
if (file) {
|
|
137
|
+
updated.files.set(activeFile, { ...file, content });
|
|
138
|
+
}
|
|
139
|
+
return updated;
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
[activeFile],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const clearError = useCallback(() => {
|
|
146
|
+
setError(null);
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
project,
|
|
151
|
+
originalProject,
|
|
152
|
+
activeFile,
|
|
153
|
+
history,
|
|
154
|
+
isApplying,
|
|
155
|
+
error,
|
|
156
|
+
streamingNotes,
|
|
157
|
+
pendingPrompt,
|
|
158
|
+
submitEdit,
|
|
159
|
+
revert,
|
|
160
|
+
updateActiveFile,
|
|
161
|
+
setActiveFile,
|
|
162
|
+
clearError,
|
|
163
|
+
};
|
|
164
|
+
}
|