@aprovan/patchwork-editor 0.1.0 → 0.1.2-dev.03aaf5b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.turbo/turbo-build.log +4 -7
  2. package/dist/components/CodeBlockExtension.d.ts +2 -0
  3. package/dist/components/CodePreview.d.ts +13 -0
  4. package/dist/components/MarkdownEditor.d.ts +10 -0
  5. package/dist/components/ServicesInspector.d.ts +49 -0
  6. package/dist/components/edit/CodeBlockView.d.ts +7 -0
  7. package/dist/components/edit/EditHistory.d.ts +10 -0
  8. package/dist/components/edit/EditModal.d.ts +20 -0
  9. package/dist/components/edit/FileTree.d.ts +8 -0
  10. package/dist/components/edit/MediaPreview.d.ts +6 -0
  11. package/dist/components/edit/SaveConfirmDialog.d.ts +9 -0
  12. package/dist/components/edit/api.d.ts +8 -0
  13. package/dist/components/edit/fileTypes.d.ts +14 -0
  14. package/dist/components/edit/index.d.ts +10 -0
  15. package/dist/components/edit/types.d.ts +41 -0
  16. package/dist/components/edit/useEditSession.d.ts +9 -0
  17. package/dist/components/index.d.ts +5 -0
  18. package/dist/index.d.ts +3 -331
  19. package/dist/index.js +812 -142
  20. package/dist/lib/code-extractor.d.ts +44 -0
  21. package/dist/lib/diff.d.ts +90 -0
  22. package/dist/lib/index.d.ts +4 -0
  23. package/dist/lib/utils.d.ts +2 -0
  24. package/dist/lib/vfs.d.ts +36 -0
  25. package/package.json +5 -4
  26. package/src/components/CodeBlockExtension.tsx +1 -1
  27. package/src/components/CodePreview.tsx +64 -4
  28. package/src/components/edit/CodeBlockView.tsx +188 -0
  29. package/src/components/edit/EditModal.tsx +172 -30
  30. package/src/components/edit/FileTree.tsx +67 -13
  31. package/src/components/edit/MediaPreview.tsx +124 -0
  32. package/src/components/edit/SaveConfirmDialog.tsx +60 -0
  33. package/src/components/edit/fileTypes.ts +125 -0
  34. package/src/components/edit/index.ts +4 -0
  35. package/src/components/edit/types.ts +3 -0
  36. package/src/components/edit/useEditSession.ts +56 -11
  37. package/src/index.ts +17 -0
  38. package/src/lib/diff.ts +2 -1
  39. package/src/lib/vfs.ts +28 -10
  40. package/tsup.config.ts +10 -5
