@aprovan/patchwork-editor 0.1.0 → 0.1.1-dev.6bd527d

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 +707 -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 +4 -4
  26. package/src/components/CodeBlockExtension.tsx +1 -1
  27. package/src/components/CodePreview.tsx +64 -4
  28. package/src/components/edit/CodeBlockView.tsx +72 -0
  29. package/src/components/edit/EditModal.tsx +169 -28
  30. package/src/components/edit/FileTree.tsx +67 -13
  31. package/src/components/edit/MediaPreview.tsx +106 -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
package/dist/index.js CHANGED
@@ -2,8 +2,8 @@ import { Node, textblockTypeInputRule } from '@tiptap/core';
2
2
  import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, useEditor, EditorContent } from '@tiptap/react';
3
3
  import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
4
4
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
5
- import { Pencil, Loader2, RotateCcw, FileCode, FolderTree, Eye, Code, X, AlertCircle, Send, MessageSquare, Cloud, Check, Server, ChevronDown, ChevronRight, Folder, File } from 'lucide-react';
6
- import { createSingleFileProject, LocalFSBackend, VFSStore, detectMainFile, createProjectFromFiles } from '@aprovan/patchwork-compiler';
5
+ import { AlertCircle, FileImage, FileVideo, Pencil, Loader2, RotateCcw, FileCode, FolderTree, Eye, Code, Save, X, Send, MessageSquare, Cloud, Check, Server, ChevronDown, ChevronRight, Folder, File, Upload } from 'lucide-react';
6
+ import { createSingleFileProject, HttpBackend, VFSStore, detectMainFile, createProjectFromFiles } from '@aprovan/patchwork-compiler';
7
7
  import Markdown from 'react-markdown';
8
8
  import remarkGfm from 'remark-gfm';
9
9
  import StarterKit from '@tiptap/starter-kit';
