@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,66 +1,123 @@
1
- import { useState } from 'react';
2
- import { ChevronDown, Server } from 'lucide-react';
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<{ children: React.ReactNode; variant?: string; className?: string }>;
16
+ BadgeComponent?: React.ComponentType<{
17
+ children: React.ReactNode;
18
+ variant?: string;
19
+ className?: string;
20
+ }>;
19
21
  /** Custom collapsible components */
20
- CollapsibleComponent?: React.ComponentType<{ children: React.ReactNode; defaultOpen?: boolean; className?: string }>;
21
- CollapsibleTriggerComponent?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
22
- CollapsibleContentComponent?: React.ComponentType<{ children: React.ReactNode }>;
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<{ children: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void }>;
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<{ children: React.ReactNode }>;
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({ children, className = '' }: { children: React.ReactNode; className?: string }) {
49
+ function DefaultBadge({
50
+ children,
51
+ className = "",
52
+ }: {
53
+ children: React.ReactNode;
54
+ className?: string;
55
+ }) {
32
56
  return (
33
- <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}`}>
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({ children, open, onOpenChange }: { children: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void }) {
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 className="fixed inset-0 z-50 bg-black/50" onClick={() => onOpenChange?.(false)}>
43
- <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()}>
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[]>>((acc, svc) => {
61
- (acc[svc.namespace] ??= []).push(svc);
62
- return acc;
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 ? 's' : ''}
130
+ {namespaces.length} service{namespaces.length !== 1 ? "s" : ""}
74
131
  </BadgeComponent>
75
132
  </button>
76
133
 
77
134
  <DialogComponent open={open} onOpenChange={setOpen}>
78
- <div className="flex justify-between items-center mb-4">
135
+ <DialogHeaderComponent>
79
136
  <h2 className="text-lg font-semibold">Available Services</h2>
80
- <button onClick={() => setOpen(false)} className="text-muted-foreground hover:text-foreground">×</button>
81
- </div>
82
- <div className="space-y-3 max-h-96 overflow-auto">
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{groupedServices[ns].length !== 1 ? 's' : ''}
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">{svc.description}</span>
158
+ <span className="truncate text-xs opacity-70">
159
+ {svc.description}
160
+ </span>
101
161
  </summary>
102
- <div className="ml-5 mt-1 p-2 rounded border bg-muted/30 overflow-auto max-h-48">
103
- <pre className="text-xs font-mono whitespace-pre-wrap break-words m-0">
104
- {JSON.stringify(svc.parameters.jsonSchema, null, 2)}
105
- </pre>
106
- </div>
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">No tool details available</p>
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
- </div>
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,60 @@
1
- import { useCallback, useRef, useEffect } from 'react';
1
+ import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
2
+ import { createHighlighter, type Highlighter, type BundledLanguage } from 'shiki';
3
+
4
+ // Singleton highlighter instance
5
+ let highlighterPromise: Promise<Highlighter> | null = null;
6
+
7
+ const COMMON_LANGUAGES: BundledLanguage[] = [
8
+ 'typescript',
9
+ 'javascript',
10
+ 'tsx',
11
+ 'jsx',
12
+ 'json',
13
+ 'html',
14
+ 'css',
15
+ 'markdown',
16
+ 'yaml',
17
+ 'python',
18
+ 'bash',
19
+ 'sql',
20
+ ];
21
+
22
+ function getHighlighter(): Promise<Highlighter> {
23
+ if (!highlighterPromise) {
24
+ highlighterPromise = createHighlighter({
25
+ themes: ['github-light'],
26
+ langs: COMMON_LANGUAGES,
27
+ });
28
+ }
29
+ return highlighterPromise;
30
+ }
31
+
32
+ // Map common file extensions/language names to shiki language identifiers
33
+ function normalizeLanguage(lang: string | null): BundledLanguage {
34
+ if (!lang) return 'typescript';
35
+ const normalized = lang.toLowerCase();
36
+ const mapping: Record<string, BundledLanguage> = {
37
+ ts: 'typescript',
38
+ tsx: 'tsx',
39
+ js: 'javascript',
40
+ jsx: 'jsx',
41
+ json: 'json',
42
+ html: 'html',
43
+ css: 'css',
44
+ md: 'markdown',
45
+ markdown: 'markdown',
46
+ yml: 'yaml',
47
+ yaml: 'yaml',
48
+ py: 'python',
49
+ python: 'python',
50
+ sh: 'bash',
51
+ bash: 'bash',
52
+ sql: 'sql',
53
+ typescript: 'typescript',
54
+ javascript: 'javascript',
55
+ };
56
+ return mapping[normalized] || 'typescript';
57
+ }
2
58
 
3
59
  export interface CodeBlockViewProps {
4
60
  content: string;
@@ -9,7 +65,19 @@ export interface CodeBlockViewProps {
9
65
 
10
66
  export function CodeBlockView({ content, language, editable = false, onChange }: CodeBlockViewProps) {
11
67
  const textareaRef = useRef<HTMLTextAreaElement>(null);
68
+ const containerRef = useRef<HTMLDivElement>(null);
69
+ const [highlighter, setHighlighter] = useState<Highlighter | null>(null);
12
70
 
71
+ // Load the highlighter
72
+ useEffect(() => {
73
+ let mounted = true;
74
+ getHighlighter().then((h) => {
75
+ if (mounted) setHighlighter(h);
76
+ });
77
+ return () => { mounted = false; };
78
+ }, []);
79
+
80
+ // Auto-resize textarea
13
81
  useEffect(() => {
14
82
  if (textareaRef.current) {
15
83
  textareaRef.current.style.height = 'auto';
@@ -43,30 +111,78 @@ export function CodeBlockView({ content, language, editable = false, onChange }:
43
111
  );
44
112
 
45
113
  const langLabel = language || 'text';
114
+ const shikiLang = useMemo(() => normalizeLanguage(language), [language]);
115
+
116
+ // Generate highlighted HTML
117
+ const highlightedHtml = useMemo(() => {
118
+ if (!highlighter) return null;
119
+ try {
120
+ return highlighter.codeToHtml(content, {
121
+ lang: shikiLang,
122
+ theme: 'github-light',
123
+ });
124
+ } catch {
125
+ // Fallback if language is not supported
126
+ return null;
127
+ }
128
+ }, [highlighter, content, shikiLang]);
46
129
 
47
130
  return (
48
- <div className="h-full flex flex-col bg-muted/10">
49
- <div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b text-xs">
50
- <span className="font-mono text-muted-foreground">{langLabel}</span>
131
+ <div className="h-full flex flex-col bg-[#ffffff]">
132
+ <div className="flex items-center justify-between px-4 py-2 bg-[#f6f8fa] border-b border-[#d0d7de] text-xs">
133
+ <span className="font-mono text-[#57606a]">{langLabel}</span>
51
134
  </div>
135
+ <div className="flex-1 overflow-y-auto overflow-x-hidden">
52
136
  {editable ? (
53
- <textarea
54
- ref={textareaRef}
55
- value={content}
56
- onChange={handleChange}
57
- onKeyDown={handleKeyDown}
58
- className="w-full min-h-full font-mono text-xs leading-relaxed bg-transparent border-none outline-none resize-none"
59
- spellCheck={false}
60
- style={{
61
- tabSize: 2,
62
- WebkitTextFillColor: 'inherit',
63
- }}
64
- />
137
+ <div className="relative min-h-full">
138
+ {/* Highlighted code layer (background) - scrolls with content */}
139
+ <div
140
+ ref={containerRef}
141
+ className="absolute top-0 left-0 right-0 pointer-events-none p-4"
142
+ aria-hidden="true"
143
+ >
144
+ {highlightedHtml ? (
145
+ <div
146
+ 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"
147
+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
148
+ />
149
+ ) : (
150
+ <pre className="text-xs font-mono whitespace-pre-wrap break-words text-[#24292f] m-0 leading-relaxed">
151
+ <code>{content}</code>
152
+ </pre>
153
+ )}
154
+ </div>
155
+ {/* Editable textarea layer (foreground) */}
156
+ <textarea
157
+ ref={textareaRef}
158
+ value={content}
159
+ onChange={handleChange}
160
+ onKeyDown={handleKeyDown}
161
+ 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"
162
+ spellCheck={false}
163
+ style={{
164
+ tabSize: 2,
165
+ caretColor: '#24292f',
166
+ wordBreak: 'break-word',
167
+ overflowWrap: 'break-word',
168
+ }}
169
+ />
170
+ </div>
65
171
  ) : (
66
- <pre className="text-xs font-mono whitespace-pre-wrap break-words m-0 leading-relaxed">
67
- <code>{content}</code>
68
- </pre>
172
+ <div className="p-4">
173
+ {highlightedHtml ? (
174
+ <div
175
+ 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"
176
+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
177
+ />
178
+ ) : (
179
+ <pre className="text-xs font-mono whitespace-pre-wrap break-words m-0 leading-relaxed text-[#24292f]">
180
+ <code>{content}</code>
181
+ </pre>
182
+ )}
183
+ </div>
69
184
  )}
185
+ </div>
70
186
  </div>
71
187
  );
72
188
  }