@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
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';
@@ -11,6 +11,7 @@ import Placeholder from '@tiptap/extension-placeholder';
11
11
  import Typography from '@tiptap/extension-typography';
12
12
  import { Markdown as Markdown$1 } from 'tiptap-markdown';
13
13
  import { TextSelection } from '@tiptap/pm/state';
14
+ import { createHighlighter } from 'shiki';
14
15
  import { Bobbin, serializeChangesToYAML } from '@aprovan/bobbin';
15
16
  import { clsx } from 'clsx';
16
17
  import { twMerge } from 'tailwind-merge';
@@ -119,7 +120,7 @@ var CodeBlockExtension = Node.create({
119
120
  default: null,
120
121
  parseHTML: (element) => {
121
122
  const { languageClassPrefix } = this.options;
122
- const classNames = [...element.firstElementChild?.classList || []];
123
+ const classNames = Array.from(element.firstElementChild?.classList || []);
123
124
  const languages = classNames.filter((className) => className.startsWith(languageClassPrefix)).map((className) => className.replace(languageClassPrefix, ""));
124
125
  return languages[0] || null;
125
126
  },
@@ -340,6 +341,7 @@ function applyDiffs(code, diffs, options = {}) {
340
341
  return { code: result, applied, failed, warning };
341
342
  }
342
343
  function hasDiffBlocks(text) {
344
+ DIFF_BLOCK_REGEX.lastIndex = 0;
343
345
  return DIFF_BLOCK_REGEX.test(text);
344
346
  }
345
347
  function extractTextWithoutDiffs(text) {
@@ -435,11 +437,23 @@ function cloneProject(project) {
435
437
  };
436
438
  }
437
439
  function useEditSession(options) {
438
- const { originalCode, compile, apiEndpoint } = options;
440
+ const {
441
+ originalCode,
442
+ originalProject: providedProject,
443
+ compile,
444
+ apiEndpoint
445
+ } = options;
446
+ console.log(
447
+ "[useEditSession] providedProject:",
448
+ providedProject?.id,
449
+ "files:",
450
+ providedProject ? Array.from(providedProject.files.keys()) : "none"
451
+ );
439
452
  const originalProject = useMemo(
440
- () => createSingleFileProject(originalCode),
441
- [originalCode]
453
+ () => providedProject ?? createSingleFileProject(originalCode ?? ""),
454
+ [providedProject, originalCode]
442
455
  );
456
+ const lastSyncedProjectRef = useRef(originalProject);
443
457
  const [project, setProject] = useState(originalProject);
444
458
  const [activeFile, setActiveFile] = useState(originalProject.entry);
445
459
  const [history, setHistory] = useState([]);
@@ -447,6 +461,16 @@ function useEditSession(options) {
447
461
  const [error, setError] = useState(null);
448
462
  const [streamingNotes, setStreamingNotes] = useState([]);
449
463
  const [pendingPrompt, setPendingPrompt] = useState(null);
464
+ useEffect(() => {
465
+ if (originalProject !== lastSyncedProjectRef.current) {
466
+ lastSyncedProjectRef.current = originalProject;
467
+ setProject(originalProject);
468
+ setActiveFile(originalProject.entry);
469
+ setHistory([]);
470
+ setError(null);
471
+ setStreamingNotes([]);
472
+ }
473
+ }, [originalProject]);
450
474
  const performEdit = useCallback(
451
475
  async (currentCode2, prompt, isRetry = false) => {
452
476
  const entries = [];
@@ -536,6 +560,21 @@ Please fix this error.`;
536
560
  },
537
561
  [activeFile]
538
562
  );
563
+ const replaceFile = useCallback(
564
+ (path, content, encoding = "utf8") => {
565
+ setProject((prev) => {
566
+ const updated = cloneProject(prev);
567
+ const file = updated.files.get(path);
568
+ if (file) {
569
+ updated.files.set(path, { ...file, content, encoding });
570
+ } else {
571
+ updated.files.set(path, { path, content, encoding });
572
+ }
573
+ return updated;
574
+ });
575
+ },
576
+ []
577
+ );
539
578
  const clearError = useCallback(() => {
540
579
  setError(null);
541
580
  }, []);
@@ -552,7 +591,8 @@ Please fix this error.`;
552
591
  revert,
553
592
  updateActiveFile,
554
593
  setActiveFile,
555
- clearError
594
+ clearError,
595
+ replaceFile
556
596
  };
557
597
  }
558
598
  function ProgressNote({ text, isLatest }) {
@@ -811,6 +851,110 @@ function MarkdownEditor({
811
851
  }
812
852
  );
813
853
  }
854
+
855
+ // src/components/edit/fileTypes.ts
856
+ var COMPILABLE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"];
857
+ var MEDIA_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".mp4", ".mov", ".webm"];
858
+ var TEXT_EXTENSIONS = [".json", ".yaml", ".yml", ".md", ".txt", ".css", ".html", ".xml", ".toml"];
859
+ var EXTENSION_TO_LANGUAGE = {
860
+ ".tsx": "tsx",
861
+ ".jsx": "jsx",
862
+ ".ts": "typescript",
863
+ ".js": "javascript",
864
+ ".json": "json",
865
+ ".yaml": "yaml",
866
+ ".yml": "yaml",
867
+ ".md": "markdown",
868
+ ".txt": "text",
869
+ ".css": "css",
870
+ ".html": "html",
871
+ ".xml": "xml",
872
+ ".toml": "toml",
873
+ ".svg": "xml"
874
+ };
875
+ var EXTENSION_TO_MIME = {
876
+ ".tsx": "text/typescript-jsx",
877
+ ".jsx": "text/javascript-jsx",
878
+ ".ts": "text/typescript",
879
+ ".js": "text/javascript",
880
+ ".json": "application/json",
881
+ ".yaml": "text/yaml",
882
+ ".yml": "text/yaml",
883
+ ".md": "text/markdown",
884
+ ".txt": "text/plain",
885
+ ".css": "text/css",
886
+ ".html": "text/html",
887
+ ".xml": "application/xml",
888
+ ".toml": "text/toml",
889
+ ".svg": "image/svg+xml",
890
+ ".png": "image/png",
891
+ ".jpg": "image/jpeg",
892
+ ".jpeg": "image/jpeg",
893
+ ".gif": "image/gif",
894
+ ".webp": "image/webp",
895
+ ".mp4": "video/mp4",
896
+ ".mov": "video/quicktime",
897
+ ".webm": "video/webm"
898
+ };
899
+ function getExtension(path) {
900
+ const lastDot = path.lastIndexOf(".");
901
+ if (lastDot === -1) return "";
902
+ return path.slice(lastDot).toLowerCase();
903
+ }
904
+ function getFileType(path) {
905
+ const ext = getExtension(path);
906
+ if (COMPILABLE_EXTENSIONS.includes(ext)) {
907
+ return {
908
+ category: "compilable",
909
+ language: EXTENSION_TO_LANGUAGE[ext] ?? null,
910
+ mimeType: EXTENSION_TO_MIME[ext] ?? "text/plain"
911
+ };
912
+ }
913
+ if (TEXT_EXTENSIONS.includes(ext)) {
914
+ return {
915
+ category: "text",
916
+ language: EXTENSION_TO_LANGUAGE[ext] ?? null,
917
+ mimeType: EXTENSION_TO_MIME[ext] ?? "text/plain"
918
+ };
919
+ }
920
+ if (MEDIA_EXTENSIONS.includes(ext)) {
921
+ return {
922
+ category: "media",
923
+ language: ext === ".svg" ? "xml" : null,
924
+ mimeType: EXTENSION_TO_MIME[ext] ?? "application/octet-stream"
925
+ };
926
+ }
927
+ return {
928
+ category: "binary",
929
+ language: null,
930
+ mimeType: "application/octet-stream"
931
+ };
932
+ }
933
+ function isCompilable(path) {
934
+ return COMPILABLE_EXTENSIONS.includes(getExtension(path));
935
+ }
936
+ function isMediaFile(path) {
937
+ return MEDIA_EXTENSIONS.includes(getExtension(path));
938
+ }
939
+ function isTextFile(path) {
940
+ return TEXT_EXTENSIONS.includes(getExtension(path));
941
+ }
942
+ function getLanguageFromExt(path) {
943
+ const ext = getExtension(path);
944
+ return EXTENSION_TO_LANGUAGE[ext] ?? null;
945
+ }
946
+ function getMimeType(path) {
947
+ const ext = getExtension(path);
948
+ return EXTENSION_TO_MIME[ext] ?? "application/octet-stream";
949
+ }
950
+ function isImageFile(path) {
951
+ const ext = getExtension(path);
952
+ return [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(ext);
953
+ }
954
+ function isVideoFile(path) {
955
+ const ext = getExtension(path);
956
+ return [".mp4", ".mov", ".webm"].includes(ext);
957
+ }
814
958
  function buildTree(files) {
815
959
  const root = { name: "", path: "", isDir: true, children: [] };
816
960
  for (const file of files) {
@@ -839,8 +983,26 @@ function buildTree(files) {
839
983
  });
840
984
  return root;
841
985
  }
842
- function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }) {
986
+ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth = 0 }) {
843
987
  const [expanded, setExpanded] = useState(true);
988
+ const [isHovered, setIsHovered] = useState(false);
989
+ const fileInputRef = useRef(null);
990
+ const handleUploadClick = useCallback((e) => {
991
+ e.stopPropagation();
992
+ fileInputRef.current?.click();
993
+ }, []);
994
+ const handleFileChange = useCallback((e) => {
995
+ const file = e.target.files?.[0];
996
+ if (!file || !onReplaceFile) return;
997
+ const reader = new FileReader();
998
+ reader.onload = () => {
999
+ const result = reader.result;
1000
+ const base64 = result.split(",")[1] ?? "";
1001
+ onReplaceFile(node.path, base64, "base64");
1002
+ };
1003
+ reader.readAsDataURL(file);
1004
+ e.target.value = "";
1005
+ }, [node.path, onReplaceFile]);
844
1006
  if (!node.name) {
845
1007
  return /* @__PURE__ */ jsx(Fragment, { children: node.children.map((child) => /* @__PURE__ */ jsx(
846
1008
  TreeNodeComponent,
@@ -848,12 +1010,15 @@ function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }) {
848
1010
  node: child,
849
1011
  activeFile,
850
1012
  onSelect,
1013
+ onReplaceFile,
851
1014
  depth
852
1015
  },
853
1016
  child.path
854
1017
  )) });
855
1018
  }