@@ -119,7 +119,7 @@ var CodeBlockExtension = Node.create({
119
119
  default: null,
120
120
  parseHTML: (element) => {
121
121
  const { languageClassPrefix } = this.options;
122
- const classNames = [...element.firstElementChild?.classList || []];
122
+ const classNames = Array.from(element.firstElementChild?.classList || []);
123
123
  const languages = classNames.filter((className) => className.startsWith(languageClassPrefix)).map((className) => className.replace(languageClassPrefix, ""));
124
124
  return languages[0] || null;
125
125
  },
@@ -340,6 +340,7 @@ function applyDiffs(code, diffs, options = {}) {
340
340
  return { code: result, applied, failed, warning };
341
341
  }
342
342
  function hasDiffBlocks(text) {
343
+ DIFF_BLOCK_REGEX.lastIndex = 0;
343
344
  return DIFF_BLOCK_REGEX.test(text);
344
345
  }
345
346
  function extractTextWithoutDiffs(text) {
@@ -435,11 +436,23 @@ function cloneProject(project) {
435
436
  };
436
437
  }
437
438
  function useEditSession(options) {
438
- const { originalCode, compile, apiEndpoint } = options;
439
+ const {
440
+ originalCode,
441
+ originalProject: providedProject,
442
+ compile,
443
+ apiEndpoint
444
+ } = options;
445
+ console.log(
446
+ "[useEditSession] providedProject:",
447
+ providedProject?.id,
448
+ "files:",
449
+ providedProject ? Array.from(providedProject.files.keys()) : "none"
450
+ );
439
451
  const originalProject = useMemo(
440
- () => createSingleFileProject(originalCode),
441
- [originalCode]
452
+ () => providedProject ?? createSingleFileProject(originalCode ?? ""),
453
+ [providedProject, originalCode]
442
454
  );
455
+ const lastSyncedProjectRef = useRef(originalProject);
443
456
  const [project, setProject] = useState(originalProject);
444
457
  const [activeFile, setActiveFile] = useState(originalProject.entry);
445
458
  const [history, setHistory] = useState([]);
@@ -447,6 +460,16 @@ function useEditSession(options) {
447
460
  const [error, setError] = useState(null);
448
461
  const [streamingNotes, setStreamingNotes] = useState([]);
449
462
  const [pendingPrompt, setPendingPrompt] = useState(null);
463
+ useEffect(() => {
464
+ if (originalProject !== lastSyncedProjectRef.current) {
465
+ lastSyncedProjectRef.current = originalProject;
466
+ setProject(originalProject);
467
+ setActiveFile(originalProject.entry);
468
+ setHistory([]);
469
+ setError(null);
470
+ setStreamingNotes([]);
471
+ }
472
+ }, [originalProject]);
450
473
  const performEdit = useCallback(
451
474
  async (currentCode2, prompt, isRetry = false) => {
452
475
  const entries = [];
@@ -536,6 +559,21 @@ Please fix this error.`;
536
559
  },
537
560
  [activeFile]
538
561
  );
562
+ const replaceFile = useCallback(
563
+ (path, content, encoding = "utf8") => {
564
+ setProject((prev) => {
565
+ const updated = cloneProject(prev);
566
+ const file = updated.files.get(path);
567
+ if (file) {
568
+ updated.files.set(path, { ...file, content, encoding });
569
+ } else {
570
+ updated.files.set(path, { path, content, encoding });
571
+ }
572
+ return updated;
573
+ });
574
+ },
575
+ []
576
+ );
539
577
  const clearError = useCallback(() => {
540
578
  setError(null);
541
579
  }, []);
@@ -552,7 +590,8 @@ Please fix this error.`;
552
590
  revert,
553
591
  updateActiveFile,
554
592
  setActiveFile,
555
- clearError
593
+ clearError,
594
+ replaceFile
556
595
  };
557
596
  }
558
597
  function ProgressNote({ text, isLatest }) {
@@ -811,6 +850,110 @@ function MarkdownEditor({
811
850
  }
812
851
  );
813
852
  }
853
+
854
+ // src/components/edit/fileTypes.ts
855
+ var COMPILABLE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"];
856
+ var MEDIA_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".mp4", ".mov", ".webm"];
857
+ var TEXT_EXTENSIONS = [".json", ".yaml", ".yml", ".md", ".txt", ".css", ".html", ".xml", ".toml"];
858
+ var EXTENSION_TO_LANGUAGE = {
859
+ ".tsx": "tsx",
860
+ ".jsx": "jsx",
861
+ ".ts": "typescript",
862
+ ".js": "javascript",
863
+ ".json": "json",
864
+ ".yaml": "yaml",
865
+ ".yml": "yaml",
866
+ ".md": "markdown",
867
+ ".txt": "text",
868
+ ".css": "css",
869
+ ".html": "html",
870
+ ".xml": "xml",
871
+ ".toml": "toml",
872
+ ".svg": "xml"
873
+ };
874
+ var EXTENSION_TO_MIME = {
875
+ ".tsx": "text/typescript-jsx",
876
+ ".jsx": "text/javascript-jsx",
877
+ ".ts": "text/typescript",
878
+ ".js": "text/javascript",
879
+ ".json": "application/json",
880
+ ".yaml": "text/yaml",
881
+ ".yml": "text/yaml",
882
+ ".md": "text/markdown",
883
+ ".txt": "text/plain",
884
+ ".css": "text/css",
885
+ ".html": "text/html",
886
+ ".xml": "application/xml",
887
+ ".toml": "text/toml",
888
+ ".svg": "image/svg+xml",
889
+ ".png": "image/png",
890
+ ".jpg": "image/jpeg",
891
+ ".jpeg": "image/jpeg",
892
+ ".gif": "image/gif",
893
+ ".webp": "image/webp",
894
+ ".mp4": "video/mp4",
895
+ ".mov": "video/quicktime",
896
+ ".webm": "video/webm"
897
+ };
898
+ function getExtension(path) {
899
+ const lastDot = path.lastIndexOf(".");
900
+ if (lastDot === -1) return "";
901
+ return path.slice(lastDot).toLowerCase();
902
+ }
903
+ function getFileType(path) {
904
+ const ext = getExtension(path);
905
+ if (COMPILABLE_EXTENSIONS.includes(ext)) {
906
+ return {
907
+ category: "compilable",
908
+ language: EXTENSION_TO_LANGUAGE[ext] ?? null,
909
+ mimeType: EXTENSION_TO_MIME[ext] ?? "text/plain"
910
+ };
911
+ }
912
+ if (TEXT_EXTENSIONS.includes(ext)) {
913
+ return {
914
+ category: "text",
915
+ language: EXTENSION_TO_LANGUAGE[ext] ?? null,
916
+ mimeType: EXTENSION_TO_MIME[ext] ?? "text/plain"
917
+ };
918
+ }
919
+ if (MEDIA_EXTENSIONS.includes(ext)) {
920
+ return {
921
+ category: "media",
922
+ language: ext === ".svg" ? "xml" : null,
923
+ mimeType: EXTENSION_TO_MIME[ext] ?? "application/octet-stream"
924
+ };
925
+ }
926
+ return {
927
+ category: "binary",
928
+ language: null,
929
+ mimeType: "application/octet-stream"
930
+ };
931
+ }
932
+ function isCompilable(path) {
933
+ return COMPILABLE_EXTENSIONS.includes(getExtension(path));
934
+ }
935
+ function isMediaFile(path) {
936
+ return MEDIA_EXTENSIONS.includes(getExtension(path));
937
+ }
938
+ function isTextFile(path) {
939
+ return TEXT_EXTENSIONS.includes(getExtension(path));
940
+ }
941
+ function getLanguageFromExt(path) {
942
+ const ext = getExtension(path);
943
+ return EXTENSION_TO_LANGUAGE[ext] ?? null;
944
+ }
945
+ function getMimeType(path) {
946
+ const ext = getExtension(path);
947
+ return EXTENSION_TO_MIME[ext] ?? "application/octet-stream";
948
+ }
949
+ function isImageFile(path) {
950
+ const ext = getExtension(path);
951
+ return [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(ext);
952
+ }
953
+ function isVideoFile(path) {
954
+ const ext = getExtension(path);
955
+ return [".mp4", ".mov", ".webm"].includes(ext);
956
+ }
814
957
  function buildTree(files) {
815
958
  const root = { name: "", path: "", isDir: true, children: [] };
816
959
  for (const file of files) {
@@ -839,8 +982,26 @@ function buildTree(files) {
839
982
  });
840
983
  return root;
841
984
  }
842
- function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }) {
985
+ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth = 0 }) {
843
986
  const [expanded, setExpanded] = useState(true);
987
+ const [isHovered, setIsHovered] = useState(false);
988
+ const fileInputRef = useRef(null);
989
+ const handleUploadClick = useCallback((e) => {
990
+ e.stopPropagation();
991
+ fileInputRef.current?.click();
992
+ }, []);
993
+ const handleFileChange = useCallback((e) => {
994
+ const file = e.target.files?.[0];
995
+ if (!file || !onReplaceFile) return;
996
+ const reader = new FileReader();
997
+ reader.onload = () => {
998
+ const result = reader.result;
999
+ const base64 = result.split(",")[1] ?? "";
1000
+ onReplaceFile(node.path, base64, "base64");
1001
+ };
1002
+ reader.readAsDataURL(file);
1003
+ e.target.value = "";
1004
+ }, [node.path, onReplaceFile]);
844
1005
  if (!node.name) {
845
1006
  return /* @__PURE__ */ jsx(Fragment, { children: node.children.map((child) => /* @__PURE__ */ jsx(
846
1007
  TreeNodeComponent,
@@ -848,12 +1009,15 @@ function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }) {
848
1009
  node: child,
849
1010
  activeFile,
850
1011
  onSelect,
1012
+ onReplaceFile,
851
1013
  depth
852
1014
  },
853
1015
  child.path
854
1016
  )) });
855
1017
  }
856
1018
  const isActive = node.path === activeFile;
1019
+ const isMedia = !node.isDir && isMediaFile(node.path);
1020
+ const showUpload = isMedia && isHovered && onReplaceFile;
857
1021
  if (node.isDir) {
858
1022
  return /* @__PURE__ */ jsxs("div", { children: [
859
1023
  /* @__PURE__ */ jsxs(
@@ -875,6 +1039,7 @@ function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }) {
875
1039
  node: child,
876
1040
  activeFile,
877
1041
  onSelect,
1042
+ onReplaceFile,
878
1043
  depth: depth + 1
879
1044
  },
880
1045
  child.path
@@ -882,19 +1047,48 @@ function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }) {
882
1047
  ] });
883
1048
  }
