vzcode 1.42.0 → 1.44.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/dist/index.html CHANGED
@@ -20,8 +20,8 @@
20
20
  href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap"
21
21
  rel="stylesheet"
22
22
  />
23
- <script type="module" crossorigin src="/assets/index-kPLgjbcb.js"></script>
24
- <link rel="stylesheet" crossorigin href="/assets/index-CP9o_hN5.css">
23
+ <script type="module" crossorigin src="/assets/index-C46Hp5ym.js"></script>
24
+ <link rel="stylesheet" crossorigin href="/assets/index-Beqaj63x.css">
25
25
  </head>
26
26
  <body>
27
27
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vzcode",
3
- "version": "1.42.0",
3
+ "version": "1.44.0",
4
4
  "description": "Multiplayer code editor system",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -124,23 +124,23 @@
124
124
  "@replit/codemirror-vscode-keymap": "^6.0.2",
125
125
  "@teamwork/websocket-json-stream": "^2.0.0",
126
126
  "@typescript/vfs": "^1.6.1",
127
- "@uiw/codemirror-theme-abcdef": "^4.24.1",
128
- "@uiw/codemirror-theme-dracula": "^4.24.1",
129
- "@uiw/codemirror-theme-eclipse": "^4.24.1",
130
- "@uiw/codemirror-theme-github": "^4.24.1",
131
- "@uiw/codemirror-theme-material": "^4.24.1",
132
- "@uiw/codemirror-theme-nord": "^4.24.1",
133
- "@uiw/codemirror-theme-okaidia": "^4.24.1",
134
- "@uiw/codemirror-theme-xcode": "^4.24.1",
135
- "@uiw/codemirror-themes": "^4.24.1",
127
+ "@uiw/codemirror-theme-abcdef": "^4.24.2",
128
+ "@uiw/codemirror-theme-dracula": "^4.24.2",
129
+ "@uiw/codemirror-theme-eclipse": "^4.24.2",
130
+ "@uiw/codemirror-theme-github": "^4.24.2",
131
+ "@uiw/codemirror-theme-material": "^4.24.2",
132
+ "@uiw/codemirror-theme-nord": "^4.24.2",
133
+ "@uiw/codemirror-theme-okaidia": "^4.24.2",
134
+ "@uiw/codemirror-theme-xcode": "^4.24.2",
135
+ "@uiw/codemirror-themes": "^4.24.2",
136
136
  "@valtown/codemirror-ts": "^2.3.1",
137
- "@vizhub/runtime": "^4.1.0",
137
+ "@vizhub/runtime": "^4.2.0",
138
138
  "@vizhub/viz-types": "^0.3.0",
139
139
  "@vizhub/viz-utils": "^1.3.0",
140
140
  "body-parser": "^2.2.0",
141
141
  "codemirror": "^6.0.2",
142
142
  "codemirror-copilot": "^0.0.7",
143
- "codemirror-ot": "^4.6.0",
143
+ "codemirror-ot": "^5.0.0",
144
144
  "color-hash": "^2.0.2",
145
145
  "comlink": "^4.4.2",
146
146
  "d3-array": "^3.2.4",
@@ -151,9 +151,9 @@
151
151
  "express": "^5.1.0",
152
152
  "ignore": "^7.0.5",
153
153
  "json0-ot-diff": "^1.1.2",
154
+ "jszip": "^3.10.1",
154
155
  "livekit-server-sdk": "^2.13.1",
155
156
  "llm-code-format": "^3.0.0",
156
- "ngrok": "^5.0.0-beta.2",
157
157
  "npm": "^11.5.1",
158
158
  "open": "^10.2.0",
159
159
  "prettier-plugin-svelte": "^3.4.0",
@@ -187,7 +187,7 @@
187
187
  "vitest": "^3.2.4"
188
188
  },
189
189
  "optionalDependencies": {
190
- "@rollup/rollup-darwin-arm64": "^4.46.1",
191
- "@rollup/rollup-win32-x64-msvc": "^4.46.1"
190
+ "@rollup/rollup-darwin-arm64": "^4.46.2",
191
+ "@rollup/rollup-win32-x64-msvc": "^4.46.2"
192
192
  }
