Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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