884
1049
  return /* @__PURE__ */ jsxs(
885
- "button",
1050
+ "div",
886
1051
  {
887
- onClick: () => onSelect(node.path),
888
- className: `flex items-center gap-1 w-full px-2 py-1 text-left text-sm hover:bg-muted/50 rounded ${isActive ? "bg-primary/10 text-primary" : ""}`,
889
- style: { paddingLeft: `${depth * 12 + 20}px` },
1052
+ className: "relative",
1053
+ onMouseEnter: () => setIsHovered(true),
1054
+ onMouseLeave: () => setIsHovered(false),
890
1055
  children: [
891
- /* @__PURE__ */ jsx(File, { className: "h-3 w-3 shrink-0 text-muted-foreground" }),
892
- /* @__PURE__ */ jsx("span", { className: "truncate", children: node.name })
1056
+ /* @__PURE__ */ jsxs(
1057
+ "button",
1058
+ {
1059
+ onClick: () => onSelect(node.path),
1060
+ className: `flex items-center gap-1 w-full px-2 py-1 text-left text-sm hover:bg-muted/50 rounded ${isActive ? "bg-primary/10 text-primary" : ""}`,
1061
+ style: { paddingLeft: `${depth * 12 + 20}px` },
1062
+ children: [
1063
+ /* @__PURE__ */ jsx(File, { className: "h-3 w-3 shrink-0 text-muted-foreground" }),
1064
+ /* @__PURE__ */ jsx("span", { className: "truncate flex-1", children: node.name }),
1065
+ showUpload && /* @__PURE__ */ jsx(
1066
+ "span",
1067
+ {
1068
+ onClick: handleUploadClick,
1069
+ className: "p-0.5 hover:bg-primary/20 rounded cursor-pointer",
1070
+ title: "Replace file",
1071
+ children: /* @__PURE__ */ jsx(Upload, { className: "h-3 w-3 text-primary" })
1072
+ }
1073
+ )
1074
+ ]
1075
+ }
1076
+ ),
1077
+ isMedia && /* @__PURE__ */ jsx(
1078
+ "input",
1079
+ {
1080
+ ref: fileInputRef,
1081
+ type: "file",
1082
+ className: "hidden",
1083
+ accept: "image/*,video/*",
1084
+ onChange: handleFileChange
1085
+ }
1086
+ )
893
1087
  ]
894
1088
  }
895
1089
  );
896
1090
  }
897
- function FileTree({ files, activeFile, onSelectFile }) {
1091
+ function FileTree({ files, activeFile, onSelectFile, onReplaceFile }) {
898
1092
  const tree = useMemo(() => buildTree(files), [files]);
899
1093
  return /* @__PURE__ */ jsxs("div", { className: "w-48 border-r bg-muted/30 overflow-auto text-foreground", children: [
900
1094
  /* @__PURE__ */ jsx("div", { className: "p-2 border-b text-xs font-medium text-muted-foreground uppercase tracking-wide", children: "Files" }),
@@ -903,11 +1097,196 @@ function FileTree({ files, activeFile, onSelectFile }) {
903
1097
  {
904
1098
  node: tree,
905
1099
  activeFile,
906
- onSelect: onSelectFile
1100
+ onSelect: onSelectFile,
1101
+ onReplaceFile
907
1102
  }
908
1103
  ) })
909
1104
  ] });
910
1105
  }
