@evolve.labs/devflow 0.8.0
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/.claude/commands/agents/architect.md +1162 -0
- package/.claude/commands/agents/architect.meta.yaml +124 -0
- package/.claude/commands/agents/builder.md +1432 -0
- package/.claude/commands/agents/builder.meta.yaml +117 -0
- package/.claude/commands/agents/chronicler.md +633 -0
- package/.claude/commands/agents/chronicler.meta.yaml +217 -0
- package/.claude/commands/agents/guardian.md +456 -0
- package/.claude/commands/agents/guardian.meta.yaml +127 -0
- package/.claude/commands/agents/strategist.md +483 -0
- package/.claude/commands/agents/strategist.meta.yaml +158 -0
- package/.claude/commands/agents/system-designer.md +1137 -0
- package/.claude/commands/agents/system-designer.meta.yaml +156 -0
- package/.claude/commands/devflow-help.md +93 -0
- package/.claude/commands/devflow-status.md +60 -0
- package/.claude/commands/quick/create-adr.md +82 -0
- package/.claude/commands/quick/new-feature.md +57 -0
- package/.claude/commands/quick/security-check.md +54 -0
- package/.claude/commands/quick/system-design.md +58 -0
- package/.claude_project +52 -0
- package/.devflow/agents/architect.meta.yaml +122 -0
- package/.devflow/agents/builder.meta.yaml +116 -0
- package/.devflow/agents/chronicler.meta.yaml +222 -0
- package/.devflow/agents/guardian.meta.yaml +127 -0
- package/.devflow/agents/strategist.meta.yaml +158 -0
- package/.devflow/agents/system-designer.meta.yaml +265 -0
- package/.devflow/project.yaml +242 -0
- package/.gitignore-template +84 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/devflow.js +54 -0
- package/lib/autopilot.js +235 -0
- package/lib/autopilotConstants.js +213 -0
- package/lib/constants.js +95 -0
- package/lib/init.js +200 -0
- package/lib/update.js +181 -0
- package/lib/utils.js +157 -0
- package/lib/web.js +119 -0
- package/package.json +57 -0
- package/web/CHANGELOG.md +192 -0
- package/web/README.md +156 -0
- package/web/app/api/autopilot/execute/route.ts +102 -0
- package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
- package/web/app/api/files/route.ts +280 -0
- package/web/app/api/files/tree/route.ts +160 -0
- package/web/app/api/git/route.ts +201 -0
- package/web/app/api/health/route.ts +94 -0
- package/web/app/api/project/open/route.ts +134 -0
- package/web/app/api/search/route.ts +247 -0
- package/web/app/api/specs/route.ts +405 -0
- package/web/app/api/terminal/route.ts +222 -0
- package/web/app/globals.css +160 -0
- package/web/app/ide/layout.tsx +43 -0
- package/web/app/ide/page.tsx +216 -0
- package/web/app/layout.tsx +34 -0
- package/web/app/page.tsx +303 -0
- package/web/components/agents/AgentIcons.tsx +281 -0
- package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
- package/web/components/autopilot/AutopilotPanel.tsx +299 -0
- package/web/components/dashboard/DashboardPanel.tsx +393 -0
- package/web/components/editor/Breadcrumbs.tsx +134 -0
- package/web/components/editor/EditorPanel.tsx +120 -0
- package/web/components/editor/EditorTabs.tsx +229 -0
- package/web/components/editor/MarkdownPreview.tsx +154 -0
- package/web/components/editor/MermaidDiagram.tsx +113 -0
- package/web/components/editor/MonacoEditor.tsx +177 -0
- package/web/components/editor/TabContextMenu.tsx +207 -0
- package/web/components/git/GitPanel.tsx +534 -0
- package/web/components/layout/Shell.tsx +15 -0
- package/web/components/layout/StatusBar.tsx +100 -0
- package/web/components/modals/CommandPalette.tsx +393 -0
- package/web/components/modals/GlobalSearch.tsx +348 -0
- package/web/components/modals/QuickOpen.tsx +241 -0
- package/web/components/modals/RecentFiles.tsx +208 -0
- package/web/components/projects/ProjectSelector.tsx +147 -0
- package/web/components/settings/SettingItem.tsx +150 -0
- package/web/components/settings/SettingsPanel.tsx +323 -0
- package/web/components/specs/SpecsPanel.tsx +1091 -0
- package/web/components/terminal/TerminalPanel.tsx +683 -0
- package/web/components/ui/ContextMenu.tsx +182 -0
- package/web/components/ui/LoadingSpinner.tsx +66 -0
- package/web/components/ui/ResizeHandle.tsx +110 -0
- package/web/components/ui/Skeleton.tsx +108 -0
- package/web/components/ui/SkipLinks.tsx +37 -0
- package/web/components/ui/Toaster.tsx +57 -0
- package/web/hooks/useFocusTrap.ts +141 -0
- package/web/hooks/useKeyboardShortcuts.ts +169 -0
- package/web/hooks/useListNavigation.ts +237 -0
- package/web/lib/autopilotConstants.ts +213 -0
- package/web/lib/constants/agents.ts +67 -0
- package/web/lib/git.ts +339 -0
- package/web/lib/ptyManager.ts +191 -0
- package/web/lib/specsParser.ts +299 -0
- package/web/lib/stores/autopilotStore.ts +288 -0
- package/web/lib/stores/fileStore.ts +550 -0
- package/web/lib/stores/gitStore.ts +386 -0
- package/web/lib/stores/projectStore.ts +196 -0
- package/web/lib/stores/settingsStore.ts +126 -0
- package/web/lib/stores/specsStore.ts +297 -0
- package/web/lib/stores/uiStore.ts +175 -0
- package/web/lib/types/index.ts +177 -0
- package/web/lib/utils.ts +98 -0
- package/web/next.config.js +50 -0
- package/web/package.json +54 -0
- package/web/postcss.config.js +6 -0
- package/web/tailwind.config.ts +68 -0
- package/web/tsconfig.json +41 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
|
+
import { useFileStore } from '@/lib/stores/fileStore';
|
|
5
|
+
import { cn, getFileName } from '@/lib/utils';
|
|
6
|
+
import { X, FileText, FileCode, FileJson, Settings, Loader2, Pin, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
7
|
+
import { TabContextMenu } from './TabContextMenu';
|
|
8
|
+
|
|
9
|
+
// Get icon by language
|
|
10
|
+
function getLanguageIcon(language: string) {
|
|
11
|
+
switch (language) {
|
|
12
|
+
case 'markdown':
|
|
13
|
+
return FileText;
|
|
14
|
+
case 'typescript':
|
|
15
|
+
case 'javascript':
|
|
16
|
+
return FileCode;
|
|
17
|
+
case 'json':
|
|
18
|
+
return FileJson;
|
|
19
|
+
case 'yaml':
|
|
20
|
+
return Settings;
|
|
21
|
+
default:
|
|
22
|
+
return FileText;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Get color by language
|
|
27
|
+
function getLanguageColor(language: string): string {
|
|
28
|
+
switch (language) {
|
|
29
|
+
case 'typescript':
|
|
30
|
+
return 'text-blue-400';
|
|
31
|
+
case 'javascript':
|
|
32
|
+
return 'text-yellow-400';
|
|
33
|
+
case 'markdown':
|
|
34
|
+
return 'text-gray-400';
|
|
35
|
+
case 'json':
|
|
36
|
+
return 'text-amber-400';
|
|
37
|
+
case 'yaml':
|
|
38
|
+
return 'text-purple-400';
|
|
39
|
+
default:
|
|
40
|
+
return 'text-gray-400';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ContextMenuState {
|
|
45
|
+
path: string;
|
|
46
|
+
position: { x: number; y: number };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function EditorTabs() {
|
|
50
|
+
const {
|
|
51
|
+
openFiles,
|
|
52
|
+
activeFile,
|
|
53
|
+
setActiveFile,
|
|
54
|
+
closeFile,
|
|
55
|
+
isSaving,
|
|
56
|
+
savingFile,
|
|
57
|
+
pinnedFiles,
|
|
58
|
+
navigateBack,
|
|
59
|
+
navigateForward,
|
|
60
|
+
canGoBack,
|
|
61
|
+
canGoForward,
|
|
62
|
+
} = useFileStore();
|
|
63
|
+
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
64
|
+
|
|
65
|
+
const handleNavigateBack = useCallback(() => {
|
|
66
|
+
if (canGoBack()) {
|
|
67
|
+
navigateBack();
|
|
68
|
+
}
|
|
69
|
+
}, [canGoBack, navigateBack]);
|
|
70
|
+
|
|
71
|
+
const handleNavigateForward = useCallback(() => {
|
|
72
|
+
if (canGoForward()) {
|
|
73
|
+
navigateForward();
|
|
74
|
+
}
|
|
75
|
+
}, [canGoForward, navigateForward]);
|
|
76
|
+
|
|
77
|
+
const handleClose = useCallback((e: React.MouseEvent, path: string) => {
|
|
78
|
+
e.stopPropagation();
|
|
79
|
+
|
|
80
|
+
// Check if dirty
|
|
81
|
+
const file = openFiles.find((f) => f.path === path);
|
|
82
|
+
if (file?.isDirty) {
|
|
83
|
+
const shouldClose = confirm('File has unsaved changes. Close anyway?');
|
|
84
|
+
if (!shouldClose) return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
closeFile(path);
|
|
88
|
+
}, [openFiles, closeFile]);
|
|
89
|
+
|
|
90
|
+
const handleMiddleClick = useCallback((e: React.MouseEvent, path: string) => {
|
|
91
|
+
if (e.button === 1) {
|
|
92
|
+
handleClose(e, path);
|
|
93
|
+
}
|
|
94
|
+
}, [handleClose]);
|
|
95
|
+
|
|
96
|
+
const handleContextMenu = useCallback((e: React.MouseEvent, path: string) => {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
e.stopPropagation();
|
|
99
|
+
setContextMenu({
|
|
100
|
+
path,
|
|
101
|
+
position: { x: e.clientX, y: e.clientY },
|
|
102
|
+
});
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
const closeContextMenu = useCallback(() => {
|
|
106
|
+
setContextMenu(null);
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<>
|
|
111
|
+
{/* Navigation buttons */}
|
|
112
|
+
<div className="flex items-center gap-0.5 px-1 border-r border-white/10">
|
|
113
|
+
<button
|
|
114
|
+
onClick={handleNavigateBack}
|
|
115
|
+
disabled={!canGoBack()}
|
|
116
|
+
className={cn(
|
|
117
|
+
'p-1.5 rounded transition-colors',
|
|
118
|
+
canGoBack()
|
|
119
|
+
? 'text-gray-400 hover:text-white hover:bg-white/10'
|
|
120
|
+
: 'text-gray-600 cursor-not-allowed'
|
|
121
|
+
)}
|
|
122
|
+
aria-label="Go back"
|
|
123
|
+
title="Go back (Alt+Left)"
|
|
124
|
+
>
|
|
125
|
+
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
|
|
126
|
+
</button>
|
|
127
|
+
<button
|
|
128
|
+
onClick={handleNavigateForward}
|
|
129
|
+
disabled={!canGoForward()}
|
|
130
|
+
className={cn(
|
|
131
|
+
'p-1.5 rounded transition-colors',
|
|
132
|
+
canGoForward()
|
|
133
|
+
? 'text-gray-400 hover:text-white hover:bg-white/10'
|
|
134
|
+
: 'text-gray-600 cursor-not-allowed'
|
|
135
|
+
)}
|
|
136
|
+
aria-label="Go forward"
|
|
137
|
+
title="Go forward (Alt+Right)"
|
|
138
|
+
>
|
|
139
|
+
<ChevronRight className="w-4 h-4" aria-hidden="true" />
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div
|
|
144
|
+
className="flex-1 flex items-center overflow-x-auto"
|
|
145
|
+
role="tablist"
|
|
146
|
+
aria-label="Open files"
|
|
147
|
+
>
|
|
148
|
+
{openFiles.map((file) => {
|
|
149
|
+
const Icon = getLanguageIcon(file.language);
|
|
150
|
+
const iconColor = getLanguageColor(file.language);
|
|
151
|
+
const isActive = activeFile === file.path;
|
|
152
|
+
const isPinned = pinnedFiles.includes(file.path);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
key={file.path}
|
|
157
|
+
className={cn(
|
|
158
|
+
'group flex items-center gap-2 px-3 py-2 border-r border-white/10 cursor-pointer transition-colors',
|
|
159
|
+
isActive
|
|
160
|
+
? 'bg-[#0a0a0f] text-white'
|
|
161
|
+
: 'bg-[#08080c] text-gray-400 hover:text-white hover:bg-white/5',
|
|
162
|
+
isPinned && 'border-l-2 border-l-purple-500/50'
|
|
163
|
+
)}
|
|
164
|
+
onClick={() => setActiveFile(file.path)}
|
|
165
|
+
onMouseDown={(e) => handleMiddleClick(e, file.path)}
|
|
166
|
+
onContextMenu={(e) => handleContextMenu(e, file.path)}
|
|
167
|
+
role="tab"
|
|
168
|
+
aria-selected={isActive}
|
|
169
|
+
aria-label={`${file.name}${file.isDirty ? ' (unsaved)' : ''}${isPinned ? ' (pinned)' : ''}`}
|
|
170
|
+
tabIndex={isActive ? 0 : -1}
|
|
171
|
+
>
|
|
172
|
+
{/* Pin indicator */}
|
|
173
|
+
{isPinned && (
|
|
174
|
+
<Pin
|
|
175
|
+
className="w-3 h-3 text-purple-400 flex-shrink-0"
|
|
176
|
+
aria-hidden="true"
|
|
177
|
+
/>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
<Icon className={cn('w-4 h-4 flex-shrink-0', iconColor)} aria-hidden="true" />
|
|
181
|
+
|
|
182
|
+
<span className="text-sm truncate max-w-[120px]">
|
|
183
|
+
{file.name}
|
|
184
|
+
</span>
|
|
185
|
+
|
|
186
|
+
{/* Saving/Dirty indicator */}
|
|
187
|
+
{isSaving && savingFile === file.path ? (
|
|
188
|
+
<Loader2
|
|
189
|
+
className="w-3 h-3 animate-spin text-purple-400 flex-shrink-0"
|
|
190
|
+
aria-label="Saving"
|
|
191
|
+
/>
|
|
192
|
+
) : file.isDirty ? (
|
|
193
|
+
<span
|
|
194
|
+
className="w-2 h-2 rounded-full bg-purple-500 flex-shrink-0"
|
|
195
|
+
aria-label="Unsaved changes"
|
|
196
|
+
/>
|
|
197
|
+
) : null}
|
|
198
|
+
|
|
199
|
+
{/* Close button - hidden for pinned tabs unless hovered */}
|
|
200
|
+
<button
|
|
201
|
+
onClick={(e) => handleClose(e, file.path)}
|
|
202
|
+
className={cn(
|
|
203
|
+
'p-0.5 rounded hover:bg-white/10 transition-colors flex-shrink-0',
|
|
204
|
+
isPinned
|
|
205
|
+
? 'opacity-0 group-hover:opacity-100'
|
|
206
|
+
: isActive
|
|
207
|
+
? 'opacity-100'
|
|
208
|
+
: 'opacity-0 group-hover:opacity-100'
|
|
209
|
+
)}
|
|
210
|
+
aria-label={`Close ${file.name}`}
|
|
211
|
+
>
|
|
212
|
+
<X className="w-3 h-3" />
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
})}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{/* Context Menu */}
|
|
220
|
+
{contextMenu && (
|
|
221
|
+
<TabContextMenu
|
|
222
|
+
path={contextMenu.path}
|
|
223
|
+
position={contextMenu.position}
|
|
224
|
+
onClose={closeContextMenu}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, Suspense, lazy } from 'react';
|
|
4
|
+
import ReactMarkdown from 'react-markdown';
|
|
5
|
+
import remarkGfm from 'remark-gfm';
|
|
6
|
+
|
|
7
|
+
// Lazy load Mermaid - only loaded when markdown contains mermaid blocks
|
|
8
|
+
const MermaidDiagram = lazy(() => import('./MermaidDiagram').then(m => ({ default: m.MermaidDiagram })));
|
|
9
|
+
|
|
10
|
+
interface MarkdownPreviewProps {
|
|
11
|
+
content: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function MermaidFallback() {
|
|
15
|
+
return (
|
|
16
|
+
<div className="my-4 p-4 bg-muted rounded-lg animate-pulse">
|
|
17
|
+
<div className="h-32 bg-white/5 rounded" />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function MarkdownPreview({ content }: MarkdownPreviewProps) {
|
|
23
|
+
// Memoize components object to prevent recreation on every render
|
|
24
|
+
const components = useMemo(() => ({
|
|
25
|
+
// Custom heading styles
|
|
26
|
+
h1: ({ children }: { children?: React.ReactNode }) => (
|
|
27
|
+
<h1 className="text-2xl font-bold border-b border-border pb-2 mb-4">
|
|
28
|
+
{children}
|
|
29
|
+
</h1>
|
|
30
|
+
),
|
|
31
|
+
h2: ({ children }: { children?: React.ReactNode }) => (
|
|
32
|
+
<h2 className="text-xl font-semibold mt-6 mb-3">{children}</h2>
|
|
33
|
+
),
|
|
34
|
+
h3: ({ children }: { children?: React.ReactNode }) => (
|
|
35
|
+
<h3 className="text-lg font-semibold mt-4 mb-2">{children}</h3>
|
|
36
|
+
),
|
|
37
|
+
|
|
38
|
+
// Code blocks
|
|
39
|
+
code: ({ className, children, ...props }: { className?: string; children?: React.ReactNode }) => {
|
|
40
|
+
const match = /language-(\w+)/.exec(className || '');
|
|
41
|
+
const language = match ? match[1] : '';
|
|
42
|
+
const codeContent = String(children).replace(/\n$/, '');
|
|
43
|
+
|
|
44
|
+
// Handle mermaid diagrams with lazy loading
|
|
45
|
+
if (language === 'mermaid') {
|
|
46
|
+
return (
|
|
47
|
+
<Suspense fallback={<MermaidFallback />}>
|
|
48
|
+
<MermaidDiagram chart={codeContent} className="my-4" />
|
|
49
|
+
</Suspense>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Inline code (no className and no newlines)
|
|
54
|
+
const isInline = !className && !codeContent.includes('\n');
|
|
55
|
+
if (isInline) {
|
|
56
|
+
return (
|
|
57
|
+
<code
|
|
58
|
+
className="px-1.5 py-0.5 rounded bg-muted text-sm font-mono"
|
|
59
|
+
{...props}
|
|
60
|
+
>
|
|
61
|
+
{children}
|
|
62
|
+
</code>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Block code with optional language label
|
|
67
|
+
return (
|
|
68
|
+
<div className="relative group my-4">
|
|
69
|
+
{language && (
|
|
70
|
+
<div className="absolute top-2 right-2 px-2 py-0.5 text-xs text-gray-500 bg-black/30 rounded">
|
|
71
|
+
{language}
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
<code
|
|
75
|
+
className="block p-4 rounded-lg bg-muted text-sm font-mono overflow-x-auto"
|
|
76
|
+
{...props}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</code>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// Tables
|
|
85
|
+
table: ({ children }: { children?: React.ReactNode }) => (
|
|
86
|
+
<div className="overflow-x-auto my-4">
|
|
87
|
+
<table className="min-w-full border-collapse border border-border">
|
|
88
|
+
{children}
|
|
89
|
+
</table>
|
|
90
|
+
</div>
|
|
91
|
+
),
|
|
92
|
+
th: ({ children }: { children?: React.ReactNode }) => (
|
|
93
|
+
<th className="border border-border bg-muted px-3 py-2 text-left font-semibold">
|
|
94
|
+
{children}
|
|
95
|
+
</th>
|
|
96
|
+
),
|
|
97
|
+
td: ({ children }: { children?: React.ReactNode }) => (
|
|
98
|
+
<td className="border border-border px-3 py-2">{children}</td>
|
|
99
|
+
),
|
|
100
|
+
|
|
101
|
+
// Links
|
|
102
|
+
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
|
103
|
+
<a
|
|
104
|
+
href={href}
|
|
105
|
+
className="text-primary hover:underline"
|
|
106
|
+
target={href?.startsWith('http') ? '_blank' : undefined}
|
|
107
|
+
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
</a>
|
|
111
|
+
),
|
|
112
|
+
|
|
113
|
+
// Blockquotes
|
|
114
|
+
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
|
115
|
+
<blockquote className="border-l-4 border-primary/50 pl-4 italic text-muted-foreground my-4">
|
|
116
|
+
{children}
|
|
117
|
+
</blockquote>
|
|
118
|
+
),
|
|
119
|
+
|
|
120
|
+
// Lists
|
|
121
|
+
ul: ({ children }: { children?: React.ReactNode }) => (
|
|
122
|
+
<ul className="list-disc list-inside my-2 space-y-1">{children}</ul>
|
|
123
|
+
),
|
|
124
|
+
ol: ({ children }: { children?: React.ReactNode }) => (
|
|
125
|
+
<ol className="list-decimal list-inside my-2 space-y-1">{children}</ol>
|
|
126
|
+
),
|
|
127
|
+
|
|
128
|
+
// Checkboxes (GFM)
|
|
129
|
+
input: ({ type, checked }: { type?: string; checked?: boolean }) => {
|
|
130
|
+
if (type === 'checkbox') {
|
|
131
|
+
return (
|
|
132
|
+
<input
|
|
133
|
+
type="checkbox"
|
|
134
|
+
checked={checked}
|
|
135
|
+
readOnly
|
|
136
|
+
className="mr-2 rounded"
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
},
|
|
142
|
+
}), []);
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="p-6 prose prose-invert prose-sm max-w-none">
|
|
146
|
+
<ReactMarkdown
|
|
147
|
+
remarkPlugins={[remarkGfm]}
|
|
148
|
+
components={components}
|
|
149
|
+
>
|
|
150
|
+
{content}
|
|
151
|
+
</ReactMarkdown>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import mermaid from 'mermaid';
|
|
5
|
+
import { AlertCircle, RefreshCw } from 'lucide-react';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface MermaidDiagramProps {
|
|
9
|
+
chart: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let mermaidInitialized = false;
|
|
14
|
+
|
|
15
|
+
function initMermaid() {
|
|
16
|
+
if (mermaidInitialized) return;
|
|
17
|
+
|
|
18
|
+
mermaid.initialize({
|
|
19
|
+
startOnLoad: false,
|
|
20
|
+
theme: 'dark',
|
|
21
|
+
securityLevel: 'strict',
|
|
22
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
23
|
+
themeVariables: {
|
|
24
|
+
primaryColor: '#8b5cf6',
|
|
25
|
+
primaryTextColor: '#fff',
|
|
26
|
+
primaryBorderColor: '#6d28d9',
|
|
27
|
+
lineColor: '#64748b',
|
|
28
|
+
secondaryColor: '#1e1b4b',
|
|
29
|
+
tertiaryColor: '#1a1a24',
|
|
30
|
+
background: '#0a0a0f',
|
|
31
|
+
mainBkg: '#1a1a24',
|
|
32
|
+
nodeBorder: '#6d28d9',
|
|
33
|
+
clusterBkg: '#1e1b4b',
|
|
34
|
+
clusterBorder: '#4c1d95',
|
|
35
|
+
titleColor: '#e2e8f0',
|
|
36
|
+
edgeLabelBackground: '#1a1a24',
|
|
37
|
+
textColor: '#e2e8f0',
|
|
38
|
+
nodeTextColor: '#fff',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
mermaidInitialized = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function MermaidDiagram({ chart, className }: MermaidDiagramProps) {
|
|
46
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
47
|
+
const [svg, setSvg] = useState<string>('');
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
50
|
+
const idRef = useRef(`mermaid-${Math.random().toString(36).substring(2, 11)}`);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
initMermaid();
|
|
54
|
+
|
|
55
|
+
const renderDiagram = async () => {
|
|
56
|
+
setIsLoading(true);
|
|
57
|
+
setError(null);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const { svg } = await mermaid.render(idRef.current, chart);
|
|
61
|
+
setSvg(svg);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error('Mermaid render error:', err);
|
|
64
|
+
setError(err instanceof Error ? err.message : 'Failed to render diagram');
|
|
65
|
+
} finally {
|
|
66
|
+
setIsLoading(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
renderDiagram();
|
|
71
|
+
}, [chart]);
|
|
72
|
+
|
|
73
|
+
if (isLoading) {
|
|
74
|
+
return (
|
|
75
|
+
<div className={cn('flex items-center justify-center p-8 bg-[#1a1a24] rounded-lg', className)}>
|
|
76
|
+
<RefreshCw className="w-5 h-5 animate-spin text-purple-400" />
|
|
77
|
+
<span className="ml-2 text-sm text-gray-400">Rendering diagram...</span>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (error) {
|
|
83
|
+
return (
|
|
84
|
+
<div className={cn('p-4 bg-red-500/10 border border-red-500/30 rounded-lg', className)}>
|
|
85
|
+
<div className="flex items-center gap-2 text-red-400 mb-2">
|
|
86
|
+
<AlertCircle className="w-4 h-4" />
|
|
87
|
+
<span className="text-sm font-medium">Diagram Error</span>
|
|
88
|
+
</div>
|
|
89
|
+
<pre className="text-xs text-red-300/80 overflow-x-auto whitespace-pre-wrap">{error}</pre>
|
|
90
|
+
<details className="mt-3">
|
|
91
|
+
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-400">
|
|
92
|
+
Show source code
|
|
93
|
+
</summary>
|
|
94
|
+
<pre className="mt-2 p-3 text-xs text-gray-400 bg-black/20 rounded overflow-x-auto">
|
|
95
|
+
{chart}
|
|
96
|
+
</pre>
|
|
97
|
+
</details>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div
|
|
104
|
+
ref={containerRef}
|
|
105
|
+
className={cn(
|
|
106
|
+
'mermaid-diagram bg-[#1a1a24] rounded-lg p-4 overflow-x-auto',
|
|
107
|
+
'[&_svg]:max-w-full [&_svg]:h-auto',
|
|
108
|
+
className
|
|
109
|
+
)}
|
|
110
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, memo } from 'react';
|
|
4
|
+
import dynamic from 'next/dynamic';
|
|
5
|
+
import { useFileStore } from '@/lib/stores/fileStore';
|
|
6
|
+
import { useSettingsStore } from '@/lib/stores/settingsStore';
|
|
7
|
+
import { Skeleton, SkeletonText } from '@/components/ui/Skeleton';
|
|
8
|
+
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
|
9
|
+
import type { OpenFile } from '@/lib/types';
|
|
10
|
+
import type { editor } from 'monaco-editor';
|
|
11
|
+
import type * as Monaco from 'monaco-editor';
|
|
12
|
+
|
|
13
|
+
// Editor Loading Skeleton
|
|
14
|
+
function EditorSkeleton() {
|
|
15
|
+
return (
|
|
16
|
+
<div className="h-full bg-[#0a0a0f] p-4">
|
|
17
|
+
{/* Line numbers + code skeleton */}
|
|
18
|
+
<div className="flex gap-4">
|
|
19
|
+
<div className="flex flex-col gap-2 w-8">
|
|
20
|
+
{Array.from({ length: 20 }).map((_, i) => (
|
|
21
|
+
<Skeleton key={i} className="h-4 w-6" />
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
<div className="flex-1 space-y-2">
|
|
25
|
+
{Array.from({ length: 20 }).map((_, i) => (
|
|
26
|
+
<Skeleton
|
|
27
|
+
key={i}
|
|
28
|
+
className="h-4"
|
|
29
|
+
style={{ width: `${Math.random() * 40 + 30}%` }}
|
|
30
|
+
/>
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
{/* Loading indicator */}
|
|
35
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/20 backdrop-blur-sm">
|
|
36
|
+
<div className="flex flex-col items-center gap-3">
|
|
37
|
+
<LoadingSpinner size="lg" />
|
|
38
|
+
<span className="text-sm text-gray-400">Loading editor...</span>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Lazy load Monaco Editor
|
|
46
|
+
const Editor = dynamic(() => import('@monaco-editor/react'), {
|
|
47
|
+
ssr: false,
|
|
48
|
+
loading: () => <EditorSkeleton />,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
interface MonacoEditorProps {
|
|
52
|
+
file: OpenFile;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function MonacoEditorComponent({ file }: MonacoEditorProps) {
|
|
56
|
+
const { updateFileContent, saveFile, scrollToLine, setScrollToLine } = useFileStore();
|
|
57
|
+
const {
|
|
58
|
+
editorFontSize,
|
|
59
|
+
editorTabSize,
|
|
60
|
+
editorWordWrap,
|
|
61
|
+
editorMinimap,
|
|
62
|
+
editorLineNumbers,
|
|
63
|
+
} = useSettingsStore();
|
|
64
|
+
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
|
65
|
+
const monacoRef = useRef<typeof Monaco | null>(null);
|
|
66
|
+
|
|
67
|
+
const handleEditorDidMount = useCallback(
|
|
68
|
+
(editor: editor.IStandaloneCodeEditor, monaco: typeof Monaco) => {
|
|
69
|
+
editorRef.current = editor;
|
|
70
|
+
monacoRef.current = monaco;
|
|
71
|
+
|
|
72
|
+
// Add save command (Cmd/Ctrl + S)
|
|
73
|
+
editor.addCommand(
|
|
74
|
+
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
|
|
75
|
+
() => {
|
|
76
|
+
saveFile(file.path);
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Focus the editor
|
|
81
|
+
editor.focus();
|
|
82
|
+
},
|
|
83
|
+
[file.path, saveFile]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Scroll to specific line when requested
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (scrollToLine && editorRef.current && monacoRef.current) {
|
|
89
|
+
const editor = editorRef.current;
|
|
90
|
+
const monaco = monacoRef.current;
|
|
91
|
+
|
|
92
|
+
// Set cursor position to the line
|
|
93
|
+
editor.setPosition({ lineNumber: scrollToLine, column: 1 });
|
|
94
|
+
|
|
95
|
+
// Reveal the line in the center of the viewport
|
|
96
|
+
editor.revealLineInCenter(scrollToLine);
|
|
97
|
+
|
|
98
|
+
// Add a highlight decoration
|
|
99
|
+
const decorations = editor.deltaDecorations([], [
|
|
100
|
+
{
|
|
101
|
+
range: new monaco.Range(scrollToLine, 1, scrollToLine, 1),
|
|
102
|
+
options: {
|
|
103
|
+
isWholeLine: true,
|
|
104
|
+
className: 'highlight-line-animation',
|
|
105
|
+
linesDecorationsClassName: 'highlight-line-margin',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
// Remove decoration after animation
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
editor.deltaDecorations(decorations, []);
|
|
113
|
+
}, 2000);
|
|
114
|
+
|
|
115
|
+
// Clear the scrollToLine state
|
|
116
|
+
setScrollToLine(null);
|
|
117
|
+
}
|
|
118
|
+
}, [scrollToLine, setScrollToLine]);
|
|
119
|
+
|
|
120
|
+
const handleChange = useCallback(
|
|
121
|
+
(value: string | undefined) => {
|
|
122
|
+
if (value !== undefined) {
|
|
123
|
+
updateFileContent(file.path, value);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[file.path, updateFileContent]
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Focus editor when file changes
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
editorRef.current?.focus();
|
|
132
|
+
}, [file.path]);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Editor
|
|
136
|
+
height="100%"
|
|
137
|
+
language={file.language}
|
|
138
|
+
value={file.content}
|
|
139
|
+
theme="vs-dark"
|
|
140
|
+
onChange={handleChange}
|
|
141
|
+
onMount={handleEditorDidMount}
|
|
142
|
+
options={{
|
|
143
|
+
fontSize: editorFontSize,
|
|
144
|
+
fontFamily: 'JetBrains Mono, Fira Code, Menlo, Monaco, monospace',
|
|
145
|
+
minimap: { enabled: editorMinimap },
|
|
146
|
+
wordWrap: editorWordWrap ? 'on' : 'off',
|
|
147
|
+
lineNumbers: editorLineNumbers ? 'on' : 'off',
|
|
148
|
+
scrollBeyondLastLine: false,
|
|
149
|
+
automaticLayout: true,
|
|
150
|
+
tabSize: editorTabSize,
|
|
151
|
+
insertSpaces: true,
|
|
152
|
+
renderWhitespace: 'selection',
|
|
153
|
+
bracketPairColorization: { enabled: true },
|
|
154
|
+
guides: {
|
|
155
|
+
indentation: true,
|
|
156
|
+
bracketPairs: true,
|
|
157
|
+
},
|
|
158
|
+
padding: { top: 16, bottom: 16 },
|
|
159
|
+
smoothScrolling: true,
|
|
160
|
+
cursorBlinking: 'smooth',
|
|
161
|
+
cursorSmoothCaretAnimation: 'on',
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Memoize to prevent unnecessary re-renders
|
|
168
|
+
// Note: Settings changes will trigger re-render via useSettingsStore hook
|
|
169
|
+
export const MonacoEditor = memo(MonacoEditorComponent, (prevProps, nextProps) => {
|
|
170
|
+
// Only re-render if file path or language changes
|
|
171
|
+
// Content updates are handled internally by Monaco
|
|
172
|
+
// Settings updates are handled via Zustand subscription
|
|
173
|
+
return (
|
|
174
|
+
prevProps.file.path === nextProps.file.path &&
|
|
175
|
+
prevProps.file.language === nextProps.file.language
|
|
176
|
+
);
|
|
177
|
+
});
|