@aprovan/patchwork-editor 0.1.2-dev.03aaf5b → 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/.turbo/turbo-build.log +2 -2
- package/dist/components/CodePreview.d.ts +10 -1
- package/dist/components/MarkdownPreview.d.ts +8 -0
- package/dist/components/SaveStatusButton.d.ts +9 -0
- package/dist/components/ServicesInspector.d.ts +3 -4
- package/dist/components/WidgetPreview.d.ts +8 -0
- package/dist/components/edit/EditModal.d.ts +2 -1
- package/dist/components/edit/FileTree.d.ts +22 -3
- package/dist/components/edit/fileTypes.d.ts +2 -0
- package/dist/components/edit/useEditSession.d.ts +1 -0
- package/dist/components/index.d.ts +7 -5
- package/dist/index.d.ts +3 -3
- package/dist/index.js +904 -176
- package/package.json +3 -3
- package/src/components/CodePreview.tsx +118 -160
- package/src/components/MarkdownPreview.tsx +147 -0
- package/src/components/SaveStatusButton.tsx +55 -0
- package/src/components/ServicesInspector.tsx +101 -37
- package/src/components/WidgetPreview.tsx +102 -0
- package/src/components/edit/EditModal.tsx +83 -26
- package/src/components/edit/FileTree.tsx +523 -28
- package/src/components/edit/api.ts +6 -1
- package/src/components/edit/fileTypes.ts +8 -0
- package/src/components/edit/useEditSession.ts +13 -3
- package/src/components/index.ts +7 -5
- package/src/index.ts +10 -6
|
@@ -1,66 +1,123 @@
|
|
|
1
|
-
import { useState } from
|
|
2
|
-
import { ChevronDown, Server } from
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ChevronDown, Server } from "lucide-react";
|
|
3
3
|
|
|
4
4
|
export interface ServiceInfo {
|
|
5
5
|
name: string;
|
|
6
6
|
namespace: string;
|
|
7
7
|
procedure: string;
|
|
8
8
|
description: string;
|
|
9
|
-
parameters
|
|
10
|
-
jsonSchema: Record<string, unknown>;
|
|
11
|
-
};
|
|
9
|
+
parameters?: Record<string, unknown>;
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
interface ServicesInspectorProps {
|
|
15
13
|
namespaces: string[];
|
|
16
14
|
services?: ServiceInfo[];
|
|
17
15
|
/** Custom badge component for rendering the service count */
|
|
18
|
-
BadgeComponent?: React.ComponentType<{
|
|
16
|
+
BadgeComponent?: React.ComponentType<{
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
variant?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
}>;
|
|
19
21
|
/** Custom collapsible components */
|
|
20
|
-
CollapsibleComponent?: React.ComponentType<{
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
CollapsibleComponent?: React.ComponentType<{
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
defaultOpen?: boolean;
|
|
25
|
+
className?: string;
|
|
26
|
+
}>;
|
|
27
|
+
CollapsibleTriggerComponent?: React.ComponentType<{
|
|
28
|
+
children: React.ReactNode;
|
|
29
|
+
className?: string;
|
|
30
|
+
}>;
|
|
31
|
+
CollapsibleContentComponent?: React.ComponentType<{
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
}>;
|
|
23
34
|
/** Custom dialog components */
|
|
24
|
-
DialogComponent?: React.ComponentType<{
|
|
35
|
+
DialogComponent?: React.ComponentType<{
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
open?: boolean;
|
|
38
|
+
onOpenChange?: (open: boolean) => void;
|
|
39
|
+
}>;
|
|
25
40
|
DialogHeaderComponent?: React.ComponentType<{ children: React.ReactNode }>;
|
|
26
|
-
DialogContentComponent?: React.ComponentType<{
|
|
41
|
+
DialogContentComponent?: React.ComponentType<{
|
|
42
|
+
children: React.ReactNode;
|
|
43
|
+
className?: string;
|
|
44
|
+
}>;
|
|
27
45
|
DialogCloseComponent?: React.ComponentType<{ onClose?: () => void }>;
|
|
28
46
|
}
|
|
29
47
|
|
|
30
48
|
// Fallback components for when custom UI is not provided
|
|
31
|
-
function DefaultBadge({
|
|
49
|
+
function DefaultBadge({
|
|
50
|
+
children,
|
|
51
|
+
className = "",
|
|
52
|
+
}: {
|
|
53
|
+
children: React.ReactNode;
|
|
54
|
+
className?: string;
|
|
55
|
+
}) {
|
|
32
56
|
return (
|
|
33
|
-
<span
|
|
57
|
+
<span
|
|
58
|
+
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}`}
|
|
59
|
+
>
|
|
34
60
|
{children}
|
|
35
61
|
</span>
|
|
36
62
|
);
|
|
37
63
|
}
|
|
38
64
|
|
|
39
|
-
function DefaultDialog({
|
|
65
|
+
function DefaultDialog({
|
|
66
|
+
children,
|
|
67
|
+
open,
|
|
68
|
+
onOpenChange,
|
|
69
|
+
}: {
|
|
70
|
+
children: React.ReactNode;
|
|
71
|
+
open?: boolean;
|
|
72
|
+
onOpenChange?: (open: boolean) => void;
|
|
73
|
+
}) {
|
|
40
74
|
if (!open) return null;
|
|
41
75
|
return (
|
|
42
|
-
<div
|
|
43
|
-
|
|
76
|
+
<div
|
|
77
|
+
className="fixed inset-0 z-50 bg-black/50"
|
|
78
|
+
onClick={() => onOpenChange?.(false)}
|
|
79
|
+
>
|
|
80
|
+
<div
|
|
81
|
+
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"
|
|
82
|
+
onClick={(e) => e.stopPropagation()}
|
|
83
|
+
>
|
|
44
84
|
{children}
|
|
45
85
|
</div>
|
|
46
86
|
</div>
|
|
47
87
|
);
|
|
48
88
|
}
|
|
49
89
|
|
|
50
|
-
export function ServicesInspector({
|
|
51
|
-
namespaces,
|
|
90
|
+
export function ServicesInspector({
|
|
91
|
+
namespaces,
|
|
52
92
|
services = [],
|
|
53
93
|
BadgeComponent = DefaultBadge,
|
|
54
94
|
DialogComponent = DefaultDialog,
|
|
95
|
+
DialogHeaderComponent = ({ children }) => (
|
|
96
|
+
<div className="flex justify-between items-center mb-4">{children}</div>
|
|
97
|
+
),
|
|
98
|
+
DialogContentComponent = ({ children, className = "" }) => (
|
|
99
|
+
<div className={className}>{children}</div>
|
|
100
|
+
),
|
|
101
|
+
DialogCloseComponent = ({ onClose }) => (
|
|
102
|
+
<button
|
|
103
|
+
onClick={() => onClose?.()}
|
|
104
|
+
className="text-muted-foreground hover:text-foreground"
|
|
105
|
+
>
|
|
106
|
+
×
|
|
107
|
+
</button>
|
|
108
|
+
),
|
|
55
109
|
}: ServicesInspectorProps) {
|
|
56
110
|
const [open, setOpen] = useState(false);
|
|
57
111
|
|
|
58
112
|
if (namespaces.length === 0) return null;
|
|
59
113
|
|
|
60
|
-
const groupedServices = services.reduce<Record<string, ServiceInfo[]>>(
|
|
61
|
-
(acc
|
|
62
|
-
|
|
63
|
-
|
|
114
|
+
const groupedServices = services.reduce<Record<string, ServiceInfo[]>>(
|
|
115
|
+
(acc, svc) => {
|
|
116
|
+
(acc[svc.namespace] ??= []).push(svc);
|
|
117
|
+
return acc;
|
|
118
|
+
},
|
|
119
|
+
{},
|
|
120
|
+
);
|
|
64
121
|
|
|
65
122
|
return (
|
|
66
123
|
<>
|
|
@@ -70,16 +127,16 @@ export function ServicesInspector({
|
|
|
70
127
|
>
|
|
71
128
|
<Server className="h-4 w-4 text-muted-foreground" />
|
|
72
129
|
<BadgeComponent className="text-xs">
|
|
73
|
-
{namespaces.length} service{namespaces.length !== 1 ?
|
|
130
|
+
{namespaces.length} service{namespaces.length !== 1 ? "s" : ""}
|
|
74
131
|
</BadgeComponent>
|
|
75
132
|
</button>
|
|
76
133
|
|
|
77
134
|
<DialogComponent open={open} onOpenChange={setOpen}>
|
|
78
|
-
<
|
|
135
|
+
<DialogHeaderComponent>
|
|
79
136
|
<h2 className="text-lg font-semibold">Available Services</h2>
|
|
80
|
-
<
|
|
81
|
-
</
|
|
82
|
-
<
|
|
137
|
+
<DialogCloseComponent onClose={() => setOpen(false)} />
|
|
138
|
+
</DialogHeaderComponent>
|
|
139
|
+
<DialogContentComponent className="space-y-3 max-h-96 overflow-auto">
|
|
83
140
|
{namespaces.map((ns) => (
|
|
84
141
|
<details key={ns} open={namespaces.length === 1}>
|
|
85
142
|
<summary className="flex items-center gap-2 w-full p-2 rounded bg-muted/50 hover:bg-muted transition-colors cursor-pointer">
|
|
@@ -87,7 +144,8 @@ export function ServicesInspector({
|
|
|
87
144
|
<span className="font-medium text-sm">{ns}</span>
|
|
88
145
|
{groupedServices[ns] && (
|
|
89
146
|
<BadgeComponent className="ml-auto text-xs">
|
|
90
|
-
{groupedServices[ns].length} tool
|
|
147
|
+
{groupedServices[ns].length} tool
|
|
148
|
+
{groupedServices[ns].length !== 1 ? "s" : ""}
|
|
91
149
|
</BadgeComponent>
|
|
92
150
|
)}
|
|
93
151
|
</summary>
|
|
@@ -95,23 +153,29 @@ export function ServicesInspector({
|
|
|
95
153
|
{groupedServices[ns]?.map((svc) => (
|
|
96
154
|
<details key={svc.name}>
|
|
97
155
|
<summary className="flex items-center gap-2 w-full text-left text-sm hover:text-foreground text-muted-foreground transition-colors cursor-pointer">
|
|
98
|
-
<ChevronDown className="h-3 w-3" />
|
|
156
|
+
{svc.parameters && <ChevronDown className="h-3 w-3" />}
|
|
99
157
|
<code className="font-mono text-xs">{svc.procedure}</code>
|
|
100
|
-
<span className="truncate text-xs opacity-70">
|
|
158
|
+
<span className="truncate text-xs opacity-70">
|
|
159
|
+
{svc.description}
|
|
160
|
+
</span>
|
|
101
161
|
</summary>
|
|
102
|
-
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
162
|
+
{svc.parameters && (
|
|
163
|
+
<div className="ml-5 mt-1 p-2 rounded border bg-muted/30 overflow-auto max-h-48">
|
|
164
|
+
<pre className="text-xs font-mono whitespace-pre-wrap break-words m-0">
|
|
165
|
+
{JSON.stringify(svc.parameters, null, 2)}
|
|
166
|
+
</pre>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
107
169
|
</details>
|
|
108
170
|
)) ?? (
|
|
109
|
-
<p className="text-xs text-muted-foreground">
|
|
171
|
+
<p className="text-xs text-muted-foreground">
|
|
172
|
+
No tool details available
|
|
173
|
+
</p>
|
|
110
174
|
)}
|
|
111
175
|
</div>
|
|
112
176
|
</details>
|
|
113
177
|
))}
|
|
114
|
-
</
|
|
178
|
+
</DialogContentComponent>
|
|
115
179
|
</DialogComponent>
|
|
116
180
|
</>
|
|
117
181
|
);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { AlertCircle, Loader2 } from 'lucide-react';
|
|
3
|
+
import type { Compiler, Manifest, MountedWidget } from '@aprovan/patchwork-compiler';
|
|
4
|
+
|
|
5
|
+
export interface WidgetPreviewProps {
|
|
6
|
+
code: string;
|
|
7
|
+
compiler: Compiler | null;
|
|
8
|
+
services?: string[];
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function createManifest(services?: string[]): Manifest {
|
|
13
|
+
return {
|
|
14
|
+
name: 'preview',
|
|
15
|
+
version: '1.0.0',
|
|
16
|
+
platform: 'browser',
|
|
17
|
+
image: '@aprovan/patchwork-image-shadcn',
|
|
18
|
+
services,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function WidgetPreview({
|
|
23
|
+
code,
|
|
24
|
+
compiler,
|
|
25
|
+
services,
|
|
26
|
+
enabled = true,
|
|
27
|
+
}: WidgetPreviewProps) {
|
|
28
|
+
const [loading, setLoading] = useState(false);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const mountedRef = useRef<MountedWidget | null>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!enabled || !compiler || !containerRef.current) return;
|
|
35
|
+
|
|
36
|
+
let cancelled = false;
|
|
37
|
+
|
|
38
|
+
const compileAndMount = async () => {
|
|
39
|
+
setLoading(true);
|
|
40
|
+
setError(null);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
if (mountedRef.current) {
|
|
44
|
+
compiler.unmount(mountedRef.current);
|
|
45
|
+
mountedRef.current = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const widget = await compiler.compile(code, createManifest(services), {
|
|
49
|
+
typescript: true,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (cancelled || !containerRef.current) return;
|
|
53
|
+
|
|
54
|
+
const mounted = await compiler.mount(widget, {
|
|
55
|
+
target: containerRef.current,
|
|
56
|
+
mode: 'embedded',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
mountedRef.current = mounted;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (!cancelled) {
|
|
62
|
+
setError(err instanceof Error ? err.message : 'Failed to render preview');
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
if (!cancelled) {
|
|
66
|
+
setLoading(false);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
void compileAndMount();
|
|
72
|
+
|
|
73
|
+
return () => {
|
|
74
|
+
cancelled = true;
|
|
75
|
+
if (mountedRef.current && compiler) {
|
|
76
|
+
compiler.unmount(mountedRef.current);
|
|
77
|
+
mountedRef.current = null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}, [code, compiler, enabled, services]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
{error && (
|
|
85
|
+
<div className="text-sm text-destructive flex items-center gap-2 p-3">
|
|
86
|
+
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
87
|
+
<span>{error}</span>
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
{loading && (
|
|
91
|
+
<div className="p-3 flex items-center gap-2 text-muted-foreground">
|
|
92
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
93
|
+
<span className="text-sm">Rendering preview...</span>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
{!compiler && enabled && !loading && !error && (
|
|
97
|
+
<div className="p-3 text-sm text-muted-foreground">Compiler not initialized</div>
|
|
98
|
+
)}
|
|
99
|
+
<div ref={containerRef} className="w-full" />
|
|
100
|
+
</>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -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
|
|
@@ -75,19 +78,33 @@ export function EditModal({
|
|
|
75
78
|
const [pillContainer, setPillContainer] = useState<HTMLDivElement | null>(null);
|
|
76
79
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
77
80
|
const [isSaving, setIsSaving] = useState(false);
|
|
81
|
+
const [saveStatus, setSaveStatus] = useState<SaveStatus>('saved');
|
|
82
|
+
const [lastSavedSnapshot, setLastSavedSnapshot] = useState('');
|
|
78
83
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
79
84
|
const [pendingClose, setPendingClose] = useState<{ code: string; count: number } | null>(null);
|
|
85
|
+
const [treePath, setTreePath] = useState(initialTreePath ?? '');
|
|
86
|
+
const wasOpenRef = useRef(false);
|
|
80
87
|
const currentCodeRef = useRef<string>('');
|
|
81
88
|
|
|
82
89
|
const session = useEditSession(sessionOptions);
|
|
83
90
|
const code = getActiveContent(session);
|
|
91
|
+
const effectiveTreePath = treePath || session.activeFile;
|
|
84
92
|
currentCodeRef.current = code;
|
|
85
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
|
+
);
|
|
86
102
|
const hasChanges = code !== (session.originalProject.files.get(session.activeFile)?.content ?? '');
|
|
87
103
|
|
|
88
104
|
const fileType = useMemo(() => getFileType(session.activeFile), [session.activeFile]);
|
|
89
105
|
const isCompilableFile = isCompilable(session.activeFile);
|
|
90
|
-
const
|
|
106
|
+
const isMarkdown = isMarkdownFile(session.activeFile);
|
|
107
|
+
const showPreviewToggle = isCompilableFile || isMarkdown;
|
|
91
108
|
|
|
92
109
|
const handleBobbinChanges = useCallback((changes: Change[]) => {
|
|
93
110
|
setBobbinChanges(changes);
|
|
@@ -111,6 +128,28 @@ export function EditModal({
|
|
|
111
128
|
|
|
112
129
|
const hasSaveHandler = onSave || onSaveProject;
|
|
113
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
|
+
|
|
114
153
|
const handleClose = useCallback(() => {
|
|
115
154
|
const editCount = session.history.length;
|
|
116
155
|
const finalCode = code;
|
|
@@ -129,6 +168,7 @@ export function EditModal({
|
|
|
129
168
|
const handleSaveAndClose = useCallback(async () => {
|
|
130
169
|
if (!pendingClose || !hasSaveHandler) return;
|
|
131
170
|
setIsSaving(true);
|
|
171
|
+
setSaveStatus('saving');
|
|
132
172
|
setSaveError(null);
|
|
133
173
|
try {
|
|
134
174
|
if (onSaveProject) {
|
|
@@ -136,6 +176,8 @@ export function EditModal({
|
|
|
136
176
|
} else if (onSave) {
|
|
137
177
|
await onSave(pendingClose.code);
|
|
138
178
|
}
|
|
179
|
+
setLastSavedSnapshot(projectSnapshot);
|
|
180
|
+
setSaveStatus('saved');
|
|
139
181
|
setShowConfirm(false);
|
|
140
182
|
setEditInput('');
|
|
141
183
|
session.clearError();
|
|
@@ -143,10 +185,11 @@ export function EditModal({
|
|
|
143
185
|
setPendingClose(null);
|
|
144
186
|
} catch (e) {
|
|
145
187
|
setSaveError(e instanceof Error ? e.message : 'Save failed');
|
|
188
|
+
setSaveStatus('error');
|
|
146
189
|
} finally {
|
|
147
190
|
setIsSaving(false);
|
|
148
191
|
}
|
|
149
|
-
}, [pendingClose, onSave, onSaveProject, session, onClose]);
|
|
192
|
+
}, [pendingClose, onSave, onSaveProject, session, onClose, projectSnapshot, hasSaveHandler]);
|
|
150
193
|
|
|
151
194
|
const handleDiscard = useCallback(() => {
|
|
152
195
|
if (!pendingClose) return;
|
|
@@ -166,6 +209,7 @@ export function EditModal({
|
|
|
166
209
|
const handleDirectSave = useCallback(async () => {
|
|
167
210
|
if (!hasSaveHandler) return;
|
|
168
211
|
setIsSaving(true);
|
|
212
|
+
setSaveStatus('saving');
|
|
169
213
|
setSaveError(null);
|
|
170
214
|
try {
|
|
171
215
|
if (onSaveProject) {
|
|
@@ -173,12 +217,15 @@ export function EditModal({
|
|
|
173
217
|
} else if (onSave && currentCodeRef.current) {
|
|
174
218
|
await onSave(currentCodeRef.current);
|
|
175
219
|
}
|
|
220
|
+
setLastSavedSnapshot(projectSnapshot);
|
|
221
|
+
setSaveStatus('saved');
|
|
176
222
|
} catch (e) {
|
|
177
223
|
setSaveError(e instanceof Error ? e.message : 'Save failed');
|
|
224
|
+
setSaveStatus('error');
|
|
178
225
|
} finally {
|
|
179
226
|
setIsSaving(false);
|
|
180
227
|
}
|
|
181
|
-
}, [onSave, onSaveProject, session.project]);
|
|
228
|
+
}, [onSave, onSaveProject, session.project, hasSaveHandler, projectSnapshot]);
|
|
182
229
|
|
|
183
230
|
if (!isOpen) return null;
|
|
184
231
|
|
|
@@ -213,30 +260,23 @@ export function EditModal({
|
|
|
213
260
|
{showTree ? <FileCode className="h-3 w-3" /> : <FolderTree className="h-3 w-3" />}
|
|
214
261
|
</button>
|
|
215
262
|
)}
|
|
263
|
+
{hasSaveHandler && (
|
|
264
|
+
<SaveStatusButton
|
|
265
|
+
status={saveStatus}
|
|
266
|
+
onClick={handleDirectSave}
|
|
267
|
+
disabled={isSaving}
|
|
268
|
+
tone="primary"
|
|
269
|
+
/>
|
|
270
|
+
)}
|
|
216
271
|
{showPreviewToggle && (
|
|
217
272
|
<button
|
|
218
273
|
onClick={() => setShowPreview(!showPreview)}
|
|
219
|
-
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'}`}
|
|
220
275
|
>
|
|
221
276
|
{showPreview ? <Eye className="h-3 w-3" /> : <Code className="h-3 w-3" />}
|
|
222
277
|
{showPreview ? 'Preview' : 'Code'}
|
|
223
278
|
</button>
|
|
224
279
|
)}
|
|
225
|
-
{hasSaveHandler && (
|
|
226
|
-
<button
|
|
227
|
-
onClick={handleDirectSave}
|
|
228
|
-
disabled={isSaving}
|
|
229
|
-
className="px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-primary/20 text-primary disabled:opacity-50"
|
|
230
|
-
title="Save changes"
|
|
231
|
-
>
|
|
232
|
-
{isSaving ? (
|
|
233
|
-
<Loader2 className="h-3 w-3 animate-spin" />
|
|
234
|
-
) : (
|
|
235
|
-
<Save className="h-3 w-3" />
|
|
236
|
-
)}
|
|
237
|
-
Save
|
|
238
|
-
</button>
|
|
239
|
-
)}
|
|
240
280
|
<button
|
|
241
281
|
onClick={handleClose}
|
|
242
282
|
className="px-2 py-1 text-xs rounded flex items-center gap-1 bg-primary text-primary-foreground hover:bg-primary/90"
|
|
@@ -253,7 +293,12 @@ export function EditModal({
|
|
|
253
293
|
<FileTree
|
|
254
294
|
files={files}
|
|
255
295
|
activeFile={session.activeFile}
|
|
256
|
-
|
|
296
|
+
activePath={effectiveTreePath}
|
|
297
|
+
onSelectFile={(path) => {
|
|
298
|
+
setTreePath(path);
|
|
299
|
+
session.setActiveFile(path);
|
|
300
|
+
}}
|
|
301
|
+
onSelectDirectory={(path) => setTreePath(path)}
|
|
257
302
|
onReplaceFile={session.replaceFile}
|
|
258
303
|
/>
|
|
259
304
|
)}
|
|
@@ -293,6 +338,14 @@ export function EditModal({
|
|
|
293
338
|
editable
|
|
294
339
|
onChange={session.updateActiveFile}
|
|
295
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>
|
|
296
349
|
) : fileType.category === 'text' ? (
|
|
297
350
|
<CodeBlockView
|
|
298
351
|
content={code}
|
|
@@ -306,10 +359,14 @@ export function EditModal({
|
|
|
306
359
|
mimeType={getMimeType(session.activeFile)}
|
|
307
360
|
fileName={session.activeFile.split('/').pop() ?? session.activeFile}
|
|
308
361
|
/>
|
|
362
|
+
// Default to code view for unknown types
|
|
309
363
|
) : (
|
|
310
|
-
<
|
|
311
|
-
|
|
312
|
-
|
|
364
|
+
<CodeBlockView
|
|
365
|
+
content={code}
|
|
366
|
+
language={fileType.language}
|
|
367
|
+
editable
|
|
368
|
+
onChange={session.updateActiveFile}
|
|
369
|
+
/>
|
|
313
370
|
)}
|
|
314
371
|
</div>
|
|
315
372
|
</div>
|