1106
+ function SaveConfirmDialog({
1107
+ isOpen,
1108
+ isSaving,
1109
+ error,
1110
+ onSave,
1111
+ onDiscard,
1112
+ onCancel
1113
+ }) {
1114
+ if (!isOpen) return null;
1115
+ return /* @__PURE__ */ jsx("div", { className: "fixed inset-0 z-[60] flex items-center justify-center bg-black/80", children: /* @__PURE__ */ jsxs("div", { className: "bg-background border rounded-lg shadow-lg w-full max-w-md", children: [
1116
+ /* @__PURE__ */ jsxs("div", { className: "p-6", children: [
1117
+ /* @__PURE__ */ jsx("h2", { className: "text-lg font-semibold leading-none tracking-tight", children: "Unsaved Changes" }),
1118
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground mt-2", children: "You have unsaved changes. Would you like to save them before closing?" }),
1119
+ error && /* @__PURE__ */ jsxs("p", { className: "text-sm text-destructive mt-3", children: [
1120
+ "Save failed: ",
1121
+ error
1122
+ ] })
1123
+ ] }),
1124
+ /* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2 p-6 pt-0", children: [
1125
+ /* @__PURE__ */ jsx(
1126
+ "button",
1127
+ {
1128
+ onClick: onCancel,
1129
+ disabled: isSaving,
1130
+ className: "inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-4 py-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50",
1131
+ children: "Cancel"
1132
+ }
1133
+ ),
1134
+ /* @__PURE__ */ jsx(
1135
+ "button",
1136
+ {
1137
+ onClick: onDiscard,
1138
+ disabled: isSaving,
1139
+ className: "inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-4 py-2 border border-input bg-background text-destructive hover:bg-destructive/10 disabled:opacity-50",
1140
+ children: "Discard"
1141
+ }
1142
+ ),
1143
+ /* @__PURE__ */ jsx(
1144
+ "button",
1145
+ {
1146
+ onClick: onSave,
1147
+ disabled: isSaving,
1148
+ className: "inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50",
1149
+ children: isSaving ? "Saving..." : "Save"
1150
+ }
1151
+ )
1152
+ ] })
1153
+ ] }) });
1154
+ }
1155
+ function CodeBlockView({ content, language, editable = false, onChange }) {
1156
+ const textareaRef = useRef(null);
1157
+ useEffect(() => {
1158
+ if (textareaRef.current) {
1159
+ textareaRef.current.style.height = "auto";
1160
+ textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
1161
+ }
1162
+ }, [content]);
1163
+ const handleChange = useCallback(
1164
+ (e) => {
1165
+ onChange?.(e.target.value);
1166
+ },
1167
+ [onChange]
1168
+ );
1169
+ const handleKeyDown = useCallback(
1170
+ (e) => {
1171
+ if (e.key === "Tab") {
1172
+ e.preventDefault();
1173
+ const target = e.target;
1174
+ const start = target.selectionStart;
1175
+ const end = target.selectionEnd;
1176
+ const value = target.value;
1177
+ const newValue = value.substring(0, start) + " " + value.substring(end);
1178
+ onChange?.(newValue);
1179
+ requestAnimationFrame(() => {
1180
+ target.selectionStart = target.selectionEnd = start + 2;
1181
+ });
1182
+ }
1183
+ },
1184
+ [onChange]
1185
+ );
1186
+ const langLabel = language || "text";
1187
+ return /* @__PURE__ */ jsxs("div", { className: "h-full flex flex-col bg-muted/10", children: [
1188
+ /* @__PURE__ */ jsx("div", { className: "flex items-center justify-between px-4 py-2 bg-muted/30 border-b text-xs", children: /* @__PURE__ */ jsx("span", { className: "font-mono text-muted-foreground", children: langLabel }) }),
1189
+ editable ? /* @__PURE__ */ jsx(
1190
+ "textarea",
1191
+ {
1192
+ ref: textareaRef,
1193
+ value: content,
1194
+ onChange: handleChange,
1195
+ onKeyDown: handleKeyDown,
1196
+ className: "w-full min-h-full font-mono text-xs leading-relaxed bg-transparent border-none outline-none resize-none",
1197
+ spellCheck: false,
1198
+ style: {
1199
+ tabSize: 2,
1200
+ WebkitTextFillColor: "inherit"
1201
+ }
1202
+ }
1203
+ ) : /* @__PURE__ */ jsx("pre", { className: "text-xs font-mono whitespace-pre-wrap break-words m-0 leading-relaxed", children: /* @__PURE__ */ jsx("code", { children: content }) })
1204
+ ] });
1205
+ }
1206
+ function formatFileSize(bytes) {
1207
+ if (bytes < 1024) return `${bytes} B`;
1208
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1209
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1210
+ }
1211
+ function getDataUrl(content, mimeType) {
1212
+ if (content.startsWith("data:")) {
1213
+ return content;
1214
+ }
1215
+ return `data:${mimeType};base64,${content}`;
1216
+ }
1217
+ function MediaPreview({ content, mimeType, fileName }) {
1218
+ const [dimensions, setDimensions] = useState(null);
1219
+ const [error, setError] = useState(null);
1220
+ const dataUrl = getDataUrl(content, mimeType);
1221
+ const isImage = isImageFile(fileName);
1222
+ const isVideo = isVideoFile(fileName);
1223
+ content.length;
1224
+ const estimatedBytes = content.startsWith("data:") ? Math.floor((content.split(",")[1]?.length ?? 0) * 0.75) : Math.floor(content.length * 0.75);
1225
+ useEffect(() => {
1226
+ setDimensions(null);
1227
+ setError(null);
1228
+ if (isImage) {
1229
+ const img = new Image();
1230
+ img.onload = () => {
1231
+ setDimensions({ width: img.naturalWidth, height: img.naturalHeight });
1232
+ };
1233
+ img.onerror = () => {
1234
+ setError("Failed to load image");
1235
+ };
1236
+ img.src = dataUrl;
1237
+ }
1238
+ }, [dataUrl, isImage]);
1239
+ if (error) {
1240
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center h-full p-8 text-muted-foreground", children: [
1241
+ /* @__PURE__ */ jsx(AlertCircle, { className: "h-12 w-12 mb-4 text-destructive" }),
1242
+ /* @__PURE__ */ jsx("p", { className: "text-sm", children: error })
1243
+ ] });
1244
+ }
1245
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center h-full p-8 bg-muted/20", children: [
1246
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 flex items-center justify-center w-full max-h-[60vh] overflow-hidden", children: [
1247
+ isImage && /* @__PURE__ */ jsx(
1248
+ "img",
1249
+ {
1250
+ src: dataUrl,
1251
+ alt: fileName,
1252
+ className: "max-w-full max-h-full object-contain rounded shadow-sm",
1253
+ style: { maxHeight: "calc(60vh - 2rem)" }
1254
+ }
1255
+ ),
1256
+ isVideo && /* @__PURE__ */ jsx(
1257
+ "video",
1258
+ {
1259
+ src: dataUrl,
1260
+ controls: true,
1261
+ className: "max-w-full max-h-full rounded shadow-sm",
1262
+ style: { maxHeight: "calc(60vh - 2rem)" },
1263
+ children: "Your browser does not support video playback."
1264
+ }
1265
+ ),
1266
+ !isImage && !isVideo && /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center text-muted-foreground", children: [
1267
+ /* @__PURE__ */ jsx(FileImage, { className: "h-16 w-16 mb-4" }),
1268
+ /* @__PURE__ */ jsx("p", { className: "text-sm", children: "Preview not available for this file type" })
1269
+ ] })
1270
+ ] }),
1271
+ /* @__PURE__ */ jsxs("div", { className: "mt-6 text-center text-sm text-muted-foreground space-y-1", children: [
1272
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-center gap-2", children: [
1273
+ isImage && /* @__PURE__ */ jsx(FileImage, { className: "h-4 w-4" }),
1274
+ isVideo && /* @__PURE__ */ jsx(FileVideo, { className: "h-4 w-4" }),
1275
+ /* @__PURE__ */ jsx("span", { className: "font-medium", children: fileName })
1276
+ ] }),
1277
+ /* @__PURE__ */ jsxs("div", { className: "text-xs space-x-3", children: [
1278
+ dimensions && /* @__PURE__ */ jsxs("span", { children: [
1279
+ dimensions.width,
1280
+ " \xD7 ",
1281
+ dimensions.height,
1282
+ " px"
1283
+ ] }),
1284
+ /* @__PURE__ */ jsx("span", { children: formatFileSize(estimatedBytes) }),
1285
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground/60", children: mimeType })
1286
+ ] })
1287
+ ] })
1288
+ ] });
1289
+ }
911
1290
  function hashCode(str) {
912
1291
  let hash = 0;
913
1292
  for (let i = 0; i < str.length; i++) {
@@ -918,22 +1297,37 @@ function hashCode(str) {
918
1297
  function EditModal({
919
1298
  isOpen,
920
1299
  onClose,
1300
+ onSave,
1301
+ onSaveProject,
921
1302
  renderPreview,
922
1303
  renderLoading,
923
1304
  renderError,
924
1305
  previewError,
925
1306
  previewLoading,
1307
+ initialState = {},
1308
+ hideFileTree = false,
926
1309
  ...sessionOptions
927
1310
  }) {
928
- const [showPreview, setShowPreview] = useState(true);
929
- const [showTree, setShowTree] = useState(false);
1311
+ const [showPreview, setShowPreview] = useState(initialState?.showPreview ?? true);
1312
+ const [showTree, setShowTree] = useState(
1313
+ hideFileTree ? false : initialState?.showTree ?? false
1314
+ );
930
1315
  const [editInput, setEditInput] = useState("");
931
1316
  const [bobbinChanges, setBobbinChanges] = useState([]);
932
1317
  const [previewContainer, setPreviewContainer] = useState(null);
1318
+ const [showConfirm, setShowConfirm] = useState(false);
1319
+ const [isSaving, setIsSaving] = useState(false);
1320
+ const [saveError, setSaveError] = useState(null);
1321
+ const [pendingClose, setPendingClose] = useState(null);
1322
+ const currentCodeRef = useRef("");
933
1323
  const session = useEditSession(sessionOptions);
934
1324
  const code = getActiveContent(session);
1325
+ currentCodeRef.current = code;
935
1326
  const files = useMemo(() => getFiles(session.project), [session.project]);
936
1327
  const hasChanges = code !== (session.originalProject.files.get(session.activeFile)?.content ?? "");
1328
+ const fileType = useMemo(() => getFileType(session.activeFile), [session.activeFile]);
1329
+ const isCompilableFile = isCompilable(session.activeFile);
1330
+ const showPreviewToggle = isCompilableFile;
937
1331
  const handleBobbinChanges = useCallback((changes) => {
938
1332
  setBobbinChanges(changes);
939
1333
  }, []);
@@ -954,146 +1348,253 @@ ${bobbinYaml}
954
1348
  setEditInput("");
955
1349
  setBobbinChanges([]);
956
1350
  };
957
- const handleClose = () => {
1351
+ const hasSaveHandler = onSave || onSaveProject;
1352
+ const handleClose = useCallback(() => {
958
1353
  const editCount = session.history.length;
959
1354
  const finalCode = code;
1355
+ const hasUnsavedChanges = editCount > 0 && finalCode !== (session.originalProject.files.get(session.activeFile)?.content ?? "");
1356
+ if (hasUnsavedChanges && hasSaveHandler) {
1357
+ setPendingClose({ code: finalCode, count: editCount });
1358
+ setShowConfirm(true);
1359
+ } else {
1360
+ setEditInput("");
1361
+ session.clearError();
1362
+ onClose(finalCode, editCount);
1363
+ }
1364
+ }, [code, session, hasSaveHandler, onClose]);
1365
+ const handleSaveAndClose = useCallback(async () => {
1366
+ if (!pendingClose || !hasSaveHandler) return;
1367
+ setIsSaving(true);
1368
+ setSaveError(null);
1369
+ try {
1370
+ if (onSaveProject) {
1371
+ await onSaveProject(session.project);
1372
+ } else if (onSave) {
1373
+ await onSave(pendingClose.code);
1374
+ }
1375
+ setShowConfirm(false);
1376
+ setEditInput("");
1377
+ session.clearError();
1378
+ onClose(pendingClose.code, pendingClose.count);
1379
+ setPendingClose(null);
1380
+ } catch (e) {
1381
+ setSaveError(e instanceof Error ? e.message : "Save failed");
1382
+ } finally {
1383
+ setIsSaving(false);
1384
+ }
1385
+ }, [pendingClose, onSave, onSaveProject, session, onClose]);
1386
+ const handleDiscard = useCallback(() => {
1387
+ if (!pendingClose) return;
1388
+ setShowConfirm(false);
960
1389
  setEditInput("");
961
1390
  session.clearError();
962
- onClose(finalCode, editCount);
963
- };
1391
+ onClose(pendingClose.code, pendingClose.count);
1392
+ setPendingClose(null);
1393
+ }, [pendingClose, session, onClose]);
1394
+ const handleCancelClose = useCallback(() => {
1395
+ setShowConfirm(false);
1396
+ setPendingClose(null);
1397
+ setSaveError(null);
1398
+ }, []);
1399
+ const handleDirectSave = useCallback(async () => {
1400
+ if (!hasSaveHandler) return;
1401
+ setIsSaving(true);
1402
+ setSaveError(null);
1403
+ try {
1404
+ if (onSaveProject) {
1405
+ await onSaveProject(session.project);
1406
+ } else if (onSave && currentCodeRef.current) {
1407
+ await onSave(currentCodeRef.current);
1408
+ }
1409
+ } catch (e) {
1410
+ setSaveError(e instanceof Error ? e.message : "Save failed");
1411
+ } finally {
1412
+ setIsSaving(false);
1413
+ }
1414
+ }, [onSave, onSaveProject, session.project]);
964
1415
  if (!isOpen) return null;
965
- return /* @__PURE__ */ jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-8", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col bg-background rounded-lg shadow-xl w-full h-full max-w-6xl max-h-[90vh] overflow-hidden", children: [
966
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-4 py-3 bg-background border-b-2", children: [
967
- /* @__PURE__ */ jsx(Pencil, { className: "h-4 w-4 text-primary" }),
968
- session.isApplying && /* @__PURE__ */ jsxs("span", { className: "text-xs font-medium text-primary flex items-center gap-1 ml-2", children: [
969
- /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }),
970
- "Applying edits..."
1416
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1417
+ /* @__PURE__ */ jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-8", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col bg-background rounded-lg shadow-xl w-full h-full max-w-6xl max-h-[90vh] overflow-hidden", children: [
1418
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-4 py-3 bg-background border-b-2", children: [
1419
+ /* @__PURE__ */ jsx(Pencil, { className: "h-4 w-4 text-primary" }),
1420
+ session.isApplying && /* @__PURE__ */ jsxs("span", { className: "text-xs font-medium text-primary flex items-center gap-1 ml-2", children: [
1421
+ /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }),
1422
+ "Applying edits..."
1423
+ ] }),
1424
+ /* @__PURE__ */ jsxs("div", { className: "ml-auto flex gap-2", children: [
1425
+ hasChanges && /* @__PURE__ */ jsx(
1426
+ "button",
1427
+ {
1428
+ onClick: session.revert,
1429
+ className: "px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-primary/20 text-primary",
1430
+ title: "Revert to original",
1431
+ children: /* @__PURE__ */ jsx(RotateCcw, { className: "h-3 w-3" })
1432
+ }
1433
+ ),
1434
+ !hideFileTree && /* @__PURE__ */ jsx(
1435
+ "button",
1436
+ {
1437
+ onClick: () => setShowTree(!showTree),
1438
+ 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"}`,
1439
+ title: showTree ? "Single file" : "File tree",
1440
+ children: showTree ? /* @__PURE__ */ jsx(FileCode, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(FolderTree, { className: "h-3 w-3" })
1441
+ }
1442
+ ),
1443
+ showPreviewToggle && /* @__PURE__ */ jsxs(
1444
+ "button",
1445
+ {
1446
+ onClick: () => setShowPreview(!showPreview),
1447
+ 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"}`,
1448
+ children: [
1449
+ showPreview ? /* @__PURE__ */ jsx(Eye, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(Code, { className: "h-3 w-3" }),
1450
+ showPreview ? "Preview" : "Code"
1451
+ ]
1452
+ }
1453
+ ),
1454
+ hasSaveHandler && /* @__PURE__ */ jsxs(
1455
+ "button",
1456
+ {
1457
+ onClick: handleDirectSave,
1458
+ disabled: isSaving,
1459
+ className: "px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-primary/20 text-primary disabled:opacity-50",
1460
+ title: "Save changes",
1461
+ children: [
1462
+ isSaving ? /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ jsx(Save, { className: "h-3 w-3" }),
1463
+ "Save"
1464
+ ]
1465
+ }
1466
+ ),
1467
+ /* @__PURE__ */ jsxs(
1468
+ "button",
1469
+ {
1470
+ onClick: handleClose,
1471
+ className: "px-2 py-1 text-xs rounded flex items-center gap-1 bg-primary text-primary-foreground hover:bg-primary/90",
1472
+ title: "Exit edit mode",
1473
+ children: [
1474
+ /* @__PURE__ */ jsx(X, { className: "h-3 w-3" }),
1475
+ "Done"
1476
+ ]
1477
+ }
1478
+ )
1479
+ ] })
971
1480
  ] }),
972
- /* @__PURE__ */ jsxs("div", { className: "ml-auto flex gap-2", children: [
973
- hasChanges && /* @__PURE__ */ jsx(
974
- "button",
1481
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 min-h-0 border-b-2 overflow-hidden flex", children: [
1482
+ !hideFileTree && showTree && /* @__PURE__ */ jsx(
1483
+ FileTree,
975
1484
  {
976
- onClick: session.revert,
977
- className: "px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-primary/20 text-primary",
978
- title: "Revert to original",
979
- children: /* @__PURE__ */ jsx(RotateCcw, { className: "h-3 w-3" })
1485
+ files,
1486
+ activeFile: session.activeFile,
1487
+ onSelectFile: session.setActiveFile,
1488
+ onReplaceFile: session.replaceFile
980
1489
  }
981
1490
  ),
982
- /* @__PURE__ */ jsx(
983
- "button",
1491
+ /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-auto", children: fileType.category === "compilable" && showPreview ? /* @__PURE__ */ jsxs("div", { className: "bg-white h-full relative", ref: setPreviewContainer, children: [
1492
+ previewError && renderError ? renderError(previewError) : previewError ? /* @__PURE__ */ jsxs("div", { className: "p-4 text-sm text-destructive flex items-center gap-2", children: [
1493
+ /* @__PURE__ */ jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }),
1494
+ /* @__PURE__ */ jsx("span", { children: previewError })
1495
+ ] }) : previewLoading && renderLoading ? renderLoading() : previewLoading ? /* @__PURE__ */ jsxs("div", { className: "p-4 flex items-center gap-2 text-muted-foreground", children: [
1496
+ /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
1497
+ /* @__PURE__ */ jsx("span", { className: "text-sm", children: "Rendering preview..." })
1498
+ ] }) : /* @__PURE__ */ jsx("div", { className: "p-4", children: renderPreview(code) }, hashCode(code)),
1499
+ !renderLoading && !renderError && !previewLoading && /* @__PURE__ */ jsx(
1500
+ Bobbin,
1501
+ {
1502
+ container: previewContainer,
1503
+ pillContainer: previewContainer,
1504
+ defaultActive: false,
1505
+ showInspector: true,
1506
+ onChanges: handleBobbinChanges,
1507
+ exclude: [".bobbin-pill", "[data-bobbin]"]
1508
+ }
1509
+ )
1510
+ ] }) : fileType.category === "compilable" && !showPreview ? /* @__PURE__ */ jsx(
1511
+ CodeBlockView,
984
1512
  {
985
- onClick: () => setShowTree(!showTree),
986
- 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"}`,
987
- title: showTree ? "Single file" : "File tree",
988
- children: showTree ? /* @__PURE__ */ jsx(FileCode, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(FolderTree, { className: "h-3 w-3" })
1513
+ content: code,
1514
+ language: fileType.language,
1515
+ editable: true,
1516
+ onChange: session.updateActiveFile
989
1517
  }
990
- ),
991
- /* @__PURE__ */ jsxs(
992
- "button",
1518
+ ) : fileType.category === "text" ? /* @__PURE__ */ jsx(
1519
+ CodeBlockView,
993
1520
  {
994
- onClick: () => setShowPreview(!showPreview),
995
- 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"}`,
996
- children: [
997
- showPreview ? /* @__PURE__ */ jsx(Eye, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(Code, { className: "h-3 w-3" }),
998
- showPreview ? "Preview" : "Code"
999
- ]
1521
+ content: code,
1522
+ language: fileType.language,
1523
+ editable: true,
1524
+ onChange: session.updateActiveFile
1000
1525
  }
1001
- ),
1002
- /* @__PURE__ */ jsxs(
1003
- "button",
1526
+ ) : fileType.category === "media" ? /* @__PURE__ */ jsx(
1527
+ MediaPreview,
1004
1528
  {
1005
- onClick: handleClose,
1006
- className: "px-2 py-1 text-xs rounded flex items-center gap-1 bg-primary text-primary-foreground hover:bg-primary/90",
1007
- title: "Exit edit mode",
1008
- children: [
1009
- /* @__PURE__ */ jsx(X, { className: "h-3 w-3" }),
1010
- "Done"
1011
- ]
1529
+ content: code,
1530
+ mimeType: getMimeType(session.activeFile),
1531
+ fileName: session.activeFile.split("/").pop() ?? session.activeFile
1012
1532
  }
1013
- )
1014
- ] })
1015
- ] }),
1016
- /* @__PURE__ */ jsxs("div", { className: "flex-1 min-h-0 border-b-2 overflow-hidden flex", children: [
1017
- showTree && /* @__PURE__ */ jsx(
1018
- FileTree,
1533
+ ) : /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center h-full text-muted-foreground", children: /* @__PURE__ */ jsx("p", { className: "text-sm", children: "Preview not available for this file type" }) }) })
1534
+ ] }),
1535
+ /* @__PURE__ */ jsx(
1536
+ EditHistory,
1019
1537
  {
1020
- files,
1021
- activeFile: session.activeFile,
1022
- onSelectFile: session.setActiveFile
1538
+ entries: session.history,
1539
+ streamingNotes: session.streamingNotes,
1540
+ isStreaming: session.isApplying,
1541
+ pendingPrompt: session.pendingPrompt,
1542
+ className: "h-48"
1023
1543
  }
1024
1544
  ),
