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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ 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 { 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';
5
+ import { Folder, Pin, X, Loader2, AlertCircle, FileImage, FileVideo, Pencil, RotateCcw, FileCode, FolderTree, Eye, Code, Send, MessageSquare, Server, ChevronDown, File, PinOff, ChevronRight, ChevronsDown, Upload, AlertTriangle, Save } from 'lucide-react';
6
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';
@@ -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';
@@ -370,7 +371,11 @@ async function sendEditRequest(request, options = {}) {
370
371
  }
371
372
  const text = await streamResponse(response, onProgress);
372
373
  if (!hasDiffBlocks(text)) {
373
- throw new Error("No valid diffs in response");
374
+ return {
375
+ newCode: request.code,
376
+ summary: text.trim(),
377
+ progressNotes: []
378
+ };
374
379
  }
375
380
  const parsed = parseEditResponse(text);
376
381
  const result = applyDiffs(request.code, parsed.diffs, { sanitize });
@@ -439,6 +444,7 @@ function useEditSession(options) {
439
444
  const {
440
445
  originalCode,
441
446
  originalProject: providedProject,
447
+ initialActiveFile,
442
448
  compile,
443
449
  apiEndpoint
444
450
  } = options;
@@ -454,7 +460,9 @@ function useEditSession(options) {
454
460
  );
455
461
  const lastSyncedProjectRef = useRef(originalProject);
456
462
  const [project, setProject] = useState(originalProject);
457
- const [activeFile, setActiveFile] = useState(originalProject.entry);
463
+ const [activeFile, setActiveFile] = useState(
464
+ initialActiveFile && originalProject.files.has(initialActiveFile) ? initialActiveFile : originalProject.entry
465
+ );
458
466
  const [history, setHistory] = useState([]);
459
467
  const [isApplying, setIsApplying] = useState(false);
460
468
  const [error, setError] = useState(null);
@@ -464,12 +472,14 @@ function useEditSession(options) {
464
472
  if (originalProject !== lastSyncedProjectRef.current) {
465
473
  lastSyncedProjectRef.current = originalProject;
466
474
  setProject(originalProject);
467
- setActiveFile(originalProject.entry);
475
+ setActiveFile(
476
+ initialActiveFile && originalProject.files.has(initialActiveFile) ? initialActiveFile : originalProject.entry
477
+ );
468
478
  setHistory([]);
469
479
  setError(null);
470
480
  setStreamingNotes([]);
471
481
  }
472
- }, [originalProject]);
482
+ }, [originalProject, initialActiveFile]);
473
483
  const performEdit = useCallback(
474
484
  async (currentCode2, prompt, isRetry = false) => {
475
485
  const entries = [];
@@ -850,6 +860,156 @@ function MarkdownEditor({
850
860
  }
851
861
  );
852
862
  }
863
+ function parseFrontmatter(content) {
864
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
865
+ if (!match) return { frontmatter: "", body: content };
866
+ return { frontmatter: match[1], body: match[2] };
867
+ }
868
+ function assembleFrontmatter(frontmatter, body) {
869
+ if (!frontmatter.trim()) return body;
870
+ return `---
871
+ ${frontmatter}
872
+ ---
873
+ ${body}`;
874
+ }
875
+ function MarkdownPreview({
876
+ value,
877
+ onChange,
878
+ editable = false,
879
+ className = ""
880
+ }) {
881
+ const { frontmatter, body } = parseFrontmatter(value);
882
+ const [fm, setFm] = useState(frontmatter);
883
+ const fmRef = useRef(frontmatter);
884
+ const bodyRef = useRef(body);
885
+ const textareaRef = useRef(null);
886
+ useEffect(() => {
887
+ const parsed = parseFrontmatter(value);
888
+ fmRef.current = parsed.frontmatter;
889
+ bodyRef.current = parsed.body;
890
+ setFm(parsed.frontmatter);
891
+ }, [value]);
892
+ const emitChange = useCallback(
893
+ (newFm, newBody) => {
894
+ onChange?.(assembleFrontmatter(newFm, newBody));
895
+ },
896
+ [onChange]
897
+ );
898
+ const handleFmChange = useCallback(
899
+ (e) => {
900
+ const newFm = e.target.value;
901
+ setFm(newFm);
902
+ fmRef.current = newFm;
903
+ emitChange(newFm, bodyRef.current);
904
+ },
905
+ [emitChange]
906
+ );
907
+ useEffect(() => {
908
+ if (textareaRef.current) {
909
+ textareaRef.current.style.height = "auto";
910
+ textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
911
+ }
912
+ }, [fm]);
913
+ const editor = useEditor({
914
+ extensions: [
915
+ StarterKit.configure({
916
+ heading: { levels: [1, 2, 3, 4, 5, 6] },
917
+ bulletList: { keepMarks: true, keepAttributes: false },
918
+ orderedList: { keepMarks: true, keepAttributes: false },
919
+ codeBlock: false,
920
+ code: {
921
+ HTMLAttributes: {
922
+ class: "bg-muted rounded px-1 py-0.5 font-mono text-sm"
923
+ }
924
+ },
925
+ blockquote: {
926
+ HTMLAttributes: {
927
+ class: "border-l-4 border-muted-foreground/30 pl-4 italic"
928
+ }
929
+ },
930
+ hardBreak: { keepMarks: false }
931
+ }),
932
+ CodeBlockExtension,
933
+ Typography,
934
+ Markdown$1.configure({
935
+ html: false,
936
+ transformPastedText: true,
937
+ transformCopiedText: true
938
+ })
939
+ ],
940
+ content: body,
941
+ editable,
942
+ editorProps: {
943
+ attributes: {
944
+ class: `outline-none ${className}`
945
+ }
946
+ },
947
+ onUpdate: ({ editor: editor2 }) => {
948
+ const markdownStorage = editor2.storage.markdown;
949
+ const newBody = markdownStorage?.getMarkdown?.() ?? editor2.getText();
950
+ bodyRef.current = newBody;
951
+ emitChange(fmRef.current, newBody);
952
+ }
953
+ });
954
+ useEffect(() => {
955
+ editor?.setEditable(editable);
956
+ }, [editor, editable]);
957
+ useEffect(() => {
958
+ if (!editor) return;
959
+ const parsed = parseFrontmatter(value);
960
+ const markdownStorage = editor.storage.markdown;
961
+ const current = markdownStorage?.getMarkdown?.() ?? editor.getText();
962
+ if (parsed.body !== current) {
963
+ editor.commands.setContent(parsed.body);
964
+ }
965
+ }, [editor, value]);
966
+ return /* @__PURE__ */ jsxs("div", { className: "markdown-preview", children: [
967
+ frontmatter && /* @__PURE__ */ jsxs("div", { className: "mb-4 rounded-md border border-border bg-muted/40 overflow-hidden", children: [
968
+ /* @__PURE__ */ jsx("div", { className: "px-3 py-1.5 text-xs font-mono text-muted-foreground border-b border-border bg-muted/60 select-none", children: "yml" }),
969
+ /* @__PURE__ */ jsx(
970
+ "textarea",
971
+ {
972
+ ref: textareaRef,
973
+ value: fm,
974
+ onChange: handleFmChange,
975
+ readOnly: !editable,
976
+ className: "w-full bg-transparent px-3 py-2 font-mono text-sm outline-none resize-none",
977
+ spellCheck: false
978
+ }
979
+ )
980
+ ] }),
981
+ /* @__PURE__ */ jsx(EditorContent, { editor, className: "markdown-editor" })
982
+ ] });
983
+ }
984
+ function getToneClass(tone, status) {
985
+ if (status === "error") {
986
+ return tone === "muted" ? "text-destructive hover:bg-muted" : "text-destructive hover:bg-destructive/10";
987
+ }
988
+ if (status === "saved") {
989
+ return tone === "muted" ? "text-muted-foreground/50 hover:bg-muted" : "text-primary/50 hover:bg-primary/10";
990
+ }
991
+ return tone === "muted" ? "text-muted-foreground hover:bg-muted" : "text-primary hover:bg-primary/20";
992
+ }
993
+ function SaveStatusButton({
994
+ status,
995
+ onClick,
996
+ disabled = false,
997
+ tone
998
+ }) {
999
+ return /* @__PURE__ */ jsxs(
1000
+ "button",
1001
+ {
1002
+ onClick,
1003
+ disabled,
1004
+ className: `px-2 py-1 text-xs rounded flex items-center gap-1 disabled:opacity-50 ${getToneClass(tone, status)}`,
1005
+ title: "Save",
1006
+ children: [
1007
+ /* @__PURE__ */ jsx("span", { className: "inline-flex h-3 w-3 items-center justify-center shrink-0", children: status === "saving" ? /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : status === "error" ? /* @__PURE__ */ jsx(AlertTriangle, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(Save, { className: `h-3 w-3 ${status === "saved" ? "opacity-60" : "opacity-100"}` }) }),
1008
+ "Save"
1009
+ ]
1010
+ }
1011
+ );
1012
+ }
853
1013
 
854
1014
  // src/components/edit/fileTypes.ts
855
1015
  var COMPILABLE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"];
@@ -938,6 +1098,12 @@ function isMediaFile(path) {
938
1098
  function isTextFile(path) {
939
1099
  return TEXT_EXTENSIONS.includes(getExtension(path));
940
1100
  }
1101
+ function isMarkdownFile(path) {
1102
+ return getExtension(path) === ".md";
1103
+ }
1104
+ function isPreviewable(path) {
1105
+ return isCompilable(path) || isMarkdownFile(path);
1106
+ }
941
1107
  function getLanguageFromExt(path) {
942
1108
  const ext = getExtension(path);
943
1109
  return EXTENSION_TO_LANGUAGE[ext] ?? null;
@@ -954,6 +1120,17 @@ function isVideoFile(path) {
954
1120
  const ext = getExtension(path);
955
1121
  return [".mp4", ".mov", ".webm"].includes(ext);
956
1122
  }
1123
+ function sortNodes(nodes) {
1124
+ nodes.sort((a, b) => {
1125
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
1126
+ return a.name.localeCompare(b.name);
1127
+ });
1128
+ for (const node of nodes) {
1129
+ if (node.children.length > 0) {
1130
+ sortNodes(node.children);
1131
+ }
1132
+ }
1133
+ }
957
1134
  function buildTree(files) {
958
1135
  const root = { name: "", path: "", isDir: true, children: [] };
959
1136
  for (const file of files) {
@@ -976,14 +1153,26 @@ function buildTree(files) {
976
1153
  current = child;
977
1154
  }
978
1155
  }
979
- root.children.sort((a, b) => {
980
- if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
981
- return a.name.localeCompare(b.name);
982
- });
1156
+ sortNodes(root.children);
983
1157
  return root;
984
1158
  }
985
- function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth = 0 }) {
986
- const [expanded, setExpanded] = useState(true);
1159
+ function TreeNodeComponent({
1160
+ node,
1161
+ activePath,
1162
+ onSelect,
1163
+ onSelectDirectory,
1164
+ onReplaceFile,
1165
+ onOpenInEditor,
1166
+ openInEditorMode = "files",
1167
+ openInEditorIcon,
1168
+ openInEditorTitle = "Open in editor",
1169
+ pinnedPaths,
1170
+ onTogglePin,
1171
+ pageSize = 10,
1172
+ depth = 0
1173
+ }) {
1174
+ const [expanded, setExpanded] = useState(false);
1175
+ const [visibleCount, setVisibleCount] = useState(pageSize);
987
1176
  const [isHovered, setIsHovered] = useState(false);
988
1177
  const fileInputRef = useRef(null);
989
1178
  const handleUploadClick = useCallback((e) => {
@@ -1002,48 +1191,119 @@ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth =
1002
1191
  reader.readAsDataURL(file);
1003
1192
  e.target.value = "";
1004
1193
  }, [node.path, onReplaceFile]);
1194
+ const isPinned = pinnedPaths?.has(node.path) ?? false;
1195
+ const showPin = onTogglePin && isHovered;
1196
+ const handleTogglePin = useCallback((e) => {
1197
+ e.stopPropagation();
1198
+ onTogglePin?.(node.path, node.isDir);
1199
+ }, [node.path, node.isDir, onTogglePin]);
1005
1200
  if (!node.name) {
1006
1201
  return /* @__PURE__ */ jsx(Fragment, { children: node.children.map((child) => /* @__PURE__ */ jsx(
1007
1202
  TreeNodeComponent,
1008
1203
  {
1009
1204
  node: child,
1010
- activeFile,
1205
+ activePath,
1011
1206
  onSelect,
1207
+ onSelectDirectory,
1012
1208
  onReplaceFile,
1209
+ onOpenInEditor,
1210
+ openInEditorMode,
1211
+ openInEditorIcon,
1212
+ openInEditorTitle,
1213
+ pinnedPaths,
1214
+ onTogglePin,
1215
+ pageSize,
1013
1216
  depth
1014
1217
  },
1015
1218
  child.path
1016
1219
  )) });
1017
1220
  }
1018
- const isActive = node.path === activeFile;
1221
+ const isActive = node.path === activePath;
1019
1222
  const isMedia = !node.isDir && isMediaFile(node.path);
1020
1223
  const showUpload = isMedia && isHovered && onReplaceFile;
1224
+ const showOpenInEditor = !!onOpenInEditor && isHovered && (openInEditorMode === "all" || (openInEditorMode === "directories" ? node.isDir : !node.isDir));
1225
+ const handleOpenInEditor = useCallback(
1226
+ (e) => {
1227
+ e.stopPropagation();
1228
+ onOpenInEditor?.(node.path, node.isDir);
1229
+ },
1230
+ [node.path, node.isDir, onOpenInEditor]
1231
+ );
1021
1232
  if (node.isDir) {
1022
1233
  return /* @__PURE__ */ jsxs("div", { children: [
1023
1234
  /* @__PURE__ */ jsxs(
1024
1235
  "button",
1025
1236
  {
1026
- onClick: () => setExpanded(!expanded),
1027
- className: "flex items-center gap-1 w-full px-2 py-1 text-left text-sm hover:bg-muted/50 rounded",
1237
+ onClick: () => {
1238
+ onSelectDirectory?.(node.path);
1239
+ setExpanded(!expanded);
1240
+ },
1241
+ 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" : ""}`,
1242
+ onMouseEnter: () => setIsHovered(true),
1243
+ onMouseLeave: () => setIsHovered(false),
1028
1244
  style: { paddingLeft: `${depth * 12 + 8}px` },
1029
1245
  children: [
1030
1246
  expanded ? /* @__PURE__ */ jsx(ChevronDown, { className: "h-3 w-3 shrink-0" }) : /* @__PURE__ */ jsx(ChevronRight, { className: "h-3 w-3 shrink-0" }),
1031
1247
  /* @__PURE__ */ jsx(Folder, { className: "h-3 w-3 shrink-0 text-muted-foreground" }),
1032
- /* @__PURE__ */ jsx("span", { className: "truncate", children: node.name })
1248
+ /* @__PURE__ */ jsx("span", { className: "truncate flex-1 flex pl-2", children: node.name }),
1249
+ (showPin || isPinned) && /* @__PURE__ */ jsx(
1250
+ "span",
1251
+ {
1252
+ onClick: handleTogglePin,
1253
+ className: "p-1 hover:bg-primary/20 rounded cursor-pointer",
1254
+ title: isPinned ? "Unpin" : "Pin",
1255
+ children: isPinned ? /* @__PURE__ */ jsx(PinOff, { className: "h-3 w-3 text-primary" }) : /* @__PURE__ */ jsx(Pin, { className: "h-3 w-3 text-muted-foreground" })
1256
+ }
1257
+ ),
1258
+ showOpenInEditor && /* @__PURE__ */ jsx(
1259
+ "span",
1260
+ {
1261
+ onClick: handleOpenInEditor,
1262
+ className: "p-1 hover:bg-primary/20 rounded cursor-pointer",
1263
+ title: openInEditorTitle,
1264
+ children: openInEditorIcon ?? /* @__PURE__ */ jsx(Pencil, { className: "h-3 w-3 text-primary" })
1265
+ }
1266
+ )
1033
1267
  ]
1034
1268
  }
1035
1269
  ),
1036
- expanded && /* @__PURE__ */ jsx("div", { children: node.children.map((child) => /* @__PURE__ */ jsx(
1037
- TreeNodeComponent,
1038
- {
1039
- node: child,
1040
- activeFile,
1041
- onSelect,
1042
- onReplaceFile,
1043
- depth: depth + 1
1044
- },
1045
- child.path
1046
- )) })
1270
+ expanded && /* @__PURE__ */ jsxs("div", { children: [
1271
+ node.children.slice(0, visibleCount).map((child) => /* @__PURE__ */ jsx(
1272
+ TreeNodeComponent,
1273
+ {
1274
+ node: child,
1275
+ activePath,
1276
+ onSelect,
1277
+ onSelectDirectory,
1278
+ onReplaceFile,
1279
+ onOpenInEditor,
1280
+ openInEditorMode,
1281
+ openInEditorIcon,
1282
+ openInEditorTitle,
1283
+ pinnedPaths,
1284
+ onTogglePin,
1285
+ pageSize,
1286
+ depth: depth + 1
1287
+ },
1288
+ child.path
1289
+ )),
1290
+ node.children.length > visibleCount && /* @__PURE__ */ jsxs(
1291
+ "button",
1292
+ {
1293
+ onClick: () => setVisibleCount((prev) => prev + pageSize),
1294
+ className: "flex items-center gap-1 w-full px-2 py-1 text-left text-xs hover:bg-muted/50 rounded text-muted-foreground",
1295
+ style: { paddingLeft: `${(depth + 1) * 12 + 20}px` },
1296
+ children: [
1297
+ /* @__PURE__ */ jsx(ChevronsDown, { className: "h-3 w-3 shrink-0" }),
1298
+ /* @__PURE__ */ jsxs("span", { children: [
1299
+ "Show ",
1300
+ Math.min(pageSize, node.children.length - visibleCount),
1301
+ " more"
1302
+ ] })
1303
+ ]
1304
+ }
1305
+ )
1306
+ ] })
1047
1307
  ] });
1048
1308
  }
1049
1309
  return /* @__PURE__ */ jsxs(
@@ -1061,12 +1321,30 @@ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth =
1061
1321
  style: { paddingLeft: `${depth * 12 + 20}px` },
1062
1322
  children: [
1063
1323
  /* @__PURE__ */ jsx(File, { className: "h-3 w-3 shrink-0 text-muted-foreground" }),
1064
- /* @__PURE__ */ jsx("span", { className: "truncate flex-1", children: node.name }),
1324
+ /* @__PURE__ */ jsx("span", { className: "truncate flex-1 flex pl-2", children: node.name }),
1325
+ (showPin || isPinned) && /* @__PURE__ */ jsx(
1326
+ "span",
1327
+ {
1328
+ onClick: handleTogglePin,
1329
+ className: "p-1 hover:bg-primary/20 rounded cursor-pointer",
1330
+ title: isPinned ? "Unpin" : "Pin",
1331
+ children: isPinned ? /* @__PURE__ */ jsx(PinOff, { className: "h-3 w-3 text-primary" }) : /* @__PURE__ */ jsx(Pin, { className: "h-3 w-3 text-muted-foreground" })
1332
+ }
1333
+ ),
1334
+ showOpenInEditor && /* @__PURE__ */ jsx(
1335
+ "span",
1336
+ {
1337
+ onClick: handleOpenInEditor,
1338
+ className: "p-1 hover:bg-primary/20 rounded cursor-pointer",
1339
+ title: openInEditorTitle,
1340
+ children: openInEditorIcon ?? /* @__PURE__ */ jsx(Pencil, { className: "h-3 w-3 text-primary" })
1341
+ }
1342
+ ),
1065
1343
  showUpload && /* @__PURE__ */ jsx(
1066
1344
  "span",
1067
1345
  {
1068
1346
  onClick: handleUploadClick,
1069
- className: "p-0.5 hover:bg-primary/20 rounded cursor-pointer",
1347
+ className: "p-1 hover:bg-primary/20 rounded cursor-pointer",
1070
1348
  title: "Replace file",
1071
1349
  children: /* @__PURE__ */ jsx(Upload, { className: "h-3 w-3 text-primary" })
1072
1350
  }
@@ -1088,17 +1366,309 @@ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth =
1088
1366
  }
1089
1367
  );
1090
1368
  }
1091
- function FileTree({ files, activeFile, onSelectFile, onReplaceFile }) {
1369
+ function LazyTreeNode({
1370
+ entry,
1371
+ activePath,
1372
+ onSelectFile,
1373
+ onSelectDirectory,
1374
+ onOpenInEditor,
1375
+ openInEditorMode = "files",
1376
+ openInEditorIcon,
1377
+ openInEditorTitle = "Open in editor",
1378
+ pinnedPaths,
1379
+ onTogglePin,
1380
+ directoryLoader,
1381
+ pageSize,
1382
+ depth = 0,
1383
+ reloadToken
1384
+ }) {
1385
+ const [expanded, setExpanded] = useState(false);
1386
+ const [loading, setLoading] = useState(false);
1387
+ const [loadError, setLoadError] = useState(null);
1388
+ const [isHovered, setIsHovered] = useState(false);
1389
+ const [children, setChildren] = useState(null);
1390
+ const [visibleCount, setVisibleCount] = useState(pageSize);
1391
+ useEffect(() => {
1392
+ setChildren(null);
1393
+ setVisibleCount(pageSize);
1394
+ if (expanded) {
1395
+ setLoading(true);
1396
+ setLoadError(null);
1397
+ directoryLoader(entry.path).then((loaded) => setChildren(loaded)).catch((err) => setLoadError(err instanceof Error ? err.message : "Failed to load directory")).finally(() => setLoading(false));
1398
+ }
1399
+ }, [reloadToken]);
1400
+ const isActive = entry.path === activePath;
1401
+ const isPinned = pinnedPaths?.has(entry.path) ?? false;
1402
+ const showPin = onTogglePin && isHovered;
1403
+ const showOpenInEditor = !!onOpenInEditor && isHovered && (openInEditorMode === "all" || (openInEditorMode === "directories" ? entry.isDir : !entry.isDir));
1404
+ const handleOpenInEditor = useCallback(
1405
+ (e) => {
1406
+ e.stopPropagation();
1407
+ onOpenInEditor?.(entry.path, entry.isDir);
1408
+ },
1409
+ [entry.path, entry.isDir, onOpenInEditor]
1410
+ );
1411
+ const handleTogglePin = useCallback(
1412
+ (e) => {
1413
+ e.stopPropagation();
1414
+ onTogglePin?.(entry.path, entry.isDir);
1415
+ },
1416
+ [entry.path, entry.isDir, onTogglePin]
1417
+ );
1418
+ const toggleDirectory = useCallback(async () => {
1419
+ if (!entry.isDir) return;
1420
+ onSelectDirectory?.(entry.path);
1421
+ if (!expanded && children === null) {
1422
+ setLoading(true);
1423
+ setLoadError(null);
1424
+ try {
1425
+ const loaded = await directoryLoader(entry.path);
1426
+ setChildren(loaded);
1427
+ } catch (err) {
1428
+ setLoadError(err instanceof Error ? err.message : "Failed to load directory");
1429
+ } finally {
1430
+ setLoading(false);
1431
+ }
1432
+ }
1433
+ setExpanded((prev) => !prev);
1434
+ }, [entry.isDir, entry.path, onSelectDirectory, expanded, children, directoryLoader]);
1435
+ if (!entry.isDir) {
1436
+ return /* @__PURE__ */ jsx(
1437
+ "div",
1438
+ {
1439
+ className: "relative",
1440
+ onMouseEnter: () => setIsHovered(true),
1441
+ onMouseLeave: () => setIsHovered(false),
1442
+ children: /* @__PURE__ */ jsxs(
1443
+ "button",
1444
+ {
1445
+ onClick: () => onSelectFile(entry.path),
1446
+ 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" : ""}`,
1447
+ style: { paddingLeft: `${depth * 12 + 20}px` },
1448
+ children: [
1449
+ /* @__PURE__ */ jsx(File, { className: "h-3 w-3 shrink-0 text-muted-foreground" }),
1450
+ /* @__PURE__ */ jsx("span", { className: "truncate flex-1 flex pl-2", children: entry.name }),
1451
+ (showPin || isPinned) && /* @__PURE__ */ jsx(
1452
+ "span",
1453
+ {
1454
+ onClick: handleTogglePin,
1455
+ className: "p-1 hover:bg-primary/20 rounded cursor-pointer",
1456
+ title: isPinned ? "Unpin" : "Pin",
1457
+ children: isPinned ? /* @__PURE__ */ jsx(PinOff, { className: "h-3 w-3 text-primary" }) : /* @__PURE__ */ jsx(Pin, { className: "h-3 w-3 text-muted-foreground" })
1458
+ }
1459
+ ),
1460
+ showOpenInEditor && /* @__PURE__ */ jsx(
1461
+ "span",
1462
+ {
1463
+ onClick: handleOpenInEditor,
1464
+ className: "p-1 hover:bg-primary/20 rounded cursor-pointer",
1465
+ title: openInEditorTitle,
1466
+ children: openInEditorIcon ?? /* @__PURE__ */ jsx(Pencil, { className: "h-3 w-3 text-primary" })
1467
+ }
1468
+ )
1469
+ ]
1470
+ }
1471
+ )
1472
+ }
1473
+ );
1474
+ }
1475
+ return /* @__PURE__ */ jsxs("div", { children: [
1476
+ /* @__PURE__ */ jsxs(
1477
+ "button",
1478
+ {
1479
+ onClick: () => void toggleDirectory(),
1480
+ 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" : ""}`,
1481
+ onMouseEnter: () => setIsHovered(true),
1482
+ onMouseLeave: () => setIsHovered(false),
1483
+ style: { paddingLeft: `${depth * 12 + 8}px` },
1484
+ children: [
1485
+ expanded ? /* @__PURE__ */ jsx(ChevronDown, { className: "h-3 w-3 shrink-0" }) : /* @__PURE__ */ jsx(ChevronRight, { className: "h-3 w-3 shrink-0" }),
1486
+ /* @__PURE__ */ jsx(Folder, { className: "h-3 w-3 shrink-0 text-muted-foreground" }),
1487
+ /* @__PURE__ */ jsx("span", { className: "truncate flex-1 flex pl-2", children: entry.name }),
1488
+ loading && /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin text-muted-foreground" }),
1489
+ (showPin || isPinned) && /* @__PURE__ */ jsx(
1490
+ "span",
1491
+ {
1492
+ onClick: handleTogglePin,
1493
+ className: "p-1 hover:bg-primary/20 rounded cursor-pointer",
1494
+ title: isPinned ? "Unpin" : "Pin",
1495
+ children: isPinned ? /* @__PURE__ */ jsx(PinOff, { className: "h-3 w-3 text-primary" }) : /* @__PURE__ */ jsx(Pin, { className: "h-3 w-3 text-muted-foreground" })
1496
+ }
1497
+ ),
1498
+ showOpenInEditor && /* @__PURE__ */ jsx(
1499
+ "span",
1500
+ {
1501
+ onClick: handleOpenInEditor,
1502
+ className: "p-1 hover:bg-primary/20 rounded cursor-pointer",
1503
+ title: openInEditorTitle,
1504
+ children: openInEditorIcon ?? /* @__PURE__ */ jsx(Pencil, { className: "h-3 w-3 text-primary" })
1505
+ }
1506
+ )
1507
+ ]
1508
+ }
1509
+ ),
1510
+ expanded && /* @__PURE__ */ jsxs("div", { children: [
1511
+ loadError && /* @__PURE__ */ jsx(
1512
+ "div",
1513
+ {
1514
+ className: "px-2 py-1 text-xs text-destructive",
1515
+ style: { paddingLeft: `${(depth + 1) * 12 + 20}px` },
1516
+ children: loadError
1517
+ }
1518
+ ),
1519
+ (children ?? []).slice(0, visibleCount).map((child) => /* @__PURE__ */ jsx(
1520
+ LazyTreeNode,
1521
+ {
1522
+ entry: child,
1523
+ activePath,
1524
+ onSelectFile,
1525
+ onSelectDirectory,
1526
+ onOpenInEditor,
1527
+ openInEditorMode,
1528
+ openInEditorIcon,
1529
+ openInEditorTitle,
1530
+ pinnedPaths,
1531
+ onTogglePin,
1532
+ directoryLoader,
1533
+ pageSize,
1534
+ depth: depth + 1,
1535
+ reloadToken
1536
+ },
1537
+ child.path
1538
+ )),
1539
+ (children?.length ?? 0) > visibleCount && /* @__PURE__ */ jsxs(
1540
+ "button",
1541
+ {
1542
+ onClick: () => setVisibleCount((prev) => prev + pageSize),
1543
+ className: "flex items-center gap-1 w-full px-2 py-1 text-left text-xs hover:bg-muted/50 rounded text-muted-foreground",
1544
+ style: { paddingLeft: `${(depth + 1) * 12 + 20}px` },
1545
+ children: [
1546
+ /* @__PURE__ */ jsx(ChevronsDown, { className: "h-3 w-3 shrink-0" }),
1547
+ /* @__PURE__ */ jsxs("span", { children: [
1548
+ "Show ",
1549
+ Math.min(pageSize, (children?.length ?? 0) - visibleCount),
1550
+ " more"
1551
+ ] })
1552
+ ]
1553
+ }
1554
+ )
1555
+ ] })
1556
+ ] });
1557
+ }
1558
+ function FileTree({
1559
+ files = [],
1560
+ activeFile,
1561
+ activePath,
1562
+ title = "Files",
1563
+ onSelectFile,
1564
+ onSelectDirectory,
1565
+ onReplaceFile,
1566
+ onOpenInEditor,
1567
+ openInEditorMode,
1568
+ openInEditorIcon,
1569
+ openInEditorTitle,
1570
+ pinnedPaths,
1571
+ onTogglePin,
1572
+ directoryLoader,
1573
+ pageSize = 10,
1574
+ reloadToken
1575
+ }) {
1092
1576
  const tree = useMemo(() => buildTree(files), [files]);
1093
- return /* @__PURE__ */ jsxs("div", { className: "w-48 border-r bg-muted/30 overflow-auto text-foreground", children: [
1094
- /* @__PURE__ */ jsx("div", { className: "p-2 border-b text-xs font-medium text-muted-foreground uppercase tracking-wide", children: "Files" }),
1095
- /* @__PURE__ */ jsx("div", { className: "p-1", children: /* @__PURE__ */ jsx(
1577
+ const selectedPath = activePath ?? activeFile ?? "";
1578
+ const [rootEntries, setRootEntries] = useState([]);
1579
+ const [rootLoading, setRootLoading] = useState(false);
1580
+ const [rootError, setRootError] = useState(null);
1581
+ useEffect(() => {
1582
+ if (!directoryLoader) return;
1583
+ let cancelled = false;
1584
+ const loadRoot = async () => {
1585
+ setRootLoading(true);
1586
+ setRootError(null);
1587
+ try {
1588
+ const entries = await directoryLoader("");
1589
+ if (!cancelled) {
1590
+ setRootEntries(entries);
1591
+ }
1592
+ } catch (err) {
1593
+ if (!cancelled) {
1594
+ setRootError(err instanceof Error ? err.message : "Failed to load files");
1595
+ }
1596
+ } finally {
1597
+ if (!cancelled) {
1598
+ setRootLoading(false);
1599
+ }
1600
+ }
1601
+ };
1602
+ void loadRoot();
1603
+ return () => {
1604
+ cancelled = true;
1605
+ };
1606
+ }, [directoryLoader, reloadToken]);
1607
+ return /* @__PURE__ */ jsxs("div", { className: "min-w-48 border-r bg-muted/30 overflow-auto text-foreground", children: [
1608
+ /* @__PURE__ */ jsx("div", { className: "p-2 border-b text-xs font-medium text-muted-foreground uppercase tracking-wide", children: title }),
1609
+ pinnedPaths && pinnedPaths.size > 0 && /* @__PURE__ */ jsx("div", { className: "px-2 py-1 border-b flex flex-wrap gap-1", children: Array.from(pinnedPaths).map(([p, isDir]) => /* @__PURE__ */ jsxs(
1610
+ "button",
1611
+ {
1612
+ onClick: () => isDir ? onSelectDirectory?.(p) : onSelectFile(p),
1613
+ className: `inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs hover:bg-muted/50 ${(activePath ?? activeFile ?? "") === p ? "bg-primary/10 text-primary" : "text-muted-foreground"}`,
1614
+ children: [
1615
+ isDir ? /* @__PURE__ */ jsx(Folder, { className: "h-2.5 w-2.5 shrink-0" }) : /* @__PURE__ */ jsx(Pin, { className: "h-2.5 w-2.5 shrink-0" }),
1616
+ /* @__PURE__ */ jsx("span", { className: "truncate max-w-[120px]", children: p.split("/").pop() }),
1617
+ onTogglePin && /* @__PURE__ */ jsx(
1618
+ "span",
1619
+ {
1620
+ onClick: (e) => {
1621
+ e.stopPropagation();
1622
+ onTogglePin(p, isDir);
1623
+ },
1624
+ className: "hover:text-destructive",
1625
+ children: /* @__PURE__ */ jsx(X, { className: "h-2.5 w-2.5" })
1626
+ }
1627
+ )
1628
+ ]
1629
+ },
1630
+ p
1631
+ )) }),
1632
+ /* @__PURE__ */ jsx("div", { className: "p-1", children: directoryLoader ? /* @__PURE__ */ jsxs(Fragment, { children: [
1633
+ rootLoading && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground", children: [
1634
+ /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }),
1635
+ /* @__PURE__ */ jsx("span", { children: "Loading..." })
1636
+ ] }),
1637
+ rootError && /* @__PURE__ */ jsx("div", { className: "px-2 py-1 text-xs text-destructive", children: rootError }),
1638
+ rootEntries.map((entry) => /* @__PURE__ */ jsx(
1639
+ LazyTreeNode,
1640
+ {
1641
+ entry,
1642
+ activePath: selectedPath,
1643
+ onSelectFile,
1644
+ onSelectDirectory,
1645
+ onOpenInEditor,
1646
+ openInEditorMode,
1647
+ openInEditorIcon,
1648
+ openInEditorTitle,
1649
+ pinnedPaths,
1650
+ onTogglePin,
1651
+ directoryLoader,
1652
+ pageSize,
1653
+ reloadToken
1654
+ },
1655
+ entry.path
1656
+ ))
1657
+ ] }) : /* @__PURE__ */ jsx(
1096
1658
  TreeNodeComponent,
1097
1659
  {
1098
1660
  node: tree,
1099
- activeFile,
1661
+ activePath: selectedPath,
1100
1662
  onSelect: onSelectFile,
1101
- onReplaceFile
1663
+ onSelectDirectory,
1664
+ onReplaceFile,
1665
+ onOpenInEditor,
1666
+ openInEditorMode,
1667
+ openInEditorIcon,
1668
+ openInEditorTitle,
1669
+ pinnedPaths,
1670
+ onTogglePin,
1671
+ pageSize
1102
1672
  }
1103
1673
  ) })
1104
1674
  ] });
@@ -1152,8 +1722,68 @@ function SaveConfirmDialog({
1152
1722
  ] })
1153
1723
  ] }) });
1154
1724
  }
