@abhinav2203/codeflow-canvas 0.1.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.
Files changed (134) hide show
  1. package/abhinav2203-codeflow-canvas-0.1.0.tgz +0 -0
  2. package/dist/bin/cli.d.ts +3 -0
  3. package/dist/bin/cli.d.ts.map +1 -0
  4. package/dist/bin/cli.js +84 -0
  5. package/dist/bin/cli.js.map +1 -0
  6. package/dist/components/blueprint-workbench.d.ts +2 -0
  7. package/dist/components/blueprint-workbench.d.ts.map +1 -0
  8. package/dist/components/blueprint-workbench.js +144 -0
  9. package/dist/components/blueprint-workbench.js.map +1 -0
  10. package/dist/components/code-diff-editor.d.ts +12 -0
  11. package/dist/components/code-diff-editor.d.ts.map +1 -0
  12. package/dist/components/code-diff-editor.js +39 -0
  13. package/dist/components/code-diff-editor.js.map +1 -0
  14. package/dist/components/code-editor.d.ts +25 -0
  15. package/dist/components/code-editor.d.ts.map +1 -0
  16. package/dist/components/code-editor.js +264 -0
  17. package/dist/components/code-editor.js.map +1 -0
  18. package/dist/components/file-tabs.d.ts +5 -0
  19. package/dist/components/file-tabs.d.ts.map +1 -0
  20. package/dist/components/file-tabs.js +164 -0
  21. package/dist/components/file-tabs.js.map +1 -0
  22. package/dist/components/file-tree.d.ts +7 -0
  23. package/dist/components/file-tree.d.ts.map +1 -0
  24. package/dist/components/file-tree.js +176 -0
  25. package/dist/components/file-tree.js.map +1 -0
  26. package/dist/components/graph-canvas.d.ts +25 -0
  27. package/dist/components/graph-canvas.d.ts.map +1 -0
  28. package/dist/components/graph-canvas.js +224 -0
  29. package/dist/components/graph-canvas.js.map +1 -0
  30. package/dist/components/ide-layout.d.ts +10 -0
  31. package/dist/components/ide-layout.d.ts.map +1 -0
  32. package/dist/components/ide-layout.js +40 -0
  33. package/dist/components/ide-layout.js.map +1 -0
  34. package/dist/components/ide-workbench.d.ts +4 -0
  35. package/dist/components/ide-workbench.d.ts.map +1 -0
  36. package/dist/components/ide-workbench.js +6 -0
  37. package/dist/components/ide-workbench.js.map +1 -0
  38. package/dist/components/index.d.ts +13 -0
  39. package/dist/components/index.d.ts.map +1 -0
  40. package/dist/components/index.js +13 -0
  41. package/dist/components/index.js.map +1 -0
  42. package/dist/components/monaco-setup.d.ts +4 -0
  43. package/dist/components/monaco-setup.d.ts.map +1 -0
  44. package/dist/components/monaco-setup.js +34 -0
  45. package/dist/components/monaco-setup.js.map +1 -0
  46. package/dist/components/opencode-settings.d.ts +8 -0
  47. package/dist/components/opencode-settings.d.ts.map +1 -0
  48. package/dist/components/opencode-settings.js +33 -0
  49. package/dist/components/opencode-settings.js.map +1 -0
  50. package/dist/components/policy-workbench.d.ts +2 -0
  51. package/dist/components/policy-workbench.d.ts.map +1 -0
  52. package/dist/components/policy-workbench.js +102 -0
  53. package/dist/components/policy-workbench.js.map +1 -0
  54. package/dist/components/ts-language-service.d.ts +14 -0
  55. package/dist/components/ts-language-service.d.ts.map +1 -0
  56. package/dist/components/ts-language-service.js +123 -0
  57. package/dist/components/ts-language-service.js.map +1 -0
  58. package/dist/index.d.ts +23 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +22 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/lib/browser/storage.d.ts +16 -0
  63. package/dist/lib/browser/storage.d.ts.map +1 -0
  64. package/dist/lib/browser/storage.js +18 -0
  65. package/dist/lib/browser/storage.js.map +1 -0
  66. package/dist/lib/edit.d.ts +14 -0
  67. package/dist/lib/edit.d.ts.map +1 -0
  68. package/dist/lib/edit.js +57 -0
  69. package/dist/lib/edit.js.map +1 -0
  70. package/dist/lib/flow-view.d.ts +80 -0
  71. package/dist/lib/flow-view.d.ts.map +1 -0
  72. package/dist/lib/flow-view.js +850 -0
  73. package/dist/lib/flow-view.js.map +1 -0
  74. package/dist/lib/heatmap.d.ts +28 -0
  75. package/dist/lib/heatmap.d.ts.map +1 -0
  76. package/dist/lib/heatmap.js +61 -0
  77. package/dist/lib/heatmap.js.map +1 -0
  78. package/dist/lib/index.d.ts +9 -0
  79. package/dist/lib/index.d.ts.map +1 -0
  80. package/dist/lib/index.js +6 -0
  81. package/dist/lib/index.js.map +1 -0
  82. package/dist/lib/node-navigation.d.ts +36 -0
  83. package/dist/lib/node-navigation.d.ts.map +1 -0
  84. package/dist/lib/node-navigation.js +52 -0
  85. package/dist/lib/node-navigation.js.map +1 -0
  86. package/dist/lib/traces.d.ts +3 -0
  87. package/dist/lib/traces.d.ts.map +1 -0
  88. package/dist/lib/traces.js +64 -0
  89. package/dist/lib/traces.js.map +1 -0
  90. package/dist/lib/types.d.ts +57 -0
  91. package/dist/lib/types.d.ts.map +1 -0
  92. package/dist/lib/types.js +7 -0
  93. package/dist/lib/types.js.map +1 -0
  94. package/dist/store/blueprint-store.d.ts +35 -0
  95. package/dist/store/blueprint-store.d.ts.map +1 -0
  96. package/dist/store/blueprint-store.js +79 -0
  97. package/dist/store/blueprint-store.js.map +1 -0
  98. package/dist/store/index.d.ts +3 -0
  99. package/dist/store/index.d.ts.map +1 -0
  100. package/dist/store/index.js +2 -0
  101. package/dist/store/index.js.map +1 -0
  102. package/package.json +52 -0
  103. package/scripts/wrap-cli.mjs +15 -0
  104. package/src/bin/cli.ts +128 -0
  105. package/src/components/blueprint-workbench.tsx +305 -0
  106. package/src/components/code-diff-editor.tsx +80 -0
  107. package/src/components/code-editor.tsx +389 -0
  108. package/src/components/file-tabs.tsx +288 -0
  109. package/src/components/file-tree.tsx +301 -0
  110. package/src/components/graph-canvas.tsx +404 -0
  111. package/src/components/ide-layout.tsx +104 -0
  112. package/src/components/ide-workbench.tsx +5 -0
  113. package/src/components/index.ts +12 -0
  114. package/src/components/monaco-setup.ts +67 -0
  115. package/src/components/opencode-settings.tsx +82 -0
  116. package/src/components/policy-workbench.tsx +233 -0
  117. package/src/components/ts-language-service.ts +170 -0
  118. package/src/index.ts +54 -0
  119. package/src/lib/browser/storage.ts +19 -0
  120. package/src/lib/edit.ts +74 -0
  121. package/src/lib/flow-view.ts +1176 -0
  122. package/src/lib/heatmap.ts +103 -0
  123. package/src/lib/index.ts +41 -0
  124. package/src/lib/node-navigation.ts +76 -0
  125. package/src/lib/traces.ts +79 -0
  126. package/src/lib/types.ts +79 -0
  127. package/src/store/blueprint-store.ts +136 -0
  128. package/src/store/index.ts +2 -0
  129. package/test-fixtures/minimal-blueprint.json +34 -0
  130. package/test-fixtures/sample-blueprint.json +184 -0
  131. package/tsconfig.build.json +9 -0
  132. package/tsconfig.json +22 -0
  133. package/tsconfig.tsbuildinfo +1 -0
  134. package/vitest.config.ts +9 -0