1025
- /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-auto", children: showPreview ? /* @__PURE__ */ jsxs("div", { className: "bg-white h-full relative", ref: setPreviewContainer, children: [
1026
- previewError && renderError ? renderError(previewError) : previewError ? /* @__PURE__ */ jsxs("div", { className: "p-4 text-sm text-destructive flex items-center gap-2", children: [
1027
- /* @__PURE__ */ jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }),
1028
- /* @__PURE__ */ jsx("span", { children: previewError })
1029
- ] }) : previewLoading && renderLoading ? renderLoading() : previewLoading ? /* @__PURE__ */ jsxs("div", { className: "p-4 flex items-center gap-2 text-muted-foreground", children: [
1030
- /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
1031
- /* @__PURE__ */ jsx("span", { className: "text-sm", children: "Rendering preview..." })
1032
- ] }) : /* @__PURE__ */ jsx("div", { className: "p-4", children: renderPreview(code) }, hashCode(code)),
1033
- !renderLoading && !renderError && !previewLoading && /* @__PURE__ */ jsx(
1034
- Bobbin,
1545
+ (session.error || saveError) && /* @__PURE__ */ jsxs("div", { className: "px-4 py-2 bg-destructive/10 text-destructive text-sm flex items-center gap-2 border-t-2 border-destructive", children: [
1546
+ /* @__PURE__ */ jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }),
1547
+ session.error || saveError
1548
+ ] }),
1549
+ bobbinChanges.length > 0 && /* @__PURE__ */ jsxs("div", { className: "px-4 py-2 bg-blue-50 text-blue-700 text-sm flex items-center gap-2 border-t", children: [
1550
+ /* @__PURE__ */ jsxs("span", { children: [
1551
+ bobbinChanges.length,
1552
+ " visual change",
1553
+ bobbinChanges.length !== 1 ? "s" : ""
1554
+ ] }),
1555
+ /* @__PURE__ */ jsx(
1556
+ "button",
1035
1557
  {
1036
- container: previewContainer,
1037
- pillContainer: previewContainer,
1038
- defaultActive: false,
1039
- showInspector: true,
1040
- onChanges: handleBobbinChanges,
1041
- exclude: [".bobbin-pill", "[data-bobbin]"]
1558
+ onClick: () => setBobbinChanges([]),
1559
+ className: "text-xs underline hover:no-underline",
1560
+ children: "Clear"
1042
1561
  }
1043
1562
  )
1044
- ] }) : /* @__PURE__ */ jsx("div", { className: "p-4 bg-muted/10 h-full overflow-auto", children: /* @__PURE__ */ jsx("pre", { className: "text-xs whitespace-pre-wrap break-words m-0", children: /* @__PURE__ */ jsx("code", { children: code }) }) }) })
1045
- ] }),
1563
+ ] }),
1564
+ /* @__PURE__ */ jsxs("div", { className: "p-4 border-t-2 bg-primary/5 flex gap-2 items-end", children: [
1565
+ /* @__PURE__ */ jsx("div", { className: "flex-1", children: /* @__PURE__ */ jsx(
1566
+ MarkdownEditor,
1567
+ {
1568
+ value: editInput,
1569
+ onChange: setEditInput,
1570
+ onSubmit: handleSubmit,
1571
+ placeholder: "Describe changes...",
1572
+ disabled: session.isApplying
1573
+ }
1574
+ ) }),
1575
+ /* @__PURE__ */ jsx(
1576
+ "button",
1577
+ {
1578
+ onClick: handleSubmit,
1579
+ disabled: !editInput.trim() && bobbinChanges.length === 0 || session.isApplying,
1580
+ 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",
1581
+ children: session.isApplying ? /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }) : /* @__PURE__ */ jsx(Send, { className: "h-4 w-4" })
1582
+ }
1583
+ )
1584
+ ] })
1585
+ ] }) }),
1046
1586
  /* @__PURE__ */ jsx(
1047
- EditHistory,
1587
+ SaveConfirmDialog,
1048
1588
  {
1049
- entries: session.history,
1050
- streamingNotes: session.streamingNotes,
1051
- isStreaming: session.isApplying,
1052
- pendingPrompt: session.pendingPrompt,
1053
- className: "h-48"
1589
+ isOpen: showConfirm,
1590
+ isSaving,
1591
+ error: saveError,
1592
+ onSave: handleSaveAndClose,
1593
+ onDiscard: handleDiscard,
1594
+ onCancel: handleCancelClose
1054
1595
  }
1055
- ),
1056
- session.error && /* @__PURE__ */ jsxs("div", { className: "px-4 py-2 bg-destructive/10 text-destructive text-sm flex items-center gap-2 border-t-2 border-destructive", children: [
1057
- /* @__PURE__ */ jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }),
1058
- session.error
1059
- ] }),
1060
- bobbinChanges.length > 0 && /* @__PURE__ */ jsxs("div", { className: "px-4 py-2 bg-blue-50 text-blue-700 text-sm flex items-center gap-2 border-t", children: [
1061
- /* @__PURE__ */ jsxs("span", { children: [
1062
- bobbinChanges.length,
1063
- " visual change",
1064
- bobbinChanges.length !== 1 ? "s" : ""
1065
- ] }),
1066
- /* @__PURE__ */ jsx(
1067
- "button",
1068
- {
1069
- onClick: () => setBobbinChanges([]),
1070
- className: "text-xs underline hover:no-underline",
1071
- children: "Clear"
1072
- }
1073
- )
1074
- ] }),
1075
- /* @__PURE__ */ jsxs("div", { className: "p-4 border-t-2 bg-primary/5 flex gap-2 items-end", children: [
1076
- /* @__PURE__ */ jsx("div", { className: "flex-1", children: /* @__PURE__ */ jsx(
1077
- MarkdownEditor,
1078
- {
1079
- value: editInput,
1080
- onChange: setEditInput,
1081
- onSubmit: handleSubmit,
1082
- placeholder: "Describe changes...",
1083
- disabled: session.isApplying
1084
- }
1085
- ) }),
1086
- /* @__PURE__ */ jsx(
1087
- "button",
1088
- {
1089
- onClick: handleSubmit,
1090
- disabled: !editInput.trim() && bobbinChanges.length === 0 || session.isApplying,
1091
- 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",
1092
- children: session.isApplying ? /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }) : /* @__PURE__ */ jsx(Send, { className: "h-4 w-4" })
1093
- }
1094
- )
1095
- ] })
1096
- ] }) });
1596
+ )
1597
+ ] });
1097
1598
  }