856
1019
  const isActive = node.path === activeFile;
1020
+ const isMedia = !node.isDir && isMediaFile(node.path);
1021
+ const showUpload = isMedia && isHovered && onReplaceFile;
857
1022
  if (node.isDir) {
858
1023
  return /* @__PURE__ */ jsxs("div", { children: [
859
1024
  /* @__PURE__ */ jsxs(
@@ -875,6 +1040,7 @@ function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }) {
875
1040
  node: child,
876
1041
  activeFile,
877
1042
  onSelect,
1043
+ onReplaceFile,
878
1044
  depth: depth + 1
879
1045
  },
880
1046
  child.path
@@ -882,19 +1048,48 @@ function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }) {
882
1048
  ] });
883
1049
  }
884
1050
  return /* @__PURE__ */ jsxs(
885
- "button",
1051
+ "div",
886
1052
  {
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` },
1053
+ className: "relative",
1054
+ onMouseEnter: () => setIsHovered(true),
1055
+ onMouseLeave: () => setIsHovered(false),
890
1056
  children: [
891
- /* @__PURE__ */ jsx(File, { className: "h-3 w-3 shrink-0 text-muted-foreground" }),
892
- /* @__PURE__ */ jsx("span", { className: "truncate", children: node.name })
1057
+ /* @__PURE__ */ jsxs(
1058
+ "button",
1059
+ {
1060
+ onClick: () => onSelect(node.path),
1061
+ 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" : ""}`,
1062
+ style: { paddingLeft: `${depth * 12 + 20}px` },
1063
+ children: [
1064
+ /* @__PURE__ */ jsx(File, { className: "h-3 w-3 shrink-0 text-muted-foreground" }),
1065
+ /* @__PURE__ */ jsx("span", { className: "truncate flex-1", children: node.name }),
1066
+ showUpload && /* @__PURE__ */ jsx(
1067
+ "span",
1068
+ {
1069
+ onClick: handleUploadClick,
1070
+ className: "p-1 hover:bg-primary/20 rounded cursor-pointer",
1071
+ title: "Replace file",
1072
+ children: /* @__PURE__ */ jsx(Upload, { className: "h-3 w-3 text-primary" })
1073
+ }
1074
+ )
1075
+ ]
1076
+ }
1077
+ ),
1078
+ isMedia && /* @__PURE__ */ jsx(
1079
+ "input",
1080
+ {
1081
+ ref: fileInputRef,
1082
+ type: "file",
1083
+ className: "hidden",
1084
+ accept: "image/*,video/*",
1085
+ onChange: handleFileChange
1086
+ }
1087
+ )
893
1088
  ]