@@ -0,0 +1,44 @@
1
+ import type { VirtualProject } from '@aprovan/patchwork-compiler';
2
+ export type TextPart = {
3
+ type: 'text';
4
+ content: string;
5
+ };
6
+ export type CodePart = {
7
+ type: 'code' | string;
8
+ content: string;
9
+ language: 'jsx' | 'tsx' | string;
10
+ attributes?: Record<string, string>;
11
+ };
12
+ export type ParsedPart = TextPart | CodePart;
13
+ export interface ExtractOptions {
14
+ /** Only extract these languages (default: all) */
15
+ filterLanguages?: Set<string>;
16
+ /** Include unclosed code blocks at the end (for streaming) */
17
+ includeUnclosed?: boolean;
18
+ }
19
+ /**
20
+ * Extract code blocks from markdown text.
21
+ */
22
+ export declare function extractCodeBlocks(text: string, options?: ExtractOptions): ParsedPart[];
23
+ /**
24
+ * Find the first JSX/TSX block in the text.
25
+ * Returns null if no JSX block is found.
26
+ */
27
+ export declare function findFirstCodeBlock(text: string): CodePart | null;
28
+ /**
29
+ * Check if text contains any JSX/TSX code blocks.
30
+ */
31
+ export declare function hasCodeBlock(text: string): boolean;
32
+ /**
33
+ * Get all unique languages found in code blocks.
34
+ */
35
+ export declare function getCodeBlockLanguages(text: string): Set<string>;
36
+ /**
37
+ * Extract code blocks as a VirtualProject.
38
+ * Groups files with path attributes into a multi-file project.
39
+ * Files without paths are treated as the main entry file.
40
+ */
41
+ export declare function extractProject(text: string, options?: ExtractOptions): {
42
+ project: VirtualProject;
43
+ textParts: TextPart[];
44
+ };
@@ -0,0 +1,90 @@
1
+ export interface CodeBlockAttributes {
2
+ /** Progress note for UI display (optional but encouraged, comes first) */
3
+ note?: string;
4
+ /** Virtual file path for multi-file generation (uses \@/ prefix) */
5
+ path?: string;
6
+ /** Additional arbitrary attributes */
7
+ [key: string]: string | undefined;
8
+ }
9
+ export interface CodeBlock {
10
+ /** Language identifier (e.g., tsx, json, diff) */
11
+ language: string;
12
+ /** Parsed attributes from the fence line */
13
+ attributes: CodeBlockAttributes;
14
+ /** Raw content between the fence markers */
15
+ content: string;
16
+ }
17
+ export interface DiffBlock {
18
+ search: string;
19
+ replace: string;
20
+ /** Progress note from the code fence attributes */
21
+ note?: string;
22
+ /** Target file path for multi-file edits */
23
+ path?: string;
24
+ }
25
+ /**
26
+ * Parse attributes from a code fence line.
27
+ *
28
+ * Example input: 'note="Adding handler" path="\@/components/Button.tsx"'
29
+ *
30
+ * Returns: an object with note, path, and any other attributes
31
+ */
32
+ export declare function parseCodeBlockAttributes(attrString: string): CodeBlockAttributes;
33
+ /**
34
+ * Parse all code blocks from text, extracting language and attributes.
35
+ * Returns blocks in order of appearance.
36
+ */
37
+ export declare function parseCodeBlocks(text: string): CodeBlock[];
38
+ /**
39
+ * Check if text contains any diff markers.
40
+ * Returns the first marker found, or null if clean.
41
+ */
42
+ export declare function findDiffMarkers(text: string): string | null;
43
+ /**
44
+ * Remove stray diff markers from text.
45
+ * Use as a fallback when markers leak into output.
46
+ */
47
+ export declare function sanitizeDiffMarkers(text: string): string;
48
+ export interface ParsedEditResponse {
49
+ /** Progress notes extracted from code block attributes (in order of appearance) */
50
+ progressNotes: string[];
51
+ /** Parsed diff blocks with their attributes */
52
+ diffs: DiffBlock[];
53
+ /** Summary markdown text (content outside of code blocks) */
54
+ summary: string;
55
+ }
56
+ /**
57
+ * Parse progress notes and diffs from an edit response.
58
+ *
59
+ * ```diff note="Adding handler" path="@/components/Button.tsx"
60
+ * <<<<<<< SEARCH
61
+ * exact code
62
+ * =======
63
+ * replacement
64
+ * >>>>>>> REPLACE
65
+ * ```
66
+ *
67
+ * Summary markdown is everything outside of code blocks.
68
+ */
69
+ export declare function parseEditResponse(text: string): ParsedEditResponse;
70
+ /**
71
+ * Parse diff blocks from text, extracting attributes from code fences.
72
+ * Supports both fenced code blocks with attributes and raw diff markers.
73
+ */
74
+ export declare function parseDiffs(text: string): DiffBlock[];
75
+ export declare function applyDiffs(code: string, diffs: DiffBlock[], options?: {
76
+ sanitize?: boolean;
77
+ }): {
78
+ code: string;
79
+ applied: number;
80
+ failed: string[];
81
+ warning?: string;
82
+ };
83
+ export declare function hasDiffBlocks(text: string): boolean;
84
+ export declare function extractTextWithoutDiffs(text: string): string;
85
+ /**
86
+ * Extract the summary markdown from an edit response.
87
+ * Removes code blocks (with their attributes), and any leading/trailing whitespace.
88
+ * Preserves regular markdown prose outside of code blocks.
89
+ */
90
+ export declare function extractSummary(text: string): string;
@@ -0,0 +1,4 @@
1
+ export * from './code-extractor';
2
+ export * from './diff';
3
+ export * from './vfs';
4
+ export { cn } from './utils';
@@ -0,0 +1,2 @@
1
+ import { type ClassValue } from 'clsx';
2
+ export declare function cn(...inputs: ClassValue[]): string;
@@ -0,0 +1,36 @@
1
+ import { VFSStore, type ChangeRecord, type VirtualProject } from '@aprovan/patchwork-compiler';
2
+ /**
3
+ * Get VFS configuration from the server.
4
+ * Caches the result for subsequent calls.
5
+ */
6
+ export declare function getVFSConfig(): Promise<{
7
+ usePaths: boolean;
8
+ }>;
9
+ /**
10
+ * Get the VFS store instance (creates one if needed).
11
+ * Store uses HttpBackend to persist to the stitchery server.
12
+ */
13
+ export declare function getVFSStore(): VFSStore;
14
+ /**
15
+ * Save a virtual project to disk via the stitchery server.
16
+ * Projects are saved under their ID in the VFS directory.
17
+ */
18
+ export declare function saveProject(project: VirtualProject): Promise<void>;
19
+ /**
20
+ * Load a project from disk by ID.
21
+ */
22
+ export declare function loadProject(id: string): Promise<VirtualProject | null>;
23
+ /**
24
+ * List all stored project IDs.
25
+ */
26
+ export declare function listProjects(): Promise<string[]>;
27
+ /**
28
+ * Save a single file to the VFS.
29
+ */
30
+ export declare function saveFile(path: string, content: string): Promise<void>;
31
+ export declare function loadFile(path: string, encoding?: 'utf8' | 'base64'): Promise<string>;
32
+ export declare function subscribeToChanges(callback: (record: ChangeRecord) => void): () => void;
33
+ /**
34
+ * Check if VFS is available (stitchery server is running with vfs-dir enabled).
35
+ */
36
+ export declare function isVFSAvailable(): Promise<boolean>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aprovan/patchwork-editor",
3
- "version": "0.1.0",
3
+ "version": "0.1.2-dev.03aaf5b",
4
4
  "description": "Components for facilitating widget generation and editing",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,10 +22,11 @@