1098
1599
  var VFS_BASE_URL = "/vfs";
1099
1600
  var vfsConfigCache = null;
@@ -1112,8 +1613,11 @@ async function getVFSConfig() {
1112
1613
  var storeInstance = null;
1113
1614
  function getVFSStore() {
1114
1615
  if (!storeInstance) {
1115
- const backend = new LocalFSBackend({ baseUrl: VFS_BASE_URL });
1116
- storeInstance = new VFSStore(backend);
1616
+ const provider = new HttpBackend({ baseUrl: VFS_BASE_URL });
1617
+ storeInstance = new VFSStore(provider, {
1618
+ sync: true,
1619
+ conflictStrategy: "local-wins"
1620
+ });
1117
1621
  }
1118
1622
  return storeInstance;
1119
1623
  }
@@ -1135,9 +1639,17 @@ async function listProjects() {
1135
1639
  }
1136
1640
  return Array.from(projectIds);
1137
1641
  }
1138
- async function saveFile(file) {
1642
+ async function saveFile(path, content) {
1643
+ const store = getVFSStore();
1644
+ await store.writeFile(path, content);
1645
+ }
1646
+ async function loadFile(path, encoding) {
1139
1647
  const store = getVFSStore();
1140
- await store.putFile(file);
1648
+ return store.readFile(path, encoding);
1649
+ }
1650
+ function subscribeToChanges(callback) {
1651
+ const store = getVFSStore();
1652
+ return store.on("change", callback);
1141
1653
  }
1142
1654
  async function isVFSAvailable() {
1143
1655
  try {
@@ -1207,12 +1719,17 @@ function useCodeCompiler(compiler, code, enabled, services) {
1207
1719
  }, [code, compiler, enabled, services]);
1208
1720
  return { containerRef, loading, error };
1209
1721
  }
1210
- function CodePreview({ code: originalCode, compiler, services, filePath }) {
1722
+ function CodePreview({ code: originalCode, compiler, services, filePath, entrypoint = "index.ts" }) {
1211
1723
  const [isEditing, setIsEditing] = useState(false);
1212
1724
  const [showPreview, setShowPreview] = useState(true);
1213
1725
  const [currentCode, setCurrentCode] = useState(originalCode);
1214
1726
  const [editCount, setEditCount] = useState(0);
1215
1727
  const [saveStatus, setSaveStatus] = useState("unsaved");
1728
+ const [lastSavedCode, setLastSavedCode] = useState(originalCode);
1729
+ const [vfsPath, setVfsPath] = useState(null);
1730
+ const currentCodeRef = useRef(currentCode);
1731
+ const lastSavedRef = useRef(lastSavedCode);
1732
+ const isEditingRef = useRef(isEditing);
1216
1733
  const fallbackId = useMemo(() => crypto.randomUUID(), []);
1217
1734
  const getProjectId = useCallback(async () => {
1218
1735
  if (filePath) {
@@ -1230,10 +1747,57 @@ function CodePreview({ code: originalCode, compiler, services, filePath }) {
1230
1747
  const getEntryFile = useCallback(() => {
1231
1748
  if (filePath) {
1232
1749
  const parts = filePath.split("/");
1233
- return parts[parts.length - 1] || "main.tsx";
1750
+ return parts[parts.length - 1] || entrypoint;
1234
1751
  }
1235
- return "main.tsx";
1752
+ return entrypoint;
1236
1753
  }, [filePath]);
1754
+ useEffect(() => {
1755
+ currentCodeRef.current = currentCode;
1756
+ }, [currentCode]);
1757
+ useEffect(() => {
1758
+ lastSavedRef.current = lastSavedCode;
1759
+ }, [lastSavedCode]);
1760
+ useEffect(() => {
1761
+ isEditingRef.current = isEditing;
1762
+ }, [isEditing]);
1763
+ useEffect(() => {
1764
+ let active = true;
1765
+ void (async () => {
1766
+ const projectId = await getProjectId();
1767
+ const entryFile = getEntryFile();
1768
+ if (!active) return;
1769
+ setVfsPath(`${projectId}/${entryFile}`);
1770
+ })();
1771
+ return () => {
1772
+ active = false;
1773
+ };
1774
+ }, [getProjectId, getEntryFile]);
1775
+ useEffect(() => {
1776
+ if (!vfsPath) return;
1777
+ const unsubscribe = subscribeToChanges(async (record) => {
1778
+ if (record.path !== vfsPath) return;
1779
+ if (record.type === "delete") {
1780
+ setSaveStatus("unsaved");
1781
+ return;
1782
+ }
1783
+ if (isEditingRef.current) return;
1784
+ try {
1785
+ const remote = await loadFile(vfsPath);
1786
+ if (currentCodeRef.current !== lastSavedRef.current) {
1787
+ setSaveStatus("unsaved");
1788
+ return;
1789
+ }
1790
+ if (remote !== currentCodeRef.current) {
1791
+ setCurrentCode(remote);
1792
+ setLastSavedCode(remote);
1793
+ setSaveStatus("saved");
1794
+ }
1795
+ } catch {
1796
+ setSaveStatus("error");
1797
+ }
1798
+ });
1799
+ return () => unsubscribe();
1800
+ }, [vfsPath]);
1237
1801
  const handleSave = useCallback(async () => {
1238
1802
  setSaveStatus("saving");
1239
1803
  try {
@@ -1241,6 +1805,7 @@ function CodePreview({ code: originalCode, compiler, services, filePath }) {
1241
1805
  const entryFile = getEntryFile();
1242
1806
  const project = createSingleFileProject(currentCode, entryFile, projectId);
1243
1807
  await saveProject(project);
1808
+ setLastSavedCode(currentCode);
1244
1809
  setSaveStatus("saved");
1245
1810
  } catch (err) {
1246
1811
  console.warn("[VFS] Failed to save project:", err);
@@ -1594,4 +2159,4 @@ function cn(...inputs) {
1594
2159
  return twMerge(clsx(inputs));
1595
2160
  }
1596
2161
 
1597
- export { CodeBlockExtension, CodePreview, EditHistory, EditModal, FileTree, MarkdownEditor, ServicesInspector, applyDiffs, cn, extractCodeBlocks, extractProject, extractSummary, extractTextWithoutDiffs, findDiffMarkers, findFirstCodeBlock, getActiveContent, getCodeBlockLanguages, getFiles, getVFSConfig, getVFSStore, hasCodeBlock, hasDiffBlocks, isVFSAvailable, listProjects, loadProject, parseCodeBlockAttributes, parseCodeBlocks, parseDiffs, parseEditResponse, sanitizeDiffMarkers, saveFile, saveProject, sendEditRequest, useEditSession };
2162
+ export { CodeBlockExtension, CodeBlockView, CodePreview, EditHistory, EditModal, FileTree, MarkdownEditor, MediaPreview, SaveConfirmDialog, ServicesInspector, applyDiffs, cn, extractCodeBlocks, extractProject, extractSummary, extractTextWithoutDiffs, findDiffMarkers, findFirstCodeBlock, getActiveContent, getCodeBlockLanguages, getFileType, getFiles, getLanguageFromExt, getMimeType, getVFSConfig, getVFSStore, hasCodeBlock, hasDiffBlocks, isCompilable, isImageFile, isMediaFile, isTextFile, isVFSAvailable, isVideoFile, listProjects, loadProject, parseCodeBlockAttributes, parseCodeBlocks, parseDiffs, parseEditResponse, sanitizeDiffMarkers, saveFile, saveProject, sendEditRequest, useEditSession };