894
1089
  }
895
1090
  );
896
1091
  }
897
- function FileTree({ files, activeFile, onSelectFile }) {
1092
+ function FileTree({ files, activeFile, onSelectFile, onReplaceFile }) {
898
1093
  const tree = useMemo(() => buildTree(files), [files]);
899
1094
  return /* @__PURE__ */ jsxs("div", { className: "w-48 border-r bg-muted/30 overflow-auto text-foreground", children: [
900
1095
  /* @__PURE__ */ jsx("div", { className: "p-2 border-b text-xs font-medium text-muted-foreground uppercase tracking-wide", children: "Files" }),
@@ -903,11 +1098,299 @@ function FileTree({ files, activeFile, onSelectFile }) {
903
1098
  {
904
1099
  node: tree,
905
1100
  activeFile,
906
- onSelect: onSelectFile
1101
+ onSelect: onSelectFile,
1102
+ onReplaceFile
907
1103
  }
908
1104
  ) })
909
1105
  ] });
910
1106
  }
1107
+ function SaveConfirmDialog({
1108
+ isOpen,
1109
+ isSaving,
1110
+ error,
1111
+ onSave,
1112
+ onDiscard,
1113
+ onCancel
1114
+ }) {
1115
+ if (!isOpen) return null;
1116
+ 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: [
1117
+ /* @__PURE__ */ jsxs("div", { className: "p-6", children: [
1118
+ /* @__PURE__ */ jsx("h2", { className: "text-lg font-semibold leading-none tracking-tight", children: "Unsaved Changes" }),
1119
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground mt-2", children: "You have unsaved changes. Would you like to save them before closing?" }),
1120
+ error && /* @__PURE__ */ jsxs("p", { className: "text-sm text-destructive mt-3", children: [
1121
+ "Save failed: ",
1122
+ error
1123
+ ] })
1124
+ ] }),
1125
+ /* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2 p-6 pt-0", children: [
1126
+ /* @__PURE__ */ jsx(
1127
+ "button",
1128
+ {
1129
+ onClick: onCancel,
1130
+ disabled: isSaving,
1131
+ 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",
1132
+ children: "Cancel"
1133
+ }
1134
+ ),
1135
+ /* @__PURE__ */ jsx(
1136
+ "button",
1137
+ {
1138
+ onClick: onDiscard,
1139
+ disabled: isSaving,
1140
+ 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",
1141
+ children: "Discard"
1142
+ }
1143
+ ),
1144
+ /* @__PURE__ */ jsx(
1145
+ "button",
1146
+ {
1147
+ onClick: onSave,
1148
+ disabled: isSaving,
1149
+ 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",
1150
+ children: isSaving ? "Saving..." : "Save"
1151
+ }
1152
+ )
1153
+ ] })
1154
+ ] }) });
1155
+ }
1156
+ var highlighterPromise = null;
1157
+ var COMMON_LANGUAGES = [
1158
+ "typescript",
1159
+ "javascript",
1160
+ "tsx",
1161
+ "jsx",
1162
+ "json",
1163
+ "html",
1164
+ "css",
1165
+ "markdown",
1166
+ "yaml",
1167
+ "python",
1168
+ "bash",
1169
+ "sql"
1170
+ ];
1171
+ function getHighlighter() {
1172
+ if (!highlighterPromise) {
1173
+ highlighterPromise = createHighlighter({
1174
+ themes: ["github-light"],
1175
+ langs: COMMON_LANGUAGES
1176
+ });
1177
+ }
1178
+ return highlighterPromise;
1179
+ }
1180
+ function normalizeLanguage(lang) {
1181
+ if (!lang) return "typescript";
1182
+ const normalized = lang.toLowerCase();
1183
+ const mapping = {
1184
+ ts: "typescript",
1185
+ tsx: "tsx",
1186
+ js: "javascript",
1187
+ jsx: "jsx",
1188
+ json: "json",
1189
+ html: "html",
1190
+ css: "css",
1191
+ md: "markdown",
1192
+ markdown: "markdown",
1193
+ yml: "yaml",
1194
+ yaml: "yaml",
1195
+ py: "python",
1196
+ python: "python",
1197
+ sh: "bash",
1198
+ bash: "bash",
1199
+ sql: "sql",
1200
+ typescript: "typescript",
1201
+ javascript: "javascript"
1202
+ };
1203
+ return mapping[normalized] || "typescript";
1204
+ }
1205
+ function CodeBlockView({ content, language, editable = false, onChange }) {
1206
+ const textareaRef = useRef(null);
1207
+ const containerRef = useRef(null);
1208
+ const [highlighter, setHighlighter] = useState(null);
1209
+ useEffect(() => {
1210
+ let mounted = true;
1211
+ getHighlighter().then((h) => {
1212
+ if (mounted) setHighlighter(h);
1213
+ });
1214
+ return () => {
1215
+ mounted = false;
1216
+ };
1217
+ }, []);
1218
+ useEffect(() => {
1219
+ if (textareaRef.current) {
1220
+ textareaRef.current.style.height = "auto";
1221
+ textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
1222
+ }
1223
+ }, [content]);
1224
+ const handleChange = useCallback(
1225
+ (e) => {
1226
+ onChange?.(e.target.value);
1227
+ },
1228
+ [onChange]
1229
+ );
1230
+ const handleKeyDown = useCallback(
1231
+ (e) => {
1232
+ if (e.key === "Tab") {
1233
+ e.preventDefault();
1234
+ const target = e.target;
1235
+ const start = target.selectionStart;
1236
+ const end = target.selectionEnd;
1237
+ const value = target.value;
1238
+ const newValue = value.substring(0, start) + " " + value.substring(end);
1239
+ onChange?.(newValue);
1240
+ requestAnimationFrame(() => {
1241
+ target.selectionStart = target.selectionEnd = start + 2;
1242
+ });
1243
+ }
1244
+ },
1245
+ [onChange]
1246
+ );
1247
+ const langLabel = language || "text";
1248
+ const shikiLang = useMemo(() => normalizeLanguage(language), [language]);
1249
+ const highlightedHtml = useMemo(() => {
1250
+ if (!highlighter) return null;
1251
+ try {
1252
+ return highlighter.codeToHtml(content, {
1253
+ lang: shikiLang,
1254
+ theme: "github-light"
1255
+ });
1256
+ } catch {
1257
+ return null;
1258
+ }
1259
+ }, [highlighter, content, shikiLang]);
1260
+ return /* @__PURE__ */ jsxs("div", { className: "h-full flex flex-col bg-[#ffffff]", children: [
1261
+ /* @__PURE__ */ jsx("div", { className: "flex items-center justify-between px-4 py-2 bg-[#f6f8fa] border-b border-[#d0d7de] text-xs", children: /* @__PURE__ */ jsx("span", { className: "font-mono text-[#57606a]", children: langLabel }) }),
1262
+ /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-y-auto overflow-x-hidden", children: editable ? /* @__PURE__ */ jsxs("div", { className: "relative min-h-full", children: [
1263
+ /* @__PURE__ */ jsx(
1264
+ "div",
1265
+ {
1266
+ ref: containerRef,
1267
+ className: "absolute top-0 left-0 right-0 pointer-events-none p-4",
1268
+ "aria-hidden": "true",
1269
+ children: highlightedHtml ? /* @__PURE__ */ jsx(
1270
+ "div",
1271
+ {
1272
+ 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",
1273
+ dangerouslySetInnerHTML: { __html: highlightedHtml }
1274
+ }
1275
+ ) : /* @__PURE__ */ jsx("pre", { className: "text-xs font-mono whitespace-pre-wrap break-words text-[#24292f] m-0 leading-relaxed", children: /* @__PURE__ */ jsx("code", { children: content }) })
1276
+ }
1277
+ ),
1278
+ /* @__PURE__ */ jsx(
1279
+ "textarea",
1280
+ {
1281
+ ref: textareaRef,
1282
+ value: content,
1283
+ onChange: handleChange,
1284
+ onKeyDown: handleKeyDown,
1285
+ 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",
1286
+ spellCheck: false,
1287
+ style: {
1288
+ tabSize: 2,
1289
+ caretColor: "#24292f",
1290
+ wordBreak: "break-word",
1291
+ overflowWrap: "break-word"
1292
+ }
1293
+ }
1294
+ )
1295
+ ] }) : /* @__PURE__ */ jsx("div", { className: "p-4", children: highlightedHtml ? /* @__PURE__ */ jsx(
1296
+ "div",
1297
+ {
1298
+ 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",
1299
+ dangerouslySetInnerHTML: { __html: highlightedHtml }
1300
+ }
1301
+ ) : /* @__PURE__ */ jsx("pre", { className: "text-xs font-mono whitespace-pre-wrap break-words m-0 leading-relaxed text-[#24292f]", children: /* @__PURE__ */ jsx("code", { children: content }) }) }) })
1302
+ ] });
1303
+ }
1304
+ function formatFileSize(bytes) {
1305
+ if (bytes < 1024) return `${bytes} B`;
1306
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1307
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1308
+ }
1309
+ function isUrl(content) {
1310
+ return content.startsWith("/") || content.startsWith("http://") || content.startsWith("https://") || content.startsWith("./") || content.startsWith("../");
1311
+ }
1312
+ function getDataUrl(content, mimeType) {
1313
+ if (content.startsWith("data:")) {
1314
+ return content;
1315
+ }
1316
+ if (isUrl(content)) {
1317
+ return content;
1318
+ }
1319
+ return `data:${mimeType};base64,${content}`;
1320
+ }
1321
+ function MediaPreview({ content, mimeType, fileName }) {
1322
+ const [dimensions, setDimensions] = useState(null);
1323
+ const [error, setError] = useState(null);
1324
+ const dataUrl = getDataUrl(content, mimeType);
1325
+ const isImage = isImageFile(fileName);
1326
+ const isVideo = isVideoFile(fileName);
1327
+ const isUrlContent = isUrl(content);
1328
+ const estimatedBytes = isUrlContent ? null : content.startsWith("data:") ? Math.floor((content.split(",")[1]?.length ?? 0) * 0.75) : Math.floor(content.length * 0.75);
1329
+ useEffect(() => {
1330
+ setDimensions(null);
1331
+ setError(null);
1332
+ if (isImage) {
1333
+ const img = new Image();
1334
+ img.onload = () => {
1335
+ setDimensions({ width: img.naturalWidth, height: img.naturalHeight });
1336
+ };
1337
+ img.onerror = () => {
1338
+ setError("Failed to load image");
1339
+ };
1340
+ img.src = dataUrl;
1341
+ }
1342
+ }, [dataUrl, isImage]);
1343
+ if (error) {
1344
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center h-full p-8 text-muted-foreground", children: [
1345
+ /* @__PURE__ */ jsx(AlertCircle, { className: "h-12 w-12 mb-4 text-destructive" }),
1346
+ /* @__PURE__ */ jsx("p", { className: "text-sm", children: error })
1347
+ ] });
1348
+ }
1349
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center h-full p-8 bg-muted/20", children: [
1350
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 flex items-center justify-center w-full max-h-[60vh] overflow-hidden", children: [
1351
+ isImage && /* @__PURE__ */ jsx(
1352
+ "img",
1353
+ {
1354
+ src: dataUrl,
1355
+ alt: fileName,
1356
+ className: "max-w-full max-h-full object-contain rounded shadow-sm",
1357
+ style: { maxHeight: "calc(60vh - 2rem)" }
1358
+ }
1359
+ ),
1360
+ isVideo && /* @__PURE__ */ jsx(
1361
+ "video",
1362
+ {
1363
+ src: dataUrl,
1364
+ controls: true,
1365
+ className: "max-w-full max-h-full rounded shadow-sm",
1366
+ style: { maxHeight: "calc(60vh - 2rem)" },
1367
+ children: "Your browser does not support video playback."
1368
+ }
1369
+ ),
1370
+ !isImage && !isVideo && /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center text-muted-foreground", children: [
1371
+ /* @__PURE__ */ jsx(FileImage, { className: "h-16 w-16 mb-4" }),
1372
+ /* @__PURE__ */ jsx("p", { className: "text-sm", children: "Preview not available for this file type" })
1373
+ ] })
1374
+ ] }),
1375
+ /* @__PURE__ */ jsxs("div", { className: "mt-6 text-center text-sm text-muted-foreground space-y-1", children: [
1376
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-center gap-2", children: [
1377
+ isImage && /* @__PURE__ */ jsx(FileImage, { className: "h-4 w-4" }),
1378
+ isVideo && /* @__PURE__ */ jsx(FileVideo, { className: "h-4 w-4" }),
1379
+ /* @__PURE__ */ jsx("span", { className: "font-medium", children: fileName })
1380
+ ] }),
1381
+ /* @__PURE__ */ jsxs("div", { className: "text-xs space-x-3", children: [
1382
+ dimensions && /* @__PURE__ */ jsxs("span", { children: [
1383
+ dimensions.width,
1384
+ " \xD7 ",
1385
+ dimensions.height,
1386
+ " px"
1387
+ ] }),
1388
+ estimatedBytes !== null && /* @__PURE__ */ jsx("span", { children: formatFileSize(estimatedBytes) }),
1389
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground/60", children: mimeType })
1390
+ ] })
1391
+ ] })
1392
+ ] });
1393
+ }
911
1394
  function hashCode(str) {
912
1395
  let hash = 0;
913
1396
  for (let i = 0; i < str.length; i++) {
@@ -918,22 +1401,38 @@ function hashCode(str) {
918
1401
  function EditModal({
919
1402
  isOpen,
920
1403
  onClose,
1404
+ onSave,
1405
+ onSaveProject,
921
1406
  renderPreview,
922
1407
  renderLoading,
923
1408
  renderError,
924
1409
  previewError,
925
1410
  previewLoading,
1411
+ initialState = {},
1412
+ hideFileTree = false,
926
1413
  ...sessionOptions
927
1414
  }) {
928
- const [showPreview, setShowPreview] = useState(true);
929
- const [showTree, setShowTree] = useState(false);
1415
+ const [showPreview, setShowPreview] = useState(initialState?.showPreview ?? true);
1416
+ const [showTree, setShowTree] = useState(
1417
+ hideFileTree ? false : initialState?.showTree ?? false
1418
+ );
930
1419
  const [editInput, setEditInput] = useState("");
931
1420
  const [bobbinChanges, setBobbinChanges] = useState([]);
932
1421
  const [previewContainer, setPreviewContainer] = useState(null);
1422
+ const [pillContainer, setPillContainer] = useState(null);
1423
+ const [showConfirm, setShowConfirm] = useState(false);
1424
+ const [isSaving, setIsSaving] = useState(false);
1425
+ const [saveError, setSaveError] = useState(null);
1426
+ const [pendingClose, setPendingClose] = useState(null);
1427
+ const currentCodeRef = useRef("");
933
1428
  const session = useEditSession(sessionOptions);
934
1429
  const code = getActiveContent(session);
1430
+ currentCodeRef.current = code;
935
1431
  const files = useMemo(() => getFiles(session.project), [session.project]);
936
1432
  const hasChanges = code !== (session.originalProject.files.get(session.activeFile)?.content ?? "");
1433
+ const fileType = useMemo(() => getFileType(session.activeFile), [session.activeFile]);
1434
+ const isCompilableFile = isCompilable(session.activeFile);
1435
+ const showPreviewToggle = isCompilableFile;
937
1436
  const handleBobbinChanges = useCallback((changes) => {
938
1437
  setBobbinChanges(changes);
939
1438
  }, []);
@@ -954,146 +1453,253 @@ ${bobbinYaml}
954
1453
  setEditInput("");
955
1454
  setBobbinChanges([]);
956
1455
  };
957
- const handleClose = () => {
1456
+ const hasSaveHandler = onSave || onSaveProject;
1457
+ const handleClose = useCallback(() => {
958
1458
  const editCount = session.history.length;
959
1459
  const finalCode = code;
1460
+ const hasUnsavedChanges = editCount > 0 && finalCode !== (session.originalProject.files.get(session.activeFile)?.content ?? "");
1461
+ if (hasUnsavedChanges && hasSaveHandler) {
1462
+ setPendingClose({ code: finalCode, count: editCount });
1463
+ setShowConfirm(true);
1464
+ } else {
1465
+ setEditInput("");
1466
+ session.clearError();
1467
+ onClose(finalCode, editCount);
1468
+ }
1469
+ }, [code, session, hasSaveHandler, onClose]);
1470
+ const handleSaveAndClose = useCallback(async () => {
1471
+ if (!pendingClose || !hasSaveHandler) return;
1472
+ setIsSaving(true);
1473
+ setSaveError(null);
1474
+ try {
1475
+ if (onSaveProject) {
1476
+ await onSaveProject(session.project);
1477
+ } else if (onSave) {
1478
+ await onSave(pendingClose.code);
1479
+ }
1480
+ setShowConfirm(false);
1481
+ setEditInput("");
1482
+ session.clearError();
1483
+ onClose(pendingClose.code, pendingClose.count);
1484
+ setPendingClose(null);
1485
+ } catch (e) {
1486
+ setSaveError(e instanceof Error ? e.message : "Save failed");
1487
+ } finally {
1488
+ setIsSaving(false);
1489
+ }
1490
+ }, [pendingClose, onSave, onSaveProject, session, onClose]);
1491
+ const handleDiscard = useCallback(() => {
1492
+ if (!pendingClose) return;
1493
+ setShowConfirm(false);
960
1494
  setEditInput("");
961
1495
  session.clearError();
962
- onClose(finalCode, editCount);
963
- };
1496
+ onClose(pendingClose.code, pendingClose.count);
1497
+ setPendingClose(null);
1498
+ }, [pendingClose, session, onClose]);
1499
+ const handleCancelClose = useCallback(() => {
1500
+ setShowConfirm(false);
1501
+ setPendingClose(null);
1502
+ setSaveError(null);
1503
+ }, []);
1504
+ const handleDirectSave = useCallback(async () => {
1505
+ if (!hasSaveHandler) return;
1506
+ setIsSaving(true);
1507
+ setSaveError(null);
1508
+ try {
1509
+ if (onSaveProject) {
1510
+ await onSaveProject(session.project);
1511
+ } else if (onSave && currentCodeRef.current) {
1512
+ await onSave(currentCodeRef.current);
1513
+ }
1514
+ } catch (e) {
1515
+ setSaveError(e instanceof Error ? e.message : "Save failed");
1516
+ } finally {
1517
+ setIsSaving(false);
1518
+ }
1519
+ }, [onSave, onSaveProject, session.project]);
964
1520
  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..."
1521
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1522
+ /* @__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: [
1523
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-4 py-3 bg-background border-b-2", children: [
1524
+ /* @__PURE__ */ jsx(Pencil, { className: "h-4 w-4 text-primary" }),
1525
+ session.isApplying && /* @__PURE__ */ jsxs("span", { className: "text-xs font-medium text-primary flex items-center gap-1 ml-2", children: [
1526
+ /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }),
1527
+ "Applying edits..."
1528
+ ] }),
1529
+ /* @__PURE__ */ jsxs("div", { className: "ml-auto flex gap-2", children: [
1530
+ hasChanges && /* @__PURE__ */ jsx(
1531
+ "button",
1532
+ {
1533
+ onClick: session.revert,
1534
+ className: "px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-primary/20 text-primary",
1535
+ title: "Revert to original",
1536
+ children: /* @__PURE__ */ jsx(RotateCcw, { className: "h-3 w-3" })
1537
+ }
1538
+ ),
1539
+ !hideFileTree && /* @__PURE__ */ jsx(
1540
+ "button",
1541
+ {
1542
+ onClick: () => setShowTree(!showTree),
1543
+ 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"}`,
1544
+ title: showTree ? "Single file" : "File tree",
1545
+ children: showTree ? /* @__PURE__ */ jsx(FileCode, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(FolderTree, { className: "h-3 w-3" })
1546
+ }
1547
+ ),
1548
+ showPreviewToggle && /* @__PURE__ */ jsxs(
1549
+ "button",
1550
+ {
1551
+ onClick: () => setShowPreview(!showPreview),
1552
+ 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"}`,
1553
+ children: [
1554
+ showPreview ? /* @__PURE__ */ jsx(Eye, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(Code, { className: "h-3 w-3" }),
1555
+ showPreview ? "Preview" : "Code"
1556
+ ]
1557
+ }
1558
+ ),
1559
+ hasSaveHandler && /* @__PURE__ */ jsxs(
1560
+ "button",
1561
+ {
1562
+ onClick: handleDirectSave,
1563
+ disabled: isSaving,
1564
+ className: "px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-primary/20 text-primary disabled:opacity-50",
1565
+ title: "Save changes",
1566
+ children: [
1567
+ isSaving ? /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ jsx(Save, { className: "h-3 w-3" }),
1568
+ "Save"
1569
+ ]
1570
+ }
1571
+ ),
1572
+ /* @__PURE__ */ jsxs(
1573
+ "button",
1574
+ {
1575
+ onClick: handleClose,
1576
+ className: "px-2 py-1 text-xs rounded flex items-center gap-1 bg-primary text-primary-foreground hover:bg-primary/90",
1577
+ title: "Exit edit mode",
1578
+ children: [
1579
+ /* @__PURE__ */ jsx(X, { className: "h-3 w-3" }),
1580
+ "Done"
1581
+ ]
1582
+ }
1583
+ )
1584
+ ] })
971
1585
  ] }),
