Skip to content

Commit b49bdf4

Browse files
authored
Update files from backend (#423)
* Make FileUpdatedPayloadSchema non-partial, allowing it to be used on both server and client * Notify frontend when files are changed. Frontend updates them in memory * ADd changeset * Fix lint issue with react deps array
1 parent e275e85 commit b49bdf4

File tree

7 files changed

+84
-46
lines changed

7 files changed

+84
-46
lines changed

.changeset/dirty-taxis-repair.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@srcbook/shared': patch
3+
'@srcbook/api': patch
4+
'@srcbook/web': patch
5+
---
6+
7+
The backend now notifies the frontend of file changes -> files update visually in realtime

packages/api/ai/plan-parser.mts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,7 @@ export async function parsePlan(
126126
const fileContent = await loadFile(app, filePath);
127127
originalContent = fileContent.source;
128128
} catch (error) {
129-
console.error(`Error reading original file ${filePath}:`, error);
130-
// If the file doesn't exist, we'll leave the original content as null
129+
// If the file doesn't exist, it's likely that it's a new file.
131130
}
132131

133132
plan.actions.push({
@@ -153,7 +152,6 @@ export async function parsePlan(
153152
}
154153
}
155154

156-
console.log('parsed plan', plan);
157155
return plan;
158156
} catch (error) {
159157
console.error('Error parsing XML:', error);

packages/api/apps/disk.mts

Lines changed: 49 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,29 @@ import { DirEntryType, FileEntryType, FileType } from '@srcbook/shared';
1010
import { FileContent } from '../ai/app-parser.mjs';
1111
import type { Plan } from '../ai/plan-parser.mjs';
1212
import archiver from 'archiver';
13+
import { wss } from '../index.mjs';
1314

1415
export function pathToApp(id: string) {
1516
return Path.join(APPS_DIR, id);
1617
}
1718

19+
export function broadcastFileUpdated(app: DBAppType, file: FileType) {
20+
wss.broadcast(`app:${app.externalId}`, 'file:updated', { file });
21+
}
22+
23+
// Use this rather than fs.writeFile to ensure we notify the client that the file has been updated.
24+
export async function writeFile(app: DBAppType, file: FileType) {
25+
// Guard against absolute / relative path issues for safety
26+
let path = file.path;
27+
if (!path.startsWith(pathToApp(app.externalId))) {
28+
path = Path.join(pathToApp(app.externalId), file.path);
29+
}
30+
const dirPath = Path.dirname(path);
31+
await fs.mkdir(dirPath, { recursive: true });
32+
await fs.writeFile(path, file.source, 'utf-8');
33+
broadcastFileUpdated(app, file);
34+
}
35+
1836
function pathToTemplate(template: string) {
1937
return Path.resolve(fileURLToPath(import.meta.url), '..', 'templates', template);
2038
}
@@ -24,14 +42,16 @@ export function deleteViteApp(id: string) {
2442
}
2543

2644
export async function applyPlan(app: DBAppType, plan: Plan) {
27-
const appPath = pathToApp(app.externalId);
2845
try {
2946
for (const item of plan.actions) {
3047
if (item.type === 'file') {
31-
const filePath = Path.join(appPath, item.path);
32-
const dirPath = Path.dirname(filePath);
33-
await fs.mkdir(dirPath, { recursive: true });
34-
await fs.writeFile(filePath, item.modified);
48+
const basename = Path.basename(item.path);
49+
await writeFile(app, {
50+
path: item.path,
51+
name: basename,
52+
source: item.modified,
53+
binary: isBinary(basename),
54+
});
3555
}
3656
}
3757
} catch (e) {
@@ -42,27 +62,16 @@ export async function applyPlan(app: DBAppType, plan: Plan) {
4262

4363
export async function createAppFromProject(app: DBAppType, project: Project) {
4464
const appPath = pathToApp(app.externalId);
45-
4665
await fs.mkdir(appPath, { recursive: true });
4766

4867
for (const item of project.items) {
4968
if (item.type === 'file') {
50-
const filePath = Path.join(appPath, item.filename);
51-
const dirPath = Path.dirname(filePath);
52-
53-
// Create nested directories if they don't exist
54-
try {
55-
await fs.stat(dirPath);
56-
} catch (error) {
57-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
58-
await fs.mkdir(dirPath, { recursive: true });
59-
} else {
60-
throw error;
61-
}
62-
}
63-
64-
// Write the file content
65-
await fs.writeFile(filePath, item.content);
69+
await writeFile(app, {
70+
path: item.filename,
71+
name: Path.basename(item.filename),
72+
source: item.content,
73+
binary: isBinary(Path.basename(item.filename)),
74+
});
6675
} else if (item.type === 'command') {
6776
// For now, we'll just log the commands
6877
// TODO: execute the commands in the right order.
@@ -106,7 +115,12 @@ async function scaffold(app: DBAppType, destDir: string) {
106115
const targetPath = Path.join(destDir, file);
107116
return content === undefined
108117
? copy(Path.join(templateDir, file), targetPath)
109-
: fs.writeFile(targetPath, content, 'utf-8');
118+
: writeFile(app, {
119+
path: targetPath,
120+
name: Path.basename(targetPath),
121+
source: content,
122+
binary: isBinary(Path.basename(targetPath)),
123+
});
110124
}
111125

112126
const templateDir = pathToTemplate(template);
@@ -135,9 +149,8 @@ async function scaffold(app: DBAppType, destDir: string) {
135149
]);
136150
}
137151

138-
export function fileUpdated(app: DBAppType, file: FileType) {
139-
const path = Path.join(pathToApp(app.externalId), file.path);
140-
return fs.writeFile(path, file.source, 'utf-8');
152+
export async function fileUpdated(app: DBAppType, file: FileType) {
153+
return writeFile(app, file);
141154
}
142155

143156
async function copy(src: string, dest: string) {
@@ -244,15 +257,15 @@ export async function createFile(
244257
basename: string,
245258
source: string,
246259
): Promise<FileEntryType> {
247-
const projectDir = Path.join(APPS_DIR, app.externalId);
248-
const filePath = Path.join(projectDir, dirname, basename);
260+
const filePath = Path.join(dirname, basename);
249261

250-
// Create intermediate directories if they don't exist
251-
await fs.mkdir(Path.dirname(filePath), { recursive: true });
252-
253-
await fs.writeFile(filePath, source, 'utf-8');
254-
const relativePath = Path.relative(projectDir, filePath);
255-
return { ...getPathInfo(relativePath), type: 'file' as const };
262+
await writeFile(app, {
263+
path: filePath,
264+
name: basename,
265+
source,
266+
binary: isBinary(basename),
267+
});
268+
return { ...getPathInfo(filePath), type: 'file' as const };
256269
}
257270

258271
export function deleteFile(app: DBAppType, path: string) {
@@ -336,7 +349,8 @@ async function getFlatFiles(dir: string, basePath: string = ''): Promise<FileCon
336349
const fullPath = Path.join(dir, entry.name);
337350

338351
if (entry.isDirectory()) {
339-
if (entry.name !== 'node_modules') {
352+
// TODO better ignore list mechanism. Should use a glob
353+
if (!['.git', 'node_modules'].includes(entry.name)) {
340354
files = files.concat(await getFlatFiles(fullPath, relativePath));
341355
}
342356
} else if (entry.isFile() && entry.name !== 'package-lock.json') {

packages/shared/src/schemas/websockets.mts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,9 @@ export const FileCreatedPayloadSchema = z.object({
150150
file: FileSchema,
151151
});
152152

153+
// Used both from client > server and server > client
153154
export const FileUpdatedPayloadSchema = z.object({
154-
file: FileSchema.partial(),
155+
file: FileSchema,
155156
});
156157

157158
export const FileRenamedPayloadSchema = z.object({

packages/web/src/clients/websocket/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export class SessionChannel extends Channel<
9999

100100
const IncomingAppEvents = {
101101
file: FilePayloadSchema,
102+
'file:updated': FileUpdatedPayloadSchema,
102103
'preview:status': PreviewStatusPayloadSchema,
103104
'preview:log': PreviewLogPayloadSchema,
104105
'deps:install:log': DepsInstallLogPayloadSchema,

packages/web/src/components/apps/use-files.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import React, {
88
useState,
99
} from 'react';
1010

11-
import type { FileType, DirEntryType, FileEntryType } from '@srcbook/shared';
11+
import type {
12+
FileType,
13+
DirEntryType,
14+
FileEntryType,
15+
FileUpdatedPayloadType,
16+
} from '@srcbook/shared';
1217
import { AppChannel } from '@/clients/websocket';
1318
import {
1419
createFile as doCreateFile,
@@ -36,7 +41,7 @@ export interface FilesContextValue {
3641
openedFile: FileType | null;
3742
openFile: (entry: FileEntryType) => void;
3843
createFile: (dirname: string, basename: string, source?: string) => Promise<FileEntryType>;
39-
updateFile: (file: FileType, attrs: Partial<FileType>) => void;
44+
updateFile: (modified: FileType) => void;
4045
renameFile: (entry: FileEntryType, name: string) => Promise<void>;
4146
deleteFile: (entry: FileEntryType) => Promise<void>;
4247
createFolder: (dirname: string, basename: string) => Promise<void>;
@@ -90,6 +95,19 @@ export function FilesProvider({
9095
[app.id],
9196
);
9297

98+
// Handle file updates from the server
99+
useEffect(() => {
100+
function onFileUpdated(payload: FileUpdatedPayloadType) {
101+
setOpenedFile(() => payload.file);
102+
forceComponentRerender();
103+
}
104+
channel.on('file:updated', onFileUpdated);
105+
106+
return () => {
107+
channel.off('file:updated', onFileUpdated);
108+
};
109+
}, [channel, setOpenedFile]);
110+
93111
const navigateToFile = useCallback(
94112
(file: { path: string }) => {
95113
navigateTo(`/apps/${app.id}/files/${encodeURIComponent(file.path)}`);
@@ -122,10 +140,9 @@ export function FilesProvider({
122140
);
123141

124142
const updateFile = useCallback(
125-
(file: FileType, attrs: Partial<FileType>) => {
126-
const updatedFile: FileType = { ...file, ...attrs };
127-
channel.push('file:updated', { file: updatedFile });
128-
setOpenedFile(() => updatedFile);
143+
(modified: FileType) => {
144+
channel.push('file:updated', { file: modified });
145+
setOpenedFile(() => modified);
129146
forceComponentRerender();
130147
},
131148
[channel, setOpenedFile],

packages/web/src/routes/apps/files-show.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default function AppFilesShow() {
1313
<CodeEditor
1414
path={openedFile.path}
1515
source={openedFile.source}
16-
onChange={(source) => updateFile(openedFile, { source })}
16+
onChange={(source) => updateFile({ ...openedFile, source })}
1717
/>
1818
)}
1919
</AppLayout>

0 commit comments

Comments
 (0)