22
22
  "lucide-react": "^0.511.0",
23
23
  "react-markdown": "^10.1.0",
24
24
  "remark-gfm": "^4.0.1",
25
+ "shiki": "^3.22.0",
25
26
  "tailwind-merge": "^3.4.0",
26
27
  "tiptap-markdown": "^0.9.0",
27
- "@aprovan/bobbin": "0.1.0",
28
- "@aprovan/patchwork-compiler": "0.1.0"
28
+ "@aprovan/bobbin": "0.1.0-dev.03aaf5b",
29
+ "@aprovan/patchwork-compiler": "0.1.2-dev.03aaf5b"
29
30
  },
30
31
  "peerDependencies": {
31
32
  "react": "^18.0.0 || ^19.0.0",
@@ -38,7 +39,7 @@
38
39
  "typescript": "^5.9.3"
39
40
  },
40
41
  "scripts": {
41
- "build": "tsup",
42
+ "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --jsx react-jsx --lib ES2022,DOM --skipLibCheck src/index.ts",
42
43
  "dev": "tsup --watch",
43
44
  "typecheck": "tsc --noEmit"
44
45
  }
@@ -115,7 +115,7 @@ export const CodeBlockExtension = Node.create({
115
115
  default: null,
116
116
  parseHTML: (element: HTMLElement) => {
117
117
  const { languageClassPrefix } = this.options;
118
- const classNames = [...(element.firstElementChild?.classList || [])];
118
+ const classNames = Array.from(element.firstElementChild?.classList || []);
119
119
  const languages = classNames
120
120
  .filter((className) => className.startsWith(languageClassPrefix))
121
121
  .map((className) => className.replace(languageClassPrefix, ''));
@@ -3,13 +3,15 @@ import { Code, Eye, AlertCircle, Loader2, Pencil, RotateCcw, MessageSquare, Clou
3
3
  import type { Compiler, MountedWidget, Manifest } from '@aprovan/patchwork-compiler';
4
4
  import { createSingleFileProject } from '@aprovan/patchwork-compiler';
5
5
  import { EditModal, type CompileFn } from './edit';
6
- import { saveProject, getVFSConfig } from '../lib/vfs';
6
+ import { saveProject, getVFSConfig, loadFile, subscribeToChanges } from '../lib/vfs';
7
7
 
8
8
  type SaveStatus = 'unsaved' | 'saving' | 'saved' | 'error';
9
9
 
10
10
  interface CodePreviewProps {
11
11
  code: string;
12
12
  compiler: Compiler | null;
13
+ /** Optional entrypoint file for the widget (default: "index.ts") */
14
+ entrypoint?: string;
13
15
  /** Available service namespaces for widget calls */
14
16
  services?: string[];
15
17
  /** Optional file path from code block attributes (e.g., "components/calculator.tsx") */
@@ -90,12 +92,17 @@ function useCodeCompiler(compiler: Compiler | null, code: string, enabled: boole
90
92
  return { containerRef, loading, error };
91
93
  }
92
94
 
93
- export function CodePreview({ code: originalCode, compiler, services, filePath }: CodePreviewProps) {
95
+ export function CodePreview({ code: originalCode, compiler, services, filePath, entrypoint = 'index.ts' }: CodePreviewProps) {
94
96
  const [isEditing, setIsEditing] = useState(false);
95
97
  const [showPreview, setShowPreview] = useState(true);
96
98
  const [currentCode, setCurrentCode] = useState(originalCode);
97
99
  const [editCount, setEditCount] = useState(0);
98
100
  const [saveStatus, setSaveStatus] = useState<SaveStatus>('unsaved');
101
+ const [lastSavedCode, setLastSavedCode] = useState(originalCode);
102
+ const [vfsPath, setVfsPath] = useState<string | null>(null);
103
+ const currentCodeRef = useRef(currentCode);
104
+ const lastSavedRef = useRef(lastSavedCode);
105
+ const isEditingRef = useRef(isEditing);
99
106
 
100
107
  // Stable project ID for this widget instance (fallback when not using paths)
101
108
  const fallbackId = useMemo(() => crypto.randomUUID(), []);
@@ -121,11 +128,63 @@ export function CodePreview({ code: originalCode, compiler, services, filePath }
121
128
  const getEntryFile = useCallback(() => {
122
129
  if (filePath) {
123
130
  const parts = filePath.split('/');
124
- return parts[parts.length - 1] || 'main.tsx';
131
+ return parts[parts.length - 1] || entrypoint;
125
132
  }
126
- return 'main.tsx';
133
+ return entrypoint;
127
134
  }, [filePath]);
128
135
 
136
+ useEffect(() => {
137
+ currentCodeRef.current = currentCode;
138
+ }, [currentCode]);
139
+
140
+ useEffect(() => {
141
+ lastSavedRef.current = lastSavedCode;
142
+ }, [lastSavedCode]);
143
+
144
+ useEffect(() => {
145
+ isEditingRef.current = isEditing;
146
+ }, [isEditing]);
147
+
148
+ useEffect(() => {
149
+ let active = true;
150
+ void (async () => {
151
+ const projectId = await getProjectId();
152
+ const entryFile = getEntryFile();
153
+ if (!active) return;
154
+ setVfsPath(`${projectId}/${entryFile}`);
155
+ })();
156
+ return () => {
157
+ active = false;
158
+ };
159
+ }, [getProjectId, getEntryFile]);
160
+
161
+ useEffect(() => {
162
+ if (!vfsPath) return;
163
+ const unsubscribe = subscribeToChanges(async (record) => {
164
+ if (record.path !== vfsPath) return;
165
+ if (record.type === 'delete') {
166
+ setSaveStatus('unsaved');
167
+ return;
168
+ }
169
+ if (isEditingRef.current) return;
170
+ try {
171
+ const remote = await loadFile(vfsPath);
172
+ if (currentCodeRef.current !== lastSavedRef.current) {
173
+ setSaveStatus('unsaved');
174
+ return;
175
+ }
176
+ if (remote !== currentCodeRef.current) {
177
+ setCurrentCode(remote);
178
+ setLastSavedCode(remote);
179
+ setSaveStatus('saved');
180
+ }
181
+ } catch {
182
+ setSaveStatus('error');
183
+ }
184
+ });
185
+ return () => unsubscribe();
186
+ }, [vfsPath]);
187
+
129
188
  // Manual save handler
130
189
  const handleSave = useCallback(async () => {
131
190
  setSaveStatus('saving');
@@ -134,6 +193,7 @@ export function CodePreview({ code: originalCode, compiler, services, filePath }
134
193
  const entryFile = getEntryFile();
135
194
  const project = createSingleFileProject(currentCode, entryFile, projectId);
136
195
  await saveProject(project);
196
+ setLastSavedCode(currentCode);
137
197
  setSaveStatus('saved');
138
198
  } catch (err) {
139
199
  console.warn('[VFS] Failed to save project:', err);
@@ -0,0 +1,188 @@
1
+ import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
2
+ import { createHighlighter, type Highlighter, type BundledLanguage } from 'shiki';
3
+
4
+ // Singleton highlighter instance
5
+ let highlighterPromise: Promise<Highlighter> | null = null;
6
+
7
+ const COMMON_LANGUAGES: BundledLanguage[] = [
8
+ 'typescript',
9
+ 'javascript',
10
+ 'tsx',
11
+ 'jsx',
12
+ 'json',
13
+ 'html',
14
+ 'css',
15
+ 'markdown',
16
+ 'yaml',
17
+ 'python',
18
+ 'bash',
19
+ 'sql',
20
+ ];
21
+
22
+ function getHighlighter(): Promise<Highlighter> {
23
+ if (!highlighterPromise) {
24
+ highlighterPromise = createHighlighter({
25
+ themes: ['github-light'],
26
+ langs: COMMON_LANGUAGES,
27
+ });
28
+ }
29
+ return highlighterPromise;
30
+ }
31
+
32
+ // Map common file extensions/language names to shiki language identifiers
33
+ function normalizeLanguage(lang: string | null): BundledLanguage {
34
+ if (!lang) return 'typescript';
35
+ const normalized = lang.toLowerCase();
36
+ const mapping: Record<string, BundledLanguage> = {
37
+ ts: 'typescript',
38
+ tsx: 'tsx',
39
+ js: 'javascript',
40
+ jsx: 'jsx',
41
+ json: 'json',
42
+ html: 'html',
43
+ css: 'css',
44
+ md: 'markdown',
45
+ markdown: 'markdown',
46
+ yml: 'yaml',
47
+ yaml: 'yaml',
48
+ py: 'python',
49
+ python: 'python',
50
+ sh: 'bash',
51
+ bash: 'bash',
52
+ sql: 'sql',
53
+ typescript: 'typescript',
54
+ javascript: 'javascript',
55
+ };
56
+ return mapping[normalized] || 'typescript';
57
+ }
58
+
59
+ export interface CodeBlockViewProps {
60
+ content: string;
61
+ language: string | null;
62
+ editable?: boolean;
63
+ onChange?: (content: string) => void;
64
+ }
65
+
66
+ export function CodeBlockView({ content, language, editable = false, onChange }: CodeBlockViewProps) {
67
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
68
+ const containerRef = useRef<HTMLDivElement>(null);
69
+ const [highlighter, setHighlighter] = useState<Highlighter | null>(null);
70
+
71
+ // Load the highlighter
72
+ useEffect(() => {
73
+ let mounted = true;
74
+ getHighlighter().then((h) => {
75
+ if (mounted) setHighlighter(h);
76
+ });
77
+ return () => { mounted = false; };
78
+ }, []);
79
+
80
+ // Auto-resize textarea
81
+ useEffect(() => {
82
+ if (textareaRef.current) {
83
+ textareaRef.current.style.height = 'auto';
84
+ textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
85
+ }
86
+ }, [content]);
87
+
88
+ const handleChange = useCallback(
89
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
90
+ onChange?.(e.target.value);
91
+ },
92
+ [onChange]
93
+ );
94
+
95
+ const handleKeyDown = useCallback(
96
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
97
+ if (e.key === 'Tab') {
98
+ e.preventDefault();
99
+ const target = e.target as HTMLTextAreaElement;
100
+ const start = target.selectionStart;
101
+ const end = target.selectionEnd;
102
+ const value = target.value;
103
+ const newValue = value.substring(0, start) + ' ' + value.substring(end);
104
+ onChange?.(newValue);
105
+ requestAnimationFrame(() => {
106
+ target.selectionStart = target.selectionEnd = start + 2;
107
+ });
108
+ }
109
+ },
110
+ [onChange]
111
+ );
112
+
113
+ const langLabel = language || 'text';
114
+ const shikiLang = useMemo(() => normalizeLanguage(language), [language]);
115
+
116
+ // Generate highlighted HTML
117
+ const highlightedHtml = useMemo(() => {
118
+ if (!highlighter) return null;
119
+ try {
120
+ return highlighter.codeToHtml(content, {
121
+ lang: shikiLang,
122
+ theme: 'github-light',
123
+ });
124
+ } catch {
125
+ // Fallback if language is not supported
126
+ return null;
127
+ }
128
+ }, [highlighter, content, shikiLang]);
129
+
130
+ return (
131
+ <div className="h-full flex flex-col bg-[#ffffff]">
132
+ <div className="flex items-center justify-between px-4 py-2 bg-[#f6f8fa] border-b border-[#d0d7de] text-xs">
133
+ <span className="font-mono text-[#57606a]">{langLabel}</span>
134
+ </div>
135
+ <div className="flex-1 overflow-y-auto overflow-x-hidden">
136
+ {editable ? (
137
+ <div className="relative min-h-full">
138
+ {/* Highlighted code layer (background) - scrolls with content */}
139
+ <div
140
+ ref={containerRef}
141
+ className="absolute top-0 left-0 right-0 pointer-events-none p-4"
142
+ aria-hidden="true"
143
+ >
144
+ {highlightedHtml ? (
145
+ <div
146
+ className="highlighted-code font-mono text-xs leading-relaxed whitespace-pre-wrap break-words [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_code]:!bg-transparent [&_code]:whitespace-pre-wrap [&_code]:break-words"
147
+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
148
+ />
149
+ ) : (
150
+ <pre className="text-xs font-mono whitespace-pre-wrap break-words text-[#24292f] m-0 leading-relaxed">
151
+ <code>{content}</code>
152
+ </pre>
153
+ )}
154
+ </div>
155
+ {/* Editable textarea layer (foreground) */}
156
+ <textarea
157
+ ref={textareaRef}
158
+ value={content}
159
+ onChange={handleChange}
160
+ onKeyDown={handleKeyDown}
161
+ className="relative w-full min-h-full font-mono text-xs leading-relaxed bg-transparent border-none outline-none resize-none p-4 text-transparent whitespace-pre-wrap break-words"
162
+ spellCheck={false}
163
+ style={{
164
+ tabSize: 2,
165
+ caretColor: '#24292f',
166
+ wordBreak: 'break-word',
167
+ overflowWrap: 'break-word',
168
+ }}
169
+ />
170
+ </div>
171
+ ) : (
172
+ <div className="p-4">
173
+ {highlightedHtml ? (
174
+ <div
175
+ className="highlighted-code font-mono text-xs leading-relaxed whitespace-pre-wrap break-words [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_code]:!bg-transparent [&_code]:whitespace-pre-wrap [&_code]:break-words"
176
+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
177
+ />
178
+ ) : (
179
+ <pre className="text-xs font-mono whitespace-pre-wrap break-words m-0 leading-relaxed text-[#24292f]">
180
+ <code>{content}</code>
181
+ </pre>
182
+ )}
183
+ </div>
184
+ )}
185
+ </div>
186
+ </div>
187
+ );
188
+ }