972
- /* @__PURE__ */ jsxs("div", { className: "ml-auto flex gap-2", children: [
973
- hasChanges && /* @__PURE__ */ jsx(
974
- "button",
1586
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 min-h-0 border-b-2 overflow-hidden flex", children: [
1587
+ !hideFileTree && showTree && /* @__PURE__ */ jsx(
1588
+ FileTree,
975
1589
  {
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" })
1590
+ files,
1591
+ activeFile: session.activeFile,
1592
+ onSelectFile: session.setActiveFile,
1593
+ onReplaceFile: session.replaceFile
980
1594
  }
981
1595
  ),
982
- /* @__PURE__ */ jsx(
983
- "button",
1596
+ /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-auto", ref: setPillContainer, children: fileType.category === "compilable" && showPreview ? /* @__PURE__ */ jsxs("div", { className: "bg-white h-full relative", ref: setPreviewContainer, children: [
1597
+ previewError && renderError ? renderError(previewError) : previewError ? /* @__PURE__ */ jsxs("div", { className: "p-4 text-sm text-destructive flex items-center gap-2", children: [
1598
+ /* @__PURE__ */ jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }),
1599
+ /* @__PURE__ */ jsx("span", { children: previewError })
1600
+ ] }) : previewLoading && renderLoading ? renderLoading() : previewLoading ? /* @__PURE__ */ jsxs("div", { className: "p-4 flex items-center gap-2 text-muted-foreground", children: [
1601
+ /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
1602
+ /* @__PURE__ */ jsx("span", { className: "text-sm", children: "Rendering preview..." })
1603
+ ] }) : /* @__PURE__ */ jsx("div", { className: "p-4", children: renderPreview(code) }, hashCode(code)),
1604
+ !renderLoading && !renderError && !previewLoading && /* @__PURE__ */ jsx(
1605
+ Bobbin,
1606
+ {
1607
+ container: previewContainer,
1608
+ pillContainer,
1609
+ defaultActive: false,
1610
+ showInspector: true,
1611
+ onChanges: handleBobbinChanges,
1612
+ exclude: [".bobbin-pill", "[data-bobbin]"]
1613
+ }
1614
+ )
1615
+ ] }) : fileType.category === "compilable" && !showPreview ? /* @__PURE__ */ jsx(
1616
+ CodeBlockView,
984
1617
  {
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" })
1618
+ content: code,
1619
+ language: fileType.language,
1620
+ editable: true,
1621
+ onChange: session.updateActiveFile
989
1622
  }
990
- ),
991
- /* @__PURE__ */ jsxs(
992
- "button",
1623
+ ) : fileType.category === "text" ? /* @__PURE__ */ jsx(
1624
+ CodeBlockView,
993
1625
  {
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
- ]
1626
+ content: code,
1627
+ language: fileType.language,
1628
+ editable: true,
1629
+ onChange: session.updateActiveFile
1000
1630
  }