193
193
  }
@@ -0,0 +1,91 @@
1
+ import { useContext } from 'react';
2
+ import { VZCodeContext } from '../VZCodeContext';
3
+ import './style.scss';
4
+
5
+ export const ImageViewer = () => {
6
+ const { activePane, content } = useContext(VZCodeContext);
7
+
8
+ // Get the active file
9
+ const activeFileId = activePane?.activeFileId;
10
+ const activeFile =
11
+ activeFileId && content?.files?.[activeFileId];
12
+
13
+ if (!activeFile) {
14
+ return (
15
+ <div className="image-viewer-error">
16
+ No file selected
17
+ </div>
18
+ );
19
+ }
20
+
21
+ // Check if it's an image file based on extension
22
+ const isImageFile = activeFile.name.match(
23
+ /\.(png|jpg|jpeg|gif|bmp|svg|webp)$/i,
24
+ );
25
+
26
+ if (!isImageFile) {
27
+ return (
28
+ <div className="image-viewer-error">
29
+ Not an image file
30
+ </div>
31
+ );
32
+ }
33
+
34
+ // Get the MIME type from the file extension
35
+ const getImageMimeType = (fileName: string): string => {
36
+ const extension = fileName
37
+ .split('.')
38
+ .pop()
39
+ ?.toLowerCase();
40
+ switch (extension) {
41
+ case 'jpg':
42
+ case 'jpeg':
43
+ return 'image/jpeg';
44
+ case 'png':
45
+ return 'image/png';
46
+ case 'gif':
47
+ return 'image/gif';
48
+ case 'bmp':
49
+ return 'image/bmp';
50
+ case 'svg':
51
+ return 'image/svg+xml';
52
+ case 'webp':
53
+ return 'image/webp';
54
+ default:
55
+ return 'image/png'; // fallback
56
+ }
57
+ };
58
+
59
+ const mimeType = getImageMimeType(activeFile.name);
60
+ const extension = activeFile.name
61
+ .split('.')
62
+ .pop()
63
+ ?.toLowerCase();
64
+
65
+ // For SVG files, the content might already be the SVG text, not base64
66
+ const imageSrc =
67
+ extension === 'svg' &&
68
+ !activeFile.text.startsWith('PHN2Zw') // Check if it's not base64-encoded SVG
69
+ ? `data:${mimeType};utf8,${encodeURIComponent(activeFile.text)}`
70
+ : `data:${mimeType};base64,${activeFile.text}`;
71
+
72
+ return (
73
+ <div className="image-viewer">
74
+ <div className="image-viewer-content">
75
+ <img
76
+ src={imageSrc}
77
+ alt={activeFile.name}
78
+ className="image-viewer-img"
79
+ onError={(e) => {
80
+ console.error(
81
+ 'Failed to load image:',
82
+ activeFile.name,
83
+ );
84
+ (e.target as HTMLImageElement).style.display =
85
+ 'none';
86
+ }}
87
+ />
88
+ </div>
89
+ </div>
90
+ );
91
+ };
@@ -0,0 +1,41 @@
1
+ .image-viewer {
2
+ height: 100%;
3
+ width: 100%;
4
+ display: flex;
5
+ flex-direction: column;
6
+ background-color: var(--bs-body-bg);
7
+ color: var(--bs-body-color);
8
+ }
9
+
10
+ .image-viewer-header {
11
+ padding: 12px 16px;
12
+ border-bottom: 1px solid var(--bs-border-color);
13
+ background-color: var(--bs-tertiary-bg);
14
+ }
15
+
16
+ .image-viewer-content {
17
+ flex: 1;
18
+ padding: 20px;
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: center;
22
+ overflow: auto;
23
+ }
24
+
25
+ .image-viewer-img {
26
+ max-width: 100%;
27
+ max-height: 100%;
28
+ border-radius: 4px;
29
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
30
+ object-fit: contain;
31
+ }
32
+
33
+ .image-viewer-error {
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+ height: 100%;
38
+ color: var(--bs-text-muted);
39
+ font-family: var(--bs-font-monospace);
40
+ font-size: 14px;
41
+ }
@@ -34,6 +34,7 @@ import { useKeyboardShortcuts } from './useKeyboardShortcuts';
34
34
  import { useOpenDirectories } from './useOpenDirectories';
