Skip to content

Commit

Permalink
Update files from backend (#423)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
nichochar authored Oct 24, 2024
1 parent e275e85 commit b49bdf4
Show file tree
Hide file tree
Showing 7 changed files with 84 additions and 46 deletions.
7 changes: 7 additions & 0 deletions .changeset/dirty-taxis-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@srcbook/shared': patch
'@srcbook/api': patch
'@srcbook/web': patch
---

The backend now notifies the frontend of file changes -> files update visually in realtime
4 changes: 1 addition & 3 deletions packages/api/ai/plan-parser.mts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,7 @@ export async function parsePlan(
const fileContent = await loadFile(app, filePath);
originalContent = fileContent.source;
} catch (error) {
console.error(`Error reading original file ${filePath}:`, error);
// If the file doesn't exist, we'll leave the original content as null
// If the file doesn't exist, it's likely that it's a new file.
}

plan.actions.push({
Expand All @@ -153,7 +152,6 @@ export async function parsePlan(
}
}

console.log('parsed plan', plan);
return plan;
} catch (error) {
console.error('Error parsing XML:', error);
Expand Down
84 changes: 49 additions & 35 deletions packages/api/apps/disk.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,29 @@ import { DirEntryType, FileEntryType, FileType } from '@srcbook/shared';
import { FileContent } from '../ai/app-parser.mjs';
import type { Plan } from '../ai/plan-parser.mjs';
import archiver from 'archiver';
import { wss } from '../index.mjs';

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

export function broadcastFileUpdated(app: DBAppType, file: FileType) {
wss.broadcast(`app:${app.externalId}`, 'file:updated', { file });
}

// Use this rather than fs.writeFile to ensure we notify the client that the file has been updated.
export async function writeFile(app: DBAppType, file: FileType) {
// Guard against absolute / relative path issues for safety
let path = file.path;
if (!path.startsWith(pathToApp(app.externalId))) {
path = Path.join(pathToApp(app.externalId), file.path);
}
const dirPath = Path.dirname(path);
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path, file.source, 'utf-8');
broadcastFileUpdated(app, file);
}

function pathToTemplate(template: string) {
return Path.resolve(fileURLToPath(import.meta.url), '..', 'templates', template);
}
Expand All @@ -24,14 +42,16 @@ export function deleteViteApp(id: string) {
}

export async function applyPlan(app: DBAppType, plan: Plan) {
const appPath = pathToApp(app.externalId);
try {
for (const item of plan.actions) {
if (item.type === 'file') {
const filePath = Path.join(appPath, item.path);
const dirPath = Path.dirname(filePath);
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(filePath, item.modified);
const basename = Path.basename(item.path);
await writeFile(app, {
path: item.path,
name: basename,
source: item.modified,
binary: isBinary(basename),
});
}
}
} catch (e) {
Expand All @@ -42,27 +62,16 @@ export async function applyPlan(app: DBAppType, plan: Plan) {

export async function createAppFromProject(app: DBAppType, project: Project) {
const appPath = pathToApp(app.externalId);

await fs.mkdir(appPath, { recursive: true });

for (const item of project.items) {
if (item.type === 'file') {
const filePath = Path.join(appPath, item.filename);
const dirPath = Path.dirname(filePath);

// Create nested directories if they don't exist
try {
await fs.stat(dirPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
await fs.mkdir(dirPath, { recursive: true });
} else {
throw error;
}
}

// Write the file content
await fs.writeFile(filePath, item.content);
await writeFile(app, {
path: item.filename,
name: Path.basename(item.filename),
source: item.content,
binary: isBinary(Path.basename(item.filename)),
});
} else if (item.type === 'command') {
// For now, we'll just log the commands
// TODO: execute the commands in the right order.
Expand Down Expand Up @@ -106,7 +115,12 @@ async function scaffold(app: DBAppType, destDir: string) {
const targetPath = Path.join(destDir, file);
return content === undefined
? copy(Path.join(templateDir, file), targetPath)
: fs.writeFile(targetPath, content, 'utf-8');
: writeFile(app, {
path: targetPath,
name: Path.basename(targetPath),
source: content,
binary: isBinary(Path.basename(targetPath)),
});
}

const templateDir = pathToTemplate(template);
Expand Down Expand Up @@ -135,9 +149,8 @@ async function scaffold(app: DBAppType, destDir: string) {
]);
}

export function fileUpdated(app: DBAppType, file: FileType) {
const path = Path.join(pathToApp(app.externalId), file.path);
return fs.writeFile(path, file.source, 'utf-8');
export async function fileUpdated(app: DBAppType, file: FileType) {
return writeFile(app, file);
}

async function copy(src: string, dest: string) {
Expand Down Expand Up @@ -244,15 +257,15 @@ export async function createFile(
basename: string,
source: string,
): Promise<FileEntryType> {
const projectDir = Path.join(APPS_DIR, app.externalId);
const filePath = Path.join(projectDir, dirname, basename);
const filePath = Path.join(dirname, basename);

// Create intermediate directories if they don't exist
await fs.mkdir(Path.dirname(filePath), { recursive: true });

await fs.writeFile(filePath, source, 'utf-8');
const relativePath = Path.relative(projectDir, filePath);
return { ...getPathInfo(relativePath), type: 'file' as const };
await writeFile(app, {
path: filePath,
name: basename,
source,
binary: isBinary(basename),
});
return { ...getPathInfo(filePath), type: 'file' as const };
}

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

if (entry.isDirectory()) {
if (entry.name !== 'node_modules') {
// TODO better ignore list mechanism. Should use a glob
if (!['.git', 'node_modules'].includes(entry.name)) {
files = files.concat(await getFlatFiles(fullPath, relativePath));
}
} else if (entry.isFile() && entry.name !== 'package-lock.json') {
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/schemas/websockets.mts
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,9 @@ export const FileCreatedPayloadSchema = z.object({
file: FileSchema,
});

// Used both from client > server and server > client
export const FileUpdatedPayloadSchema = z.object({
file: FileSchema.partial(),
file: FileSchema,
});

export const FileRenamedPayloadSchema = z.object({
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/clients/websocket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export class SessionChannel extends Channel<

const IncomingAppEvents = {
file: FilePayloadSchema,
'file:updated': FileUpdatedPayloadSchema,
'preview:status': PreviewStatusPayloadSchema,
'preview:log': PreviewLogPayloadSchema,
'deps:install:log': DepsInstallLogPayloadSchema,
Expand Down
29 changes: 23 additions & 6 deletions packages/web/src/components/apps/use-files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import React, {
useState,
} from 'react';

import type { FileType, DirEntryType, FileEntryType } from '@srcbook/shared';
import type {
FileType,
DirEntryType,
FileEntryType,
FileUpdatedPayloadType,
} from '@srcbook/shared';
import { AppChannel } from '@/clients/websocket';
import {
createFile as doCreateFile,
Expand Down Expand Up @@ -36,7 +41,7 @@ export interface FilesContextValue {
openedFile: FileType | null;
openFile: (entry: FileEntryType) => void;
createFile: (dirname: string, basename: string, source?: string) => Promise<FileEntryType>;
updateFile: (file: FileType, attrs: Partial<FileType>) => void;
updateFile: (modified: FileType) => void;
renameFile: (entry: FileEntryType, name: string) => Promise<void>;
deleteFile: (entry: FileEntryType) => Promise<void>;
createFolder: (dirname: string, basename: string) => Promise<void>;
Expand Down Expand Up @@ -90,6 +95,19 @@ export function FilesProvider({
[app.id],
);

// Handle file updates from the server
useEffect(() => {
function onFileUpdated(payload: FileUpdatedPayloadType) {
setOpenedFile(() => payload.file);
forceComponentRerender();
}
channel.on('file:updated', onFileUpdated);

return () => {
channel.off('file:updated', onFileUpdated);
};
}, [channel, setOpenedFile]);

const navigateToFile = useCallback(
(file: { path: string }) => {
navigateTo(`/apps/${app.id}/files/${encodeURIComponent(file.path)}`);
Expand Down Expand Up @@ -122,10 +140,9 @@ export function FilesProvider({
);

const updateFile = useCallback(
(file: FileType, attrs: Partial<FileType>) => {
const updatedFile: FileType = { ...file, ...attrs };
channel.push('file:updated', { file: updatedFile });
setOpenedFile(() => updatedFile);
(modified: FileType) => {
channel.push('file:updated', { file: modified });
setOpenedFile(() => modified);
forceComponentRerender();
},
[channel, setOpenedFile],
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/routes/apps/files-show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function AppFilesShow() {
<CodeEditor
path={openedFile.path}
source={openedFile.source}
onChange={(source) => updateFile(openedFile, { source })}
onChange={(source) => updateFile({ ...openedFile, source })}
/>
)}
</AppLayout>
Expand Down

0 comments on commit b49bdf4

Please sign in to comment.