@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.
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useMemo, useRef, type ReactNode } from 'react';
1
+ import { useState, useCallback, useMemo, useRef, useEffect, type ReactNode } from 'react';
2
2
  import {
3
3
  Code,
4
4
  Eye,
@@ -10,9 +10,10 @@ import {
10
10
  Send,
11
11
  FolderTree,
12
12
  FileCode,
13
- Save,
14
13
  } from 'lucide-react';
15
14
  import { MarkdownEditor } from '../MarkdownEditor';
15
+ import { MarkdownPreview } from '../MarkdownPreview';
16
+ import { SaveStatusButton, type SaveStatus } from '../SaveStatusButton';
16
17
  import { EditHistory } from './EditHistory';
17
18
  import { FileTree } from './FileTree';
18
19
  import { SaveConfirmDialog } from './SaveConfirmDialog';
@@ -20,7 +21,7 @@ import { CodeBlockView } from './CodeBlockView';
20
21
  import { MediaPreview } from './MediaPreview';
21
22
  import { useEditSession, type UseEditSessionOptions } from './useEditSession';
22
23
  import { getActiveContent, getFiles } from './types';
23
- import { getFileType, isCompilable, getMimeType } from './fileTypes';
24
+ import { getFileType, isCompilable, isMarkdownFile, getMimeType } from './fileTypes';
24
25
  import { Bobbin, serializeChangesToYAML, type Change } from '@aprovan/bobbin';
25
26
  import type { VirtualProject } from '@aprovan/patchwork-compiler';
26
27
 
@@ -36,6 +37,7 @@ function hashCode(str: string): number {
36
37
 
37
38
  export interface EditModalProps extends UseEditSessionOptions {
38
39
  isOpen: boolean;
40
+ initialTreePath?: string;
39
41
  initialState?: Partial<{
40
42
  showTree: boolean;
41
43
  showPreview: boolean;
@@ -61,6 +63,7 @@ export function EditModal({
61
63
  renderError,
62
64
  previewError,
63
65
  previewLoading,
66
+ initialTreePath,
64
67
  initialState = {},
65
68
  hideFileTree = false,
66
69
  ...sessionOptions
@@ -72,21 +75,36 @@ export function EditModal({
72
75
  const [editInput, setEditInput] = useState('');
73
76
  const [bobbinChanges, setBobbinChanges] = useState<Change[]>([]);
74
77
  const [previewContainer, setPreviewContainer] = useState<HTMLDivElement | null>(null);
78
+ const [pillContainer, setPillContainer] = useState<HTMLDivElement | null>(null);
75
79
  const [showConfirm, setShowConfirm] = useState(false);
76
80
  const [isSaving, setIsSaving] = useState(false);
81
+ const [saveStatus, setSaveStatus] = useState<SaveStatus>('saved');
82
+ const [lastSavedSnapshot, setLastSavedSnapshot] = useState('');
77
83
  const [saveError, setSaveError] = useState<string | null>(null);
78
84
  const [pendingClose, setPendingClose] = useState<{ code: string; count: number } | null>(null);
85
+ const [treePath, setTreePath] = useState(initialTreePath ?? '');
86
+ const wasOpenRef = useRef(false);
79
87
  const currentCodeRef = useRef<string>('');
80
88
 
81
89
  const session = useEditSession(sessionOptions);
82
90
  const code = getActiveContent(session);
91
+ const effectiveTreePath = treePath || session.activeFile;
83
92
  currentCodeRef.current = code;
84
93
  const files = useMemo(() => getFiles(session.project), [session.project]);
94
+ const projectSnapshot = useMemo(
95
+ () =>
96
+ Array.from(session.project.files.entries())
97
+ .sort(([a], [b]) => a.localeCompare(b))
98
+ .map(([path, file]) => `${path}\u0000${file.content}`)
99
+ .join('\u0001'),
100
+ [session.project]
101
+ );
85
102
  const hasChanges = code !== (session.originalProject.files.get(session.activeFile)?.content ?? '');
86
103
 
87
104
  const fileType = useMemo(() => getFileType(session.activeFile), [session.activeFile]);
88
105
  const isCompilableFile = isCompilable(session.activeFile);
89
- const showPreviewToggle = isCompilableFile;
106
+ const isMarkdown = isMarkdownFile(session.activeFile);
107
+ const showPreviewToggle = isCompilableFile || isMarkdown;
90
108
 
91
109
  const handleBobbinChanges = useCallback((changes: Change[]) => {
92
110
  setBobbinChanges(changes);
@@ -110,6 +128,28 @@ export function EditModal({
110
128
 
111
129
  const hasSaveHandler = onSave || onSaveProject;
112
130
 
131
+ useEffect(() => {
132
+ if (isOpen && !wasOpenRef.current) {
133
+ setLastSavedSnapshot(projectSnapshot);
134
+ setSaveStatus('saved');
135
+ setSaveError(null);
136
+ }
137
+ wasOpenRef.current = isOpen;
138
+ }, [isOpen, projectSnapshot]);
139
+
140
+ useEffect(() => {
141
+ if (!hasSaveHandler) return;
142
+ if (projectSnapshot === lastSavedSnapshot) {
143
+ if (saveStatus !== 'saving' && saveStatus !== 'saved') {
144
+ setSaveStatus('saved');
145
+ }
146
+ return;
147
+ }
148
+ if (saveStatus === 'saved' || saveStatus === 'error') {
149
+ setSaveStatus('unsaved');
150
+ }
151
+ }, [projectSnapshot, lastSavedSnapshot, saveStatus, hasSaveHandler]);
152
+
113
153
  const handleClose = useCallback(() => {
114
154
  const editCount = session.history.length;
115
155
  const finalCode = code;
@@ -128,6 +168,7 @@ export function EditModal({
128
168
  const handleSaveAndClose = useCallback(async () => {
129
169
  if (!pendingClose || !hasSaveHandler) return;
130
170
  setIsSaving(true);
171
+ setSaveStatus('saving');
131
172
  setSaveError(null);
132
173
  try {
133
174
  if (onSaveProject) {
@@ -135,6 +176,8 @@ export function EditModal({
135
176
  } else if (onSave) {
136
177
  await onSave(pendingClose.code);
137
178
  }
179
+ setLastSavedSnapshot(projectSnapshot);
180
+ setSaveStatus('saved');
138
181
  setShowConfirm(false);
139
182
  setEditInput('');
140
183
  session.clearError();
@@ -142,10 +185,11 @@ export function EditModal({
142
185
  setPendingClose(null);
143
186
  } catch (e) {
144
187
  setSaveError(e instanceof Error ? e.message : 'Save failed');
188
+ setSaveStatus('error');
145
189
  } finally {
146
190
  setIsSaving(false);
147
191
  }
148
- }, [pendingClose, onSave, onSaveProject, session, onClose]);
192
+ }, [pendingClose, onSave, onSaveProject, session, onClose, projectSnapshot, hasSaveHandler]);
149
193
 
150
194
  const handleDiscard = useCallback(() => {
151
195
  if (!pendingClose) return;
@@ -165,6 +209,7 @@ export function EditModal({
165
209
  const handleDirectSave = useCallback(async () => {
166
210
  if (!hasSaveHandler) return;
167
211
  setIsSaving(true);
212
+ setSaveStatus('saving');
168
213
  setSaveError(null);
169
214
  try {
170
215
  if (onSaveProject) {
@@ -172,12 +217,15 @@ export function EditModal({
172
217
  } else if (onSave && currentCodeRef.current) {
173
218
  await onSave(currentCodeRef.current);
174
219
  }
220
+ setLastSavedSnapshot(projectSnapshot);
221
+ setSaveStatus('saved');
175
222
  } catch (e) {
176
223
  setSaveError(e instanceof Error ? e.message : 'Save failed');
224
+ setSaveStatus('error');
177
225
  } finally {
178
226
  setIsSaving(false);
179
227
  }
180
- }, [onSave, onSaveProject, session.project]);
228
+ }, [onSave, onSaveProject, session.project, hasSaveHandler, projectSnapshot]);
181
229
 
182
230
  if (!isOpen) return null;
183
231
 
@@ -212,30 +260,23 @@ export function EditModal({
212
260
  {showTree ? <FileCode className="h-3 w-3" /> : <FolderTree className="h-3 w-3" />}
213
261
  </button>
214
262
  )}
263
+ {hasSaveHandler && (
264
+ <SaveStatusButton
265
+ status={saveStatus}
266
+ onClick={handleDirectSave}
267
+ disabled={isSaving}
268
+ tone="primary"
269
+ />
270
+ )}
215
271
  {showPreviewToggle && (
216
272
  <button
217
273
  onClick={() => setShowPreview(!showPreview)}
218
- 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'}`}
274
+ 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'}`}
219
275
  >
220
276
  {showPreview ? <Eye className="h-3 w-3" /> : <Code className="h-3 w-3" />}
221
277
  {showPreview ? 'Preview' : 'Code'}
222
278
  </button>
223
279
  )}
224
- {hasSaveHandler && (
225
- <button
226
- onClick={handleDirectSave}
227
- disabled={isSaving}
228
- className="px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-primary/20 text-primary disabled:opacity-50"
229
- title="Save changes"
230
- >
231
- {isSaving ? (
232
- <Loader2 className="h-3 w-3 animate-spin" />
233
- ) : (
234
- <Save className="h-3 w-3" />
235
- )}
236
- Save
237
- </button>
238
- )}
239
280
  <button
240
281
  onClick={handleClose}
241
282
  className="px-2 py-1 text-xs rounded flex items-center gap-1 bg-primary text-primary-foreground hover:bg-primary/90"
@@ -252,11 +293,16 @@ export function EditModal({
252
293
  <FileTree
253
294
  files={files}
254
295
  activeFile={session.activeFile}
255
- onSelectFile={session.setActiveFile}
296
+ activePath={effectiveTreePath}
297
+ onSelectFile={(path) => {
298
+ setTreePath(path);
299
+ session.setActiveFile(path);
300
+ }}
301
+ onSelectDirectory={(path) => setTreePath(path)}
256
302
  onReplaceFile={session.replaceFile}
257
303
  />
258
304
  )}
259
- <div className="flex-1 overflow-auto">
305
+ <div className="flex-1 overflow-auto" ref={setPillContainer}>
260
306
  {fileType.category === 'compilable' && showPreview ? (
261
307
  <div className="bg-white h-full relative" ref={setPreviewContainer}>
262
308
  {previewError && renderError ? (
@@ -278,7 +324,7 @@ export function EditModal({
278
324
  )}
279
325
  {!renderLoading && !renderError && !previewLoading && <Bobbin
280
326
  container={previewContainer}
281
- pillContainer={previewContainer}
327
+ pillContainer={pillContainer}
282
328
  defaultActive={false}
283
329
  showInspector
284
330
  onChanges={handleBobbinChanges}
@@ -292,6 +338,14 @@ export function EditModal({
292
338
  editable
293
339
  onChange={session.updateActiveFile}
294
340
  />
341
+ ) : isMarkdown && showPreview ? (
342
+ <div className="p-4 prose prose-sm dark:prose-invert max-w-none h-full overflow-auto">
343
+ <MarkdownPreview
344
+ value={code}
345
+ editable
346
+ onChange={session.updateActiveFile}
347
+ />
348
+ </div>
295
349
  ) : fileType.category === 'text' ? (
296
350
  <CodeBlockView
297
351
  content={code}
@@ -305,10 +359,14 @@ export function EditModal({
305
359
  mimeType={getMimeType(session.activeFile)}
306
360
  fileName={session.activeFile.split('/').pop() ?? session.activeFile}
307
361
  />
362
+ // Default to code view for unknown types
308
363
  ) : (
309
- <div className="flex items-center justify-center h-full text-muted-foreground">
310
- <p className="text-sm">Preview not available for this file type</p>
311
- </div>
364
+ <CodeBlockView
365
+ content={code}
366
+ language={fileType.language}
367
+ editable
368
+ onChange={session.updateActiveFile}
369
+ />
312
370
  )}
313
371
  </div>
314
372
  </div>