@@ -0,0 +1,389 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef } from "react";
4
+
5
+ import dynamic from "next/dynamic";
6
+ import type * as Monaco from "monaco-editor";
7
+
8
+ import type { BlueprintGraph } from "@abhinav2203/codeflow-core/schema";
9
+ import type { NavigationTarget } from "../lib/node-navigation.js";
10
+ import { prepareMonaco, toMonacoPath } from "./monaco-setup.js";
11
+ import { getTypeScriptLanguageService } from "./ts-language-service.js";
12
+
13
+ const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
14
+ ssr: false,
15
+ loading: () => <div className="code-editor-loading">Loading editor...</div>
16
+ });
17
+
18
+ type CodeEditorProps = {
19
+ path: string;
20
+ value: string;
21
+ onChange: (value: string) => void;
22
+ language?: "typescript" | "javascript" | "json" | "markdown";
23
+ height?: string;
24
+ ariaLabel?: string;
25
+ readOnly?: boolean;
26
+ theme?: "light" | "dark";
27
+ onSave?: () => void | Promise<void>;
28
+ revealTarget?: NavigationTarget | null;
29
+ completionContext?: {
30
+ enabled: boolean;
31
+ graph: BlueprintGraph;
32
+ nodeId: string;
33
+ nvidiaApiKey?: string;
34
+ retrievalQuery?: string;
35
+ retrievalDepth?: number;
36
+ };
37
+ };
38
+
39
+ type CompletionResponse = {
40
+ suggestions: Array<{
41
+ label: string;
42
+ insertText: string;
43
+ detail?: string;
44
+ documentation?: string;
45
+ kind?: string;
46
+ }>;
47
+ };
48
+
49
+ const COMPLETION_TTL_MS = 15_000;
50
+ const COMPLETION_DEBOUNCE_MS = 220;
51
+
52
+ const toCompletionKind = (
53
+ monaco: typeof Monaco,
54
+ kind?: string
55
+ ): Monaco.languages.CompletionItemKind => {
56
+ switch (kind) {
57
+ case "method":
58
+ return monaco.languages.CompletionItemKind.Method;
59
+ case "function":
60
+ return monaco.languages.CompletionItemKind.Function;
61
+ case "constructor":
62
+ return monaco.languages.CompletionItemKind.Constructor;
63
+ case "field":
64
+ return monaco.languages.CompletionItemKind.Field;
65
+ case "variable":
66
+ return monaco.languages.CompletionItemKind.Variable;
67
+ case "class":
68
+ return monaco.languages.CompletionItemKind.Class;
69
+ case "interface":
70
+ return monaco.languages.CompletionItemKind.Interface;
71
+ case "module":
72
+ return monaco.languages.CompletionItemKind.Module;
73
+ case "property":
74
+ return monaco.languages.CompletionItemKind.Property;
75
+ case "unit":
76
+ return monaco.languages.CompletionItemKind.Unit;
77
+ case "value":
78
+ return monaco.languages.CompletionItemKind.Value;
79
+ case "enum":
80
+ return monaco.languages.CompletionItemKind.Enum;
81
+ case "keyword":
82
+ return monaco.languages.CompletionItemKind.Keyword;
83
+ case "snippet":
84
+ return monaco.languages.CompletionItemKind.Snippet;
85
+ case "color":
86
+ return monaco.languages.CompletionItemKind.Color;
87
+ case "file":
88
+ return monaco.languages.CompletionItemKind.File;
89
+ case "reference":
90
+ return monaco.languages.CompletionItemKind.Reference;
91
+ default:
92
+ return monaco.languages.CompletionItemKind.Text;
93
+ }
94
+ };
95
+
96
+ export function CodeEditor({
97
+ path,
98
+ value,
99
+ onChange,
100
+ language = "typescript",
101
+ height = "28rem",
102
+ ariaLabel,
103
+ readOnly = false,
104
+ theme = "dark",
105
+ onSave,
106
+ revealTarget,
107
+ completionContext
108
+ }: CodeEditorProps) {
109
+ const monacoRef = useRef<typeof Monaco | null>(null);
110
+ const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
111
+ const decorationIdsRef = useRef<string[]>([]);
112
+ const completionContextRef = useRef(completionContext);
113
+ const providerRef = useRef<Monaco.IDisposable | null>(null);
114
+ const cacheRef = useRef(
115
+ new Map<string, { createdAt: number; suggestions: Monaco.languages.CompletionItem[] }>()
116
+ );
117
+ const inflightRef = useRef(new Map<string, Promise<Monaco.languages.CompletionItem[]>>());
118
+ const debounceRef = useRef<{
119
+ timer: number | null;
120
+ resolve: ((ready: boolean) => void) | null;
121
+ }>({
122
+ timer: null,
123
+ resolve: null
124
+ });
125
+
126
+ const waitForDebounce = () =>
127
+ new Promise<boolean>((resolve) => {
128
+ if (debounceRef.current.timer) {
129
+ window.clearTimeout(debounceRef.current.timer);
130
+ debounceRef.current.resolve?.(false);
131
+ }
132
+
133
+ debounceRef.current.resolve = resolve;
134
+ debounceRef.current.timer = window.setTimeout(() => {
135
+ debounceRef.current.timer = null;
136
+ debounceRef.current.resolve = null;
137
+ resolve(true);
138
+ }, COMPLETION_DEBOUNCE_MS);
139
+ });
140
+
141
+ const registerCompletionProvider = useCallback(
142
+ (monaco: typeof Monaco) => {
143
+ providerRef.current?.dispose();
144
+
145
+ if (readOnly || (language !== "typescript" && language !== "javascript")) {
146
+ return;
147
+ }
148
+
149
+ providerRef.current = monaco.languages.registerCompletionItemProvider(language, {
150
+ triggerCharacters: [".", "("],
151
+ provideCompletionItems: async (model, position, context) => {
152
+ const activeContext = completionContextRef.current;
153
+ if (!activeContext?.enabled) {
154
+ return { suggestions: [] };
155
+ }
156
+
157
+ if (
158
+ context.triggerKind === monaco.languages.CompletionTriggerKind.TriggerCharacter &&
159
+ ![".", "("].includes(context.triggerCharacter ?? "")
160
+ ) {
161
+ return { suggestions: [] };
162
+ }
163
+
164
+ const word = model.getWordUntilPosition(position);
165
+ const lineContent = model.getLineContent(position.lineNumber);
166
+ const linePrefix = lineContent.slice(0, position.column - 1);
167
+ const lineSuffix = lineContent.slice(position.column - 1);
168
+ const currentCode = model.getValue();
169
+ const cursorOffset = model.getOffsetAt(position);
170
+ const recentPrefix = currentCode.slice(Math.max(0, cursorOffset - 180), cursorOffset);
171
+
172
+ if (
173
+ context.triggerKind !== monaco.languages.CompletionTriggerKind.TriggerCharacter &&
174
+ recentPrefix.trim().length < 3
175
+ ) {
176
+ return { suggestions: [] };
177
+ }
178
+
179
+ const cacheKey = JSON.stringify([
180
+ activeContext.nodeId,
181
+ activeContext.retrievalQuery ?? "",
182
+ activeContext.retrievalDepth ?? 0,
183
+ context.triggerCharacter ?? "manual",
184
+ recentPrefix
185
+ ]);
186
+ const cached = cacheRef.current.get(cacheKey);
187
+
188
+ if (cached && Date.now() - cached.createdAt < COMPLETION_TTL_MS) {
189
+ return { suggestions: cached.suggestions };
190
+ }
191
+
192
+ const inflight = inflightRef.current.get(cacheKey);
193
+ if (inflight) {
194
+ return { suggestions: await inflight };
195
+ }
196
+
197
+ const shouldContinue = await waitForDebounce();
198
+ if (!shouldContinue) {
199
+ return { suggestions: [] };
200
+ }
201
+
202
+ const completionPromise = (async () => {
203
+ try {
204
+ const response = await fetch("/api/code-completions", {
205
+ method: "POST",
206
+ headers: {
207
+ "content-type": "application/json"
208
+ },
209
+ body: JSON.stringify({
210
+ graph: activeContext.graph,
211
+ nodeId: activeContext.nodeId,
212
+ currentCode,
213
+ cursorOffset,
214
+ linePrefix,
215
+ lineSuffix,
216
+ triggerCharacter: context.triggerCharacter ?? undefined,
217
+ retrievalQuery: activeContext.retrievalQuery,
218
+ retrievalDepth: activeContext.retrievalDepth,
219
+ nvidiaApiKey: activeContext.nvidiaApiKey
220
+ })
221
+ });
222
+
223
+ if (!response.ok) {
224
+ return [];
225
+ }
226
+
227
+ const body = (await response.json()) as CompletionResponse;
228
+ const range = new monaco.Range(
229
+ position.lineNumber,
230
+ position.column - word.word.length,
231
+ position.lineNumber,
232
+ position.column
233
+ );
234
+
235
+ return body.suggestions.map((suggestion) => ({
236
+ detail: suggestion.detail,
237
+ documentation: suggestion.documentation,
238
+ insertText: suggestion.insertText,
239
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
240
+ kind: toCompletionKind(monaco, suggestion.kind),
241
+ label: suggestion.label,
242
+ range
243
+ }));
244
+ } catch {
245
+ return [];
246
+ }
247
+ })();
248
+
249
+ inflightRef.current.set(cacheKey, completionPromise);
250
+
251
+ try {
252
+ const suggestions = await completionPromise;
253
+ cacheRef.current.set(cacheKey, {
254
+ createdAt: Date.now(),
255
+ suggestions
256
+ });
257
+ return { suggestions };
258
+ } finally {
259
+ inflightRef.current.delete(cacheKey);
260
+ }
261
+ }
262
+ });
263
+ },
264
+ [language, readOnly]
265
+ );
266
+
267
+ useEffect(() => {
268
+ completionContextRef.current = completionContext;
269
+ }, [completionContext]);
270
+
271
+ useEffect(() => {
272
+ if (monacoRef.current) {
273
+ registerCompletionProvider(monacoRef.current);
274
+ }
275
+ }, [language, readOnly, registerCompletionProvider]);
276
+
277
+ useEffect(() => {
278
+ if (!monacoRef.current || (language !== "typescript" && language !== "javascript")) {
279
+ return;
280
+ }
281
+
282
+ getTypeScriptLanguageService(monacoRef.current).upsertWorkspaceFile(path, value);
283
+ }, [language, path, value]);
284
+
285
+ useEffect(() => {
286
+ if (!editorRef.current || !monacoRef.current || !revealTarget) {
287
+ return;
288
+ }
289
+
290
+ const monaco = monacoRef.current;
291
+ const editor = editorRef.current;
292
+ const startColumn = Math.max(1, revealTarget.columnStart ?? 1);
293
+ const endLineNumber = Math.max(revealTarget.endLineNumber ?? revealTarget.lineNumber, revealTarget.lineNumber);
294
+ const endColumn = Math.max(
295
+ revealTarget.columnEnd ?? (endLineNumber === revealTarget.lineNumber ? startColumn + 1 : 1),
296
+ 1
297
+ );
298
+ const range = new monaco.Range(
299
+ revealTarget.lineNumber,
300
+ startColumn,
301
+ endLineNumber,
302
+ endColumn
303
+ );
304
+
305
+ editor.revealRangeInCenter(range);
306
+ editor.setSelection(range);
307
+ decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, [
308
+ {
309
+ range,
310
+ options: {
311
+ className: "code-editor-highlight",
312
+ inlineClassName: "code-editor-highlight-inline",
313
+ isWholeLine: revealTarget.lineNumber === endLineNumber && startColumn === 1
314
+ }
315
+ }
316
+ ]);
317
+ }, [revealTarget]);
318
+
319
+ useEffect(() => {
320
+ const debounceState = debounceRef.current;
321
+
322
+ return () => {
323
+ providerRef.current?.dispose();
324
+ if (editorRef.current) {
325
+ decorationIdsRef.current = editorRef.current.deltaDecorations(decorationIdsRef.current, []);
326
+ }
327
+
328
+ if (debounceState.timer) {
329
+ window.clearTimeout(debounceState.timer);
330
+ }
331
+ debounceState.resolve?.(false);
332
+ };
333
+ }, []);
334
+
335
+ return (
336
+ <div
337
+ className="code-editor-shell"
338
+ style={{
339
+ height,
340
+ minHeight: height === "100%" ? 0 : height
341
+ }}
342
+ >
343
+ <MonacoEditor
344
+ beforeMount={prepareMonaco}
345
+ height={height}
346
+ language={language}
347
+ onMount={(editor, monaco) => {
348
+ monacoRef.current = monaco;
349
+ editorRef.current = editor;
350
+ getTypeScriptLanguageService(monaco).upsertWorkspaceFile(path, value);
351
+ registerCompletionProvider(monaco);
352
+ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
353
+ void onSave?.();
354
+ });
355
+ }}
356
+ onChange={(nextValue) => onChange(nextValue ?? "")}
357
+ options={{
358
+ automaticLayout: true,
359
+ ariaLabel: ariaLabel ?? path,
360
+ fontFamily: "IBM Plex Mono, SFMono-Regular, SF Mono, monospace",
361
+ fontLigatures: true,
362
+ fontSize: 14,
363
+ lineNumbersMinChars: 3,
364
+ minimap: { enabled: false },
365
+ padding: { top: 16, bottom: 16 },
366
+ readOnly,
367
+ scrollBeyondLastLine: false,
368
+ smoothScrolling: true,
369
+ tabSize: 2,
370
+ wordWrap: "on"
371
+ }}
372
+ path={toMonacoPath(path)}
373
+ theme={theme === "dark" ? "vs-dark" : "vs-light"}
374
+ value={value}
375
+ />
376
+ <style dangerouslySetInnerHTML={{ __html: `
377
+ .code-editor-shell .code-editor-highlight {
378
+ background: rgba(96, 165, 250, 0.18);
379
+ border-left: 2px solid rgba(125, 211, 252, 0.8);
380
+ }
381
+
382
+ .code-editor-shell .code-editor-highlight-inline {
383
+ background: rgba(96, 165, 250, 0.18);
384
+ border-radius: 3px;
385
+ }
386
+ ` }} />
387
+ </div>
388
+ );
389
+ }
@@ -0,0 +1,288 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+
5
+ import { CodeEditor } from "./code-editor.js";
6
+ import type { NavigationTarget } from "../lib/node-navigation.js";
7
+ import { useBlueprintStore } from "../store/blueprint-store.js";
8
+
9
+ const LANGUAGE_MAP: Record<string, "typescript" | "javascript" | "json" | "markdown"> = {
10
+ ".ts": "typescript",
11
+ ".tsx": "typescript",
12
+ ".js": "javascript",
13
+ ".jsx": "javascript",
14
+ ".json": "json",
15
+ ".md": "markdown"
16
+ };
17
+
18
+ type FileRecord = Record<string, string>;
19
+
20
+ function getLanguageFromPath(filePath: string): "typescript" | "javascript" | "json" | "markdown" {
21
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
22
+ return LANGUAGE_MAP[ext] ?? "typescript";
23
+ }
24
+
25
+ function getFileName(filePath: string): string {
26
+ const parts = filePath.split(/[\\/]/);
27
+ return parts[parts.length - 1] ?? filePath;
28
+ }
29
+
30
+ function getFileBadge(filePath: string): string {
31
+ const extension = getFileName(filePath).split(".").pop()?.toLowerCase();
32
+ switch (extension) {
33
+ case "ts":
34
+ return "TS";
35
+ case "tsx":
36
+ return "TSX";
37
+ case "js":
38
+ return "JS";
39
+ case "jsx":
40
+ return "JSX";
41
+ case "json":
42
+ return "{}";
43
+ case "md":
44
+ return "MD";
45
+ default:
46
+ return "FILE";
47
+ }
48
+ }
49
+
50
+ function createRepoHeaders(repoPath: string | null): Record<string, string> {
51
+ const headers: Record<string, string> = { "content-type": "application/json" };
52
+ if (repoPath) {
53
+ headers["x-codeflow-repo-path"] = repoPath;
54
+ }
55
+
56
+ return headers;
57
+ }
58
+
59
+ export function FileTabs({ revealTarget }: { revealTarget?: NavigationTarget | null }) {
60
+ const {
61
+ activeFile,
62
+ clearFileDirty,
63
+ closeFile,
64
+ dirtyFiles,
65
+ openFiles,
66
+ repoPath,
67
+ setActiveFile,
68
+ setFileDirty
69
+ } = useBlueprintStore();
70
+ const [fileContents, setFileContents] = useState<FileRecord>({});
71
+ const [savedContents, setSavedContents] = useState<FileRecord>({});
72
+ const [loadingFiles, setLoadingFiles] = useState<Record<string, boolean>>({});
73
+ const [savingFiles, setSavingFiles] = useState<Record<string, boolean>>({});
74
+ const [loadError, setLoadError] = useState<string | null>(null);
75
+ const [saveError, setSaveError] = useState<string | null>(null);
76
+
77
+ const activeFileContent = activeFile ? fileContents[activeFile] : undefined;
78
+ const activeIsDirty = activeFile ? Boolean(dirtyFiles[activeFile]) : false;
79
+ const activeIsSaving = activeFile ? Boolean(savingFiles[activeFile]) : false;
80
+
81
+ const fetchFileContent = useCallback(
82
+ async (path: string) => {
83
+ setLoadingFiles((current) => ({ ...current, [path]: true }));
84
+ setLoadError(null);
85
+
86
+ try {
87
+ const response = await fetch(`/api/files/get?path=${encodeURIComponent(path)}`, {
88
+ headers: createRepoHeaders(repoPath)
89
+ });
90
+ if (!response.ok) {
91
+ const body = (await response.json().catch(() => null)) as { error?: string } | null;
92
+ throw new Error(body?.error ?? `Failed to load ${path}`);
93
+ }
94
+
95
+ const contentType = response.headers.get("content-type") ?? "";
96
+ const content =
97
+ contentType.includes("application/json")
98
+ ? ((await response.json()) as { content?: string }).content ?? ""
99
+ : await response.text();
100
+
101
+ setFileContents((current) => ({ ...current, [path]: content }));
102
+ setSavedContents((current) => ({ ...current, [path]: content }));
103
+ clearFileDirty(path);
104
+ } catch (error) {
105
+ const message = error instanceof Error ? error.message : `Failed to load ${path}`;
106
+ setLoadError(message);
107
+ } finally {
108
+ setLoadingFiles((current) => ({ ...current, [path]: false }));
109
+ }
110
+ },
111
+ [clearFileDirty, repoPath]
112
+ );
113
+
114
+ useEffect(() => {
115
+ if (!activeFile || activeFile in fileContents) {
116
+ return;
117
+ }
118
+
119
+ void fetchFileContent(activeFile);
120
+ }, [activeFile, fetchFileContent, fileContents]);
121
+
122
+ useEffect(() => {
123
+ setFileContents((current) => {
124
+ const nextEntries = Object.fromEntries(
125
+ Object.entries(current).filter(([path]) => openFiles.includes(path))
126
+ );
127
+
128
+ return Object.keys(nextEntries).length === Object.keys(current).length ? current : nextEntries;
129
+ });
130
+
131
+ setSavedContents((current) => {
132
+ const nextEntries = Object.fromEntries(
133
+ Object.entries(current).filter(([path]) => openFiles.includes(path))
134
+ );
135
+
136
+ return Object.keys(nextEntries).length === Object.keys(current).length ? current : nextEntries;
137
+ });
138
+ }, [openFiles]);
139
+
140
+ const handleCloseFile = useCallback(
141
+ (path: string, event: React.MouseEvent<HTMLButtonElement>) => {
142
+ event.stopPropagation();
143
+ closeFile(path);
144
+ },
145
+ [closeFile]
146
+ );
147
+
148
+ const handleContentChange = useCallback(
149
+ (path: string, value: string) => {
150
+ setFileContents((current) => ({ ...current, [path]: value }));
151
+ setFileDirty(path, value !== (savedContents[path] ?? ""));
152
+ },
153
+ [savedContents, setFileDirty]
154
+ );
155
+
156
+ const handleSave = useCallback(
157
+ async (path: string) => {
158
+ const content = fileContents[path];
159
+ if (content === undefined) {
160
+ return;
161
+ }
162
+
163
+ setSavingFiles((current) => ({ ...current, [path]: true }));
164
+ setSaveError(null);
165
+
166
+ try {
167
+ const response = await fetch("/api/files/post", {
168
+ method: "POST",
169
+ headers: createRepoHeaders(repoPath),
170
+ body: JSON.stringify({ path, content })
171
+ });
172
+ if (!response.ok) {
173
+ const body = (await response.json().catch(() => null)) as { error?: string } | null;
174
+ throw new Error(body?.error ?? `Failed to save ${path}`);
175
+ }
176
+
177
+ setSavedContents((current) => ({ ...current, [path]: content }));
178
+ clearFileDirty(path);
179
+ } catch (error) {
180
+ setSaveError(error instanceof Error ? error.message : `Failed to save ${path}`);
181
+ } finally {
182
+ setSavingFiles((current) => ({ ...current, [path]: false }));
183
+ }
184
+ },
185
+ [clearFileDirty, fileContents, repoPath]
186
+ );
187
+
188
+ const statusMessage = useMemo(() => {
189
+ if (activeFile && loadingFiles[activeFile]) {
190
+ return `Loading ${getFileName(activeFile)}...`;
191
+ }
192
+ if (loadError) {
193
+ return loadError;
194
+ }
195
+ if (saveError) {
196
+ return saveError;
197
+ }
198
+ if (!activeFile) {
199
+ return "Select a file from the explorer to begin editing.";
200
+ }
201
+ if (activeIsSaving) {
202
+ return `Saving ${getFileName(activeFile)}...`;
203
+ }
204
+ if (activeIsDirty) {
205
+ return `${getFileName(activeFile)} has unsaved changes.`;
206
+ }
207
+
208
+ return `${getFileName(activeFile)} is synced with the repo.`;
209
+ }, [activeFile, activeIsDirty, activeIsSaving, loadError, loadingFiles, saveError]);
210
+
211
+ return (
212
+ <div className="file-tabs-container">
213
+ <div className="tab-bar" role="tablist">
214
+ {openFiles.length === 0 ? (
215
+ <div className="no-tabs">No files open</div>
216
+ ) : (
217
+ openFiles.map((path) => {
218
+ const isActive = path === activeFile;
219
+ const isDirty = Boolean(dirtyFiles[path]);
220
+
221
+ return (
222
+ <div key={path} className={`tab ${isActive ? "active" : ""}`}>
223
+ <button
224
+ aria-selected={isActive}
225
+ className="tab-content"
226
+ onClick={() => setActiveFile(path)}
227
+ role="tab"
228
+ type="button"
229
+ >
230
+ <span className="tab-icon" aria-hidden="true">{getFileBadge(path)}</span>
231
+ <span className="tab-name">{getFileName(path)}</span>
232
+ {isDirty ? <span className="tab-dirty" aria-label="Unsaved changes">●</span> : null}
233
+ </button>
234
+ <button
235
+ aria-label={`Close ${getFileName(path)}`}
236
+ className="tab-close"
237
+ onClick={(event) => handleCloseFile(path, event)}
238
+ type="button"
239
+ >
240
+ ×
241
+ </button>
242
+ </div>
243
+ );
244
+ })
245
+ )}
246
+ </div>
247
+
248
+ <div className="editor-toolbar">
249
+ <p className={`editor-status ${loadError || saveError ? "is-error" : ""}`}>{statusMessage}</p>
250
+ {activeFile ? (
251
+ <button
252
+ className="editor-save-button"
253
+ disabled={activeIsSaving || !activeIsDirty}
254
+ onClick={() => void handleSave(activeFile)}
255
+ type="button"
256
+ >
257
+ {activeIsSaving ? "Saving..." : activeIsDirty ? "Save" : "Saved"}
258
+ </button>
259
+ ) : null}
260
+ </div>
261
+
262
+ <div className="editor-content">
263
+ {activeFile ? (
264
+ activeFileContent !== undefined ? (
265
+ <CodeEditor
266
+ ariaLabel="Code editor"
267
+ height="100%"
268
+ language={getLanguageFromPath(activeFile)}
269
+ onChange={(value) => handleContentChange(activeFile, value)}
270
+ onSave={() => handleSave(activeFile)}
271
+ path={activeFile}
272
+ revealTarget={revealTarget?.filePath === activeFile ? revealTarget : null}
273
+ value={activeFileContent}
274
+ />
275
+ ) : (
276
+ <div className="empty-editor">
277
+ <p>{loadingFiles[activeFile] ? `Loading ${getFileName(activeFile)}...` : "Preparing editor..."}</p>
278
+ </div>
279
+ )
280
+ ) : (
281
+ <div className="empty-editor">
282
+ <p>Select a file from the explorer to open Monaco in the main area.</p>
283
+ </div>
284
+ )}
285
+ </div>
286
+ </div>
287
+ );
288
+ }