1001
- ),
1002
- /* @__PURE__ */ jsxs(
1003
- "button",
1631
+ ) : fileType.category === "media" ? /* @__PURE__ */ jsx(
1632
+ MediaPreview,
1004
1633
  {
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
- ]
1634
+ content: code,
1635
+ mimeType: getMimeType(session.activeFile),
1636
+ fileName: session.activeFile.split("/").pop() ?? session.activeFile
1012
1637
  }
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,
1638
+ ) : /* @__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" }) }) })
1639
+ ] }),
1640
+ /* @__PURE__ */ jsx(
1641
+ EditHistory,
1019
1642
  {
1020
- files,
1021
- activeFile: session.activeFile,
1022
- onSelectFile: session.setActiveFile
1643
+ entries: session.history,
1644
+ streamingNotes: session.streamingNotes,
1645
+ isStreaming: session.isApplying,
1646
+ pendingPrompt: session.pendingPrompt,
1647
+ className: "h-48"
1023
1648
  }
1024
1649
  ),
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,
1650
+ (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: [
1651
+ /* @__PURE__ */ jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }),
1652
+ session.error || saveError
1653
+ ] }),
1654
+ 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: [
1655
+ /* @__PURE__ */ jsxs("span", { children: [
1656
+ bobbinChanges.length,
1657
+ " visual change",
1658
+ bobbinChanges.length !== 1 ? "s" : ""
1659
+ ] }),
1660
+ /* @__PURE__ */ jsx(
1661
+ "button",
1035
1662
  {
1036
- container: previewContainer,
1037
- pillContainer: previewContainer,
1038
- defaultActive: false,
1039
- showInspector: true,
1040
- onChanges: handleBobbinChanges,
1041
- exclude: [".bobbin-pill", "[data-bobbin]"]
1663
+ onClick: () => setBobbinChanges([]),
1664
+ className: "text-xs underline hover:no-underline",
1665
+ children: "Clear"
1042
1666
  }
1043
1667
  )
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
- ] }),
1668
+ ] }),
1669
+ /* @__PURE__ */ jsxs("div", { className: "p-4 border-t-2 bg-primary/5 flex gap-2 items-end", children: [
1670
+ /* @__PURE__ */ jsx("div", { className: "flex-1", children: /* @__PURE__ */ jsx(
1671
+ MarkdownEditor,
1672
+ {
1673
+ value: editInput,
1674
+ onChange: setEditInput,
1675
+ onSubmit: handleSubmit,
1676
+ placeholder: "Describe changes...",
1677
+ disabled: session.isApplying
1678
+ }
1679
+ ) }),
1680
+ /* @__PURE__ */ jsx(
1681
+ "button",
1682
+ {
1683
+ onClick: handleSubmit,
1684
+ disabled: !editInput.trim() && bobbinChanges.length === 0 || session.isApplying,
1685
+ 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",
1686
+ children: session.isApplying ? /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }) : /* @__PURE__ */ jsx(Send, { className: "h-4 w-4" })
1687
+ }
1688
+ )
1689
+ ] })
1690
+ ] }) }),
1046
1691
  /* @__PURE__ */ jsx(
1047
- EditHistory,
1692
+ SaveConfirmDialog,
1048
1693
  {
1049
- entries: session.history,
1050
- streamingNotes: session.streamingNotes,
1051
- isStreaming: session.isApplying,
1052
- pendingPrompt: session.pendingPrompt,
1053
- className: "h-48"
1694
+ isOpen: showConfirm,
1695
+ isSaving,
1696
+ error: saveError,
1697
+ onSave: handleSaveAndClose,
1698
+ onDiscard: handleDiscard,
1699
+ onCancel: handleCancelClose
1054
1700
  }
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
- ] }) });
1701
+ )
1702
+ ] });
1097
1703
  }