1725
+ var highlighterPromise = null;
1726
+ var COMMON_LANGUAGES = [
1727
+ "typescript",
1728
+ "javascript",
1729
+ "tsx",
1730
+ "jsx",
1731
+ "json",
1732
+ "html",
1733
+ "css",
1734
+ "markdown",
1735
+ "yaml",
1736
+ "python",
1737
+ "bash",
1738
+ "sql"
1739
+ ];
1740
+ function getHighlighter() {
1741
+ if (!highlighterPromise) {
1742
+ highlighterPromise = createHighlighter({
1743
+ themes: ["github-light"],
1744
+ langs: COMMON_LANGUAGES
1745
+ });
1746
+ }
1747
+ return highlighterPromise;
1748
+ }
1749
+ function normalizeLanguage(lang) {
1750
+ if (!lang) return "typescript";
1751
+ const normalized = lang.toLowerCase();
1752
+ const mapping = {
1753
+ ts: "typescript",
1754
+ tsx: "tsx",
1755
+ js: "javascript",
1756
+ jsx: "jsx",
1757
+ json: "json",
1758
+ html: "html",
1759
+ css: "css",
1760
+ md: "markdown",
1761
+ markdown: "markdown",
1762
+ yml: "yaml",
1763
+ yaml: "yaml",
1764
+ py: "python",
1765
+ python: "python",
1766
+ sh: "bash",
1767
+ bash: "bash",
1768
+ sql: "sql",
1769
+ typescript: "typescript",
1770
+ javascript: "javascript"
1771
+ };
1772
+ return mapping[normalized] || "typescript";
1773
+ }
1155
1774
  function CodeBlockView({ content, language, editable = false, onChange }) {
1156
1775
  const textareaRef = useRef(null);
1776
+ const containerRef = useRef(null);
1777
+ const [highlighter, setHighlighter] = useState(null);
1778
+ useEffect(() => {
1779
+ let mounted = true;
1780
+ getHighlighter().then((h) => {
1781
+ if (mounted) setHighlighter(h);
1782
+ });
1783
+ return () => {
1784
+ mounted = false;
1785
+ };
1786
+ }, []);
1157
1787
  useEffect(() => {
1158
1788
  if (textareaRef.current) {
1159
1789
  textareaRef.current.style.height = "auto";
@@ -1184,23 +1814,60 @@ function CodeBlockView({ content, language, editable = false, onChange }) {
1184
1814
  [onChange]
1185
1815
  );
1186
1816
  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"
1817
+ const shikiLang = useMemo(() => normalizeLanguage(language), [language]);
1818
+ const highlightedHtml = useMemo(() => {
1819
+ if (!highlighter) return null;
1820
+ try {
1821
+ return highlighter.codeToHtml(content, {
1822
+ lang: shikiLang,
1823
+ theme: "github-light"
1824
+ });
1825
+ } catch {
1826
+ return null;
1827
+ }
1828
+ }, [highlighter, content, shikiLang]);
1829
+ return /* @__PURE__ */ jsxs("div", { className: "h-full flex flex-col bg-[#ffffff]", children: [
1830
+ /* @__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 }) }),
1831
+ /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-y-auto overflow-x-hidden", children: editable ? /* @__PURE__ */ jsxs("div", { className: "relative min-h-full", children: [
1832
+ /* @__PURE__ */ jsx(
1833
+ "div",
1834
+ {
1835
+ ref: containerRef,
1836
+ className: "absolute top-0 left-0 right-0 pointer-events-none p-4",
1837
+ "aria-hidden": "true",
1838
+ children: highlightedHtml ? /* @__PURE__ */ jsx(
1839
+ "div",
1840
+ {
1841
+ 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",
1842
+ dangerouslySetInnerHTML: { __html: highlightedHtml }
1843
+ }
1844
+ ) : /* @__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 }) })
1201
1845
  }
1846
+ ),
1847
+ /* @__PURE__ */ jsx(
1848
+ "textarea",
1849
+ {
1850
+ ref: textareaRef,
1851
+ value: content,
1852
+ onChange: handleChange,
1853
+ onKeyDown: handleKeyDown,
1854
+ 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",
1855
+ spellCheck: false,
1856
+ style: {
1857
+ tabSize: 2,
1858
+ caretColor: "#24292f",
1859
+ wordBreak: "break-word",
1860
+ overflowWrap: "break-word"
1861
+ }
1862
+ }
1863
+ )
1864
+ ] }) : /* @__PURE__ */ jsx("div", { className: "p-4", children: highlightedHtml ? /* @__PURE__ */ jsx(
1865
+ "div",
1866
+ {
1867
+ 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",
1868
+ dangerouslySetInnerHTML: { __html: highlightedHtml }
1202
1869
  }
1203
- ) : /* @__PURE__ */ jsx("pre", { className: "text-xs font-mono whitespace-pre-wrap break-words m-0 leading-relaxed", children: /* @__PURE__ */ jsx("code", { children: content }) })
1870
+ ) : /* @__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 }) }) }) })
1204
1871
  ] });
1205
1872
  }
1206
1873
  function formatFileSize(bytes) {
@@ -1208,10 +1875,16 @@ function formatFileSize(bytes) {
1208
1875
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1209
1876
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1210
1877
  }
1878
+ function isUrl(content) {
1879
+ return content.startsWith("/") || content.startsWith("http://") || content.startsWith("https://") || content.startsWith("./") || content.startsWith("../");
1880
+ }
1211
1881
  function getDataUrl(content, mimeType) {
1212
1882
  if (content.startsWith("data:")) {
1213
1883
  return content;
1214
1884
  }
1885
+ if (isUrl(content)) {
1886
+ return content;
1887
+ }
1215
1888
  return `data:${mimeType};base64,${content}`;
1216
1889
  }
1217
1890
  function MediaPreview({ content, mimeType, fileName }) {
@@ -1220,8 +1893,8 @@ function MediaPreview({ content, mimeType, fileName }) {
1220
1893
  const dataUrl = getDataUrl(content, mimeType);
1221
1894
  const isImage = isImageFile(fileName);
1222
1895
  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);
1896
+ const isUrlContent = isUrl(content);
1897
+ const estimatedBytes = isUrlContent ? null : content.startsWith("data:") ? Math.floor((content.split(",")[1]?.length ?? 0) * 0.75) : Math.floor(content.length * 0.75);
1225
1898
  useEffect(() => {
1226
1899
  setDimensions(null);
1227
1900
  setError(null);
@@ -1281,7 +1954,7 @@ function MediaPreview({ content, mimeType, fileName }) {
1281
1954
  dimensions.height,
1282
1955
  " px"
1283
1956
  ] }),
1284
- /* @__PURE__ */ jsx("span", { children: formatFileSize(estimatedBytes) }),
1957
+ estimatedBytes !== null && /* @__PURE__ */ jsx("span", { children: formatFileSize(estimatedBytes) }),
1285
1958
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground/60", children: mimeType })
1286
1959
  ] })
1287
1960
  ] })
@@ -1304,6 +1977,7 @@ function EditModal({
1304
1977
  renderError,
1305
1978
  previewError,
1306
1979
  previewLoading,
1980
+ initialTreePath,
1307
1981
  initialState = {},
1308
1982
  hideFileTree = false,
1309
1983
  ...sessionOptions
@@ -1315,19 +1989,30 @@ function EditModal({
1315
1989
  const [editInput, setEditInput] = useState("");
1316
1990
  const [bobbinChanges, setBobbinChanges] = useState([]);
1317
1991
  const [previewContainer, setPreviewContainer] = useState(null);
1992
+ const [pillContainer, setPillContainer] = useState(null);
1318
1993
  const [showConfirm, setShowConfirm] = useState(false);
1319
1994
  const [isSaving, setIsSaving] = useState(false);
1995
+ const [saveStatus, setSaveStatus] = useState("saved");
1996
+ const [lastSavedSnapshot, setLastSavedSnapshot] = useState("");
1320
1997
  const [saveError, setSaveError] = useState(null);
1321
1998
  const [pendingClose, setPendingClose] = useState(null);
1999
+ const [treePath, setTreePath] = useState(initialTreePath ?? "");
2000
+ const wasOpenRef = useRef(false);
1322
2001
  const currentCodeRef = useRef("");
1323
2002
  const session = useEditSession(sessionOptions);
1324
2003
  const code = getActiveContent(session);
2004
+ const effectiveTreePath = treePath || session.activeFile;
1325
2005
  currentCodeRef.current = code;
1326
2006
  const files = useMemo(() => getFiles(session.project), [session.project]);
2007
+ const projectSnapshot = useMemo(
2008
+ () => Array.from(session.project.files.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([path, file]) => `${path}\0${file.content}`).join(""),
2009
+ [session.project]
2010
+ );
1327
2011
  const hasChanges = code !== (session.originalProject.files.get(session.activeFile)?.content ?? "");
1328
2012
  const fileType = useMemo(() => getFileType(session.activeFile), [session.activeFile]);
1329
2013
  const isCompilableFile = isCompilable(session.activeFile);
1330
- const showPreviewToggle = isCompilableFile;
2014
+ const isMarkdown = isMarkdownFile(session.activeFile);
2015
+ const showPreviewToggle = isCompilableFile || isMarkdown;
1331
2016
  const handleBobbinChanges = useCallback((changes) => {
1332
2017
  setBobbinChanges(changes);
1333
2018
  }, []);
@@ -1349,6 +2034,26 @@ ${bobbinYaml}
1349
2034
  setBobbinChanges([]);
1350
2035
  };
1351
2036
  const hasSaveHandler = onSave || onSaveProject;
2037
+ useEffect(() => {
2038
+ if (isOpen && !wasOpenRef.current) {
2039
+ setLastSavedSnapshot(projectSnapshot);
2040
+ setSaveStatus("saved");
2041
+ setSaveError(null);
2042
+ }
2043
+ wasOpenRef.current = isOpen;
2044
+ }, [isOpen, projectSnapshot]);
2045
+ useEffect(() => {
2046
+ if (!hasSaveHandler) return;
2047
+ if (projectSnapshot === lastSavedSnapshot) {
2048
+ if (saveStatus !== "saving" && saveStatus !== "saved") {
2049
+ setSaveStatus("saved");
2050
+ }
2051
+ return;
2052
+ }
2053
+ if (saveStatus === "saved" || saveStatus === "error") {
2054
+ setSaveStatus("unsaved");
2055
+ }
2056
+ }, [projectSnapshot, lastSavedSnapshot, saveStatus, hasSaveHandler]);
1352
2057
  const handleClose = useCallback(() => {
1353
2058
  const editCount = session.history.length;
1354
2059
  const finalCode = code;
@@ -1365,6 +2070,7 @@ ${bobbinYaml}
1365
2070
  const handleSaveAndClose = useCallback(async () => {
1366
2071
  if (!pendingClose || !hasSaveHandler) return;
1367
2072
  setIsSaving(true);
2073
+ setSaveStatus("saving");
1368
2074
  setSaveError(null);
1369
2075
  try {
1370
2076
  if (onSaveProject) {
@@ -1372,6 +2078,8 @@ ${bobbinYaml}
1372
2078
  } else if (onSave) {
1373
2079
  await onSave(pendingClose.code);
1374
2080
  }
2081
+ setLastSavedSnapshot(projectSnapshot);
2082
+ setSaveStatus("saved");
1375
2083
  setShowConfirm(false);
1376
2084
  setEditInput("");
1377
2085
  session.clearError();
@@ -1379,10 +2087,11 @@ ${bobbinYaml}
1379
2087
  setPendingClose(null);
1380
2088
  } catch (e) {
1381
2089
  setSaveError(e instanceof Error ? e.message : "Save failed");
2090
+ setSaveStatus("error");
1382
2091
  } finally {
1383
2092
  setIsSaving(false);
1384
2093
  }
1385
- }, [pendingClose, onSave, onSaveProject, session, onClose]);
2094
+ }, [pendingClose, onSave, onSaveProject, session, onClose, projectSnapshot, hasSaveHandler]);
1386
2095
  const handleDiscard = useCallback(() => {
1387
2096
  if (!pendingClose) return;
1388
2097
  setShowConfirm(false);
@@ -1399,6 +2108,7 @@ ${bobbinYaml}
1399
2108
  const handleDirectSave = useCallback(async () => {
1400
2109
  if (!hasSaveHandler) return;
1401
2110
  setIsSaving(true);
2111
+ setSaveStatus("saving");
1402
2112
  setSaveError(null);
1403
2113
  try {
1404
2114
  if (onSaveProject) {
@@ -1406,12 +2116,15 @@ ${bobbinYaml}
1406
2116
  } else if (onSave && currentCodeRef.current) {
1407
2117
  await onSave(currentCodeRef.current);
1408
2118
  }
2119
+ setLastSavedSnapshot(projectSnapshot);
2120
+ setSaveStatus("saved");
1409
2121
  } catch (e) {
1410
2122
  setSaveError(e instanceof Error ? e.message : "Save failed");
2123
+ setSaveStatus("error");
1411
2124
  } finally {
1412
2125
  setIsSaving(false);
1413
2126
  }
1414
- }, [onSave, onSaveProject, session.project]);
2127
+ }, [onSave, onSaveProject, session.project, hasSaveHandler, projectSnapshot]);
1415
2128
  if (!isOpen) return null;
1416
2129
  return /* @__PURE__ */ jsxs(Fragment, { children: [
1417
2130
  /* @__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: [
@@ -1440,30 +2153,26 @@ ${bobbinYaml}
1440
2153
  children: showTree ? /* @__PURE__ */ jsx(FileCode, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(FolderTree, { className: "h-3 w-3" })
1441
2154
  }
1442
2155
  ),
2156
+ hasSaveHandler && /* @__PURE__ */ jsx(
2157
+ SaveStatusButton,
2158
+ {
2159
+ status: saveStatus,
2160
+ onClick: handleDirectSave,
2161
+ disabled: isSaving,
2162
+ tone: "primary"
2163
+ }
2164
+ ),
1443
2165
  showPreviewToggle && /* @__PURE__ */ jsxs(
1444
2166
  "button",
1445
2167
  {
1446
2168
  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"}`,
2169
+ className: `w-[5rem] 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
2170
  children: [
1449
2171
  showPreview ? /* @__PURE__ */ jsx(Eye, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(Code, { className: "h-3 w-3" }),
1450
2172
  showPreview ? "Preview" : "Code"
1451
2173
  ]
1452
2174
  }
1453
2175
  ),
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
2176
  /* @__PURE__ */ jsxs(
1468
2177
  "button",
1469
2178
  {
@@ -1484,11 +2193,16 @@ ${bobbinYaml}
1484
2193
  {
1485
2194
  files,
1486
2195
  activeFile: session.activeFile,
1487
- onSelectFile: session.setActiveFile,
2196
+ activePath: effectiveTreePath,
2197
+ onSelectFile: (path) => {
2198
+ setTreePath(path);
2199
+ session.setActiveFile(path);
2200
+ },
2201
+ onSelectDirectory: (path) => setTreePath(path),
1488
2202
  onReplaceFile: session.replaceFile
1489
2203
  }
1490
2204
  ),
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: [
2205
+ /* @__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: [
1492
2206
  previewError && renderError ? renderError(previewError) : previewError ? /* @__PURE__ */ jsxs("div", { className: "p-4 text-sm text-destructive flex items-center gap-2", children: [
1493
2207
  /* @__PURE__ */ jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }),
1494
2208
  /* @__PURE__ */ jsx("span", { children: previewError })
@@ -1500,7 +2214,7 @@ ${bobbinYaml}
1500
2214
  Bobbin,
1501
2215
  {
1502
2216
  container: previewContainer,
1503
- pillContainer: previewContainer,
2217
+ pillContainer,
1504
2218
  defaultActive: false,
1505
2219
  showInspector: true,
1506
2220
  onChanges: handleBobbinChanges,
@@ -1515,7 +2229,14 @@ ${bobbinYaml}
1515
2229
  editable: true,
1516
2230
  onChange: session.updateActiveFile
1517
2231
  }
1518
- ) : fileType.category === "text" ? /* @__PURE__ */ jsx(
2232
+ ) : isMarkdown && showPreview ? /* @__PURE__ */ jsx("div", { className: "p-4 prose prose-sm dark:prose-invert max-w-none h-full overflow-auto", children: /* @__PURE__ */ jsx(
2233
+ MarkdownPreview,
2234
+ {
2235
+ value: code,
2236
+ editable: true,
2237
+ onChange: session.updateActiveFile
2238
+ }
2239
+ ) }) : fileType.category === "text" ? /* @__PURE__ */ jsx(
1519
2240
  CodeBlockView,
1520
2241
  {
1521
2242
  content: code,
@@ -1530,7 +2251,15 @@ ${bobbinYaml}
1530
2251
  mimeType: getMimeType(session.activeFile),
1531
2252
  fileName: session.activeFile.split("/").pop() ?? session.activeFile
1532
2253
  }
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" }) }) })
2254
+ ) : /* @__PURE__ */ jsx(
2255
+ CodeBlockView,
2256
+ {
2257
+ content: code,
2258
+ language: fileType.language,
2259
+ editable: true,
2260
+ onChange: session.updateActiveFile
2261
+ }
2262
+ ) })
1534
2263
  ] }),
1535
2264
  /* @__PURE__ */ jsx(
1536
2265
  EditHistory,
@@ -1596,6 +2325,77 @@ ${bobbinYaml}
1596
2325
  )
1597
2326
  ] });
1598
2327
  }
2328
+ function createManifest(services) {
2329
+ return {
2330
+ name: "preview",
2331
+ version: "1.0.0",
2332
+ platform: "browser",
2333
+ image: "@aprovan/patchwork-image-shadcn",
2334
+ services
2335
+ };
2336
+ }
2337
+ function WidgetPreview({
2338
+ code,
2339
+ compiler,
2340
+ services,
2341
+ enabled = true
2342
+ }) {
2343
+ const [loading, setLoading] = useState(false);
2344
+ const [error, setError] = useState(null);
2345
+ const containerRef = useRef(null);
2346
+ const mountedRef = useRef(null);
2347
+ useEffect(() => {
2348
+ if (!enabled || !compiler || !containerRef.current) return;
2349
+ let cancelled = false;
2350
+ const compileAndMount = async () => {
2351
+ setLoading(true);
2352
+ setError(null);
2353
+ try {
2354
+ if (mountedRef.current) {
2355
+ compiler.unmount(mountedRef.current);
2356
+ mountedRef.current = null;
2357
+ }
2358
+ const widget = await compiler.compile(code, createManifest(services), {
2359
+ typescript: true
2360
+ });
2361
+ if (cancelled || !containerRef.current) return;
2362
+ const mounted = await compiler.mount(widget, {
2363
+ target: containerRef.current,
2364
+ mode: "embedded"
2365
+ });
2366
+ mountedRef.current = mounted;
2367
+ } catch (err) {
2368
+ if (!cancelled) {
2369
+ setError(err instanceof Error ? err.message : "Failed to render preview");
2370
+ }
2371
+ } finally {
2372
+ if (!cancelled) {
2373
+ setLoading(false);
2374
+ }
2375
+ }
2376
+ };
2377
+ void compileAndMount();
2378
+ return () => {
2379
+ cancelled = true;
2380
+ if (mountedRef.current && compiler) {
2381
+ compiler.unmount(mountedRef.current);
2382
+ mountedRef.current = null;
2383
+ }
2384
+ };
2385
+ }, [code, compiler, enabled, services]);
2386
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2387
+ error && /* @__PURE__ */ jsxs("div", { className: "text-sm text-destructive flex items-center gap-2 p-3", children: [
2388
+ /* @__PURE__ */ jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }),
2389
+ /* @__PURE__ */ jsx("span", { children: error })
2390
+ ] }),
2391
+ loading && /* @__PURE__ */ jsxs("div", { className: "p-3 flex items-center gap-2 text-muted-foreground", children: [
2392
+ /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
2393
+ /* @__PURE__ */ jsx("span", { className: "text-sm", children: "Rendering preview..." })
2394
+ ] }),
2395
+ !compiler && enabled && !loading && !error && /* @__PURE__ */ jsx("div", { className: "p-3 text-sm text-muted-foreground", children: "Compiler not initialized" }),
2396
+ /* @__PURE__ */ jsx("div", { ref: containerRef, className: "w-full" })
2397
+ ] });
2398
+ }
1599
2399
  var VFS_BASE_URL = "/vfs";
1600
2400
  var vfsConfigCache = null;
1601
2401
  async function getVFSConfig() {
@@ -1659,7 +2459,7 @@ async function isVFSAvailable() {
1659
2459
  return false;
1660
2460
  }
1661
2461
  }
1662
- function createManifest(services) {
2462
+ function createManifest2(services) {
1663
2463
  return {
1664
2464
  name: "preview",
1665
2465
  version: "1.0.0",
@@ -1668,63 +2468,19 @@ function createManifest(services) {
1668
2468
  services
1669
2469
  };
1670
2470
  }
1671
- function useCodeCompiler(compiler, code, enabled, services) {
1672
- const [loading, setLoading] = useState(false);
1673
- const [error, setError] = useState(null);
1674
- const containerRef = useRef(null);
1675
- const mountedRef = useRef(null);
1676
- useEffect(() => {
1677
- if (!enabled || !compiler || !containerRef.current) return;
1678
- let cancelled = false;
1679
- async function compileAndMount() {
1680
- if (!containerRef.current || !compiler) return;
1681
- setLoading(true);
1682
- setError(null);
1683
- try {
1684
- if (mountedRef.current) {
1685
- compiler.unmount(mountedRef.current);
1686
- mountedRef.current = null;
1687
- }
1688
- const widget = await compiler.compile(
1689
- code,
1690
- createManifest(services),
1691
- { typescript: true }
1692
- );
1693
- if (cancelled) {
1694
- return;
1695
- }
1696
- const mounted = await compiler.mount(widget, {
1697
- target: containerRef.current,
1698
- mode: "embedded"
1699
- });
1700
- mountedRef.current = mounted;
1701
- } catch (err) {
1702
- if (!cancelled) {
1703
- setError(err instanceof Error ? err.message : "Failed to render JSX");
1704
- }
1705
- } finally {
1706
- if (!cancelled) {
1707
- setLoading(false);
1708
- }
1709
- }
1710
- }
1711
- compileAndMount();
1712
- return () => {
1713
- cancelled = true;
1714
- if (mountedRef.current && compiler) {
1715
- compiler.unmount(mountedRef.current);
1716
- mountedRef.current = null;
1717
- }
1718
- };
1719
- }, [code, compiler, enabled, services]);
1720
- return { containerRef, loading, error };
1721
- }
1722
- function CodePreview({ code: originalCode, compiler, services, filePath, entrypoint = "index.ts" }) {
2471
+ function CodePreview({
2472
+ code: originalCode,
2473
+ compiler,
2474
+ services,
2475
+ filePath,
2476
+ entrypoint = "index.ts",
2477
+ onOpenEditSession
2478
+ }) {
1723
2479
  const [isEditing, setIsEditing] = useState(false);
1724
2480
  const [showPreview, setShowPreview] = useState(true);
1725
2481
  const [currentCode, setCurrentCode] = useState(originalCode);
1726
2482
  const [editCount, setEditCount] = useState(0);
1727
- const [saveStatus, setSaveStatus] = useState("unsaved");
2483
+ const [saveStatus, setSaveStatus] = useState("saved");
1728
2484
  const [lastSavedCode, setLastSavedCode] = useState(originalCode);
1729
2485
  const [vfsPath, setVfsPath] = useState(null);
1730
2486
  const currentCodeRef = useRef(currentCode);
@@ -1760,6 +2516,14 @@ function CodePreview({ code: originalCode, compiler, services, filePath, entrypo
1760
2516
  useEffect(() => {
1761
2517
  isEditingRef.current = isEditing;
1762
2518
  }, [isEditing]);
2519
+ useEffect(() => {
2520
+ if (saveStatus === "saving") return;
2521
+ if (currentCode === lastSavedCode) {
2522
+ if (saveStatus !== "saved") setSaveStatus("saved");
2523
+ return;
2524
+ }
2525
+ if (saveStatus === "saved") setSaveStatus("unsaved");
2526
+ }, [currentCode, lastSavedCode, saveStatus]);
1763
2527
  useEffect(() => {
1764
2528
  let active = true;
1765
2529
  void (async () => {
@@ -1812,14 +2576,12 @@ function CodePreview({ code: originalCode, compiler, services, filePath, entrypo
1812
2576
  setSaveStatus("error");
1813
2577
  }
1814
2578
  }, [currentCode, getProjectId, getEntryFile]);
1815
- const { containerRef, loading, error } = useCodeCompiler(
1816
- compiler,
1817
- currentCode,
1818
- showPreview && !isEditing,
1819
- services
1820
- );
2579
+ const previewPath = filePath ?? entrypoint;
2580
+ const fileType = useMemo(() => getFileType(previewPath), [previewPath]);
2581
+ const canRenderWidget = fileType.category === "compilable";
1821
2582
  const compile = useCallback(
1822
2583
  async (code) => {
2584
+ if (!canRenderWidget) return { success: true };
1823
2585
  if (!compiler) return { success: true };
1824
2586
  const errors = [];
1825
2587
  const originalError = console.error;
@@ -1830,7 +2592,7 @@ function CodePreview({ code: originalCode, compiler, services, filePath, entrypo
1830
2592
  try {
1831
2593
  await compiler.compile(
1832
2594
  code,
1833
- createManifest(services),
2595
+ createManifest2(services),
1834
2596
  { typescript: true }
1835
2597
  );
1836
2598
  return { success: true };
@@ -1848,15 +2610,64 @@ ${errors.join("\n")}` : "";
1848
2610
  console.error = originalError;
1849
2611
  }
1850
2612
  },
1851
- [compiler, services]
2613
+ [canRenderWidget, compiler, services]
1852
2614
  );
1853
2615
  const handleRevert = () => {
1854
2616
  setCurrentCode(originalCode);
1855
2617
  setEditCount(0);
1856
2618
  };
1857
2619
  const hasChanges = currentCode !== originalCode;
2620
+ const previewBody = useMemo(() => {
2621
+ if (canRenderWidget) {
2622
+ return /* @__PURE__ */ jsx(
2623
+ WidgetPreview,
2624
+ {
2625
+ code: currentCode,
2626
+ compiler,
2627
+ services,
2628
+ enabled: showPreview && !isEditing
2629
+ }
2630
+ );
2631
+ }
2632
+ if (fileType.category === "media") {
2633
+ return /* @__PURE__ */ jsx(
2634
+ MediaPreview,
2635
+ {
2636
+ content: currentCode,
2637
+ mimeType: fileType.mimeType,
2638
+ fileName: previewPath
2639
+ }
2640
+ );
2641
+ }
2642
+ if (fileType.language === "markdown") {
2643
+ return /* @__PURE__ */ jsx("div", { className: "p-4 prose prose-sm dark:prose-invert max-w-none", children: /* @__PURE__ */ jsx(MarkdownPreview, { value: currentCode }) });
2644
+ }
2645
+ return /* @__PURE__ */ jsx(
2646
+ CodeBlockView,
2647
+ {
2648
+ content: currentCode,
2649
+ language: fileType.language
2650
+ }
2651
+ );
2652
+ }, [canRenderWidget, compiler, currentCode, fileType, isEditing, previewPath, services, showPreview]);
2653
+ const handleOpenEditor = useCallback(async () => {
2654
+ if (!onOpenEditSession) {
2655
+ setIsEditing(true);
2656
+ return;
2657
+ }
2658
+ const projectId = await getProjectId();
2659
+ const entryFile = getEntryFile();
2660
+ const initialProject = createSingleFileProject(currentCode, entryFile, projectId);
2661
+ onOpenEditSession({
2662
+ projectId,
2663
+ entryFile,
2664
+ filePath,
2665
+ initialCode: currentCode,
2666
+ initialProject
2667
+ });
2668
+ }, [onOpenEditSession, getProjectId, getEntryFile, currentCode, filePath]);
1858
2669
  return /* @__PURE__ */ jsxs(Fragment, { children: [
1859
- /* @__PURE__ */ jsxs("div", { className: "my-3 border rounded-lg", children: [
2670
+ /* @__PURE__ */ jsxs("div", { className: "border rounded-lg overflow-hidden min-w-0", children: [
1860
2671
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-3 py-2 bg-muted/50 border-b rounded-t-lg", children: [
1861
2672
  /* @__PURE__ */ jsx(Code, { className: "h-4 w-4 text-muted-foreground" }),
1862
2673
  editCount > 0 && /* @__PURE__ */ jsxs("span", { className: "text-xs text-muted-foreground flex items-center gap-1", children: [
@@ -1865,19 +2676,6 @@ ${errors.join("\n")}` : "";
1865
2676
  " edit",
1866
2677
  editCount !== 1 ? "s" : ""
1867
2678
  ] }),
1868
- /* @__PURE__ */ jsx(
1869
- "button",
1870
- {
1871
- onClick: handleSave,
1872
- disabled: saveStatus === "saving",
1873
- className: `px-2 py-1 text-xs rounded flex items-center gap-1 ${saveStatus === "saved" ? "text-green-600" : saveStatus === "error" ? "text-destructive hover:bg-muted" : "text-muted-foreground hover:bg-muted"}`,
1874
- title: saveStatus === "saved" ? "Saved to disk" : saveStatus === "saving" ? "Saving..." : saveStatus === "error" ? "Save failed - click to retry" : "Click to save",
1875
- children: saveStatus === "saving" ? /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ jsxs("span", { className: "relative", children: [
1876
- /* @__PURE__ */ jsx(Cloud, { className: "h-3 w-3" }),
1877
- saveStatus === "saved" && /* @__PURE__ */ jsx(Check, { className: "h-2 w-2 absolute -bottom-0.5 -right-0.5 stroke-[3]" })
1878
- ] })
1879
- }
1880
- ),
1881
2679
  /* @__PURE__ */ jsxs("div", { className: "ml-auto flex gap-1", children: [
1882
2680
  hasChanges && /* @__PURE__ */ jsx(
1883
2681
  "button",
@@ -1891,17 +2689,26 @@ ${errors.join("\n")}` : "";
1891
2689
  /* @__PURE__ */ jsx(
1892
2690
  "button",
1893
2691
  {
1894
- onClick: () => setIsEditing(true),
2692
+ onClick: () => void handleOpenEditor(),
1895
2693
  className: "px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-muted",
1896
2694
  title: "Edit component",
1897
2695
  children: /* @__PURE__ */ jsx(Pencil, { className: "h-3 w-3" })
1898
2696
  }
1899
2697
  ),
2698
+ /* @__PURE__ */ jsx(
2699
+ SaveStatusButton,
2700
+ {
2701
+ status: saveStatus,
2702
+ onClick: handleSave,
2703
+ disabled: saveStatus === "saving",
2704
+ tone: "muted"
2705
+ }
2706
+ ),
1900
2707
  /* @__PURE__ */ jsxs(
1901
2708
  "button",
1902
2709
  {
1903
2710
  onClick: () => setShowPreview(!showPreview),
1904
- 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"}`,
2711
+ className: `w-[5rem] px-2 py-1 text-xs rounded flex items-center gap-1 ${showPreview ? "bg-primary text-primary-foreground" : "hover:bg-primary/20 text-primary"}`,
1905
2712
  children: [
1906
2713
  showPreview ? /* @__PURE__ */ jsx(Eye, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(Code, { className: "h-3 w-3" }),
1907
2714
  showPreview ? "Preview" : "Code"
@@ -1910,16 +2717,13 @@ ${errors.join("\n")}` : "";
1910
2717
  )
1911
2718
  ] })
1912
2719
  ] }),
1913
- showPreview ? /* @__PURE__ */ jsxs("div", { className: "bg-white", children: [
1914
- error ? /* @__PURE__ */ jsxs("div", { className: "p-3 text-sm text-destructive flex items-center gap-2", children: [
1915
- /* @__PURE__ */ jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }),
1916
- /* @__PURE__ */ jsx("span", { children: error })
1917
- ] }) : loading ? /* @__PURE__ */ jsxs("div", { className: "p-3 flex items-center gap-2 text-muted-foreground", children: [
1918
- /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
1919
- /* @__PURE__ */ jsx("span", { className: "text-sm", children: "Rendering preview..." })
1920
- ] }) : !compiler ? /* @__PURE__ */ jsx("div", { className: "p-3 text-sm text-muted-foreground", children: "Compiler not initialized" }) : null,
1921
- /* @__PURE__ */ jsx("div", { ref: containerRef })
1922
- ] }) : /* @__PURE__ */ jsx("div", { className: "p-3 bg-muted/30 overflow-auto max-h-96", children: /* @__PURE__ */ jsx("pre", { className: "text-xs whitespace-pre-wrap break-words m-0", children: /* @__PURE__ */ jsx("code", { children: currentCode }) }) })
2720
+ showPreview ? /* @__PURE__ */ jsx("div", { className: "bg-white overflow-y-auto overflow-x-hidden max-h-[60vh]", children: previewBody }) : /* @__PURE__ */ jsx("div", { className: "bg-muted/30 overflow-auto max-h-[60vh]", children: /* @__PURE__ */ jsx(
2721
+ CodeBlockView,
2722
+ {
2723
+ content: currentCode,
2724
+ language: fileType.language
2725
+ }
2726
+ ) })
1923
2727
  ] }),
1924
2728
  /* @__PURE__ */ jsx(
1925
2729
  EditModal,
@@ -1937,6 +2741,7 @@ ${errors.join("\n")}` : "";
1937
2741
  const entryFile = getEntryFile();
1938
2742
  const project = createSingleFileProject(finalCode, entryFile, projectId);
1939
2743
  await saveProject(project);
2744
+ setLastSavedCode(finalCode);
1940
2745
  setSaveStatus("saved");
1941
2746
  } catch (err) {
1942
2747
  console.warn("[VFS] Failed to save project:", err);
@@ -1947,49 +2752,77 @@ ${errors.join("\n")}` : "";
1947
2752
  },
1948
2753
  originalCode: currentCode,
1949
2754
  compile,
1950
- renderPreview: (code) => /* @__PURE__ */ jsx(ModalPreview, { code, compiler, services })
2755
+ renderPreview: (code) => /* @__PURE__ */ jsx(
2756
+ WidgetPreview,
2757
+ {
2758
+ code,
2759
+ compiler,
2760
+ services
2761
+ }
2762
+ )
1951
2763
  }
1952
2764
  )
1953
2765
  ] });
1954
2766
  }
1955
- function ModalPreview({
1956
- code,
1957
- compiler,
1958
- services
2767
+ function DefaultBadge({
2768
+ children,
2769
+ className = ""
1959
2770
  }) {
1960
- const { containerRef, loading, error } = useCodeCompiler(compiler, code, true, services);
1961
- return /* @__PURE__ */ jsxs(Fragment, { children: [
1962
- error && /* @__PURE__ */ jsxs("div", { className: "text-sm text-destructive flex items-center gap-2", children: [
1963
- /* @__PURE__ */ jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }),
1964
- /* @__PURE__ */ jsx("span", { children: error })
1965
- ] }),
1966
- loading && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-muted-foreground", children: [
1967
- /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
1968
- /* @__PURE__ */ jsx("span", { className: "text-sm", children: "Rendering preview..." })
1969
- ] }),
1970
- !compiler && !loading && !error && /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: "Compiler not initialized" }),
1971
- /* @__PURE__ */ jsx("div", { ref: containerRef })
1972
- ] });
1973
- }
1974
- function DefaultBadge({ children, className = "" }) {
1975
- return /* @__PURE__ */ jsx("span", { className: `inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${className}`, children });
2771
+ return /* @__PURE__ */ jsx(
2772
+ "span",
2773
+ {
2774
+ className: `inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${className}`,
2775
+ children
2776
+ }
2777
+ );
1976
2778
  }
1977
- function DefaultDialog({ children, open, onOpenChange }) {
2779
+ function DefaultDialog({
2780
+ children,
2781
+ open,
2782
+ onOpenChange
2783
+ }) {
1978
2784
  if (!open) return null;
1979
- return /* @__PURE__ */ jsx("div", { className: "fixed inset-0 z-50 bg-black/50", onClick: () => onOpenChange?.(false), children: /* @__PURE__ */ jsx("div", { className: "fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 bg-background p-6 shadow-lg rounded-lg", onClick: (e) => e.stopPropagation(), children }) });
2785
+ return /* @__PURE__ */ jsx(
2786
+ "div",
2787
+ {
2788
+ className: "fixed inset-0 z-50 bg-black/50",
2789
+ onClick: () => onOpenChange?.(false),
2790
+ children: /* @__PURE__ */ jsx(
2791
+ "div",
2792
+ {
2793
+ className: "fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 bg-background p-6 shadow-lg rounded-lg",
2794
+ onClick: (e) => e.stopPropagation(),
2795
+ children
2796
+ }
2797
+ )
2798
+ }
2799
+ );
1980
2800
  }
1981
2801
  function ServicesInspector({
1982
2802
  namespaces,
1983
2803
  services = [],
1984
2804
  BadgeComponent = DefaultBadge,
1985
- DialogComponent = DefaultDialog
2805
+ DialogComponent = DefaultDialog,
2806
+ DialogHeaderComponent = ({ children }) => /* @__PURE__ */ jsx("div", { className: "flex justify-between items-center mb-4", children }),
2807
+ DialogContentComponent = ({ children, className = "" }) => /* @__PURE__ */ jsx("div", { className, children }),
2808
+ DialogCloseComponent = ({ onClose }) => /* @__PURE__ */ jsx(
2809
+ "button",
2810
+ {
2811
+ onClick: () => onClose?.(),
2812
+ className: "text-muted-foreground hover:text-foreground",
2813
+ children: "\xD7"
2814
+ }
2815
+ )
1986
2816
  }) {
1987
2817
  const [open, setOpen] = useState(false);
1988
2818
  if (namespaces.length === 0) return null;
1989
- const groupedServices = services.reduce((acc, svc) => {
1990
- (acc[svc.namespace] ??= []).push(svc);
1991
- return acc;
1992
- }, {});
2819
+ const groupedServices = services.reduce(
2820
+ (acc, svc) => {
2821
+ (acc[svc.namespace] ??= []).push(svc);
2822
+ return acc;
2823
+ },
2824
+ {}
2825
+ );
1993
2826
  return /* @__PURE__ */ jsxs(Fragment, { children: [
1994
2827
  /* @__PURE__ */ jsxs(
1995
2828
  "button",
@@ -2007,11 +2840,11 @@ function ServicesInspector({
2007
2840
  }
2008
2841
  ),
2009
2842
  /* @__PURE__ */ jsxs(DialogComponent, { open, onOpenChange: setOpen, children: [
2010
- /* @__PURE__ */ jsxs("div", { className: "flex justify-between items-center mb-4", children: [
2843
+ /* @__PURE__ */ jsxs(DialogHeaderComponent, { children: [
2011
2844
  /* @__PURE__ */ jsx("h2", { className: "text-lg font-semibold", children: "Available Services" }),
2012
- /* @__PURE__ */ jsx("button", { onClick: () => setOpen(false), className: "text-muted-foreground hover:text-foreground", children: "\xD7" })
2845
+ /* @__PURE__ */ jsx(DialogCloseComponent, { onClose: () => setOpen(false) })
2013
2846
  ] }),
2014
- /* @__PURE__ */ jsx("div", { className: "space-y-3 max-h-96 overflow-auto", children: namespaces.map((ns) => /* @__PURE__ */ jsxs("details", { open: namespaces.length === 1, children: [
2847
+ /* @__PURE__ */ jsx(DialogContentComponent, { className: "space-y-3 max-h-96 overflow-auto", children: namespaces.map((ns) => /* @__PURE__ */ jsxs("details", { open: namespaces.length === 1, children: [
2015
2848
  /* @__PURE__ */ jsxs("summary", { className: "flex items-center gap-2 w-full p-2 rounded bg-muted/50 hover:bg-muted transition-colors cursor-pointer", children: [
2016
2849
  /* @__PURE__ */ jsx(ChevronDown, { className: "h-4 w-4 text-muted-foreground" }),
2017
2850
  /* @__PURE__ */ jsx("span", { className: "font-medium text-sm", children: ns }),
@@ -2023,11 +2856,11 @@ function ServicesInspector({
2023
2856
  ] }),
2024
2857
  /* @__PURE__ */ jsx("div", { className: "ml-6 mt-2 space-y-2", children: groupedServices[ns]?.map((svc) => /* @__PURE__ */ jsxs("details", { children: [
2025
2858
  /* @__PURE__ */ jsxs("summary", { className: "flex items-center gap-2 w-full text-left text-sm hover:text-foreground text-muted-foreground transition-colors cursor-pointer", children: [
2026
- /* @__PURE__ */ jsx(ChevronDown, { className: "h-3 w-3" }),
2859
+ svc.parameters && /* @__PURE__ */ jsx(ChevronDown, { className: "h-3 w-3" }),
2027
2860
  /* @__PURE__ */ jsx("code", { className: "font-mono text-xs", children: svc.procedure }),
2028
2861
  /* @__PURE__ */ jsx("span", { className: "truncate text-xs opacity-70", children: svc.description })
2029
2862
  ] }),
2030
- /* @__PURE__ */ jsx("div", { className: "ml-5 mt-1 p-2 rounded border bg-muted/30 overflow-auto max-h-48", children: /* @__PURE__ */ jsx("pre", { className: "text-xs font-mono whitespace-pre-wrap break-words m-0", children: JSON.stringify(svc.parameters.jsonSchema, null, 2) }) })
2863
+ svc.parameters && /* @__PURE__ */ jsx("div", { className: "ml-5 mt-1 p-2 rounded border bg-muted/30 overflow-auto max-h-48", children: /* @__PURE__ */ jsx("pre", { className: "text-xs font-mono whitespace-pre-wrap break-words m-0", children: JSON.stringify(svc.parameters, null, 2) }) })
2031
2864
  ] }, svc.name)) ?? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "No tool details available" }) })
2032
2865
  ] }, ns)) })
2033
2866
  ] })
@@ -2159,4 +2992,4 @@ function cn(...inputs) {
2159
2992
  return twMerge(clsx(inputs));
2160
2993
  }
2161
2994
 
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 };
2995
+ export { CodeBlockExtension, CodeBlockView, CodePreview, EditHistory, EditModal, FileTree, MarkdownEditor, MarkdownPreview, MediaPreview, SaveConfirmDialog, ServicesInspector, WidgetPreview, applyDiffs, cn, extractCodeBlocks, extractProject, extractSummary, extractTextWithoutDiffs, findDiffMarkers, findFirstCodeBlock, getActiveContent, getCodeBlockLanguages, getFileType, getFiles, getLanguageFromExt, getMimeType, getVFSConfig, getVFSStore, hasCodeBlock, hasDiffBlocks, isCompilable, isImageFile, isMarkdownFile, isMediaFile, isPreviewable, isTextFile, isVFSAvailable, isVideoFile, listProjects, loadProject, parseCodeBlockAttributes, parseCodeBlocks, parseDiffs, parseEditResponse, sanitizeDiffMarkers, saveFile, saveProject, sendEditRequest, useEditSession };