35
35
  import { usePrettier } from './usePrettier';
36
36
  import { useRunCode } from './useRunCode';
37
+ import { useRuntimeError } from './useRuntimeError';
37
38
  import { useURLSync } from './useURLSync';
38
39
  import { createInitialState, vzReducer } from './vzReducer';
39
40
  import { findPane } from './vzReducer/findPane';
@@ -110,6 +111,12 @@ export type VZCodeContextValue = {
110
111
 
111
112
  errorMessage: string | null;
112
113
 
114
+ // Runtime error handling
115
+ handleRuntimeError: (
116
+ formattedErrorMessage: string,
117
+ ) => void;
118
+ clearRuntimeError: () => void;
119
+
113
120
  search: SearchResults;
114
121
  isSearchOpen: boolean;
115
122
  setIsSearchOpen: (isSearchOpen: boolean) => void;
@@ -231,21 +238,32 @@ export const VZCodeProvider = ({
231
238
  prettierWorker,
232
239
  });
233
240
 
241
+ // Handle runtime errors from the iframe
242
+ const {
243
+ runtimeError,
244
+ handleRuntimeError,
245
+ clearRuntimeError,
246
+ } = useRuntimeError();
247
+
234
248
  const runCodeRef = useRunCode(submitOperation);
235
249
 
236
250
  const sidebarRef = useRef(null);
237
251
 
238
252
  const codeEditorRef = useRef(null);
239
253
 
240
- // The error message shows either:
254
+ // The error message shows errors in order of priority:
255
+ // * `runtimeError` - errors from runtime execution, highest priority
241
256
  // * `prettierError` - errors from Prettier, client-side only
242
257
  // * `codeError` - errors from an external source, such as
243
258
  // build-time errors or intercepted runtime errors.
244
- // Since `prettierError` surfaces syntax errors, it's more likely to be
245
- // useful to the user, so we prioritize it.
246
- const errorMessage: string | null = prettierError
247
- ? prettierError
248
- : codeError;
259
+ // Runtime errors are prioritized since they indicate immediate execution issues.
260
+ // Prettier errors are next since they surface syntax errors that are likely
261
+ // to be useful to the user.
262
+ const errorMessage: string | null = runtimeError
263
+ ? runtimeError
264
+ : prettierError
265
+ ? prettierError
266
+ : codeError;
249
267
 
250
268
  // Set up the reducer that manages much of the application state.
251
269
  // See https://react.dev/reference/react/useReducer
@@ -493,6 +511,9 @@ export const VZCodeProvider = ({
493
511
 
494
512
  errorMessage,
495
513
 
514
+ handleRuntimeError,
515
+ clearRuntimeError,
516
+
496
517
  isCreateFileModalOpen,
497
518
  handleOpenCreateFileModal,
498
519
  handleCloseCreateFileModal,
@@ -2,6 +2,7 @@ import { useContext, useEffect, useState } from 'react';
2
2
  import { SplitPaneResizeContext } from './SplitPaneResizeContext';
3
3
  import { TabList } from './TabList';
4
4
  import { CodeEditor } from './CodeEditor';
5
+ import { ImageViewer } from './ImageViewer';
5
6
  import { CodeErrorOverlay } from './CodeErrorOverlay';
6
7
  import { PresenceNotifications } from './PresenceNotifications';
7
8
  import { AIAssistWidget } from './AIAssist/AIAssistWidget';
@@ -12,6 +13,7 @@ import { EditorView } from '@codemirror/view';
12
13
  import { Diagnostic } from '@codemirror/lint';
13
14
  import { VizContent } from '@vizhub/viz-types';
14
15
  import { LeafPane } from '../types';
16
+ import { isImageFile } from './utils/isImageFile';
15
17
 
16
18
  // TODO modify this to handle the SplitPane type
17
19
  // Recursive structure?
@@ -50,13 +52,26 @@ const PaneView = ({
50
52
  activeFileId={pane.activeFileId}
51
53
  tabList={pane.tabList}
52
54
  />
53
- {isClient && content && pane.activeFileId && (
54
- <CodeEditor
55
- customInteractRules={customInteractRules}
56
- aiCopilotEndpoint={aiCopilotEndpoint}
57
- esLintSource={esLintSource}
58
- />
59
- )}
55
+ {isClient &&
56
+ content &&
57
+ pane.activeFileId &&
58
+ (() => {
59
+ // Get the active file to determine if it's an image
60
+ const activeFile =
61
+ content.files[pane.activeFileId];
62
+ const shouldShowImageViewer =
63
+ activeFile && isImageFile(activeFile.name);
64
+
65
+ return shouldShowImageViewer ? (
66
+ <ImageViewer />
67
+ ) : (
68
+ <CodeEditor
69
+ customInteractRules={customInteractRules}
70
+ aiCopilotEndpoint={aiCopilotEndpoint}
71
+ esLintSource={esLintSource}
72
+ />
73
+ );
74
+ })()}
60
75
  {isClient &&
61
76
  enableAIAssist &&
62
77
  content &&
@@ -20,7 +20,8 @@ export const VZRight = () => {
20
20
  const isFirstRunRef = useRef(true);
21
21
 
22
22
  // Get access to the current files.
23
- const { content } = useContext(VZCodeContext);
23
+ const { content, handleRuntimeError, clearRuntimeError } =
24
+ useContext(VZCodeContext);
24
25
 
25
26
  const files = useMemo(
26
27
  () =>
@@ -46,11 +47,15 @@ export const VZRight = () => {
46
47
  console.error('Build error:', error);
47
48
  }
48
49
  },
50
+ handleRuntimeError,
49
51
  });
50
52
  }
51
53
 
52
54
  // Run code in the iframe
53
55
  if (isFirstRunRef.current || isInteracting) {
56
+ // Clear runtime errors when new code runs
57
+ clearRuntimeError();
58
+
54
59
  runtimeRef.current?.run({
55
60
  files,
56
61
  enableHotReloading: true,
@@ -8,12 +8,14 @@ import {
8
8
  } from 'editcodewithai';
9
9
  import { VizFiles } from '@vizhub/viz-types';
10
10
  import { vizFilesToFileCollection } from '@vizhub/viz-utils';
11
+ import JSZip from 'jszip';
11
12
 
12
13
  export const createAICopyPasteHandlers = (
13
14
  files: VizFiles,
14
15
  submitOperation: any,
15
16
  setCopyButtonText: (text: string) => void,
16
17
  setPasteButtonText: (text: string) => void,
18
+ setExportButtonText: (text: string) => void,
17
19
  ) => {
18
20
  // Copy for AI - formats all files and copies to clipboard
19
21
  const handleCopyForAI = async () => {
@@ -124,8 +126,61 @@ export const createAICopyPasteHandlers = (
124
126
  }
125
127
  };
126
128
 
129
+ // Export to ZIP - creates and downloads a ZIP file with all viz files
130
+ const handleExportToZip = async () => {
131
+ if (!files || Object.keys(files).length === 0) {
132
+ setExportButtonText('No files to export');
133
+ setTimeout(
134
+ () => setExportButtonText('Export to ZIP'),
135
+ 2000,
136
+ );
137
+ return;
138
+ }
139
+
140
+ try {
141
+ // Create the ZIP
142
+ const zip = new JSZip();
143
+
144
+ // Add each file to the ZIP
145
+ Object.entries(files).forEach(([fileId, file]) => {
146
+ const fileName = file.name || fileId;
147
+ const fileContent = file.text || '';
148
+ zip.file(fileName, fileContent);
149
+ });
150
+
151
+ // Generate and download
152
+ const blob = await zip.generateAsync({
153
+ type: 'blob',
154
+ });
155
+ const url = URL.createObjectURL(blob);
156
+ const a = document.createElement('a');
157
+ a.href = url;
158
+ a.download = 'viz-files.zip';
159
+ a.click();
160
+ URL.revokeObjectURL(url);
161
+
162
+ // Show success feedback
163
+ setExportButtonText('Exported!');
164
+ setTimeout(
165
+ () => setExportButtonText('Export to ZIP'),
166
+ 2000,
167
+ );
168
+ } catch (error) {
169
+ console.error(
170
+ 'Failed to export files to ZIP:',
171
+ error,
172
+ );
173
+ setExportButtonText('Error');
174
+ setTimeout(
175
+ () => setExportButtonText('Export to ZIP'),
176
+ 2000,
177
+ );
178
+ }
179
+ };
180
+
127
181
  return {
128
182
  handleCopyForAI,
129
183
  handlePasteForAI,
184
+ handleExportToZip,
130
185
  };
131
186
  };
@@ -164,15 +164,22 @@ export const VZSidebar = ({
164
164
  useState('Copy for AI');
165
165
  const [pasteButtonText, setPasteButtonText] =
166
166
  useState('Paste for AI');
167
+ const [exportButtonText, setExportButtonText] =
168
+ useState('Export to ZIP');
167
169
 
168
170
  // Create AI copy/paste handlers
169
- const { handleCopyForAI, handlePasteForAI } = useMemo(
171
+ const {
172
+ handleCopyForAI,
173
+ handlePasteForAI,
174
+ handleExportToZip,
175
+ } = useMemo(
170
176
  () =>
171
177
  createAICopyPasteHandlers(
172
178
  files,
173
179
  submitOperation,
174
180
  setCopyButtonText,
175
181
  setPasteButtonText,
182
+ setExportButtonText,
176
183
  ),
177
184
  [files, submitOperation],
178
185
  );
@@ -548,6 +555,15 @@ export const VZSidebar = ({
548
555
  </div>
549
556
  {!isAIChatOpen && !isSearchOpen && (
550
557
  <div className="ai-buttons">
558
+ {filesExist && (
559
+ <button
560
+ className="ai-button export-button"
561
+ onClick={handleExportToZip}
562
+ title="Export files to ZIP"
563
+ >
564
+ {exportButtonText}
565
+ </button>
566
+ )}
551
567
  {filesExist && (
552
568
  <button
553
569
  className="ai-button copy-button"
@@ -557,6 +573,7 @@ export const VZSidebar = ({
557
573
  {copyButtonText}
558
574
  </button>
559
575
  )}
576
+
560
577
  <button
561
578
  className="ai-button paste-button"
562
579
  onClick={handlePasteForAI}
@@ -335,6 +335,15 @@
335
335
  }
336
336
  }
337
337
 
338
+ &.export-button {
339
+ border-color: var(--vh-color-primary-03);
340
+
341
+ &:hover {
342
+ background: var(--vh-color-hover-dark);
343
+ border-color: var(--vh-color-neutral-04);
344
+ }
345
+ }
346
+
338
347
  &.paste-button {
339
348
  border-color: var(--vh-color-success-03);
340
349
 
@@ -78,6 +78,23 @@ export const useDragAndDrop = () => {
78
78
  return isText;
79
79
  };
80
80
 
81
+ const isImageFile = (file: File): boolean => {
82
+ const imageTypes = ['image/'];
83
+ const isImage =
84
+ imageTypes.some((type) =>
85
+ file.type.startsWith(type),
86
+ ) ||
87
+ file.name.match(
88
+ /\.(png|jpg|jpeg|gif|bmp|svg|webp)$/i,
89
+ ) !== null;
90
+
91
+ DEBUG &&
92
+ console.log(
93
+ `[useDragAndDrop] File ${file.name} is${isImage ? '' : ' not'} an image file`,
94
+ );
95
+ return isImage;
96
+ };
97
+
81
98
  const readFileAsText = (file: File): Promise<string> => {
82
99
  return new Promise((resolve, reject) => {
83
100
  DEBUG &&
@@ -127,6 +144,98 @@ export const useDragAndDrop = () => {
127
144
  });
128
145
  };
129
146
 
147
+ const readImageAsBase64 = (
148
+ file: File,
149
+ ): Promise<string> => {
150
+ return new Promise((resolve, reject) => {
151
+ DEBUG &&
152
+ console.log(
153
+ `[useDragAndDrop] Reading image file: ${file.name}`,
154
+ );
155
+
156
+ if (!isImageFile(file)) {
157
+ DEBUG &&
158
+ console.log(
159
+ `[useDragAndDrop] Rejected non-image file: ${file.name}`,
160
+ );
161
+ reject(
162
+ new Error(
163
+ `File ${file.name} is not an image file`,
164
+ ),
165
+ );
166
+ return;
167
+ }
168
+
169
+ // For SVG files, read as text if they're text-based
170
+ if (
171
+ file.type === 'image/svg+xml' ||
172
+ file.name.toLowerCase().endsWith('.svg')
173
+ ) {
174
+ const textReader = new FileReader();
175
+ textReader.onload = (e) => {
176
+ if (e.target?.result) {
177
+ DEBUG &&
178
+ console.log(
179
+ `[useDragAndDrop] Successfully read SVG file as text: ${file.name}`,
180
+ );
181
+ resolve(e.target.result as string);
182
+ } else {
183
+ reject(
184
+ new Error(
185
+ `Failed to read SVG file ${file.name}`,
186
+ ),
187
+ );
188
+ }
189
+ };
190
+ textReader.onerror = () =>
191
+ reject(
192
+ new Error(
193
+ `Error reading SVG file ${file.name}`,
194
+ ),
195
+ );
196
+ textReader.readAsText(file);
197
+ return;
198
+ }
199
+
200
+ const reader = new FileReader();
201
+ reader.onload = (e) => {
202
+ if (e.target?.result) {
203
+ DEBUG &&
204
+ console.log(
205
+ `[useDragAndDrop] Successfully read image file: ${file.name}`,
206
+ );
207
+ // Return just the base64 part without the data URL prefix
208
+ const base64 = (e.target.result as string).split(
209
+ ',',
210
+ )[1];
211
+ resolve(base64);
212
+ } else {
213
+ DEBUG &&
214
+ console.log(
215
+ `[useDragAndDrop] Failed to read image file: ${file.name}`,
216
+ );
217
+ reject(
218
+ new Error(
219
+ `Failed to read image file ${file.name}`,
220
+ ),
221
+ );
222
+ }
223
+ };
224
+ reader.onerror = () => {
225
+ DEBUG &&
226
+ console.log(
227
+ `[useDragAndDrop] Error reading image file: ${file.name}`,
228
+ );
229
+ reject(
230
+ new Error(
231
+ `Error reading image file ${file.name}`,
232
+ ),
233
+ );
234
+ };
235
+ reader.readAsDataURL(file);
236
+ });
237
+ };
238
+
130
239
  const processEntry = async (
131
240
  entry: FileSystemEntry,
132
241
  path: string,
@@ -144,12 +253,21 @@ export const useDragAndDrop = () => {
144
253
  );
145
254
  entry.file(async (file) => {
146
255
  try {
147
- const content = await readFileAsText(file);
148
- DEBUG &&
149
- console.log(
150
- `[useDragAndDrop] Creating file: ${path}${file.name}`,
151
- );
152
- createFile(`${path}${file.name}`, content);
256
+ if (isImageFile(file)) {
257
+ const content = await readImageAsBase64(file);
258
+ DEBUG &&
259
+ console.log(
260
+ `[useDragAndDrop] Creating image file: ${path}${file.name}`,
261
+ );
262
+ createFile(`${path}${file.name}`, content);
263
+ } else {
264
+ const content = await readFileAsText(file);
265
+ DEBUG &&
266
+ console.log(
267
+ `[useDragAndDrop] Creating text file: ${path}${file.name}`,
268
+ );
269
+ createFile(`${path}${file.name}`, content);
270
+ }
153
271
  } catch (error) {
154
272
  console.error(
155
273
  `Error processing file ${file.name}:`,
@@ -237,8 +355,15 @@ export const useDragAndDrop = () => {
237
355
  `[useDragAndDrop] Processing dropped file: ${file.name}`,
238
356
  );
239
357
  try {
240
- const content = await readFileAsText(file);
241
- createFile(file.name, content);
358
+ if (isImageFile(file)) {
359
+ const content =
360
+ await readImageAsBase64(file);
361
+ createFile(file.name, content);
362
+ } else {
363
+ const content =
364
+ await readFileAsText(file);
365
+ createFile(file.name, content);
366
+ }
242
367
  } catch (error) {
243
368
  console.error(
244
369
  `Error processing file ${file.name}:`,