1098
1704
  var VFS_BASE_URL = "/vfs";
1099
1705
  var vfsConfigCache = null;
@@ -1112,8 +1718,11 @@ async function getVFSConfig() {
1112
1718
  var storeInstance = null;
1113
1719
  function getVFSStore() {
1114
1720
  if (!storeInstance) {
1115
- const backend = new LocalFSBackend({ baseUrl: VFS_BASE_URL });
1116
- storeInstance = new VFSStore(backend);
1721
+ const provider = new HttpBackend({ baseUrl: VFS_BASE_URL });
1722
+ storeInstance = new VFSStore(provider, {
1723
+ sync: true,
1724
+ conflictStrategy: "local-wins"
1725
+ });
1117
1726
  }
1118
1727
  return storeInstance;
1119
1728
  }
@@ -1135,9 +1744,17 @@ async function listProjects() {
1135
1744
  }
1136
1745
  return Array.from(projectIds);
1137
1746
  }
1138
- async function saveFile(file) {
1747
+ async function saveFile(path, content) {
1748
+ const store = getVFSStore();
1749
+ await store.writeFile(path, content);
1750
+ }
1751
+ async function loadFile(path, encoding) {
1752
+ const store = getVFSStore();
1753
+ return store.readFile(path, encoding);
1754
+ }
1755
+ function subscribeToChanges(callback) {
1139
1756
  const store = getVFSStore();
1140
- await store.putFile(file);
1757
+ return store.on("change", callback);
1141
1758
  }
1142
1759
  async function isVFSAvailable() {
1143
1760
  try {
@@ -1207,12 +1824,17 @@ function useCodeCompiler(compiler, code, enabled, services) {
1207
1824
  }, [code, compiler, enabled, services]);
1208
1825
  return { containerRef, loading, error };
1209
1826
  }
1210
- function CodePreview({ code: originalCode, compiler, services, filePath }) {
1827
+ function CodePreview({ code: originalCode, compiler, services, filePath, entrypoint = "index.ts" }) {
1211
1828
  const [isEditing, setIsEditing] = useState(false);
1212
1829
  const [showPreview, setShowPreview] = useState(true);
1213
1830
  const [currentCode, setCurrentCode] = useState(originalCode);
1214
1831
  const [editCount, setEditCount] = useState(0);
1215
1832
  const [saveStatus, setSaveStatus] = useState("unsaved");
1833
+ const [lastSavedCode, setLastSavedCode] = useState(originalCode);
1834
+ const [vfsPath, setVfsPath] = useState(null);
1835
+ const currentCodeRef = useRef(currentCode);
1836
+ const lastSavedRef = useRef(lastSavedCode);
1837
+ const isEditingRef = useRef(isEditing);
1216
1838
  const fallbackId = useMemo(() => crypto.randomUUID(), []);
1217
1839
  const getProjectId = useCallback(async () => {
1218
1840
  if (filePath) {
@@ -1230,10 +1852,57 @@ function CodePreview({ code: originalCode, compiler, services, filePath }) {
1230
1852
  const getEntryFile = useCallback(() => {
1231
1853
  if (filePath) {
1232
1854
  const parts = filePath.split("/");
1233
- return parts[parts.length - 1] || "main.tsx";
1855
+ return parts[parts.length - 1] || entrypoint;
1234
1856
  }
1235
- return "main.tsx";
1857
+ return entrypoint;
1236
1858
  }, [filePath]);
1859
+ useEffect(() => {
1860
+ currentCodeRef.current = currentCode;
1861
+ }, [currentCode]);
1862
+ useEffect(() => {
1863
+ lastSavedRef.current = lastSavedCode;
1864
+ }, [lastSavedCode]);
1865
+ useEffect(() => {
1866
+ isEditingRef.current = isEditing;
1867
+ }, [isEditing]);
1868
+ useEffect(() => {
1869
+ let active = true;
1870
+ void (async () => {
1871
+ const projectId = await getProjectId();
1872
+ const entryFile = getEntryFile();
1873
+ if (!active) return;
1874
+ setVfsPath(`${projectId}/${entryFile}`);
1875
+ })();
1876
+ return () => {
1877
+ active = false;
1878
+ };
1879
+ }, [getProjectId, getEntryFile]);
1880
+ useEffect(() => {
1881
+ if (!vfsPath) return;
1882
+ const unsubscribe = subscribeToChanges(async (record) => {
1883
+ if (record.path !== vfsPath) return;
1884
+ if (record.type === "delete") {
1885
+ setSaveStatus("unsaved");
1886
+ return;
1887
+ }
1888
+ if (isEditingRef.current) return;
1889
+ try {
1890
+ const remote = await loadFile(vfsPath);
1891
+ if (currentCodeRef.current !== lastSavedRef.current) {
1892
+ setSaveStatus("unsaved");
1893
+ return;
1894
+ }
1895
+ if (remote !== currentCodeRef.current) {
1896
+ setCurrentCode(remote);
1897
+ setLastSavedCode(remote);
1898
+ setSaveStatus("saved");
1899
+ }
1900
+ } catch {
1901
+ setSaveStatus("error");
1902
+ }
1903
+ });
1904
+ return () => unsubscribe();
1905
+ }, [vfsPath]);
1237
1906
  const handleSave = useCallback(async () => {
1238
1907
  setSaveStatus("saving");
1239
1908
  try {
@@ -1241,6 +1910,7 @@ function CodePreview({ code: originalCode, compiler, services, filePath }) {
1241
1910
  const entryFile = getEntryFile();
1242
1911
  const project = createSingleFileProject(currentCode, entryFile, projectId);
1243
1912
  await saveProject(project);
1913
+ setLastSavedCode(currentCode);
1244
1914
  setSaveStatus("saved");
1245
1915
  } catch (err) {
1246
1916
  console.warn("[VFS] Failed to save project:", err);
@@ -1594,4 +2264,4 @@ function cn(...inputs) {
1594
2264
  return twMerge(clsx(inputs));
1595
2265
  }
1596
2266
 
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 };
2267
+ 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 };