From 9e43fb976e01e44f9e1de1a50dea91d669252989 Mon Sep 17 00:00:00 2001 From: Nicholas Charriere Date: Mon, 6 Apr 2026 14:13:06 -0700 Subject: [PATCH] Remove all app-builder stuff --- README.md | 28 +- packages/api/ai/app-parser.mts | 103 --- packages/api/ai/generate.mts | 84 +-- packages/api/ai/logger.mts | 31 - packages/api/ai/plan-parser.mts | 284 -------- packages/api/apps/app.mts | 132 ---- packages/api/apps/disk.mts | 396 ----------- packages/api/apps/git.mts | 132 ---- packages/api/apps/processes.mts | 165 ----- packages/api/apps/schemas.mts | 14 - .../templates/react-typescript/index.html | 15 - .../templates/react-typescript/package.json | 28 - .../react-typescript/postcss.config.js | 6 - .../templates/react-typescript/src/App.tsx | 12 - .../templates/react-typescript/src/index.css | 7 - .../templates/react-typescript/src/main.tsx | 10 - .../react-typescript/src/vite-env.d.ts | 1 - .../react-typescript/tailwind.config.js | 11 - .../templates/react-typescript/tsconfig.json | 24 - .../templates/react-typescript/vite.config.ts | 7 - packages/api/apps/utils.mts | 9 - packages/api/config.mts | 29 +- packages/api/db/schema.mts | 17 - packages/api/drizzle/0017_drop_apps.sql | 1 + packages/api/drizzle/meta/0017_snapshot.json | 220 ++++++ packages/api/drizzle/meta/_journal.json | 7 + packages/api/exec.mts | 15 - packages/api/package.json | 6 +- packages/api/prompts/app-builder.txt | 89 --- packages/api/prompts/app-editor.txt | 87 --- packages/api/server/channels/app.mts | 247 ------- packages/api/server/http.mts | 442 +----------- packages/api/server/ws.mts | 4 - packages/api/test/app-parser.test.mts | 44 -- packages/api/test/plan-parser.test.mts | 136 ---- packages/components/package.json | 1 + packages/shared/index.mts | 4 - packages/shared/package.json | 1 + packages/shared/src/schemas/apps.mts | 8 - packages/shared/src/schemas/files.mts | 0 packages/shared/src/schemas/websockets.mts | 67 -- packages/shared/src/types/apps.mts | 36 - packages/shared/src/types/feedback.mts | 4 - packages/shared/src/types/history.mts | 74 -- packages/shared/src/types/websockets.mts | 33 - packages/web/package.json | 6 - packages/web/src/clients/http/apps.ts | 346 --------- packages/web/src/clients/websocket/index.ts | 52 -- .../src/components/apps/AiFeedbackModal.tsx | 60 -- .../web/src/components/apps/bottom-drawer.tsx | 137 ---- .../web/src/components/apps/create-modal.tsx | 153 ---- .../web/src/components/apps/diff-modal.tsx | 95 --- .../web/src/components/apps/diff-stats.tsx | 30 - packages/web/src/components/apps/editor.tsx | 95 --- packages/web/src/components/apps/header.tsx | 300 -------- packages/web/src/components/apps/lib/diff.ts | 87 --- .../web/src/components/apps/lib/file-tree.ts | 236 ------- packages/web/src/components/apps/lib/path.ts | 7 - .../web/src/components/apps/local-storage.ts | 15 - packages/web/src/components/apps/markdown.tsx | 10 - .../components/apps/package-install-toast.tsx | 109 --- .../src/components/apps/panels/explorer.tsx | 384 ---------- .../src/components/apps/panels/settings.tsx | 65 -- packages/web/src/components/apps/sidebar.tsx | 191 ----- packages/web/src/components/apps/types.ts | 21 - packages/web/src/components/apps/use-app.tsx | 52 -- .../web/src/components/apps/use-files.tsx | 284 -------- packages/web/src/components/apps/use-logs.tsx | 114 --- .../src/components/apps/use-package-json.tsx | 146 ---- .../web/src/components/apps/use-preview.tsx | 91 --- .../web/src/components/apps/use-version.tsx | 82 --- packages/web/src/components/chat.tsx | 662 ------------------ .../web/src/components/delete-app-dialog.tsx | 63 -- .../components/keyboard-shortcuts-dialog.tsx | 6 +- packages/web/src/components/onboarding.tsx | 51 -- packages/web/src/components/srcbook-cards.tsx | 56 +- packages/web/src/main.tsx | 44 -- packages/web/src/routes/apps/context.tsx | 46 -- packages/web/src/routes/apps/files-show.tsx | 21 - packages/web/src/routes/apps/files.tsx | 26 - packages/web/src/routes/apps/layout.tsx | 69 -- packages/web/src/routes/apps/loaders.tsx | 24 - packages/web/src/routes/apps/preview.tsx | 72 -- packages/web/src/routes/home.tsx | 76 +- packages/web/src/routes/session.tsx | 3 +- pnpm-lock.yaml | 322 +-------- 86 files changed, 259 insertions(+), 7521 deletions(-) delete mode 100644 packages/api/ai/app-parser.mts delete mode 100644 packages/api/ai/logger.mts delete mode 100644 packages/api/ai/plan-parser.mts delete mode 100644 packages/api/apps/app.mts delete mode 100644 packages/api/apps/disk.mts delete mode 100644 packages/api/apps/git.mts delete mode 100644 packages/api/apps/processes.mts delete mode 100644 packages/api/apps/schemas.mts delete mode 100644 packages/api/apps/templates/react-typescript/index.html delete mode 100644 packages/api/apps/templates/react-typescript/package.json delete mode 100644 packages/api/apps/templates/react-typescript/postcss.config.js delete mode 100644 packages/api/apps/templates/react-typescript/src/App.tsx delete mode 100644 packages/api/apps/templates/react-typescript/src/index.css delete mode 100644 packages/api/apps/templates/react-typescript/src/main.tsx delete mode 100644 packages/api/apps/templates/react-typescript/src/vite-env.d.ts delete mode 100644 packages/api/apps/templates/react-typescript/tailwind.config.js delete mode 100644 packages/api/apps/templates/react-typescript/tsconfig.json delete mode 100644 packages/api/apps/templates/react-typescript/vite.config.ts delete mode 100644 packages/api/apps/utils.mts create mode 100644 packages/api/drizzle/0017_drop_apps.sql create mode 100644 packages/api/drizzle/meta/0017_snapshot.json delete mode 100644 packages/api/prompts/app-builder.txt delete mode 100644 packages/api/prompts/app-editor.txt delete mode 100644 packages/api/server/channels/app.mts delete mode 100644 packages/api/test/app-parser.test.mts delete mode 100644 packages/api/test/plan-parser.test.mts delete mode 100644 packages/shared/src/schemas/apps.mts delete mode 100644 packages/shared/src/schemas/files.mts delete mode 100644 packages/shared/src/types/apps.mts delete mode 100644 packages/shared/src/types/feedback.mts delete mode 100644 packages/shared/src/types/history.mts delete mode 100644 packages/web/src/clients/http/apps.ts delete mode 100644 packages/web/src/components/apps/AiFeedbackModal.tsx delete mode 100644 packages/web/src/components/apps/bottom-drawer.tsx delete mode 100644 packages/web/src/components/apps/create-modal.tsx delete mode 100644 packages/web/src/components/apps/diff-modal.tsx delete mode 100644 packages/web/src/components/apps/diff-stats.tsx delete mode 100644 packages/web/src/components/apps/editor.tsx delete mode 100644 packages/web/src/components/apps/header.tsx delete mode 100644 packages/web/src/components/apps/lib/diff.ts delete mode 100644 packages/web/src/components/apps/lib/file-tree.ts delete mode 100644 packages/web/src/components/apps/lib/path.ts delete mode 100644 packages/web/src/components/apps/local-storage.ts delete mode 100644 packages/web/src/components/apps/markdown.tsx delete mode 100644 packages/web/src/components/apps/package-install-toast.tsx delete mode 100644 packages/web/src/components/apps/panels/explorer.tsx delete mode 100644 packages/web/src/components/apps/panels/settings.tsx delete mode 100644 packages/web/src/components/apps/sidebar.tsx delete mode 100644 packages/web/src/components/apps/types.ts delete mode 100644 packages/web/src/components/apps/use-app.tsx delete mode 100644 packages/web/src/components/apps/use-files.tsx delete mode 100644 packages/web/src/components/apps/use-logs.tsx delete mode 100644 packages/web/src/components/apps/use-package-json.tsx delete mode 100644 packages/web/src/components/apps/use-preview.tsx delete mode 100644 packages/web/src/components/apps/use-version.tsx delete mode 100644 packages/web/src/components/chat.tsx delete mode 100644 packages/web/src/components/delete-app-dialog.tsx delete mode 100644 packages/web/src/components/onboarding.tsx delete mode 100644 packages/web/src/routes/apps/context.tsx delete mode 100644 packages/web/src/routes/apps/files-show.tsx delete mode 100644 packages/web/src/routes/apps/files.tsx delete mode 100644 packages/web/src/routes/apps/layout.tsx delete mode 100644 packages/web/src/routes/apps/loaders.tsx delete mode 100644 packages/web/src/routes/apps/preview.tsx diff --git a/README.md b/README.md index 6d92126fd..0c9906af0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Online app builder · + Examples · Discord · Youtube · Hub @@ -18,36 +18,16 @@ ## Maintainers Note -Srcbook is currently not under active development. After overwhelming demand of making it a managed service, we have built and are operating https://getmocha.com for this purpose! - -In the future, we might revive Srcbook for its notebook product (over the app builder one, which is better served by Mocha). +Srcbook is not under active development. ## Srcbook -Srcbook is a TypeScript-centric app development platform, with 2 main products: - -- an AI app builder (also available [hosted online](https://srcbook.com/)) -- a TypeScript notebook +Srcbook is a TypeScript notebook that runs locally on your machine. Srcbook is open-source (apache2) and runs locally on your machine. You'll need to bring your own API key for AI usage (we strongly recommend Anthropic with `claude-3-5-sonnet-latest`). ## Features -### App Builder - -- AI app builder for TypeScript -- Create, edit and run web apps -- Use AI to generate the boilerplate, modify the code, and fix things -- Edit the app with a hot-reloading web preview - - - - - Example Srcbook - - -### Notebooks - - Create, run, and share TypeScript notebooks - Export to valid markdown format (.src.md) - AI features for exploring and iterating on ideas @@ -120,7 +100,7 @@ Options: Commands: start [options] Start the Srcbook server - import [options] Import a Notebook + import [options] Import a notebook help [command] display help for command ``` diff --git a/packages/api/ai/app-parser.mts b/packages/api/ai/app-parser.mts deleted file mode 100644 index 215f6cf4e..000000000 --- a/packages/api/ai/app-parser.mts +++ /dev/null @@ -1,103 +0,0 @@ -import { XMLParser } from 'fast-xml-parser'; - -// TODO reuse and cleanup types -export interface FileContent { - filename: string; - content: string; -} - -export type Project = { - id: string; - items: (File | Command)[]; -}; - -type File = { - type: 'file'; - filename: string; - content: string; -}; - -type Command = { - type: 'command'; - content: string; -}; - -type ParsedResult = { - project: { - '@_id': string; - file?: { '@_filename': string; '#text': string }[] | { '@_filename': string; '#text': string }; - command?: string[] | string; - }; -}; - -export function parseProjectXML(response: string): Project { - try { - const parser = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: '@_', - textNodeName: '#text', - }); - const result = parser.parse(response) as ParsedResult; - - if (!result.project) { - throw new Error('Invalid response: missing project tag'); - } - - const project: Project = { - id: result.project['@_id'], - items: [], - }; - - const files = Array.isArray(result.project.file) - ? result.project.file - : [result.project.file].filter(Boolean); - const commands = Array.isArray(result.project.command) - ? result.project.command - : [result.project.command].filter(Boolean); - - // TODO this ruins the order as it makes all the file changes first. - // @FIXME: later - for (const file of files) { - if (file) { - project.items.push({ - type: 'file', - filename: file['@_filename'], - content: file['#text'], - }); - } - } - - for (const command of commands) { - if (command) { - project.items.push({ - type: 'command', - content: command, - }); - } - } - - return project; - } catch (error) { - console.error('Error parsing XML for the app:', error); - throw new Error('Failed to parse XML response'); - } -} - -export function buildProjectXml(files: FileContent[], projectId: string): string { - const fileXmls = files - .map( - (file) => ` - - - `, - ) - .join('\n'); - - return ` - -${fileXmls} - - `.trim(); -} diff --git a/packages/api/ai/generate.mts b/packages/api/ai/generate.mts index 13245e148..e0a3c0113 100644 --- a/packages/api/ai/generate.mts +++ b/packages/api/ai/generate.mts @@ -1,4 +1,4 @@ -import { streamText, generateText, type GenerateTextResult } from 'ai'; +import { generateText, type GenerateTextResult } from 'ai'; import { getModel } from './config.mjs'; import { type CodeLanguageType, @@ -12,8 +12,6 @@ import { readFileSync } from 'node:fs'; import Path from 'node:path'; import { PROMPTS_DIR } from '../constants.mjs'; import { encode, decodeCells } from '../srcmd.mjs'; -import { buildProjectXml, type FileContent } from '../ai/app-parser.mjs'; -import { logAppGeneration } from './logger.mjs'; const makeGenerateSrcbookSystemPrompt = () => { return readFileSync(Path.join(PROMPTS_DIR, 'srcbook-generator.txt'), 'utf-8'); @@ -26,34 +24,6 @@ const makeGenerateCellSystemPrompt = (language: CodeLanguageType) => { const makeFixDiagnosticsSystemPrompt = () => { return readFileSync(Path.join(PROMPTS_DIR, 'fix-cell-diagnostics.txt'), 'utf-8'); }; -const makeAppBuilderSystemPrompt = () => { - return readFileSync(Path.join(PROMPTS_DIR, 'app-builder.txt'), 'utf-8'); -}; -const makeAppEditorSystemPrompt = () => { - return readFileSync(Path.join(PROMPTS_DIR, 'app-editor.txt'), 'utf-8'); -}; - -const makeAppEditorUserPrompt = (projectId: string, files: FileContent[], query: string) => { - const projectXml = buildProjectXml(files, projectId); - const userRequestXml = `${query}`; - return `Following below are the project XML and the user request. - -${projectXml} - -${userRequestXml} - `.trim(); -}; - -const makeAppCreateUserPrompt = (projectId: string, files: FileContent[], query: string) => { - const projectXml = buildProjectXml(files, projectId); - const userRequestXml = `${query}`; - return `Following below are the project XML and the user request. - -${projectXml} - -${userRequestXml} - `.trim(); -}; const makeGenerateCellUserPrompt = (session: SessionType, insertIdx: number, query: string) => { // Make sure we copy cells so we don't mutate the session @@ -242,55 +212,3 @@ export async function fixDiagnostics( return result.text; } - -export async function generateApp( - projectId: string, - files: FileContent[], - query: string, -): Promise { - const model = await getModel(); - const result = await generateText({ - model, - system: makeAppBuilderSystemPrompt(), - prompt: makeAppCreateUserPrompt(projectId, files, query), - }); - return result.text; -} - -export async function streamEditApp( - projectId: string, - files: FileContent[], - query: string, - appId: string, - planId: string, -) { - const model = await getModel(); - - const systemPrompt = makeAppEditorSystemPrompt(); - const userPrompt = makeAppEditorUserPrompt(projectId, files, query); - - let response = ''; - - const result = await streamText({ - model, - system: systemPrompt, - prompt: userPrompt, - onChunk: (chunk) => { - if (chunk.chunk.type === 'text-delta') { - response += chunk.chunk.textDelta; - } - }, - onFinish: () => { - if (process.env.SRCBOOK_DISABLE_ANALYTICS !== 'true') { - logAppGeneration({ - appId, - planId, - llm_request: { model, system: systemPrompt, prompt: userPrompt }, - llm_response: response, - }); - } - }, - }); - - return result.textStream; -} diff --git a/packages/api/ai/logger.mts b/packages/api/ai/logger.mts deleted file mode 100644 index 47cb5fc80..000000000 --- a/packages/api/ai/logger.mts +++ /dev/null @@ -1,31 +0,0 @@ -export type AppGenerationLog = { - appId: string; - planId: string; - llm_request: any; - llm_response: any; -}; - -/* - * Log the LLM request / response to the analytics server. - * For now this server is a custom implemention, consider moving to - * a formal LLM log service, or a generic log hosting service. - * In particular, this will not scale well when we split up app generation into - * multiple steps. We will need spans/traces at that point. - */ -export async function logAppGeneration(log: AppGenerationLog): Promise { - try { - const response = await fetch('https://hub.srcbook.com/api/app_generation_log', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(log), - }); - - if (!response.ok) { - console.error('Error sending app generation log'); - } - } catch (error) { - console.error('Error sending app generation log:', error); - } -} diff --git a/packages/api/ai/plan-parser.mts b/packages/api/ai/plan-parser.mts deleted file mode 100644 index ac00e33f8..000000000 --- a/packages/api/ai/plan-parser.mts +++ /dev/null @@ -1,284 +0,0 @@ -import { XMLParser } from 'fast-xml-parser'; -import Path from 'node:path'; -import { type App as DBAppType } from '../db/schema.mjs'; -import { loadFile } from '../apps/disk.mjs'; -import { StreamingXMLParser, TagType } from './stream-xml-parser.mjs'; -import { ActionChunkType, DescriptionChunkType } from '@srcbook/shared'; - -// The ai proposes a plan that we expect to contain both files and commands -// Here is an example of a plan: - -/* - * Example of a plan: - * - * - * - * {Short justification of changes. Be as brief as possible, like a commit message} - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * npm install - * react-redux - * react-router-dom - * - * ... - * - */ - -interface FileAction { - type: 'file'; - dirname: string; - basename: string; - path: string; - modified: string; - original: string | null; // null if this is a new file. Consider using an enum for 'edit' | 'create' | 'delete' instead. - description: string; -} - -type NpmInstallCommand = { - type: 'command'; - command: 'npm install'; - packages: string[]; - description: string; -}; - -// Later we can add more commands. For now, we only support npm install -type Command = NpmInstallCommand; - -export interface Plan { - // The high level description of the plan - // Will be shown to the user above the diff box. - id: string; - query: string; - description: string; - actions: (FileAction | Command)[]; -} - -interface ParsedResult { - plan: { - planDescription: string; - action: - | { - '@_type': string; - description: string; - file?: { '@_filename': string; '#text': string }; - commandType?: string; - package?: string | string[]; - }[] - | { - '@_type': string; - description: string; - file?: { '@_filename': string; '#text': string }; - commandType?: string; - package?: string | string[]; - }; - }; -} - -export async function parsePlan( - response: string, - app: DBAppType, - query: string, - planId: string, -): Promise { - try { - const parser = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: '@_', - textNodeName: '#text', - }); - const result = parser.parse(response) as ParsedResult; - - if (!result.plan) { - throw new Error('Invalid response: missing plan tag'); - } - - const plan: Plan = { - id: planId, - query, - actions: [], - description: result.plan.planDescription, - }; - const actions = Array.isArray(result.plan.action) ? result.plan.action : [result.plan.action]; - - for (const action of actions) { - if (action['@_type'] === 'file' && action.file) { - const filePath = action.file['@_filename']; - let originalContent = null; - - try { - const fileContent = await loadFile(app, filePath); - originalContent = fileContent.source; - } catch (error) { - // If the file doesn't exist, it's likely that it's a new file. - } - - plan.actions.push({ - type: 'file', - path: filePath, - dirname: Path.dirname(filePath), - basename: Path.basename(filePath), - modified: action.file['#text'], - original: originalContent, - description: action.description, - }); - } else if (action['@_type'] === 'command' && action.commandType === 'npm install') { - if (!action.package) { - console.error('Invalid response: missing package tag'); - continue; - } - plan.actions.push({ - type: 'command', - command: 'npm install', - packages: Array.isArray(action.package) ? action.package : [action.package], - description: action.description, - }); - } - } - - return plan; - } catch (error) { - console.error('Error parsing XML:', error); - throw new Error('Failed to parse XML response'); - } -} - -export function getPackagesToInstall(plan: Plan): string[] { - return plan.actions - .filter( - (action): action is NpmInstallCommand => - action.type === 'command' && action.command === 'npm install', - ) - .flatMap((action) => action.packages); -} -export async function streamParsePlan( - stream: AsyncIterable, - app: DBAppType, - _query: string, - planId: string, -) { - let parser: StreamingXMLParser; - const parsePromises: Promise[] = []; - - return new ReadableStream({ - async pull(controller) { - if (parser === undefined) { - parser = new StreamingXMLParser({ - async onTag(tag) { - if (tag.name === 'planDescription' || tag.name === 'action') { - const promise = (async () => { - const chunk = await toStreamingChunk(app, tag, planId); - if (chunk) { - controller.enqueue(JSON.stringify(chunk) + '\n'); - } - })(); - parsePromises.push(promise); - } - }, - }); - } - - try { - for await (const chunk of stream) { - parser.parse(chunk); - } - // Wait for all pending parse operations to complete before closing - await Promise.all(parsePromises); - controller.close(); - } catch (error) { - console.error(error); - controller.enqueue( - JSON.stringify({ - type: 'error', - data: { content: 'Error while parsing streaming response' }, - }) + '\n', - ); - controller.error(error); - } - }, - }); -} - -async function toStreamingChunk( - app: DBAppType, - tag: TagType, - planId: string, -): Promise { - switch (tag.name) { - case 'planDescription': - return { - type: 'description', - planId: planId, - data: { content: tag.content }, - } as DescriptionChunkType; - case 'action': { - const descriptionTag = tag.children.find((t) => t.name === 'description'); - const description = descriptionTag?.content ?? ''; - const type = tag.attributes.type; - - if (type === 'file') { - const fileTag = tag.children.find((t) => t.name === 'file')!; - - const filePath = fileTag.attributes.filename as string; - let originalContent = null; - - try { - const fileContent = await loadFile(app, filePath); - originalContent = fileContent.source; - } catch (error) { - // If the file doesn't exist, it's likely that it's a new file. - } - - return { - type: 'action', - planId: planId, - data: { - type: 'file', - description, - path: filePath, - dirname: Path.dirname(filePath), - basename: Path.basename(filePath), - modified: fileTag.content, - original: originalContent, - }, - } as ActionChunkType; - } else if (type === 'command') { - const commandTag = tag.children.find((t) => t.name === 'commandType')!; - const packageTags = tag.children.filter((t) => t.name === 'package'); - - return { - type: 'action', - planId: planId, - data: { - type: 'command', - description, - command: commandTag.content, - packages: packageTags.map((t) => t.content), - }, - } as ActionChunkType; - } else { - return null; - } - } - default: - return null; - } -} diff --git a/packages/api/apps/app.mts b/packages/api/apps/app.mts deleted file mode 100644 index 9a42dee8b..000000000 --- a/packages/api/apps/app.mts +++ /dev/null @@ -1,132 +0,0 @@ -import { randomid, type AppType } from '@srcbook/shared'; -import { db } from '../db/index.mjs'; -import { type App as DBAppType, apps as appsTable } from '../db/schema.mjs'; -import { applyPlan, createViteApp, deleteViteApp, getFlatFilesForApp } from './disk.mjs'; -import { CreateAppSchemaType, CreateAppWithAiSchemaType } from './schemas.mjs'; -import { asc, desc, eq } from 'drizzle-orm'; -import { npmInstall } from './processes.mjs'; -import { generateApp } from '../ai/generate.mjs'; -import { toValidPackageName } from '../apps/utils.mjs'; -import { getPackagesToInstall, parsePlan } from '../ai/plan-parser.mjs'; -import { commitAllFiles, initRepo } from './git.mjs'; - -function toSecondsSinceEpoch(date: Date): number { - return Math.floor(date.getTime() / 1000); -} - -export function serializeApp(app: DBAppType): AppType { - return { - id: app.externalId, - name: app.name, - createdAt: toSecondsSinceEpoch(app.createdAt), - updatedAt: toSecondsSinceEpoch(app.updatedAt), - }; -} - -async function insert(attrs: Pick): Promise { - const [app] = await db.insert(appsTable).values(attrs).returning(); - return app!; -} - -export async function createAppWithAi(data: CreateAppWithAiSchemaType): Promise { - const app = await insert({ - name: data.name, - externalId: randomid(), - }); - - await createViteApp(app); - - await initRepo(app); - - // Note: we don't surface issues or retries and this is "running in the background". - // In this case it works in our favor because we'll kickoff generation while it happens - const firstNpmInstallProcess = npmInstall(app.externalId, { - stdout(data) { - console.log(data.toString('utf8')); - }, - stderr(data) { - console.error(data.toString('utf8')); - }, - onExit(code) { - console.log(`npm install exit code: ${code}`); - }, - }); - - const files = await getFlatFilesForApp(app.externalId); - const result = await generateApp(toValidPackageName(app.name), files, data.prompt); - const plan = await parsePlan(result, app, data.prompt, randomid()); - await applyPlan(app, plan); - - const packagesToInstall = getPackagesToInstall(plan); - - if (packagesToInstall.length > 0) { - await firstNpmInstallProcess; - - console.log('installing packages', packagesToInstall); - npmInstall(app.externalId, { - packages: packagesToInstall, - stdout(data) { - console.log(data.toString('utf8')); - }, - stderr(data) { - console.error(data.toString('utf8')); - }, - onExit(code) { - console.log(`npm install exit code: ${code}`); - console.log('Applying git commit'); - commitAllFiles(app, `Add dependencies: ${packagesToInstall.join(', ')}`); - }, - }); - } - - return app; -} -export async function createApp(data: CreateAppSchemaType): Promise { - const app = await insert({ - name: data.name, - externalId: randomid(), - }); - - await createViteApp(app); - - // TODO: handle this better. - // This should be done somewhere else and surface issues or retries. - // Not awaiting here because it's "happening in the background". - npmInstall(app.externalId, { - stdout(data) { - console.log(data.toString('utf8')); - }, - stderr(data) { - console.error(data.toString('utf8')); - }, - onExit(code) { - console.log(`npm install exit code: ${code}`); - }, - }); - - return app; -} - -export async function deleteApp(id: string) { - await db.delete(appsTable).where(eq(appsTable.externalId, id)); - await deleteViteApp(id); -} - -export function loadApps(sort: 'asc' | 'desc') { - const sorter = sort === 'asc' ? asc : desc; - return db.select().from(appsTable).orderBy(sorter(appsTable.updatedAt)); -} - -export async function loadApp(id: string) { - const [app] = await db.select().from(appsTable).where(eq(appsTable.externalId, id)); - return app; -} - -export async function updateApp(id: string, attrs: { name: string }) { - const [updatedApp] = await db - .update(appsTable) - .set({ name: attrs.name }) - .where(eq(appsTable.externalId, id)) - .returning(); - return updatedApp; -} diff --git a/packages/api/apps/disk.mts b/packages/api/apps/disk.mts deleted file mode 100644 index 05b2d6601..000000000 --- a/packages/api/apps/disk.mts +++ /dev/null @@ -1,396 +0,0 @@ -import type { RmOptions } from 'node:fs'; -import fs from 'node:fs/promises'; -import type { Project } from '../ai/app-parser.mjs'; -import Path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { type App as DBAppType } from '../db/schema.mjs'; -import { APPS_DIR } from '../constants.mjs'; -import { toValidPackageName } from './utils.mjs'; -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); -} - -export function deleteViteApp(id: string) { - return fs.rm(pathToApp(id), { recursive: true }); -} - -export async function applyPlan(app: DBAppType, plan: Plan) { - try { - for (const item of plan.actions) { - if (item.type === 'file') { - const basename = Path.basename(item.path); - await writeFile(app, { - path: item.path, - name: basename, - source: item.modified, - binary: isBinary(basename), - }); - } - } - } catch (e) { - console.error('Error applying plan to app', app.externalId, e); - throw e; - } -} - -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') { - 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. - console.log(`Command to execute: ${item.content}`); - } - } - return app; -} - -export async function createViteApp(app: DBAppType) { - const appPath = pathToApp(app.externalId); - - // Use recursive because its parent directory may not exist. - await fs.mkdir(appPath, { recursive: true }); - - // Scaffold all the necessary project files. - await scaffold(app, appPath); - - return app; -} - -/** - * Scaffolds a new Vite app using a predefined template. - * - * - * The current template includes: React, TypeScript, Vite, Tailwind CSS - * - * This function performs the following steps: - * 1. Copies all template files to the destination directory - * 2. Updates the package.json with the new app name - * 3. Updates the index.html title with the app name - * - * @param {DBAppType} app - The database app object. - * @param {string} destDir - The destination directory for the app. - * @returns {Promise} - */ -async function scaffold(app: DBAppType, destDir: string) { - const template = `react-typescript`; - - function write(file: string, content?: string) { - const targetPath = Path.join(destDir, file); - return content === undefined - ? copy(Path.join(templateDir, file), targetPath) - : writeFile(app, { - path: targetPath, - name: Path.basename(targetPath), - source: content, - binary: isBinary(Path.basename(targetPath)), - }); - } - - const templateDir = pathToTemplate(template); - const files = await fs.readdir(templateDir); - for (const file of files.filter((f) => f !== 'package.json')) { - await write(file); - } - - const [pkgContents, idxContents] = await Promise.all([ - fs.readFile(Path.join(templateDir, 'package.json'), 'utf-8'), - fs.readFile(Path.join(templateDir, 'index.html'), 'utf-8'), - ]); - - const pkg = JSON.parse(pkgContents); - pkg.name = toValidPackageName(app.name); - const updatedPkgContents = JSON.stringify(pkg, null, 2) + '\n'; - - const updatedIdxContents = idxContents.replace( - /.*<\/title>/, - `<title>${app.name}`, - ); - - await Promise.all([ - write('package.json', updatedPkgContents), - write('index.html', updatedIdxContents), - ]); -} - -export async function fileUpdated(app: DBAppType, file: FileType) { - return writeFile(app, file); -} - -async function copy(src: string, dest: string) { - const stat = await fs.stat(src); - if (stat.isDirectory()) { - return copyDir(src, dest); - } else { - return fs.copyFile(src, dest); - } -} - -async function copyDir(srcDir: string, destDir: string) { - await fs.mkdir(destDir, { recursive: true }); - const files = await fs.readdir(srcDir); - for (const file of files) { - const srcFile = Path.resolve(srcDir, file); - const destFile = Path.resolve(destDir, file); - await copy(srcFile, destFile); - } -} - -export async function loadDirectory( - app: DBAppType, - path: string, - excludes = ['node_modules', 'dist', '.git'], -): Promise { - const projectDir = Path.join(APPS_DIR, app.externalId); - const dirPath = Path.join(projectDir, path); - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - - const children = entries - .filter((entry) => excludes.indexOf(entry.name) === -1) - .map((entry) => { - const fullPath = Path.join(dirPath, entry.name); - const relativePath = Path.relative(projectDir, fullPath); - const paths = getPathInfo(relativePath); - return entry.isDirectory() - ? { ...paths, type: 'directory' as const, children: null } - : { ...paths, type: 'file' as const }; - }); - - const relativePath = Path.relative(projectDir, dirPath); - - return { - ...getPathInfo(relativePath), - type: 'directory' as const, - children: children, - }; -} - -export async function createDirectory( - app: DBAppType, - dirname: string, - basename: string, -): Promise { - const projectDir = Path.join(APPS_DIR, app.externalId); - const dirPath = Path.join(projectDir, dirname, basename); - - await fs.mkdir(dirPath, { recursive: false }); - - const relativePath = Path.relative(projectDir, dirPath); - - return { - ...getPathInfo(relativePath), - type: 'directory' as const, - children: null, - }; -} - -export function deleteDirectory(app: DBAppType, path: string) { - return deleteEntry(app, path, { recursive: true, force: true }); -} - -export async function renameDirectory( - app: DBAppType, - path: string, - name: string, -): Promise { - const result = await rename(app, path, name); - return { ...result, type: 'directory' as const, children: null }; -} - -export async function loadFile(app: DBAppType, path: string): Promise { - const projectDir = Path.join(APPS_DIR, app.externalId); - const filePath = Path.join(projectDir, path); - const relativePath = Path.relative(projectDir, filePath); - const basename = Path.basename(filePath); - - if (isBinary(basename)) { - return { path: relativePath, name: basename, source: `TODO: handle this`, binary: true }; - } else { - return { - path: relativePath, - name: basename, - source: await fs.readFile(filePath, 'utf-8'), - binary: false, - }; - } -} - -export async function createFile( - app: DBAppType, - dirname: string, - basename: string, - source: string, -): Promise { - const filePath = Path.join(dirname, basename); - - 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) { - return deleteEntry(app, path); -} - -export async function renameFile( - app: DBAppType, - path: string, - name: string, -): Promise { - const result = await rename(app, path, name); - return { ...result, type: 'file' as const }; -} - -async function rename(app: DBAppType, path: string, name: string) { - const projectDir = Path.join(APPS_DIR, app.externalId); - const oldPath = Path.join(projectDir, path); - const dirname = Path.dirname(oldPath); - const newPath = Path.join(dirname, name); - await fs.rename(oldPath, newPath); - const relativePath = Path.relative(projectDir, newPath); - return getPathInfo(relativePath); -} - -function deleteEntry(app: DBAppType, path: string, options: RmOptions = {}) { - const filePath = Path.join(APPS_DIR, app.externalId, path); - return fs.rm(filePath, options); -} - -// TODO: This does not scale. -// What's the best way to know whether a file is a "binary" -// file or not? Inspecting bytes for invalid utf8? -const TEXT_FILE_EXTENSIONS = [ - '.ts', - '.cts', - '.mts', - '.tsx', - '.js', - '.cjs', - '.mjs', - '.jsx', - '.md', - '.markdown', - '.json', - '.css', - '.html', -]; - -export function toFileType(path: string, source: string): FileType { - return { - path, - name: Path.basename(path), - source, - binary: isBinary(Path.basename(path)), - }; -} - -function isBinary(basename: string) { - const isDotfile = basename.startsWith('.'); // Assume these are text for now, e.g., .gitignore - const isTextFile = TEXT_FILE_EXTENSIONS.includes(Path.extname(basename)); - return !(isDotfile || isTextFile); -} - -function getPathInfo(path: string) { - if (Path.isAbsolute(path)) { - throw new Error(`Expected a relative path but got '${path}'`); - } - - path = path === '' ? '.' : path; - - return { - path: path, - dirname: Path.dirname(path), - basename: Path.basename(path), - }; -} - -export async function getFlatFilesForApp(id: string): Promise { - const appPath = pathToApp(id); - return getFlatFiles(appPath); -} - -async function getFlatFiles(dir: string, basePath: string = ''): Promise { - const entries = await fs.readdir(dir, { withFileTypes: true }); - let files: FileContent[] = []; - - for (const entry of entries) { - const relativePath = Path.join(basePath, entry.name); - const fullPath = Path.join(dir, entry.name); - - if (entry.isDirectory()) { - // 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') { - const content = await fs.readFile(fullPath, 'utf-8'); - files.push({ filename: relativePath, content }); - } - } - - return files; -} - -export async function createZipFromApp(app: DBAppType): Promise { - const appPath = pathToApp(app.externalId); - const archive = archiver('zip', { zlib: { level: 9 } }); - const chunks: any[] = []; - - return new Promise((resolve, reject) => { - archive.directory(appPath, false); - - archive.on('error', (err) => { - console.error('Error creating zip archive:', err); - reject(err); - }); - - archive.on('data', (chunk: Buffer) => chunks.push(chunk)); - - archive.on('end', () => { - const buffer = Buffer.concat(chunks); - resolve(buffer); - }); - - archive.finalize(); - }); -} diff --git a/packages/api/apps/git.mts b/packages/api/apps/git.mts deleted file mode 100644 index 2ef5fbfd0..000000000 --- a/packages/api/apps/git.mts +++ /dev/null @@ -1,132 +0,0 @@ -import simpleGit, { SimpleGit, DefaultLogFields, ListLogLine } from 'simple-git'; -import fs from 'node:fs/promises'; -import { broadcastFileUpdated, pathToApp, toFileType } from './disk.mjs'; -import type { App as DBAppType } from '../db/schema.mjs'; -import Path from 'node:path'; - -// Helper to get git instance for an app -function getGit(app: DBAppType): SimpleGit { - const dir = pathToApp(app.externalId); - return simpleGit(dir); -} - -// Initialize a git repository in the app directory -export async function initRepo(app: DBAppType): Promise { - const git = getGit(app); - await git.init(); - await commitAllFiles(app, 'Initial commit'); -} - -// Commit all current files in the app directory -export async function commitAllFiles(app: DBAppType, message: string): Promise { - const git = getGit(app); - - // Stage all files - await git.add('.'); - - // Create commit - await git.commit(message, { - '--author': 'Srcbook ', - }); - - // Get the exact SHA of the new commit. Sometimes it's 'HEAD ' for some reason - const sha = await git.revparse(['HEAD']); - return sha; -} - -// Checkout to a specific commit, and notify the client that the files have changed -export async function checkoutCommit(app: DBAppType, commitSha: string): Promise { - const git = getGit(app); - // get the files that are different between the current state and the commit - const files = await getChangedFiles(app, commitSha); - - // we might have a dirty working directory, so we need to stash any changes - // TODO: we should probably handle this better - await git.stash(); - - // checkout the commit - await git.checkout(commitSha); - - // notify the client to update the files - for (const file of files.added) { - const source = await fs.readFile(Path.join(pathToApp(app.externalId), file), 'utf-8'); - broadcastFileUpdated(app, toFileType(file, source)); - } - for (const file of files.modified) { - const source = await fs.readFile(Path.join(pathToApp(app.externalId), file), 'utf-8'); - broadcastFileUpdated(app, toFileType(file, source)); - } -} - -// Get commit history -export async function getCommitHistory( - app: DBAppType, - limit: number = 100, -): Promise> { - const git = getGit(app); - const log = await git.log({ maxCount: limit }); - return log.all; -} - -// Helper function to ensure the repo exists -export async function ensureRepoExists(app: DBAppType): Promise { - const git = getGit(app); - const isRepo = await git.checkIsRepo(); - - if (!isRepo) { - await initRepo(app); - } -} - -// Get the current commit SHA -export async function getCurrentCommitSha(app: DBAppType): Promise { - const git = getGit(app); - // There might not be a .git initialized yet, so we need to handle that - const isRepo = await git.checkIsRepo(); - if (!isRepo) { - await initRepo(app); - } - - const revparse = await git.revparse(['HEAD']); - - return revparse; -} - -// Get list of changed files between current state and a commit -export async function getChangedFiles( - app: DBAppType, - commitSha: string, -): Promise<{ added: string[]; modified: string[]; deleted: string[] }> { - const git = getGit(app); - - // Get the diff between current state and the specified commit - const diffSummary = await git.diff(['--name-status', commitSha]); - - const changes = { - added: [] as string[], - modified: [] as string[], - deleted: [] as string[], - }; - - // Parse the diff output - diffSummary.split('\n').forEach((line) => { - const [status, ...fileParts] = line.split('\t'); - const file = fileParts.join('\t'); // Handle filenames with tabs - - if (!file || !status) return; - - switch (status[0]) { - case 'A': - changes.added.push(file); - break; - case 'M': - changes.modified.push(file); - break; - case 'D': - changes.deleted.push(file); - break; - } - }); - - return changes; -} diff --git a/packages/api/apps/processes.mts b/packages/api/apps/processes.mts deleted file mode 100644 index e758acb1c..000000000 --- a/packages/api/apps/processes.mts +++ /dev/null @@ -1,165 +0,0 @@ -import { ChildProcess } from 'node:child_process'; -import { pathToApp } from './disk.mjs'; -import { npmInstall as execNpmInstall, vite as execVite } from '../exec.mjs'; -import { wss } from '../index.mjs'; - -export type ProcessType = 'npm:install' | 'vite:server'; - -export interface NpmInstallProcessType { - type: 'npm:install'; - process: ChildProcess; -} - -export interface ViteServerProcessType { - type: 'vite:server'; - process: ChildProcess; - port: number | null; -} - -export type AppProcessType = NpmInstallProcessType | ViteServerProcessType; - -class Processes { - private map: Map = new Map(); - - has(appId: string, type: ProcessType) { - return this.map.has(this.toKey(appId, type)); - } - - get(appId: string, type: ProcessType) { - return this.map.get(this.toKey(appId, type)); - } - - set(appId: string, process: AppProcessType) { - this.map.set(this.toKey(appId, process.type), process); - } - - del(appId: string, type: ProcessType) { - return this.map.delete(this.toKey(appId, type)); - } - - private toKey(appId: string, type: ProcessType) { - return `${appId}:${type}`; - } -} - -const processes = new Processes(); - -export function getAppProcess(appId: string, type: 'npm:install'): NpmInstallProcessType; -export function getAppProcess(appId: string, type: 'vite:server'): ViteServerProcessType; -export function getAppProcess(appId: string, type: ProcessType): AppProcessType { - switch (type) { - case 'npm:install': - return processes.get(appId, type) as NpmInstallProcessType; - case 'vite:server': - return processes.get(appId, type) as ViteServerProcessType; - } -} - -export function setAppProcess(appId: string, process: AppProcessType) { - processes.set(appId, process); -} - -export function deleteAppProcess(appId: string, process: ProcessType) { - processes.del(appId, process); -} - -async function waitForProcessToComplete(process: AppProcessType) { - if (process.process.exitCode !== null) { - return process; - } - - return new Promise((resolve, reject) => { - process.process.once('exit', () => { - resolve(process); - }); - process.process.once('error', (err) => { - reject(err); - }); - }); -} - -/** - * Runs npm install for the given app. - * - * If there's already a process running npm install, it will return that process. - */ -export function npmInstall( - appId: string, - options: Omit[0]>, 'cwd'> & { onStart?: () => void }, -) { - const runningProcess = processes.get(appId, 'npm:install'); - if (runningProcess) { - return waitForProcessToComplete(runningProcess); - } - - wss.broadcast(`app:${appId}`, 'deps:install:status', { status: 'installing' }); - if (options.onStart) { - options.onStart(); - } - - const newlyStartedProcess: NpmInstallProcessType = { - type: 'npm:install', - process: execNpmInstall({ - ...options, - - cwd: pathToApp(appId), - stdout: (data) => { - wss.broadcast(`app:${appId}`, 'deps:install:log', { - log: { type: 'stdout', data: data.toString('utf8') }, - }); - - if (options.stdout) { - options.stdout(data); - } - }, - stderr: (data) => { - wss.broadcast(`app:${appId}`, 'deps:install:log', { - log: { type: 'stderr', data: data.toString('utf8') }, - }); - - if (options.stderr) { - options.stderr(data); - } - }, - onExit: (code, signal) => { - // We must clean up this process so that we can run npm install again - deleteAppProcess(appId, 'npm:install'); - - wss.broadcast(`app:${appId}`, 'deps:install:status', { - status: code === 0 ? 'complete' : 'failed', - code, - }); - - if (code === 0) { - wss.broadcast(`app:${appId}`, 'deps:status:response', { - nodeModulesExists: true, - }); - } - - if (options.onExit) { - options.onExit(code, signal); - } - }, - }), - }; - processes.set(appId, newlyStartedProcess); - - return waitForProcessToComplete(newlyStartedProcess); -} - -/** - * Runs a vite dev server for the given app. - * - * If there's already a process running the vite dev server, it will return that process. - */ -export function viteServer(appId: string, options: Omit[0], 'cwd'>) { - if (!processes.has(appId, 'vite:server')) { - processes.set(appId, { - type: 'vite:server', - process: execVite({ cwd: pathToApp(appId), ...options }), - port: null, - }); - } - - return processes.get(appId, 'vite:server'); -} diff --git a/packages/api/apps/schemas.mts b/packages/api/apps/schemas.mts deleted file mode 100644 index f2b1597dd..000000000 --- a/packages/api/apps/schemas.mts +++ /dev/null @@ -1,14 +0,0 @@ -import z from 'zod'; - -export const CreateAppSchema = z.object({ - name: z.string(), - prompt: z.string().optional(), -}); - -export const CreateAppWithAiSchema = z.object({ - name: z.string(), - prompt: z.string(), -}); - -export type CreateAppSchemaType = z.infer; -export type CreateAppWithAiSchemaType = z.infer; diff --git a/packages/api/apps/templates/react-typescript/index.html b/packages/api/apps/templates/react-typescript/index.html deleted file mode 100644 index b903bd21a..000000000 --- a/packages/api/apps/templates/react-typescript/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Vite + React + TS - - - -

- - - - diff --git a/packages/api/apps/templates/react-typescript/package.json b/packages/api/apps/templates/react-typescript/package.json deleted file mode 100644 index 888ca13cd..000000000 --- a/packages/api/apps/templates/react-typescript/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "vite-react-typescript-starter", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" - }, - "dependencies": { - "lucide-react": "^0.453.0", - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@types/react": "^18.3.6", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", - "autoprefixer": "^10.4.20", - "globals": "^15.9.0", - "postcss": "^8.4.47", - "tailwindcss": "^3.4.14", - "typescript": "^5.5.3", - "vite": "^5.4.6" - } -} diff --git a/packages/api/apps/templates/react-typescript/postcss.config.js b/packages/api/apps/templates/react-typescript/postcss.config.js deleted file mode 100644 index 2e7af2b7f..000000000 --- a/packages/api/apps/templates/react-typescript/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/packages/api/apps/templates/react-typescript/src/App.tsx b/packages/api/apps/templates/react-typescript/src/App.tsx deleted file mode 100644 index 386cc16f0..000000000 --- a/packages/api/apps/templates/react-typescript/src/App.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import './index.css' - -function App() { - - return ( -

- Hello world! -

- ) -} - -export default App diff --git a/packages/api/apps/templates/react-typescript/src/index.css b/packages/api/apps/templates/react-typescript/src/index.css deleted file mode 100644 index 9b3c93868..000000000 --- a/packages/api/apps/templates/react-typescript/src/index.css +++ /dev/null @@ -1,7 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; -} diff --git a/packages/api/apps/templates/react-typescript/src/main.tsx b/packages/api/apps/templates/react-typescript/src/main.tsx deleted file mode 100644 index 6f4ac9bcc..000000000 --- a/packages/api/apps/templates/react-typescript/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import App from './App.tsx' -import './index.css' - -createRoot(document.getElementById('root')!).render( - - - , -) diff --git a/packages/api/apps/templates/react-typescript/src/vite-env.d.ts b/packages/api/apps/templates/react-typescript/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a..000000000 --- a/packages/api/apps/templates/react-typescript/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/packages/api/apps/templates/react-typescript/tailwind.config.js b/packages/api/apps/templates/react-typescript/tailwind.config.js deleted file mode 100644 index dca8ba02d..000000000 --- a/packages/api/apps/templates/react-typescript/tailwind.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: {}, - }, - plugins: [], -} diff --git a/packages/api/apps/templates/react-typescript/tsconfig.json b/packages/api/apps/templates/react-typescript/tsconfig.json deleted file mode 100644 index f0a235055..000000000 --- a/packages/api/apps/templates/react-typescript/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"] -} diff --git a/packages/api/apps/templates/react-typescript/vite.config.ts b/packages/api/apps/templates/react-typescript/vite.config.ts deleted file mode 100644 index 627a31962..000000000 --- a/packages/api/apps/templates/react-typescript/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], -}); diff --git a/packages/api/apps/utils.mts b/packages/api/apps/utils.mts deleted file mode 100644 index 3636400b8..000000000 --- a/packages/api/apps/utils.mts +++ /dev/null @@ -1,9 +0,0 @@ -// Copied from https://github.com/vitejs/vite/tree/main/packages/create-vite -export function toValidPackageName(projectName: string) { - return projectName - .trim() - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/^[._]/, '') - .replace(/[^a-z\d\-~]+/g, '-'); -} diff --git a/packages/api/config.mts b/packages/api/config.mts index 49133fad5..d68d4d6d6 100644 --- a/packages/api/config.mts +++ b/packages/api/config.mts @@ -1,14 +1,6 @@ import { eq, and, inArray } from 'drizzle-orm'; import { type SecretWithAssociatedSessions, randomid } from '@srcbook/shared'; -import { MessageType, HistoryType } from '@srcbook/shared'; -import { - configs, - type Config, - secrets, - type Secret, - secretsToSession, - apps, -} from './db/schema.mjs'; +import { configs, type Config, secrets, type Secret, secretsToSession } from './db/schema.mjs'; import { db } from './db/index.mjs'; import { HOME_DIR } from './constants.mjs'; @@ -52,25 +44,6 @@ export async function updateConfig(attrs: Partial) { return db.update(configs).set(attrs).returning(); } -export async function getHistory(appId: string): Promise { - const results = await db.select().from(apps).where(eq(apps.externalId, appId)).limit(1); - const history = results[0]!.history; - return JSON.parse(history); -} - -export async function appendToHistory(appId: string, messages: MessageType | MessageType[]) { - const results = await db.select().from(apps).where(eq(apps.externalId, appId)).limit(1); - const history = results[0]!.history; - const decodedHistory = JSON.parse(history); - const newHistory = Array.isArray(messages) - ? [...decodedHistory, ...messages] - : [...decodedHistory, messages]; - await db - .update(apps) - .set({ history: JSON.stringify(newHistory) }) - .where(eq(apps.externalId, appId)); -} - export async function getSecrets(): Promise> { const secretsResult = await db.select().from(secrets); const secretsToSessionResult = await db diff --git a/packages/api/db/schema.mts b/packages/api/db/schema.mts index 7d0700f81..7d832941a 100644 --- a/packages/api/db/schema.mts +++ b/packages/api/db/schema.mts @@ -1,4 +1,3 @@ -import { sql } from 'drizzle-orm'; import { sqliteTable, text, integer, unique } from 'drizzle-orm/sqlite-core'; import { randomid } from '@srcbook/shared'; @@ -48,19 +47,3 @@ export const secretsToSession = sqliteTable( ); export type SecretsToSession = typeof secretsToSession.$inferSelect; - -export const apps = sqliteTable('apps', { - id: integer('id').primaryKey(), - name: text('name').notNull(), - externalId: text('external_id').notNull().unique(), - history: text('history').notNull().default('[]'), // JSON encoded value of the history - historyVersion: integer('history_version').notNull().default(1), // internal versioning of history type for migrations - createdAt: integer('created_at', { mode: 'timestamp' }) - .notNull() - .default(sql`(unixepoch())`), - updatedAt: integer('updated_at', { mode: 'timestamp' }) - .notNull() - .default(sql`(unixepoch())`), -}); - -export type App = typeof apps.$inferSelect; diff --git a/packages/api/drizzle/0017_drop_apps.sql b/packages/api/drizzle/0017_drop_apps.sql new file mode 100644 index 000000000..cb57bc248 --- /dev/null +++ b/packages/api/drizzle/0017_drop_apps.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `apps`; diff --git a/packages/api/drizzle/meta/0017_snapshot.json b/packages/api/drizzle/meta/0017_snapshot.json new file mode 100644 index 000000000..3b22b2212 --- /dev/null +++ b/packages/api/drizzle/meta/0017_snapshot.json @@ -0,0 +1,220 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5a12096f-88f6-4d4e-b4ab-311e4f2c88c8", + "prevId": "f20efb4d-77a9-41b3-9aa0-43192b59caef", + "tables": { + "config": { + "name": "config", + "columns": { + "base_dir": { + "name": "base_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_language": { + "name": "default_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'typescript'" + }, + "openai_api_key": { + "name": "openai_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anthropic_api_key": { + "name": "anthropic_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xai_api_key": { + "name": "xai_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gemini_api_key": { + "name": "gemini_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "openrouter_api_key": { + "name": "openrouter_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_api_key": { + "name": "custom_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled_analytics": { + "name": "enabled_analytics", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "srcbook_installation_id": { + "name": "srcbook_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'s6qi7h4bk4014r3sm1kuvfrfac'" + }, + "ai_provider": { + "name": "ai_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'openai'" + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'gpt-4o'" + }, + "ai_base_url": { + "name": "ai_base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_email": { + "name": "subscription_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "secrets": { + "name": "secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "secrets_name_unique": { + "name": "secrets_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "secrets_to_sessions": { + "name": "secrets_to_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_id": { + "name": "secret_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "secrets_to_sessions_session_id_secret_id_unique": { + "name": "secrets_to_sessions_session_id_secret_id_unique", + "columns": [ + "session_id", + "secret_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "secrets_to_sessions_secret_id_secrets_id_fk": { + "name": "secrets_to_sessions_secret_id_secrets_id_fk", + "tableFrom": "secrets_to_sessions", + "tableTo": "secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index 801f9614d..3be2e35cc 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1743191674243, "tag": "0016_add_openrouter_api_key", "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1775433600000, + "tag": "0017_drop_apps", + "breakpoints": true } ] } diff --git a/packages/api/exec.mts b/packages/api/exec.mts index ba87e58bb..921de55e0 100644 --- a/packages/api/exec.mts +++ b/packages/api/exec.mts @@ -23,10 +23,6 @@ export type NPMInstallRequestType = BaseExecRequestType & { args?: Array; }; -type NpxRequestType = BaseExecRequestType & { - args: Array; -}; - type SpawnCallRequestType = { cwd: string; env: NodeJS.ProcessEnv; @@ -159,14 +155,3 @@ export function npmInstall(options: NPMInstallRequestType) { env: process.env, }); } - -/** - * Run vite. - */ -export function vite(options: NpxRequestType) { - return spawnCall({ - ...options, - command: Path.join(options.cwd, 'node_modules', '.bin', 'vite'), - env: process.env, - }); -} diff --git a/packages/api/package.json b/packages/api/package.json index f1c259254..2a1c7c893 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -11,7 +11,7 @@ "dev": "vite-node -w dev-server.mts", "test": "vitest", "prebuild": "rm -rf ./dist", - "build": "tsc && cp -R ./drizzle ./dist/drizzle && cp -R ./srcbook/examples ./dist/srcbook/examples && cp -R ./prompts ./dist/prompts && cp -R ./apps/templates ./dist/apps/templates", + "build": "tsc && cp -R ./drizzle ./dist/drizzle && cp -R ./srcbook/examples ./dist/srcbook/examples && cp -R ./prompts ./dist/prompts", "lint": "eslint . --max-warnings 0", "check-types": "tsc", "depcheck": "depcheck", @@ -28,21 +28,17 @@ "@ai-sdk/provider": "^1.0.1", "@srcbook/shared": "workspace:^", "ai": "^3.4.33", - "archiver": "^7.0.1", "better-sqlite3": "^11.3.0", "cors": "^2.8.5", "depcheck": "^1.4.7", "drizzle-orm": "^0.33.0", "express": "^4.20.0", - "fast-xml-parser": "^4.5.0", "marked": "catalog:", "posthog-node": "^4.2.0", - "simple-git": "^3.27.0", "ws": "catalog:", "zod": "catalog:" }, "devDependencies": { - "@types/archiver": "^6.0.2", "@types/better-sqlite3": "^7.6.11", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/packages/api/prompts/app-builder.txt b/packages/api/prompts/app-builder.txt deleted file mode 100644 index 2a88185f5..000000000 --- a/packages/api/prompts/app-builder.txt +++ /dev/null @@ -1,89 +0,0 @@ -## Context - -- You are helping a user build a front-end website application. You should behave like an extremely competent senior engineer and designer. -- The user is asking you to create the app from scratch through a and you will be given the skeleton of the app that already exists as a . -- You will be given an app skeleton in the following format: - - - - - - - - ... - -- You will be given the user request, passed as: - - {user request in plain english} - - - -## Instructions - -- Your job is to come up with the relevant changes, you do so by suggesting a with one or more and a . -- There can be one or more in a . -- A is a brief description of your plan in plain english. It will be shown to the user as context. -- An is one of: - - type="file": a new or updated file with ALL of the new contents - - type="command": a command that the user will run in the command line. Currently the only supported command is 'npm install': it allows you to install one or more npm packages. -- When installing dependencies, don't update the package.json file. Instead use the with the npm install; running this command will update the package.json. -- Only respond with the plan, all information you provide should be in it. -- You will receive a user request like "build a todo list app" or "build a food logger". It might be a lot more requirements, but keep your MVP functional and simple. -- You should use localStorage for storage, unless specifically requested otherwise -- Your stack is React, vite, typescript, tailwind. Keep things simple. -- The goal is to get a FUNCTIONAL MVP. All of the parts for this MVP should be included. -- Your job is to be precise and effective, so avoid extraneous steps even if they offer convenience. -- Do not talk or worry about testing. The user wants to _use_ the app: the core goal is for it to _work_. -- For react: modularize components into their own files, even small ones. We don't want one large App.tsx with everything inline, but different components in their respective src/components/{Component}.tsx files -- For styles: apply modern, minimalistic styles. Things shoud look modern, clean and slick. -- Use lucide-react for icons. It is pre-installed -- If the user asks for features that require routing, favor using react-router - - -## Example response - - - - - - - - - - - - - - - - - - - - - - - - - npm install - {package1} - {package2} - - ... - \ No newline at end of file diff --git a/packages/api/prompts/app-editor.txt b/packages/api/prompts/app-editor.txt deleted file mode 100644 index b75174da5..000000000 --- a/packages/api/prompts/app-editor.txt +++ /dev/null @@ -1,87 +0,0 @@ -## Context - -- You are helping a user build a front-end website application. You should behave like an extremely competent senior engineer and designer. -- The user wants to make a change to update or fix the app. Your goal is to help him with that request by suggesting updates for files. -- The structure we use to describe the app is the following: - - - - - - - - -- You will be passed the app with the above format, as well as the user request, passed as: - - {user request in plain english} - - - -## Instructions - -- Your job is to come up with the relevant changes, you do so by suggesting a with one or more and a . -- There can be one or more in a . -- A is a brief description of your plan in plain english. It will be shown to the user as context. -- An is one of: - - type="file": a new or updated file with ALL of the new contents - - type="command": a command that the user will run in the command line. Currently the only supported command is 'npm install': it allows you to install one or more npm packages. -- When installing dependencies, don't update the package.json file. Instead use the with the npm install; running this command will update the package.json. -- Only respond with the plan, all information you provide should be in it. -- You should use localStorage for storage, unless specifically requested otherwise. -- Your stack is React, vite, typescript, tailwind. Keep things simple. -- The goal is to get a FUNCTIONAL MVP. All of the parts for this MVP should be included. -- Your job is to be precise and effective, so avoid extraneous steps even if they offer convenience. -- Do not talk or worry about testing. The user wants to _use_ the app: the core goal is for it to _work_. -- For react: modularize components into their own files, even small ones. We don't want one large App.tsx with everything inline, but different components in their respective src/components/{Component}.tsx files -- For styles: apply modern, minimalistic styles. Things shoud look modern, clean and slick. -- Use lucide-react for icons. It is pre-installed -- If the user asks for features that require routing, favor using react-router - - -## Example response - - - - - - - - - - - - - - - - - - - - - - - - npm install - react-redux - react-router-dom - - ... - \ No newline at end of file diff --git a/packages/api/server/channels/app.mts b/packages/api/server/channels/app.mts deleted file mode 100644 index 1d8602703..000000000 --- a/packages/api/server/channels/app.mts +++ /dev/null @@ -1,247 +0,0 @@ -import path from 'node:path'; -import fs from 'node:fs/promises'; -import { - PreviewStartPayloadSchema, - PreviewStopPayloadSchema, - FileUpdatedPayloadSchema, - FileType, - FileUpdatedPayloadType, - PreviewStartPayloadType, - PreviewStopPayloadType, - DepsInstallPayloadType, - DepsInstallPayloadSchema, - DepsClearPayloadType, - DepsStatusPayloadSchema, -} from '@srcbook/shared'; - -import WebSocketServer, { - type MessageContextType, - type ConnectionContextType, -} from '../ws-client.mjs'; -import { loadApp } from '../../apps/app.mjs'; -import { fileUpdated, pathToApp } from '../../apps/disk.mjs'; -import { directoryExists } from '../../fs-utils.mjs'; -import { - getAppProcess, - setAppProcess, - deleteAppProcess, - npmInstall, - viteServer, -} from '../../apps/processes.mjs'; - -const VITE_PORT_REGEX = /Local:.*http:\/\/localhost:([0-9]{1,4})/; - -type AppContextType = MessageContextType<'appId'>; - -async function previewStart( - _payload: PreviewStartPayloadType, - context: AppContextType, - wss: WebSocketServer, -) { - const app = await loadApp(context.params.appId); - - if (!app) { - return; - } - - const existingProcess = getAppProcess(app.externalId, 'vite:server'); - - if (existingProcess) { - wss.broadcast(`app:${app.externalId}`, 'preview:status', { - status: 'running', - url: `http://localhost:${existingProcess.port}/`, - }); - return; - } - - wss.broadcast(`app:${app.externalId}`, 'preview:status', { - url: null, - status: 'booting', - }); - - const onChangePort = (newPort: number) => { - const process = getAppProcess(app.externalId, 'vite:server'); - - // This is not expected to happen - if (!process) { - wss.broadcast(`app:${app.externalId}`, 'preview:status', { - url: null, - status: 'stopped', - code: null, - }); - return; - } - - setAppProcess(app.externalId, { ...process, port: newPort }); - - wss.broadcast(`app:${app.externalId}`, 'preview:status', { - url: `http://localhost:${newPort}/`, - status: 'running', - }); - }; - - viteServer(app.externalId, { - args: [], - stdout: (data) => { - const encodedData = data.toString('utf8'); - console.log(encodedData); - - wss.broadcast(`app:${app.externalId}`, 'preview:log', { - log: { - type: 'stdout', - data: encodedData, - }, - }); - - const potentialPortMatch = VITE_PORT_REGEX.exec(encodedData); - if (potentialPortMatch) { - const portString = potentialPortMatch[1]!; - const port = parseInt(portString, 10); - onChangePort(port); - } - }, - stderr: (data) => { - const encodedData = data.toString('utf8'); - console.error(encodedData); - - wss.broadcast(`app:${app.externalId}`, 'preview:log', { - log: { - type: 'stderr', - data: encodedData, - }, - }); - }, - onExit: (code) => { - deleteAppProcess(app.externalId, 'vite:server'); - - wss.broadcast(`app:${app.externalId}`, 'preview:status', { - url: null, - status: 'stopped', - code: code, - }); - }, - onError: (_error) => { - // Errors happen when we try to run vite before node modules are installed. - // Make sure we clean up the app process and inform the client. - deleteAppProcess(app.externalId, 'vite:server'); - - // TODO: Use a different event to communicate to the client there was an error. - // If the error is ENOENT, for example, it means node_modules and/or vite is missing. - wss.broadcast(`app:${app.externalId}`, 'preview:status', { - url: null, - status: 'stopped', - code: null, - }); - }, - }); -} - -async function previewStop( - _payload: PreviewStopPayloadType, - context: AppContextType, - conn: ConnectionContextType, -) { - const app = await loadApp(context.params.appId); - - if (!app) { - return; - } - - const result = getAppProcess(app.externalId, 'vite:server'); - - if (!result) { - conn.reply(`app:${app.externalId}`, 'preview:status', { - url: null, - status: 'stopped', - code: null, - }); - return; - } - - // Killing the process should result in its onExit handler being called. - // The onExit handler will remove the process from the processMetadata map - // and send the `preview:status` event with a value of 'stopped' - result.process.kill('SIGTERM'); -} - -async function dependenciesInstall(payload: DepsInstallPayloadType, context: AppContextType) { - const app = await loadApp(context.params.appId); - - if (!app) { - return; - } - - npmInstall(app.externalId, { - packages: payload.packages ?? undefined, - }); -} - -async function clearNodeModules( - _payload: DepsClearPayloadType, - context: AppContextType, - conn: ConnectionContextType, -) { - const app = await loadApp(context.params.appId); - - if (!app) { - return; - } - - const appPath = pathToApp(app.externalId); - const nodeModulesPath = path.join(appPath, 'node_modules'); - await fs.rm(nodeModulesPath, { recursive: true, force: true }); - - conn.reply(`app:${app.externalId}`, 'deps:status:response', { - nodeModulesExists: false, - }); -} - -async function dependenciesStatus( - _payload: DepsClearPayloadType, - context: AppContextType, - conn: ConnectionContextType, -) { - const app = await loadApp(context.params.appId); - - if (!app) { - return; - } - - const appPath = pathToApp(app.externalId); - const nodeModulesPath = path.join(appPath, 'node_modules'); - conn.reply(`app:${app.externalId}`, 'deps:status:response', { - nodeModulesExists: await directoryExists(nodeModulesPath), - }); -} - -async function onFileUpdated(payload: FileUpdatedPayloadType, context: AppContextType) { - const app = await loadApp(context.params.appId); - - if (!app) { - return; - } - - fileUpdated(app, payload.file as FileType); -} - -export function register(wss: WebSocketServer) { - wss - .channel('app:') - .on('preview:start', PreviewStartPayloadSchema, (payload, context) => - previewStart(payload, context, wss), - ) - .on('preview:stop', PreviewStopPayloadSchema, previewStop) - .on('deps:install', DepsInstallPayloadSchema, dependenciesInstall) - .on('deps:clear', DepsInstallPayloadSchema, clearNodeModules) - .on('deps:status', DepsStatusPayloadSchema, dependenciesStatus) - .on('file:updated', FileUpdatedPayloadSchema, onFileUpdated) - .onJoin((_payload, context, conn) => { - const appExternalId = (context as AppContextType).params.appId; - - // When connecting, send back info about an in flight npm install if one exists - const npmInstallProcess = getAppProcess(appExternalId, 'npm:install'); - if (npmInstallProcess) { - conn.reply(`app:${appExternalId}`, 'deps:install:status', { status: 'installing' }); - } - }); -} diff --git a/packages/api/server/http.mts b/packages/api/server/http.mts index 9ce3fedf9..b967d2239 100644 --- a/packages/api/server/http.mts +++ b/packages/api/server/http.mts @@ -2,7 +2,7 @@ import Path from 'node:path'; import { posthog } from '../posthog-client.mjs'; import fs from 'node:fs/promises'; import { SRCBOOKS_DIR } from '../constants.mjs'; -import express, { type Application, type Response } from 'express'; +import express, { type Application } from 'express'; import cors from 'cors'; import { createSession, @@ -13,15 +13,12 @@ import { listSessions, exportSrcmdText, } from '../session.mjs'; -import { generateCells, generateSrcbook, healthcheck, streamEditApp } from '../ai/generate.mjs'; -import { streamParsePlan } from '../ai/plan-parser.mjs'; +import { generateCells, generateSrcbook, healthcheck } from '../ai/generate.mjs'; import { getConfig, updateConfig, getSecrets, addSecret, - getHistory, - appendToHistory, removeSecret, associateSecretWithSession, disassociateSecretWithSession, @@ -38,32 +35,6 @@ import { readdir } from '../fs-utils.mjs'; import { EXAMPLE_SRCBOOKS } from '../srcbook/examples.mjs'; import { pathToSrcbook } from '../srcbook/path.mjs'; import { isSrcmdPath } from '../srcmd/paths.mjs'; -import { - loadApps, - loadApp, - createApp, - serializeApp, - deleteApp, - createAppWithAi, - updateApp, -} from '../apps/app.mjs'; -import { toValidPackageName } from '../apps/utils.mjs'; -import { - deleteFile, - renameFile, - loadDirectory, - loadFile, - createFile, - createDirectory, - renameDirectory, - deleteDirectory, - getFlatFilesForApp, -} from '../apps/disk.mjs'; -import { CreateAppSchema } from '../apps/schemas.mjs'; -import { AppGenerationFeedbackType } from '@srcbook/shared'; -import { createZipFromApp } from '../apps/disk.mjs'; -import { checkoutCommit, commitAllFiles, getCurrentCommitSha } from '../apps/git.mjs'; -import { streamJsonResponse } from './utils.mjs'; const app: Application = express(); @@ -422,415 +393,6 @@ router.post('/subscribe', cors(), async (req, res) => { } }); -function error500(res: Response, e: Error) { - const error = e as unknown as Error; - console.error(error); - return res.status(500).json({ error: 'An unexpected error occurred.' }); -} - -router.options('/apps', cors()); -router.post('/apps', cors(), async (req, res) => { - const result = CreateAppSchema.safeParse(req.body); - - if (result.success === false) { - const errors = result.error.errors.map((error) => error.message); - return res.status(400).json({ errors }); - } - - const attrs = result.data; - - posthog.capture({ - event: 'user created app', - properties: { prompt: typeof attrs.prompt === 'string' ? attrs.prompt : 'N/A' }, - }); - - try { - if (typeof attrs.prompt === 'string') { - const app = await createAppWithAi({ name: attrs.name, prompt: attrs.prompt }); - return res.json({ data: serializeApp(app) }); - } else { - // TODO do we really need to keep this? - const app = await createApp(attrs); - return res.json({ data: serializeApp(app) }); - } - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps', cors()); -router.get('/apps', cors(), async (req, res) => { - const sort = req.query.sort === 'desc' ? 'desc' : 'asc'; - - try { - const apps = await loadApps(sort); - return res.json({ data: apps.map(serializeApp) }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id', cors()); -router.get('/apps/:id', cors(), async (req, res) => { - const { id } = req.params; - - try { - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - return res.json({ data: serializeApp(app) }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id', cors()); -router.put('/apps/:id', cors(), async (req, res) => { - const { id } = req.params; - const { name } = req.body; - - if (typeof name !== 'string' || name.trim() === '') { - return res.status(400).json({ error: 'Name is required' }); - } - - try { - const app = await updateApp(id, { name }); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - return res.json({ data: serializeApp(app) }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id', cors()); -router.delete('/apps/:id', cors(), async (req, res) => { - const { id } = req.params; - - try { - await deleteApp(id); - return res.json({ deleted: true }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id/directories', cors()); -router.get('/apps/:id/directories', cors(), async (req, res) => { - const { id } = req.params; - - // TODO: validate and ensure path is not absolute - const path = typeof req.query.path === 'string' ? req.query.path : '.'; - - try { - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - const directory = await loadDirectory(app, path); - - return res.json({ data: directory }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id/edit', cors()); -router.post('/apps/:id/edit', cors(), async (req, res) => { - const { id } = req.params; - const { query, planId } = req.body; - posthog.capture({ event: 'user edited app with ai' }); - try { - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - const validName = toValidPackageName(app.name); - const files = await getFlatFilesForApp(String(app.externalId)); - const result = await streamEditApp(validName, files, query, app.externalId, planId); - const planStream = await streamParsePlan(result, app, query, planId); - - return streamJsonResponse(planStream, res, { status: 200 }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id/commit', cors()); -router.get('/apps/:id/commit', cors(), async (req, res) => { - const { id } = req.params; - const app = await loadApp(id); - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - const sha = await getCurrentCommitSha(app); - return res.json({ sha }); -}); -router.post('/apps/:id/commit', cors(), async (req, res) => { - const { id } = req.params; - const { message } = req.body; - // import the commit function from the apps/git.mjs file - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - const sha = await commitAllFiles(app, message); - return res.json({ sha }); -}); - -router.options('/apps/:id/checkout/:sha', cors()); -router.post('/apps/:id/checkout/:sha', cors(), async (req, res) => { - const { id, sha } = req.params; - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - await checkoutCommit(app, sha); - return res.json({ success: true, sha }); -}); - -router.options('/apps/:id/directories', cors()); -router.post('/apps/:id/directories', cors(), async (req, res) => { - const { id } = req.params; - - // TODO: validate and ensure path is not absolute - const { dirname, basename } = req.body; - - try { - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - const directory = await createDirectory(app, dirname, basename); - - return res.json({ data: directory }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id/directories', cors()); -router.delete('/apps/:id/directories', cors(), async (req, res) => { - const { id } = req.params; - - // TODO: validate and ensure path is not absolute - const path = typeof req.query.path === 'string' ? req.query.path : '.'; - - try { - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - await deleteDirectory(app, path); - - return res.json({ data: { deleted: true } }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id/directories/rename', cors()); -router.post('/apps/:id/directories/rename', cors(), async (req, res) => { - const { id } = req.params; - - // TODO: validate and ensure path is not absolute - const path = typeof req.query.path === 'string' ? req.query.path : '.'; - const name = req.query.name as string; - - try { - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - const directory = await renameDirectory(app, path, name); - - return res.json({ data: directory }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id/files', cors()); -router.get('/apps/:id/files', cors(), async (req, res) => { - const { id } = req.params; - - // TODO: validate and ensure path is not absolute - const path = typeof req.query.path === 'string' ? req.query.path : '.'; - - try { - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - const file = await loadFile(app, path); - - return res.json({ data: file }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id/files', cors()); -router.post('/apps/:id/files', cors(), async (req, res) => { - const { id } = req.params; - - // TODO: validate and ensure path is not absolute - const { dirname, basename, source } = req.body; - - try { - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - const file = await createFile(app, dirname, basename, source); - - return res.json({ data: file }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id/files', cors()); -router.delete('/apps/:id/files', cors(), async (req, res) => { - const { id } = req.params; - - // TODO: validate and ensure path is not absolute - const path = typeof req.query.path === 'string' ? req.query.path : '.'; - - try { - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - await deleteFile(app, path); - - return res.json({ data: { deleted: true } }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id/files/rename', cors()); -router.post('/apps/:id/files/rename', cors(), async (req, res) => { - const { id } = req.params; - - // TODO: validate and ensure path is not absolute - const path = typeof req.query.path === 'string' ? req.query.path : '.'; - const name = req.query.name as string; - - try { - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - const file = await renameFile(app, path, name); - - return res.json({ data: file }); - } catch (e) { - return error500(res, e as Error); - } -}); - -router.options('/apps/:id/export', cors()); -router.post('/apps/:id/export', cors(), async (req, res) => { - const { id } = req.params; - const { name } = req.body; - - try { - posthog.capture({ event: 'user exported app' }); - const app = await loadApp(id); - - if (!app) { - return res.status(404).json({ error: 'App not found' }); - } - - const zipBuffer = await createZipFromApp(app); - - res.setHeader('Content-Type', 'application/zip'); - res.setHeader('Content-Disposition', `attachment; filename="${name}.zip"`); - res.send(zipBuffer); - } catch (e) { - return error500(res, e as Error); - } -}); - app.use('/api', router); export default app; - -router.options('/apps/:id/history', cors()); -router.get('/apps/:id/history', cors(), async (req, res) => { - const { id } = req.params; - const history = await getHistory(id); - return res.json({ data: history }); -}); - -router.post('/apps/:id/history', cors(), async (req, res) => { - const { id } = req.params; - const { messages } = req.body; - await appendToHistory(id, messages); - return res.json({ data: { success: true } }); -}); - -router.options('/apps/:id/feedback', cors()); -router.post('/apps/:id/feedback', cors(), async (req, res) => { - const { id } = req.params; - const { planId, feedback } = req.body as AppGenerationFeedbackType; - - if (process.env.SRCBOOK_DISABLE_ANALYTICS === 'true') { - return res.status(403).json({ error: 'Analytics are disabled' }); - } - posthog.capture({ event: 'user sent feedback', properties: { type: feedback.type } }); - - try { - const response = await fetch('https://hub.srcbook.com/api/app_generation_feedback', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - appId: id, - planId, - feedback, - }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const result = await response.json(); - return res.json(result); - } catch (error) { - console.error('Error sending feedback:', error); - return res.status(500).json({ error: 'Failed to send feedback' }); - } -}); diff --git a/packages/api/server/ws.mts b/packages/api/server/ws.mts index 062576f92..d0db26ebf 100644 --- a/packages/api/server/ws.mts +++ b/packages/api/server/ws.mts @@ -63,7 +63,6 @@ import WebSocketServer, { MessageContextType } from './ws-client.mjs'; import { filenameFromPath, pathToCodeFile } from '../srcbook/path.mjs'; import { normalizeDiagnostic } from '../tsserver/utils.mjs'; import { removeCodeCellFromDisk } from '../srcbook/index.mjs'; -import { register as registerAppChannel } from './channels/app.mjs'; type SessionsContextType = MessageContextType<'sessionId'>; @@ -890,7 +889,4 @@ wss TsServerDefinitionLocationRequestPayloadSchema, getCompletions, ); - -registerAppChannel(wss); - export default wss; diff --git a/packages/api/test/app-parser.test.mts b/packages/api/test/app-parser.test.mts deleted file mode 100644 index 8afaf72c3..000000000 --- a/packages/api/test/app-parser.test.mts +++ /dev/null @@ -1,44 +0,0 @@ -import { parseProjectXML } from '../ai/app-parser.mjs'; - -describe.skip('parseProjectXML', () => { - it('should correctly parse XML and return a Project object', () => { - const testXML = ` - - - - - - - - - - - - `; - - const result = parseProjectXML(testXML); - - const expectedResult = { - id: 'test-project', - items: [ - { type: 'file', filename: './test1.txt', content: 'Test content 1' }, - { type: 'file', filename: './test2.txt', content: 'Test content 2' }, - { type: 'command', content: 'npm install' }, - ], - }; - - expect(result).toEqual(expectedResult); - }); - - it('should throw an error for invalid XML', () => { - const invalidXML = 'XML'; - - expect(() => parseProjectXML(invalidXML)).toThrow('Failed to parse XML response'); - }); - - it('should throw an error for XML without a project tag', () => { - const noProjectXML = 'Content'; - - expect(() => parseProjectXML(noProjectXML)).toThrow('Failed to parse XML response'); - }); -}); diff --git a/packages/api/test/plan-parser.test.mts b/packages/api/test/plan-parser.test.mts deleted file mode 100644 index 5a095c83e..000000000 --- a/packages/api/test/plan-parser.test.mts +++ /dev/null @@ -1,136 +0,0 @@ -import { expect, test, describe } from 'vitest'; -import { parsePlan } from '../ai/plan-parser.mjs'; -import { type App as DBAppType } from '../db/schema.mjs'; -import { vi } from 'vitest'; - -// Mock the loadFile function -vi.mock('../apps/disk.mjs', () => ({ - loadFile: vi.fn().mockImplementation((_app, filePath) => { - if (filePath === 'src/App.tsx') { - return Promise.resolve({ source: 'Original App.tsx content' }); - } - return Promise.reject(new Error('File not found')); - }), -})); - -const mockApp: DBAppType = { - id: 123, - externalId: '123', - name: 'Test App', - createdAt: new Date(), - updatedAt: new Date(), - history: '', - historyVersion: 1, -}; - -const mockXMLResponse = ` - - Implement a basic todo list app - - Update App.tsx with todo list functionality - - ([]); - const [inputValue, setInputValue] = useState(''); - - useEffect(() => { - const storedTodos = localStorage.getItem('todos'); - if (storedTodos) { - setTodos(JSON.parse(storedTodos)); - } - }, []); - - useEffect(() => { - localStorage.setItem('todos', JSON.stringify(todos)); - }, [todos]); - - const addTodo = () => { - if (inputValue.trim() !== '') { - setTodos([...todos, { id: Date.now(), text: inputValue, completed: false }]); - setInputValue(''); - } - }; - - const toggleTodo = (id: number) => { - setTodos(todos.map(todo => - todo.id === id ? { ...todo, completed: !todo.completed } : todo - )); - }; - - return ( -
-

Todo List

-
- setInputValue(e.target.value)} - className="flex-grow p-2 border rounded-l" - placeholder="Add a new todo" - /> - -
-
    - {todos.map(todo => ( -
  • - toggleTodo(todo.id)} - className="mr-2" - /> - {todo.text} -
  • - ))} -
-
- ); -} - -export default App; - ]]> -
-
- - Install required packages - npm install - @types/react - @types/react-dom - -
-`; - -describe('parsePlan', () => { - test('should correctly parse a plan with file and command actions', async () => { - const plan = await parsePlan(mockXMLResponse, mockApp, 'test query', '123445'); - - expect(plan.id).toBe('123445'); - expect(plan.query).toBe('test query'); - expect(plan.description).toBe('Implement a basic todo list app'); - expect(plan.actions).toHaveLength(2); - - // Check file action - const fileAction = plan.actions[0] as any; - expect(fileAction.type).toBe('file'); - expect(fileAction.path).toBe('src/App.tsx'); - expect(fileAction.modified).toContain('function App()'); - expect(fileAction.original).toBe('Original App.tsx content'); - expect(fileAction.description).toBe('Update App.tsx with todo list functionality'); - - // Check command action - const commandAction = plan.actions[1] as any; - expect(commandAction.type).toBe('command'); - expect(commandAction.command).toBe('npm install'); - expect(commandAction.packages).toEqual(['@types/react', '@types/react-dom']); - expect(commandAction.description).toBe('Install required packages'); - }); -}); diff --git a/packages/components/package.json b/packages/components/package.json index a276aa9d9..6c1bbee51 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -3,6 +3,7 @@ "version": "0.0.7", "type": "module", "main": "./dist/index.js", + "types": "./dist/index.d.ts", "scripts": { "prebuild": "rm -rf ./dist", "build": "tsc", diff --git a/packages/shared/index.mts b/packages/shared/index.mts index 8c9bbf844..2d0c3093e 100644 --- a/packages/shared/index.mts +++ b/packages/shared/index.mts @@ -1,13 +1,9 @@ -export * from './src/schemas/apps.mjs'; export * from './src/schemas/cells.mjs'; export * from './src/schemas/tsserver.mjs'; export * from './src/schemas/websockets.mjs'; -export * from './src/types/apps.mjs'; export * from './src/types/cells.mjs'; export * from './src/types/tsserver.mjs'; -export * from './src/types/history.mjs'; export * from './src/types/websockets.mjs'; export * from './src/types/secrets.mjs'; -export * from './src/types/feedback.mjs'; export * from './src/utils.mjs'; export * from './src/ai.mjs'; diff --git a/packages/shared/package.json b/packages/shared/package.json index 5cbc63bac..c4fb092ee 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,7 @@ "version": "0.0.13", "type": "module", "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", "scripts": { "prebuild": "rimraf ./dist", "build": "tsc", diff --git a/packages/shared/src/schemas/apps.mts b/packages/shared/src/schemas/apps.mts deleted file mode 100644 index 95f8a64bd..000000000 --- a/packages/shared/src/schemas/apps.mts +++ /dev/null @@ -1,8 +0,0 @@ -import z from 'zod'; - -export const FileSchema = z.object({ - path: z.string(), - name: z.string(), - source: z.string(), - binary: z.boolean(), -}); diff --git a/packages/shared/src/schemas/files.mts b/packages/shared/src/schemas/files.mts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/shared/src/schemas/websockets.mts b/packages/shared/src/schemas/websockets.mts index d2245f56e..9f4fce1e7 100644 --- a/packages/shared/src/schemas/websockets.mts +++ b/packages/shared/src/schemas/websockets.mts @@ -7,7 +7,6 @@ import { TsServerQuickInfoResponseSchema, TsServerCompletionEntriesSchema, } from './tsserver.mjs'; -import { FileSchema } from './apps.mjs'; // A _message_ over websockets export const WebSocketMessageSchema = z.tuple([ @@ -138,72 +137,6 @@ export const TsConfigUpdatedPayloadSchema = z.object({ source: z.string(), }); -////////// -// APPS // -////////// - -export const FilePayloadSchema = z.object({ - file: FileSchema, -}); - -export const FileCreatedPayloadSchema = z.object({ - file: FileSchema, -}); - -// Used both from client > server and server > client -export const FileUpdatedPayloadSchema = z.object({ - file: FileSchema, -}); - -export const FileRenamedPayloadSchema = z.object({ - oldPath: z.string(), - newPath: z.string(), -}); - -export const FileDeletedPayloadSchema = z.object({ - path: z.string(), -}); - -export const PreviewStatusPayloadSchema = z.union([ - z.object({ url: z.string().nullable(), status: z.enum(['booting', 'running']) }), - z.object({ - url: z.string().nullable(), - status: z.literal('stopped'), - code: z.number().int().nullable(), - }), -]); - -export const PreviewStartPayloadSchema = z.object({}); -export const PreviewStopPayloadSchema = z.object({}); - -export const PreviewLogPayloadSchema = z.object({ - log: z.union([ - z.object({ type: z.literal('stdout'), data: z.string() }), - z.object({ type: z.literal('stderr'), data: z.string() }), - ]), -}); - -export const DepsInstallLogPayloadSchema = z.object({ - log: z.union([ - z.object({ type: z.literal('stdout'), data: z.string() }), - z.object({ type: z.literal('stderr'), data: z.string() }), - ]), -}); - -export const DepsInstallStatusPayloadSchema = z.union([ - z.object({ status: z.literal('installing') }), - z.object({ - status: z.enum(['complete', 'failed']), - code: z.number().int(), - }), -]); - -export const DepsClearPayloadSchema = z.object({}); -export const DepsStatusPayloadSchema = z.object({}); -export const DepsStatusResponsePayloadSchema = z.object({ - nodeModulesExists: z.boolean(), -}); - /////////////////////// // APPS & NOTEBOOKS // /////////////////////// diff --git a/packages/shared/src/types/apps.mts b/packages/shared/src/types/apps.mts deleted file mode 100644 index 83bd39e34..000000000 --- a/packages/shared/src/types/apps.mts +++ /dev/null @@ -1,36 +0,0 @@ -import z from 'zod'; - -import { FileSchema } from '../schemas/apps.mjs'; - -export type AppType = { - id: string; - name: string; - createdAt: number; - updatedAt: number; -}; - -export type DirEntryType = { - type: 'directory'; - // The full path relative to app root, e.g. src/assets - path: string; - // The path dirname relative to app root, e.g. src - dirname: string; - // The path basename relative to app root, e.g. assets - basename: string; - // null if not loaded - children: FsEntryTreeType | null; -}; - -export type FileEntryType = { - type: 'file'; - // The full path relative to app root, e.g. src/components/input.tsx - path: string; - // The path dirname relative to app root, e.g. src/components - dirname: string; - // The path basename relative to app root, e.g. input.tsx - basename: string; -}; - -export type FsEntryTreeType = Array; - -export type FileType = z.infer; diff --git a/packages/shared/src/types/feedback.mts b/packages/shared/src/types/feedback.mts deleted file mode 100644 index 182cfc82c..000000000 --- a/packages/shared/src/types/feedback.mts +++ /dev/null @@ -1,4 +0,0 @@ -export type AppGenerationFeedbackType = { - planId: string; - feedback: any; -}; diff --git a/packages/shared/src/types/history.mts b/packages/shared/src/types/history.mts deleted file mode 100644 index 0e495c0f6..000000000 --- a/packages/shared/src/types/history.mts +++ /dev/null @@ -1,74 +0,0 @@ -export type FileDiffType = { - modified: string; - original: string | null; - basename: string; - dirname: string; - path: string; - additions: number; - deletions: number; - type: 'edit' | 'create' | 'delete'; -}; - -export type UserMessageType = { - type: 'user'; - message: string; - planId: string; -}; - -export type CommandMessageType = { - type: 'command'; - planId: string; - command: 'npm install'; - packages: string[]; - description: string; -}; - -export type DiffMessageType = { - type: 'diff'; - planId: string; - version: string; - diff: FileDiffType[]; -}; - -export type PlanMessageType = { - type: 'plan'; - planId: string; - content: string; -}; - -export type MessageType = UserMessageType | DiffMessageType | CommandMessageType | PlanMessageType; - -export type HistoryType = Array; - -////////////////////////////////////////// -// When streaming file objects from LLM // -////////////////////////////////////////// - -export type DescriptionChunkType = { - type: 'description'; - planId: string; - data: { content: string }; -}; - -export type FileActionChunkType = { - type: 'file'; - description: string; - modified: string; - original: string | null; - basename: string; - dirname: string; - path: string; -}; - -export type CommandActionChunkType = { - type: 'command'; - description: string; - command: 'npm install'; - packages: string[]; -}; - -export type ActionChunkType = { - type: 'action'; - planId: string; - data: FileActionChunkType | CommandActionChunkType; -}; diff --git a/packages/shared/src/types/websockets.mts b/packages/shared/src/types/websockets.mts index aa0fcb450..02f8234d4 100644 --- a/packages/shared/src/types/websockets.mts +++ b/packages/shared/src/types/websockets.mts @@ -29,20 +29,6 @@ import { TsServerDefinitionLocationRequestPayloadSchema, TsServerDefinitionLocationResponsePayloadSchema, TsServerCompletionEntriesPayloadSchema, - FilePayloadSchema, - FileCreatedPayloadSchema, - FileUpdatedPayloadSchema, - FileRenamedPayloadSchema, - FileDeletedPayloadSchema, - PreviewStatusPayloadSchema, - PreviewStopPayloadSchema, - PreviewStartPayloadSchema, - DepsInstallLogPayloadSchema, - DepsInstallStatusPayloadSchema, - DepsClearPayloadSchema, - DepsStatusPayloadSchema, - DepsStatusResponsePayloadSchema, - PreviewLogPayloadSchema, } from '../schemas/websockets.mjs'; export type CellExecPayloadType = z.infer; @@ -59,9 +45,6 @@ export type AiGeneratedCellPayloadType = z.infer; export type DepsInstallPayloadType = z.infer; -export type DepsClearPayloadType = z.infer; -export type DepsStatusPayloadType = z.infer; -export type DepsStatusResponsePayloadType = z.infer; export type DepsValidateResponsePayloadType = z.infer; export type DepsValidatePayloadType = z.infer; @@ -97,19 +80,3 @@ export type TsServerDefinitionLocationResponsePayloadType = z.infer< export type TsServerCompletionEntriesPayloadType = z.infer< typeof TsServerCompletionEntriesPayloadSchema >; - -////////// -// APPS // -////////// - -export type FilePayloadType = z.infer; -export type FileCreatedPayloadType = z.infer; -export type FileUpdatedPayloadType = z.infer; -export type FileRenamedPayloadType = z.infer; -export type FileDeletedPayloadType = z.infer; -export type PreviewStatusPayloadType = z.infer; -export type PreviewStartPayloadType = z.infer; -export type PreviewStopPayloadType = z.infer; -export type PreviewLogPayloadType = z.infer; -export type DepsInstallLogPayloadType = z.infer; -export type DepsInstallStatusPayloadType = z.infer; diff --git a/packages/web/package.json b/packages/web/package.json index ae218d91d..7cda29a2b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,13 +14,10 @@ }, "dependencies": { "@codemirror/autocomplete": "^6.18.1", - "@codemirror/lang-css": "^6.3.0", - "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-markdown": "^6.2.5", "@codemirror/lint": "^6.8.1", - "@codemirror/merge": "^6.7.0", "@codemirror/state": "^6.4.1", "@srcbook/components": "workspace:^", "@srcbook/shared": "workspace:^", @@ -28,10 +25,8 @@ "@uiw/react-codemirror": "^4.23.2", "clsx": "^2.1.1", "codemirror": "^6.0.1", - "diff": "^7.0.0", "lucide-react": "^0.439.0", "marked": "catalog:", - "marked-react": "^2.0.0", "posthog-js": "^1.174.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -46,7 +41,6 @@ "zod": "catalog:" }, "devDependencies": { - "@types/diff": "^5.2.3", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.7.0", diff --git a/packages/web/src/clients/http/apps.ts b/packages/web/src/clients/http/apps.ts deleted file mode 100644 index 8f62101c8..000000000 --- a/packages/web/src/clients/http/apps.ts +++ /dev/null @@ -1,346 +0,0 @@ -import type { - ActionChunkType, - AppGenerationFeedbackType, - AppType, - DescriptionChunkType, - DirEntryType, - FileEntryType, - FileType, -} from '@srcbook/shared'; -import SRCBOOK_CONFIG from '@/config'; -import type { HistoryType, MessageType } from '@srcbook/shared'; -import { StreamToIterable } from '@srcbook/shared'; - -const API_BASE_URL = `${SRCBOOK_CONFIG.api.origin}/api`; - -export async function createApp(request: { - name: string; - prompt?: string; -}): Promise<{ data: AppType }> { - const response = await fetch(API_BASE_URL + '/apps', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(request), - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function deleteApp(id: string): Promise { - const response = await fetch(API_BASE_URL + '/apps/' + id, { - method: 'DELETE', - headers: { 'content-type': 'application/json' }, - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } -} - -export async function loadApps(sort: 'asc' | 'desc'): Promise<{ data: AppType[] }> { - const response = await fetch(API_BASE_URL + '/apps?sort=' + sort, { - method: 'GET', - headers: { 'content-type': 'application/json' }, - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function loadApp(id: string): Promise<{ data: AppType }> { - const response = await fetch(API_BASE_URL + '/apps/' + id, { - method: 'GET', - headers: { 'content-type': 'application/json' }, - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function updateApp(id: string, attrs: { name: string }): Promise<{ data: AppType }> { - const response = await fetch(API_BASE_URL + '/apps/' + id, { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(attrs), - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function loadDirectory(id: string, path: string): Promise<{ data: DirEntryType }> { - const queryParams = new URLSearchParams({ path }); - - const response = await fetch(API_BASE_URL + `/apps/${id}/directories?${queryParams}`, { - method: 'GET', - headers: { 'content-type': 'application/json' }, - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function createDirectory( - id: string, - dirname: string, - basename: string, -): Promise<{ data: DirEntryType }> { - const response = await fetch(API_BASE_URL + `/apps/${id}/directories`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ dirname, basename }), - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function deleteDirectory( - id: string, - path: string, -): Promise<{ data: { deleted: true } }> { - const queryParams = new URLSearchParams({ path }); - - const response = await fetch(API_BASE_URL + `/apps/${id}/directories?${queryParams}`, { - method: 'DELETE', - headers: { 'content-type': 'application/json' }, - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function renameDirectory( - id: string, - path: string, - name: string, -): Promise<{ data: DirEntryType }> { - const queryParams = new URLSearchParams({ path, name }); - - const response = await fetch(API_BASE_URL + `/apps/${id}/directories/rename?${queryParams}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function loadFile(id: string, path: string): Promise<{ data: FileType }> { - const queryParams = new URLSearchParams({ path }); - - const response = await fetch(API_BASE_URL + `/apps/${id}/files?${queryParams}`, { - method: 'GET', - headers: { 'content-type': 'application/json' }, - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function createFile( - id: string, - dirname: string, - basename: string, - source: string, -): Promise<{ data: FileEntryType }> { - const response = await fetch(API_BASE_URL + `/apps/${id}/files`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ dirname, basename, source }), - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function deleteFile(id: string, path: string): Promise<{ data: { deleted: true } }> { - const queryParams = new URLSearchParams({ path }); - - const response = await fetch(API_BASE_URL + `/apps/${id}/files?${queryParams}`, { - method: 'DELETE', - headers: { 'content-type': 'application/json' }, - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function renameFile( - id: string, - path: string, - name: string, -): Promise<{ data: FileEntryType }> { - const queryParams = new URLSearchParams({ path, name }); - - const response = await fetch(API_BASE_URL + `/apps/${id}/files/rename?${queryParams}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function aiEditApp( - id: string, - query: string, - planId: string, -): Promise> { - const response = await fetch(API_BASE_URL + `/apps/${id}/edit`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ query, planId }), - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - const JSONDecoder = new TransformStream({ - transform(chunk, controller) { - const lines = chunk.split('\n'); - for (const line of lines) { - if (line.trim() !== '') { - const parsed = JSON.parse(line); - controller.enqueue(parsed); - } - } - }, - }); - - return StreamToIterable( - response.body!.pipeThrough(new TextDecoderStream()).pipeThrough(JSONDecoder), - ); -} - -export async function loadHistory(id: string): Promise<{ data: HistoryType }> { - const response = await fetch(API_BASE_URL + `/apps/${id}/history`, { - method: 'GET', - headers: { 'content-type': 'application/json' }, - }); - - if (!response.ok) { - console.error(response); - throw new Error('Request failed'); - } - - return response.json(); -} - -export async function appendToHistory(id: string, messages: MessageType | MessageType[]) { - const response = await fetch(API_BASE_URL + `/apps/${id}/history`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ messages }), - }); - return response.json(); -} - -export async function aiGenerationFeedback(id: string, feedback: AppGenerationFeedbackType) { - const response = await fetch(API_BASE_URL + `/apps/${id}/feedback`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(feedback), - }); - return response.json(); -} - -export async function exportApp(id: string, name: string): Promise { - const response = await fetch(API_BASE_URL + `/apps/${id}/export`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name }), - }); - - if (!response.ok) { - console.error(response); - throw new Error('Export failed'); - } - - return response.blob(); -} - -type VersionResponse = { - sha: string; -}; - -export async function getCurrentVersion(id: string): Promise { - const response = await fetch(API_BASE_URL + `/apps/${id}/commit`, { - method: 'GET', - headers: { 'content-type': 'application/json' }, - }); - return response.json(); -} - -export async function commitVersion(id: string, message: string): Promise { - const response = await fetch(API_BASE_URL + `/apps/${id}/commit`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ message }), - }); - - return response.json(); -} - -export async function checkoutVersion( - id: string, - sha: string, -): Promise<{ success: true; sha: string }> { - const response = await fetch(API_BASE_URL + `/apps/${id}/checkout/${sha}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - }); - return response.json(); -} diff --git a/packages/web/src/clients/websocket/index.ts b/packages/web/src/clients/websocket/index.ts index 04cd670c4..40de71d7c 100644 --- a/packages/web/src/clients/websocket/index.ts +++ b/packages/web/src/clients/websocket/index.ts @@ -27,20 +27,6 @@ import { TsServerDefinitionLocationResponsePayloadSchema, TsServerDefinitionLocationRequestPayloadSchema, TsServerCompletionEntriesPayloadSchema, - FileCreatedPayloadSchema, - FileUpdatedPayloadSchema, - FileRenamedPayloadSchema, - FileDeletedPayloadSchema, - FilePayloadSchema, - PreviewStatusPayloadSchema, - PreviewStartPayloadSchema, - PreviewStopPayloadSchema, - DepsInstallLogPayloadSchema, - DepsInstallStatusPayloadSchema, - DepsClearPayloadSchema, - DepsStatusResponsePayloadSchema, - DepsStatusPayloadSchema, - PreviewLogPayloadSchema, } from '@srcbook/shared'; import Channel from '@/clients/websocket/channel'; import WebSocketClient from '@/clients/websocket/client'; @@ -96,41 +82,3 @@ export class SessionChannel extends Channel< }); } } - -const IncomingAppEvents = { - file: FilePayloadSchema, - 'file:updated': FileUpdatedPayloadSchema, - 'preview:status': PreviewStatusPayloadSchema, - 'preview:log': PreviewLogPayloadSchema, - 'deps:install:log': DepsInstallLogPayloadSchema, - 'deps:install:status': DepsInstallStatusPayloadSchema, - 'deps:status:response': DepsStatusResponsePayloadSchema, -}; - -const OutgoingAppEvents = { - 'file:created': FileCreatedPayloadSchema, - 'file:updated': FileUpdatedPayloadSchema, - 'file:renamed': FileRenamedPayloadSchema, - 'file:deleted': FileDeletedPayloadSchema, - 'preview:start': PreviewStartPayloadSchema, - 'preview:stop': PreviewStopPayloadSchema, - 'deps:install': DepsInstallPayloadSchema, - 'deps:clear': DepsClearPayloadSchema, - 'deps:status': DepsStatusPayloadSchema, -}; - -export class AppChannel extends Channel { - appId: string; - - static create(appId: string) { - return new AppChannel(appId); - } - - constructor(appId: string) { - super(client, `app:${appId}`, { - incoming: IncomingAppEvents, - outgoing: OutgoingAppEvents, - }); - this.appId = appId; - } -} diff --git a/packages/web/src/components/apps/AiFeedbackModal.tsx b/packages/web/src/components/apps/AiFeedbackModal.tsx deleted file mode 100644 index 9396d3ed5..000000000 --- a/packages/web/src/components/apps/AiFeedbackModal.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from 'react'; -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@srcbook/components'; -import TextareaAutosize from 'react-textarea-autosize'; - -interface AiFeedbackModalProps { - isOpen: boolean; - onClose: () => void; - onSubmit: (feedback: string) => void; -} - -export function AiFeedbackModal({ isOpen, onClose, onSubmit }: AiFeedbackModalProps) { - const [feedback, setFeedback] = React.useState(''); - - const handleSubmit = () => { - onSubmit(feedback); - setFeedback(''); - onClose(); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleSubmit(); - } - }; - - return ( - - - - Provide Feedback - -
- setFeedback(e.target.value)} - onKeyDown={handleKeyDown} - minRows={3} - maxRows={10} - /> -
- - - - -
-
- ); -} diff --git a/packages/web/src/components/apps/bottom-drawer.tsx b/packages/web/src/components/apps/bottom-drawer.tsx deleted file mode 100644 index fb81c22d8..000000000 --- a/packages/web/src/components/apps/bottom-drawer.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { BanIcon, XIcon } from 'lucide-react'; -import { useHotkeys } from 'react-hotkeys-hook'; - -import { Button } from '@srcbook/components/src/components/ui/button'; -import { cn } from '@/lib/utils.ts'; -import { useLogs } from './use-logs'; -import { useEffect, useRef } from 'react'; - -const DRAWER_HEIGHT = 320; - -export default function BottomDrawer() { - const { logs, clearLogs, open, togglePane, closePane } = useLogs(); - - useHotkeys('mod+shift+y', () => { - togglePane(); - }); - - const scrollWrapperRef = useRef(null); - - // Scroll to the bottom of the logs panel when the user opens the panel fresh - useEffect(() => { - if (!scrollWrapperRef.current) { - return; - } - scrollWrapperRef.current.scrollTop = scrollWrapperRef.current.scrollHeight; - }, [open]); - - // Determine if the user has scrolled all the way to the bottom of the div - const scrollPinnedToBottomRef = useRef(false); - useEffect(() => { - if (!scrollWrapperRef.current) { - return; - } - const element = scrollWrapperRef.current; - - const onScroll = () => { - scrollPinnedToBottomRef.current = - element.scrollTop === element.scrollHeight - element.clientHeight; - }; - - element.addEventListener('scroll', onScroll); - return () => element.removeEventListener('scroll', onScroll); - }, []); - - // If the user has scrolled all the way to the bottom, then keep the bottom scroll pinned as new - // logs come in. - useEffect(() => { - if (!scrollWrapperRef.current) { - return; - } - - if (scrollPinnedToBottomRef.current) { - scrollWrapperRef.current.scrollTop = scrollWrapperRef.current.scrollHeight; - } - }, [logs]); - - return ( -
-
- - -
- {open && logs.length > 0 && ( - - )} - {open && ( - - )} -
-
- - {open && ( -
- - - {logs.map((log, index) => ( - - - - - - ))} - -
- - {log.timestamp.toISOString()} - - - {log.source} - -
-                      {log.message}
-                    
-
- {logs.length === 0 && ( -
- No logs -
- )} -
- )} -
- ); -} diff --git a/packages/web/src/components/apps/create-modal.tsx b/packages/web/src/components/apps/create-modal.tsx deleted file mode 100644 index 99900be3d..000000000 --- a/packages/web/src/components/apps/create-modal.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useState, KeyboardEvent } from 'react'; -import { cn } from '@/lib/utils'; -import { Input } from '@srcbook/components/src/components/ui/input'; -import { Button } from '@srcbook/components/src/components/ui/button'; -import { useNavigate } from 'react-router-dom'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@srcbook/components/src/components/ui/dialog'; - -import { HelpCircle, Sparkles, Loader2 } from 'lucide-react'; -import { Textarea } from '@srcbook/components/src/components/ui/textarea'; -import { - TooltipContent, - TooltipProvider, - TooltipTrigger, - Tooltip, -} from '@srcbook/components/src/components/ui/tooltip'; -import { useSettings } from '../use-settings'; - -type PropsType = { - onClose: () => void; - onCreate: (name: string, prompt?: string) => Promise; -}; - -export default function CreateAppModal({ onClose, onCreate }: PropsType) { - const [name, setName] = useState(''); - const [prompt, setPrompt] = useState(''); - - const { aiEnabled } = useSettings(); - const navigate = useNavigate(); - - const [submitting, setSubmitting] = useState(false); - - const validPrompt = prompt.trim() !== ''; - - async function onSubmit(e: React.FormEvent) { - e.preventDefault(); - e.stopPropagation(); - - if (submitting || !validPrompt) { - return; - } - - setSubmitting(true); - - try { - await onCreate(name, prompt.trim() === '' ? undefined : prompt); - } finally { - setSubmitting(false); - } - } - - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - onSubmit(e); - } - }; - - return ( - { - if (open === false) { - onClose(); - } - }} - > - - - Create application - - Create a web app powered by React, Vite and Tailwind. - - - {!aiEnabled && ( -
-

AI provider not configured.

- -
- )} -
-
-
- - setName(e.currentTarget.value)} - placeholder="Spotify Light" - /> -
- -
-
- - - - - - - - Use AI to scaffold your app - - - -
- -
- - - - - - -
-
-
- ); -} diff --git a/packages/web/src/components/apps/diff-modal.tsx b/packages/web/src/components/apps/diff-modal.tsx deleted file mode 100644 index e3bee5803..000000000 --- a/packages/web/src/components/apps/diff-modal.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { cn } from '@/lib/utils'; -import { Button } from '@srcbook/components'; -import { - Dialog, - DialogContent, - DialogTitle, - DialogDescription, -} from '@srcbook/components/src/components/ui/dialog'; -import { Undo2Icon } from 'lucide-react'; -import type { FileDiffType } from '@srcbook/shared'; -import { DiffSquares, DiffStats } from './diff-stats'; -import { DiffEditor } from './editor'; - -type PropsType = { - onUndoAll: () => void; - onClose: () => void; - files: FileDiffType[]; -}; - -export default function DiffModal({ files, onClose, onUndoAll }: PropsType) { - return ( - { - if (open === false) { - onClose(); - } - }} - > - - {/* Got browser console warnings without this */} - View diff of files changed - -
- {files.map((file) => ( - - ))} -
-
-
- ); -} - -function DiffModalHeader({ - numFiles, - onClose, - onUndoAll, -}: { - numFiles: number; - onClose: () => void; - onUndoAll: () => void; -}) { - return ( -
-
- {`${numFiles} ${numFiles === 1 ? 'file' : 'files'} changed`} -
-
- - -
-
- ); -} - -function FileDiff({ file }: { file: FileDiffType }) { - return ( -
-
-
-

{file.path}

- - -
-
-
- -
-
- ); -} diff --git a/packages/web/src/components/apps/diff-stats.tsx b/packages/web/src/components/apps/diff-stats.tsx deleted file mode 100644 index 23b6bbfdb..000000000 --- a/packages/web/src/components/apps/diff-stats.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { cn } from '@/lib/utils'; -import { calculateSquares } from './lib/diff'; - -export function DiffStats(props: { additions: number; deletions: number; className?: string }) { - return ( -
- +{props.additions} - -{props.deletions} -
- ); -} - -export function DiffSquares(props: { additions: number; deletions: number; className?: string }) { - const squares = calculateSquares(props.additions, props.deletions); - - return ( -
- {squares.map((square, index) => ( - - ))} -
- ); -} diff --git a/packages/web/src/components/apps/editor.tsx b/packages/web/src/components/apps/editor.tsx deleted file mode 100644 index bf77eeef1..000000000 --- a/packages/web/src/components/apps/editor.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import CodeMirror from '@uiw/react-codemirror'; -import { css } from '@codemirror/lang-css'; -import { html } from '@codemirror/lang-html'; -import { json } from '@codemirror/lang-json'; -import { javascript } from '@codemirror/lang-javascript'; -import { markdown } from '@codemirror/lang-markdown'; -import useTheme from '@srcbook/components/src/components/use-theme'; -import { extname } from './lib/path'; -import { EditorView } from 'codemirror'; -import { EditorState } from '@codemirror/state'; -import { unifiedMergeView } from '@codemirror/merge'; - -export function CodeEditor({ - path, - source, - onChange, -}: { - path: string; - source: string; - onChange: (updatedSource: string) => void; -}) { - const { codeTheme } = useTheme(); - - const languageExtension = getCodeMirrorLanguageExtension(path); - const extensions = languageExtension ? [languageExtension] : []; - - return ( - - ); -} - -export function DiffEditor({ - path, - modified, - original, - collapseUnchanged, -}: { - path: string; - modified: string; - original: string | null; - collapseUnchanged?: { - minSize: number; - margin: number; - }; -}) { - const { codeTheme } = useTheme(); - - const extensions = [ - EditorView.editable.of(false), - EditorState.readOnly.of(true), - unifiedMergeView({ - original: original ?? '', - mergeControls: false, - highlightChanges: false, - collapseUnchanged: collapseUnchanged, - }), - ]; - - const languageExtension = getCodeMirrorLanguageExtension(path); - - if (languageExtension) { - extensions.unshift(languageExtension); - } - - return ; -} - -function getCodeMirrorLanguageExtension(path: string) { - switch (extname(path)) { - case '.json': - return json(); - case '.css': - return css(); - case '.html': - return html(); - case '.md': - case '.markdown': - return markdown(); - case '.js': - case '.cjs': - case '.mjs': - case '.jsx': - case '.ts': - case '.cts': - case '.mts': - case '.tsx': - return javascript({ typescript: true, jsx: true }); - } -} diff --git a/packages/web/src/components/apps/header.tsx b/packages/web/src/components/apps/header.tsx deleted file mode 100644 index f65161403..000000000 --- a/packages/web/src/components/apps/header.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { - ShareIcon, - PlayIcon, - StopCircleIcon, - PlayCircleIcon, - Code2Icon, - Loader2Icon, - CircleAlertIcon, - PanelBottomOpenIcon, - PanelBottomCloseIcon, - ExternalLinkIcon, -} from 'lucide-react'; -import { Link } from 'react-router-dom'; -import { SrcbookLogo } from '@/components/logos'; - -import { Button } from '@srcbook/components/src/components/ui/button'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@srcbook/components/src/components/ui/tooltip'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@srcbook/components/src/components/ui/dialog'; -import { cn } from '@/lib/utils'; -import { usePackageJson } from './use-package-json'; -import { useApp } from './use-app'; -import { Input } from '@srcbook/components'; -import { useState } from 'react'; -import { usePreview } from './use-preview'; -import { exportApp } from '@/clients/http/apps'; -import { toast } from 'sonner'; -import { useLogs } from './use-logs'; - -export type HeaderTab = 'code' | 'preview'; - -type PropsType = { - className?: string; - tab: HeaderTab; - onChangeTab: (newTab: HeaderTab) => void; -}; - -export default function EditorHeader(props: PropsType) { - const { app, updateApp } = useApp(); - const { url, start: startPreview, stop: stopPreview, status: previewStatus } = usePreview(); - const { status: npmInstallStatus, nodeModulesExists } = usePackageJson(); - const [isExporting, setIsExporting] = useState(false); - const { open, togglePane, panelIcon } = useLogs(); - - const [nameChangeDialogOpen, setNameChangeDialogOpen] = useState(false); - - const handleExport = async () => { - try { - setIsExporting(true); - const blob = await exportApp(app.id, app.name); - const url = window.URL.createObjectURL(blob); - - // Create a temporary anchor element to trigger the download - const a = document.createElement('a'); - a.style.display = 'none'; - a.href = url; - a.download = `${app.name}.zip`; - - // Append to the document, trigger click, and remove - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - - toast.success('App exported successfully!'); - setIsExporting(false); - } catch (error) { - console.error('Export failed:', error); - toast.error('Failed to export app. Please try again.'); - } - }; - - return ( - <> - {nameChangeDialogOpen && ( - { - updateApp({ name }); - setNameChangeDialogOpen(false); - }} - onClose={() => { - setNameChangeDialogOpen(false); - }} - /> - )} - - {npmInstallStatus === 'installing' ? ( -
-
-
- ) : null} - -
- - - - -
- - ); -} - -function UpdateAppNameDialog(props: { - name: string; - onClose: () => void; - onUpdate: (name: string) => void; -}) { - const [name, setName] = useState(props.name); - - return ( - { - if (!open) { - props.onClose(); - } - }} - > - - - Rename app - Rename this app -
- setName(e.currentTarget.value)} /> -
-
- - -
-
-
-
- ); -} diff --git a/packages/web/src/components/apps/lib/diff.ts b/packages/web/src/components/apps/lib/diff.ts deleted file mode 100644 index 5407b8ff7..000000000 --- a/packages/web/src/components/apps/lib/diff.ts +++ /dev/null @@ -1,87 +0,0 @@ -import * as Diff from 'diff'; - -export function diffFiles( - original: string, - modified: string, -): { additions: number; deletions: number } { - const changes: Diff.Change[] = Diff.diffLines(original, modified); - - let additions: number = 0; - let deletions: number = 0; - - changes.forEach((part: Diff.Change) => { - if (part.added) { - additions += part.count ?? 0; - } else if (part.removed) { - deletions += part.count ?? 0; - } - }); - - return { additions, deletions }; -} - -type AddedType = 1; -type RemovedType = -1; -type UnChangedType = 0; -type ChangeType = AddedType | RemovedType | UnChangedType; - -export function calculateSquares( - additions: number, - deletions: number, - maxSquares: number = 5, -): ChangeType[] { - const totalChanges = additions + deletions; - - if (totalChanges === 0) { - return Array(maxSquares).fill(0); - } - - if (totalChanges <= maxSquares) { - return createSquares(additions, deletions, maxSquares); - } - - // Calculate the proportion of added and removed lines - const addedProportion = additions / totalChanges; - - // Calculate the number of squares for added, ensuring at least 1 if there are any additions - let addedSquares = Math.round(addedProportion * maxSquares); - addedSquares = additions > 0 ? Math.max(1, addedSquares) : 0; - - // Calculate removed squares, ensuring at least 1 if there are any removals - let deletedSquares = maxSquares - addedSquares; - deletedSquares = deletions > 0 ? Math.max(1, deletedSquares) : 0; - - // Final adjustment to ensure we don't exceed maxSquares - if (addedSquares + deletedSquares > maxSquares) { - if (additions > deletions) { - deletedSquares = maxSquares - addedSquares; - } else { - addedSquares = maxSquares - deletedSquares; - } - } - - return createSquares(addedSquares, deletedSquares, maxSquares); -} - -function createSquares(added: number, deleted: number, max: number): ChangeType[] { - if (added + deleted > max) { - console.error(`Expected max ${max} squares but got ${added + deleted}`); - } - - const result: ChangeType[] = []; - - for (let i = 0; i < added; i++) { - result.push(1); - } - - for (let i = 0; i < deleted; i++) { - result.push(-1); - } - - // If there's remaining space, fill with 'unchanged' - for (let i = 0, len = max - result.length; i < len; i++) { - result.push(0); - } - - return result; -} diff --git a/packages/web/src/components/apps/lib/file-tree.ts b/packages/web/src/components/apps/lib/file-tree.ts deleted file mode 100644 index f30aadd76..000000000 --- a/packages/web/src/components/apps/lib/file-tree.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { DirEntryType, FileEntryType, FsEntryTreeType } from '@srcbook/shared'; - -/** - * Sorts a file tree (in place) by name. Folders come first, then files. - */ -export function sortTree(tree: DirEntryType): DirEntryType { - tree.children?.sort((a, b) => { - if (a.type === 'directory') sortTree(a); - if (b.type === 'directory') sortTree(b); - if (a.type === 'directory' && b.type === 'file') return -1; - if (a.type === 'file' && b.type === 'directory') return 1; - return a.basename.localeCompare(b.basename); - }); - - return tree; -} - -/** - * Update a directory node in the file tree. - * - * This function is complex due to the merging of children. We do it to maintain - * nested state of a given tree. Consider the following file tree that the user - * has open in their file tree viewer: - * - * /src - * │ - * ├── components - * │ ├── ui - * │ │ └── table - * │ │ ├── index.tsx - * │ │ └── show.tsx - * │ │ - * │ └── use-files.tsx - * │ - * └── index.tsx - * - * If the user closes and then reopens the "components" folder, the reopening of - * the "components" folder will make a call to load its children. However, calls - * to load children only load the immediate children, not all nested children. - * This means that the call will not load the "ui" folder's children. - * - * Now, given that the user had previously opened the "ui" folder and we have the - * results of that folder loaded in our state, we don't want to throw away those - * values. So we merge the children of the new node and any nested children of - * the old node. - * - * This supports behavior where a user may open many nested folders and then close - * and later reopen a ancestor folder. We want the tree to look the same when the - * reopen occurs with only the immediate children updated. - */ -export function updateDirNode(tree: DirEntryType, node: DirEntryType): DirEntryType { - return sortTree(doUpdateDirNode(tree, node)); -} - -function doUpdateDirNode(tree: DirEntryType, node: DirEntryType): DirEntryType { - if (tree.path === node.path) { - if (node.children === null) { - return { ...node, children: tree.children }; - } else { - return { ...node, children: merge(tree.children, node.children) }; - } - } - - if (tree.children) { - return { - ...tree, - children: tree.children.map((entry) => { - if (entry.type === 'directory') { - return doUpdateDirNode(entry, node); - } else { - return entry; - } - }), - }; - } - - return tree; -} - -function merge(oldChildren: FsEntryTreeType | null, newChildren: FsEntryTreeType): FsEntryTreeType { - if (!oldChildren) { - return newChildren; - } - - return newChildren.map((newChild) => { - const oldChild = oldChildren.find((old) => old.path === newChild.path); - - if (oldChild && oldChild.type === 'directory' && newChild.type === 'directory') { - return { - ...newChild, - children: - newChild.children === null - ? oldChild.children - : merge(oldChild.children, newChild.children), - }; - } - - return newChild; - }); -} - -export function renameDirNode( - tree: DirEntryType, - oldNode: DirEntryType, - newNode: DirEntryType, -): DirEntryType { - return sortTree(doRenameDirNode(tree, oldNode, newNode)); -} - -function doRenameDirNode( - tree: DirEntryType, - oldNode: DirEntryType, - newNode: DirEntryType, -): DirEntryType { - const children = - tree.children === null - ? null - : tree.children.map((entry) => { - if (entry.type === 'directory') { - return doRenameDirNode(entry, oldNode, newNode); - } else { - if (entry.path.startsWith(oldNode.path)) { - return { ...entry, path: entry.path.replace(oldNode.path, newNode.path) }; - } else { - return entry; - } - } - }); - - if (tree.path === oldNode.path) { - return { ...newNode, children }; - } else if (tree.path.startsWith(oldNode.path)) { - const path = tree.path.replace(oldNode.path, newNode.path); - return { ...tree, path, children }; - } else { - return { ...tree, children }; - } -} - -export function updateFileNode( - tree: DirEntryType, - oldNode: FileEntryType, - newNode: FileEntryType, -): DirEntryType { - return sortTree(doUpdateFileNode(tree, oldNode, newNode)); -} - -function doUpdateFileNode( - tree: DirEntryType, - oldNode: FileEntryType, - newNode: FileEntryType, -): DirEntryType { - if (tree.children === null) { - return tree; - } - - const children = []; - - for (const entry of tree.children) { - if (entry.path === oldNode.path) { - children.push(newNode); - } else { - if (entry.type === 'directory') { - children.push(doUpdateFileNode(entry, oldNode, newNode)); - } else { - children.push(entry); - } - } - } - - return { ...tree, children }; -} - -/** - * Delete a node from the file tree. - * - * This doesn't affect sort order, so no need to call sortTree. - */ -export function deleteNode(tree: DirEntryType, path: string): DirEntryType { - if (tree.children === null) { - return tree; - } - - const children: FsEntryTreeType = []; - - for (const entry of tree.children) { - if (entry.path === path) { - continue; - } - - if (entry.type === 'directory') { - children.push(deleteNode(entry, path)); - } else { - children.push(entry); - } - } - - return { ...tree, children }; -} - -/** - * Create a new node in the file tree. - */ -export function createNode(tree: DirEntryType, node: DirEntryType | FileEntryType): DirEntryType { - return sortTree(doCreateNode(tree, node)); -} - -function doCreateNode(tree: DirEntryType, node: DirEntryType | FileEntryType): DirEntryType { - if (tree.children === null) { - return tree; - } - - // To avoid duplicate entries in the tree, ensure that we 'upsert' here. - if (tree.path === node.dirname) { - const idx = tree.children.findIndex((entry) => entry.path === node.path); - const children = [...tree.children]; - - if (idx === -1) { - children.push(node); - } else { - children.splice(idx, 1, node); - } - - return { ...tree, children }; - } - - const children = tree.children.map((entry) => { - if (entry.type === 'directory') { - return doCreateNode(entry, node); - } else { - return entry; - } - }); - - return { ...tree, children }; -} diff --git a/packages/web/src/components/apps/lib/path.ts b/packages/web/src/components/apps/lib/path.ts deleted file mode 100644 index d92407176..000000000 --- a/packages/web/src/components/apps/lib/path.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file and client side code assumes posix paths. It is incomplete and handles basic -// functionality. That should be ok as we expect a subset of behavior and assume simple paths. - -export function extname(path: string) { - const idx = path.lastIndexOf('.'); - return idx === -1 ? '' : path.slice(idx); -} diff --git a/packages/web/src/components/apps/local-storage.ts b/packages/web/src/components/apps/local-storage.ts deleted file mode 100644 index 553420872..000000000 --- a/packages/web/src/components/apps/local-storage.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { FileType } from '@srcbook/shared'; - -export function getLastOpenedFile(appId: string) { - const value = window.localStorage.getItem(`apps:${appId}:last_opened_file`); - - if (typeof value === 'string') { - return JSON.parse(value); - } - - return null; -} - -export function setLastOpenedFile(appId: string, file: FileType) { - return window.localStorage.setItem(`apps:${appId}:last_opened_file`, JSON.stringify(file)); -} diff --git a/packages/web/src/components/apps/markdown.tsx b/packages/web/src/components/apps/markdown.tsx deleted file mode 100644 index b5df654c7..000000000 --- a/packages/web/src/components/apps/markdown.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import MarkdownReact from 'marked-react'; -import { cn } from '@srcbook/components'; - -export default function Markdown(props: { source: string; className?: string }) { - return ( -
- {props.source} -
- ); -} diff --git a/packages/web/src/components/apps/package-install-toast.tsx b/packages/web/src/components/apps/package-install-toast.tsx deleted file mode 100644 index 004c6e3cc..000000000 --- a/packages/web/src/components/apps/package-install-toast.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useEffect, useState } from 'react'; -import { CircleAlertIcon, InfoIcon, Loader2Icon } from 'lucide-react'; - -import { usePackageJson } from './use-package-json'; -import { useLogs } from './use-logs'; -import { Button } from '@srcbook/components/src/components/ui/button'; -import { cn } from '@/lib/utils'; - -const ToastWrapper: React.FC<{ - showToast: boolean; - className?: string; - children: React.ReactNode; -}> = ({ className, showToast, children }) => ( -
- {children} -
-); - -const PackageInstallToast: React.FunctionComponent = () => { - const { togglePane } = useLogs(); - const { status, npmInstall, nodeModulesExists } = usePackageJson(); - const [showToast, setShowToast] = useState(false); - - useEffect(() => { - if (nodeModulesExists === false && (status === 'idle' || status === 'complete')) { - setShowToast(true); - } else if (nodeModulesExists === true) { - setShowToast(false); - } - }, [nodeModulesExists, status]); - - switch (status) { - case 'installing': - return ( - -
- - Installing Packages... -
- - -
- ); - - case 'failed': - return ( - -
- - Packages failed to install -
- -
- - -
-
- ); - - case 'idle': - case 'complete': - return ( - -
- - Packages need to be installed -
- - -
- ); - } -}; - -export default PackageInstallToast; diff --git a/packages/web/src/components/apps/panels/explorer.tsx b/packages/web/src/components/apps/panels/explorer.tsx deleted file mode 100644 index cc37ba0a6..000000000 --- a/packages/web/src/components/apps/panels/explorer.tsx +++ /dev/null @@ -1,384 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { FileIcon, ChevronRightIcon } from 'lucide-react'; -import { useFiles } from '../use-files'; -import type { DirEntryType, FileEntryType } from '@srcbook/shared'; -import { cn } from '@srcbook/components'; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from '@srcbook/components/src/components/ui/context-menu'; -import { useVersion } from '../use-version'; - -export default function ExplorerPanel() { - const { fileTree } = useFiles(); - const { currentVersion } = useVersion(); - const [editingEntry, setEditingEntry] = useState(null); - const [newEntry, setNewEntry] = useState(null); - - return ( -
- - -
    - -
-
- - - setNewEntry({ type: 'file', path: 'untitled', dirname: '.', basename: 'untitled' }) - } - > - New file... - - - setNewEntry({ - type: 'directory', - path: 'untitled', - dirname: '.', - basename: 'untitled', - children: null, - }) - } - > - New folder... - - -
- - {currentVersion && ( -

- version: {currentVersion.sha.slice(0, 7)} -

- )} -
- ); -} - -function FileTree(props: { - depth: number; - tree: DirEntryType; - newEntry: FileEntryType | DirEntryType | null; - setNewEntry: (entry: FileEntryType | DirEntryType | null) => void; - editingEntry: FileEntryType | DirEntryType | null; - setEditingEntry: (entry: FileEntryType | DirEntryType | null) => void; -}) { - const { depth, tree, newEntry, setNewEntry, editingEntry, setEditingEntry } = props; - - const { - openFile, - createFile, - deleteFile, - renameFile, - openedFile, - toggleFolder, - isFolderOpen, - openFolder, - createFolder, - deleteFolder, - renameFolder, - } = useFiles(); - - if (tree.children === null) { - return null; - } - - const dirEntries = []; - const fileEntries = []; - - for (const entry of tree.children) { - if (entry.type === 'directory') { - dirEntries.push(entry); - } else { - fileEntries.push(entry); - } - } - - const elements = []; - - if (newEntry !== null && newEntry.type === 'directory' && newEntry.dirname === tree.path) { - elements.push( -
  • - { - createFolder(tree.path, name); - setNewEntry(null); - }} - onCancel={() => setNewEntry(null)} - /> -
  • , - ); - } - - for (const entry of dirEntries) { - const opened = isFolderOpen(entry); - - if (editingEntry?.path === entry.path) { - elements.push( -
  • - { - renameFolder(entry, name); - setEditingEntry(null); - }} - onCancel={() => setEditingEntry(null)} - /> -
  • , - ); - } else { - elements.push( -
  • - toggleFolder(entry)} - onDelete={() => deleteFolder(entry)} - onRename={() => setEditingEntry(entry)} - onNewFile={() => { - if (!isFolderOpen(entry)) { - openFolder(entry); - } - setNewEntry({ - type: 'file', - path: entry.path + '/untitled', - dirname: entry.path, - basename: 'untitled', - }); - }} - onNewfolder={() => { - if (!isFolderOpen(entry)) { - openFolder(entry); - } - setNewEntry({ - type: 'directory', - path: entry.path + '/untitled', - dirname: entry.path, - basename: 'untitled', - children: null, - }); - }} - /> -
  • , - ); - } - - if (opened) { - elements.push( - , - ); - } - } - - if (newEntry !== null && newEntry.type === 'file' && newEntry.dirname === tree.path) { - elements.push( -
  • - { - const diskEntry = await createFile(tree.path, name); - openFile(diskEntry); - setNewEntry(null); - }} - onCancel={() => setNewEntry(null)} - /> -
  • , - ); - } - - for (const entry of fileEntries) { - if (entry.path === editingEntry?.path) { - elements.push( -
  • - { - renameFile(entry, name); - setEditingEntry(null); - }} - onCancel={() => setEditingEntry(null)} - /> -
  • , - ); - } else { - elements.push( -
  • - openFile(entry)} - onDelete={() => deleteFile(entry)} - onRename={() => setEditingEntry(entry)} - /> -
  • , - ); - } - } - - return elements; -} - -function FileNode(props: { - depth: number; - label: string; - active: boolean; - onClick: () => void; - onDelete: () => void; - onRename: () => void; -}) { - return ( - - - } /> - - - Rename - Delete - - - ); -} - -function FolderNode(props: { - depth: number; - label: string; - opened: boolean; - onClick: () => void; - onDelete: () => void; - onRename: () => void; - onNewFile: () => void; - onNewfolder: () => void; -}) { - return ( - - - - } - /> - - { - // This is an important line of code. It is needed to prevent focus - // from returning to other elements when this menu is closed. Without this, - // when a user clicks "New [file|folder]" or "Rename", the input box will - // render and sometimes immediately dismiss because this returns focus to - // the button element the user right clicked on, causing the input's onBlur - // to trigger. - e.preventDefault(); - }} - > - New file... - New folder... - Rename - Delete - - - ); -} - -function EditNameNode(props: { - depth: number; - name: string; - onSubmit: (name: string) => void; - onCancel: () => void; -}) { - const ref = useRef(null); - - useEffect(() => { - function focusAndSelect() { - const input = ref.current; - - if (input) { - input.focus(); - const idx = input.value.lastIndexOf('.'); - input.setSelectionRange(0, idx === -1 ? input.value.length : idx); - } - } - - // This setTimeout is intentional. We need to draw focus to this - // input after the current event loop clears out because other elements - // are getting focused in some situations immediately after this renders. - setTimeout(focusAndSelect, 0); - }, []); - - return ( - { - if (e.key === 'Enter' && ref.current) { - e.preventDefault(); - e.stopPropagation(); - props.onSubmit(ref.current.value); - } else if (e.key === 'Escape') { - ref.current?.blur(); - } - }} - /> - ); -} - -function Node(props: { - depth: number; - label: string; - icon: React.ReactNode; - active?: boolean; - onClick: () => void; -}) { - const { depth, label, icon, active, onClick } = props; - - return ( - - ); -} diff --git a/packages/web/src/components/apps/panels/settings.tsx b/packages/web/src/components/apps/panels/settings.tsx deleted file mode 100644 index dc5954ea4..000000000 --- a/packages/web/src/components/apps/panels/settings.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Button } from '@srcbook/components/src/components/ui/button'; -import { usePackageJson } from '../use-package-json'; -import { PackagePlus } from 'lucide-react'; -import Shortcut from '@srcbook/components/src/components/keyboard-shortcut'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@srcbook/components/src/components/ui/tooltip'; - -export default function PackagesPanel() { - const { setShowInstallModal, npmInstall, clearNodeModules, nodeModulesExists, status } = - usePackageJson(); - - return ( -
    -
    -

    - To add packages, you can simply ask the AI in chat, or use the button below. -

    - -
    - - - - - - - Install packages - - - -
    -
    - -
    -

    - If you suspect your node_modules are corrupted, you can clear them and reinstall all - packages. -

    -
    - -
    -
    - -
    -

    - Re-run npm install. This will run against the package.json - from the project root. -

    -
    - -
    -
    -
    - ); -} diff --git a/packages/web/src/components/apps/sidebar.tsx b/packages/web/src/components/apps/sidebar.tsx deleted file mode 100644 index dd82aa53d..000000000 --- a/packages/web/src/components/apps/sidebar.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { useState } from 'react'; -import useTheme from '@srcbook/components/src/components/use-theme'; - -import { - ChevronsLeftIcon, - FlagIcon, - FolderTreeIcon, - KeyboardIcon, - MoonIcon, - PackageIcon, - SunIcon, -} from 'lucide-react'; -import { Button } from '@srcbook/components/src/components/ui/button'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@srcbook/components/src/components/ui/tooltip'; -import KeyboardShortcutsDialog from '../keyboard-shortcuts-dialog'; -import FeedbackDialog from '../feedback-dialog'; -import { cn } from '@/lib/utils'; -import ExplorerPanel from './panels/explorer'; -import PackagesPanel from './panels/settings'; -import { usePackageJson } from './use-package-json'; - -export type PanelType = 'explorer' | 'packages'; - -function getTitleForPanel(panel: PanelType | null): string | null { - switch (panel) { - case 'explorer': - return 'Files'; - case 'packages': - return 'Manage Packages'; - default: - return null; - } -} - -type SidebarProps = { - initialPanel: PanelType | null; -}; - -export default function Sidebar({ initialPanel }: SidebarProps) { - const { theme, toggleTheme } = useTheme(); - - const { status } = usePackageJson(); - const [panel, _setPanel] = useState(initialPanel); - const [showShortcuts, setShowShortcuts] = useState(false); - const [showFeedback, setShowFeedback] = useState(false); - - function setPanel(nextPanel: PanelType) { - _setPanel(nextPanel === panel ? null : nextPanel); - } - - return ( - <> - - - -
    -
    -
    - setPanel('explorer')}> - - - setPanel('packages')}> - - -
    -
    - - {theme === 'light' ? ( - - ) : ( - - )} - - setShowShortcuts(true)} - > - - - setShowFeedback(true)} - > - - -
    -
    - { - if (panel !== null) { - setPanel(panel); - } - }} - > - {panel === 'explorer' && } - {panel === 'packages' && } - -
    - - ); -} - -function NavItemWithTooltip(props: { - children: React.ReactNode; - tooltipContent: string; - onClick: () => void; -}) { - return ( - - - - - - {props.tooltipContent} - - - ); -} - -function Panel(props: { - open: boolean; - title: string | null; - onClose: () => void; - children: React.ReactNode; -}) { - if (!props.open) { - return null; - } - - return ( -
    -
    -

    {props.title}

    - -
    -
    {props.children}
    -
    - ); -} diff --git a/packages/web/src/components/apps/types.ts b/packages/web/src/components/apps/types.ts deleted file mode 100644 index cf2c0cd49..000000000 --- a/packages/web/src/components/apps/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { CommandMessageType } from '@srcbook/shared'; - -export type FileType = { - type: 'file'; - modified: string; - original: string | null; - path: string; - basename: string; - dirname: string; - description: string; -}; - -// TODO this should likely all be shared types eventually. -export type PlanItemType = FileType | CommandMessageType; - -export type PlanType = { - id: string; - query: string; - description: string; - actions: Array; -}; diff --git a/packages/web/src/components/apps/use-app.tsx b/packages/web/src/components/apps/use-app.tsx deleted file mode 100644 index a7f2f4206..000000000 --- a/packages/web/src/components/apps/use-app.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { createContext, useContext, useEffect, useRef, useState } from 'react'; -import type { AppType } from '@srcbook/shared'; -import { updateApp as doUpdateApp } from '@/clients/http/apps'; -import { AppChannel } from '@/clients/websocket'; - -export interface AppContextValue { - app: AppType; - channel: AppChannel; - updateApp: (attrs: { name: string }) => void; -} - -const AppContext = createContext(undefined); - -type ProviderPropsType = { - app: AppType; - children: React.ReactNode; -}; - -export function AppProvider({ app: initialApp, children }: ProviderPropsType) { - const [app, setApp] = useState(initialApp); - - const channelRef = useRef(AppChannel.create(app.id)); - - useEffect(() => { - // If the app ID has changed, create a new channel for the new app. - if (channelRef.current.appId !== app.id) { - channelRef.current.unsubscribe(); - channelRef.current = AppChannel.create(app.id); - } - - // Subscribe to the channel - channelRef.current.subscribe(); - - // Unsubscribe when the component is unmounted - return () => channelRef.current.unsubscribe(); - }, [app.id]); - - async function updateApp(attrs: { name: string }) { - const { data: updatedApp } = await doUpdateApp(app.id, attrs); - setApp(updatedApp); - } - - return ( - - {children} - - ); -} - -export function useApp(): AppContextValue { - return useContext(AppContext) as AppContextValue; -} diff --git a/packages/web/src/components/apps/use-files.tsx b/packages/web/src/components/apps/use-files.tsx deleted file mode 100644 index 47eee2e43..000000000 --- a/packages/web/src/components/apps/use-files.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React, { - createContext, - useCallback, - useContext, - useEffect, - useReducer, - useRef, - useState, -} from 'react'; - -import type { - FileType, - DirEntryType, - FileEntryType, - FileUpdatedPayloadType, -} from '@srcbook/shared'; -import { AppChannel } from '@/clients/websocket'; -import { - createFile as doCreateFile, - deleteFile as doDeleteFile, - renameFile as doRenameFile, - createDirectory, - deleteDirectory, - renameDirectory, - loadDirectory, -} from '@/clients/http/apps'; -import { - createNode, - deleteNode, - renameDirNode, - sortTree, - updateDirNode, - updateFileNode, -} from './lib/file-tree'; -import { useApp } from './use-app'; -import { useNavigate } from 'react-router-dom'; -import { setLastOpenedFile } from './local-storage'; - -export interface FilesContextValue { - fileTree: DirEntryType; - openedFile: FileType | null; - openFile: (entry: FileEntryType) => void; - createFile: (dirname: string, basename: string, source?: string) => Promise; - updateFile: (modified: FileType) => void; - renameFile: (entry: FileEntryType, name: string) => Promise; - deleteFile: (entry: FileEntryType) => Promise; - createFolder: (dirname: string, basename: string) => Promise; - renameFolder: (entry: DirEntryType, name: string) => Promise; - deleteFolder: (entry: DirEntryType) => Promise; - openFolder: (entry: DirEntryType) => Promise; - closeFolder: (entry: DirEntryType) => void; - toggleFolder: (entry: DirEntryType) => void; - isFolderOpen: (entry: DirEntryType) => boolean; -} - -const FilesContext = createContext(undefined); - -type ProviderPropsType = { - channel: AppChannel; - children: React.ReactNode; - initialOpenedFile: FileType | null; - rootDirEntries: DirEntryType; -}; - -export function FilesProvider({ - channel, - rootDirEntries, - initialOpenedFile, - children, -}: ProviderPropsType) { - // Because we use refs for our state, we need a way to trigger - // component re-renders when the ref state changes. - // - // https://legacy.reactjs.org/docs/hooks-faq.html#is-there-something-like-forceupdate - // - const [, forceComponentRerender] = useReducer((x) => x + 1, 0); - - const { app } = useApp(); - const navigateTo = useNavigate(); - - const fileTreeRef = useRef(sortTree(rootDirEntries)); - const openedDirectoriesRef = useRef>(new Set()); - const [openedFile, _setOpenedFile] = useState(initialOpenedFile); - - const setOpenedFile = useCallback( - (fn: (file: FileType | null) => FileType | null) => { - _setOpenedFile((prevOpenedFile) => { - const openedFile = fn(prevOpenedFile); - if (openedFile) { - setLastOpenedFile(app.id, openedFile); - } - return openedFile; - }); - }, - [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)}`); - }, - [app.id, navigateTo], - ); - - useEffect(() => { - if (initialOpenedFile !== null && initialOpenedFile?.path !== openedFile?.path) { - setOpenedFile(() => initialOpenedFile); - } - }, [initialOpenedFile, openedFile?.path, setOpenedFile]); - - const openFile = useCallback( - (entry: FileEntryType) => { - navigateToFile(entry); - }, - [navigateToFile], - ); - - const createFile = useCallback( - async (dirname: string, basename: string, source?: string) => { - source = source || ''; - const { data: fileEntry } = await doCreateFile(app.id, dirname, basename, source); - fileTreeRef.current = createNode(fileTreeRef.current, fileEntry); - forceComponentRerender(); // required - return fileEntry; - }, - [app.id], - ); - - const updateFile = useCallback( - (modified: FileType) => { - channel.push('file:updated', { file: modified }); - setOpenedFile(() => modified); - forceComponentRerender(); - }, - [channel, setOpenedFile], - ); - - const deleteFile = useCallback( - async (entry: FileEntryType) => { - await doDeleteFile(app.id, entry.path); - setOpenedFile((openedFile) => { - if (openedFile && openedFile.path === entry.path) { - return null; - } - return openedFile; - }); - fileTreeRef.current = deleteNode(fileTreeRef.current, entry.path); - forceComponentRerender(); // required - }, - [app.id, setOpenedFile], - ); - - const renameFile = useCallback( - async (entry: FileEntryType, name: string) => { - const { data: newEntry } = await doRenameFile(app.id, entry.path, name); - setOpenedFile((openedFile) => { - if (openedFile && openedFile.path === entry.path) { - return { ...openedFile, path: newEntry.path, name: newEntry.basename }; - } - return openedFile; - }); - fileTreeRef.current = updateFileNode(fileTreeRef.current, entry, newEntry); - forceComponentRerender(); // required - }, - [app.id, setOpenedFile], - ); - - const isFolderOpen = useCallback((entry: DirEntryType) => { - return openedDirectoriesRef.current.has(entry.path); - }, []); - - const openFolder = useCallback( - async (entry: DirEntryType) => { - // Optimistically open the folder. - openedDirectoriesRef.current.add(entry.path); - forceComponentRerender(); - const { data: directory } = await loadDirectory(app.id, entry.path); - fileTreeRef.current = updateDirNode(fileTreeRef.current, directory); - forceComponentRerender(); - }, - [app.id], - ); - - const closeFolder = useCallback((entry: DirEntryType) => { - openedDirectoriesRef.current.delete(entry.path); - forceComponentRerender(); - }, []); - - const toggleFolder = useCallback( - (entry: DirEntryType) => { - if (isFolderOpen(entry)) { - closeFolder(entry); - } else { - openFolder(entry); - } - }, - [isFolderOpen, openFolder, closeFolder], - ); - - const createFolder = useCallback( - async (dirname: string, basename: string) => { - const { data: folderEntry } = await createDirectory(app.id, dirname, basename); - fileTreeRef.current = createNode(fileTreeRef.current, folderEntry); - forceComponentRerender(); // required - openFolder(folderEntry); - }, - [app.id, openFolder], - ); - - const deleteFolder = useCallback( - async (entry: DirEntryType) => { - await deleteDirectory(app.id, entry.path); - setOpenedFile((openedFile) => { - if (openedFile && openedFile.path.startsWith(entry.path)) { - return null; - } - return openedFile; - }); - openedDirectoriesRef.current.delete(entry.path); - fileTreeRef.current = deleteNode(fileTreeRef.current, entry.path); - forceComponentRerender(); // required - }, - [app.id, setOpenedFile], - ); - - const renameFolder = useCallback( - async (entry: DirEntryType, name: string) => { - const { data: newEntry } = await renameDirectory(app.id, entry.path, name); - - setOpenedFile((openedFile) => { - if (openedFile && openedFile.path.startsWith(entry.path)) { - return { ...openedFile, path: openedFile.path.replace(entry.path, newEntry.path) }; - } - return openedFile; - }); - - if (openedDirectoriesRef.current.has(entry.path)) { - openedDirectoriesRef.current.delete(entry.path); - openedDirectoriesRef.current.add(newEntry.path); - } - - fileTreeRef.current = renameDirNode(fileTreeRef.current, entry, newEntry); - - forceComponentRerender(); // required - }, - [app.id, setOpenedFile], - ); - - const context: FilesContextValue = { - fileTree: fileTreeRef.current, - openedFile, - openFile, - createFile, - updateFile, - renameFile, - deleteFile, - createFolder, - renameFolder, - deleteFolder, - openFolder, - closeFolder, - toggleFolder, - isFolderOpen, - }; - - return {children}; -} - -export function useFiles(): FilesContextValue { - return useContext(FilesContext) as FilesContextValue; -} diff --git a/packages/web/src/components/apps/use-logs.tsx b/packages/web/src/components/apps/use-logs.tsx deleted file mode 100644 index d9dc49469..000000000 --- a/packages/web/src/components/apps/use-logs.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; - -import { AppChannel } from '@/clients/websocket'; -import { DepsInstallLogPayloadType, PreviewLogPayloadType } from '@srcbook/shared'; - -export type LogMessage = { - type: 'stderr' | 'stdout' | 'info'; - source: 'srcbook' | 'vite' | 'npm'; - timestamp: Date; - message: string; -}; - -export interface LogsContextValue { - logs: Array; - clearLogs: () => void; - unreadLogsCount: number; - panelIcon: 'default' | 'error'; - - addLog: (type: LogMessage['type'], source: LogMessage['source'], message: string) => void; - - open: boolean; - togglePane: () => void; - closePane: () => void; -} - -const LogsContext = createContext(undefined); - -type ProviderPropsType = { - channel: AppChannel; - children: React.ReactNode; -}; - -export function LogsProvider({ channel, children }: ProviderPropsType) { - const [logs, setLogs] = useState>([]); - const [unreadLogsCount, setUnreadLogsCount] = useState(0); - const [panelIcon, setPanelIcon] = useState('default'); - - const [open, setOpen] = useState(false); - - function clearLogs() { - setLogs([]); - setPanelIcon('default'); - setUnreadLogsCount(0); - } - - const addLog = useCallback( - (type: LogMessage['type'], source: LogMessage['source'], message: LogMessage['message']) => { - setLogs((logs) => [...logs, { type, message, source, timestamp: new Date() }]); - if (type === 'stderr') { - setPanelIcon('error'); - } - setUnreadLogsCount((n) => n + 1); - }, - [], - ); - - function togglePane() { - setOpen((n) => !n); - setPanelIcon('default'); - setUnreadLogsCount(0); - } - - function closePane() { - setOpen(false); - setPanelIcon('default'); - setUnreadLogsCount(0); - } - - // As the server generates logs, show them in the logs panel - useEffect(() => { - function onPreviewLog(payload: PreviewLogPayloadType) { - for (const row of payload.log.data.split('\n')) { - addLog(payload.log.type, 'vite', row); - } - } - - channel.on('preview:log', onPreviewLog); - - function onDepsInstallLog(payload: DepsInstallLogPayloadType) { - for (const row of payload.log.data.split('\n')) { - addLog(payload.log.type, 'npm', row); - } - } - channel.on('deps:install:log', onDepsInstallLog); - - return () => { - channel.off('preview:log', onPreviewLog); - channel.off('deps:install:log', onDepsInstallLog); - }; - }, [channel, addLog]); - - return ( - - {children} - - ); -} - -export function useLogs(): LogsContextValue { - return useContext(LogsContext) as LogsContextValue; -} diff --git a/packages/web/src/components/apps/use-package-json.tsx b/packages/web/src/components/apps/use-package-json.tsx deleted file mode 100644 index 1c0d1c215..000000000 --- a/packages/web/src/components/apps/use-package-json.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; -import { OutputType } from '@srcbook/components/src/types'; -import { AppChannel } from '@/clients/websocket'; -import { - DepsInstallLogPayloadType, - DepsInstallStatusPayloadType, - DepsStatusResponsePayloadType, -} from '@srcbook/shared'; -import { useLogs } from './use-logs'; - -type NpmInstallStatus = 'idle' | 'installing' | 'complete' | 'failed'; - -export interface PackageJsonContextValue { - npmInstall: (packages?: string[]) => Promise; - clearNodeModules: () => void; - - nodeModulesExists: boolean | null; - status: NpmInstallStatus; - installing: boolean; - failed: boolean; - output: Array; - showInstallModal: boolean; - setShowInstallModal: (value: boolean) => void; -} - -const PackageJsonContext = createContext(undefined); - -type ProviderPropsType = { - channel: AppChannel; - children: React.ReactNode; -}; - -export function PackageJsonProvider({ channel, children }: ProviderPropsType) { - const [status, setStatus] = useState('idle'); - const [output, setOutput] = useState>([]); - const [nodeModulesExists, setNodeModulesExists] = useState(null); - const [showInstallModal, setShowInstallModal] = useState(false); - const { addLog } = useLogs(); - - useEffect(() => { - channel.push('deps:status', {}); - }, [channel]); - - useEffect(() => { - const callback = (data: DepsStatusResponsePayloadType) => { - setNodeModulesExists(data.nodeModulesExists); - }; - channel.on('deps:status:response', callback); - - return () => { - channel.off('deps:status:response', callback); - }; - }, [channel]); - - useEffect(() => { - const callback = (payload: DepsInstallStatusPayloadType) => { - setStatus(payload.status); - }; - channel.on('deps:install:status', callback); - - return () => { - channel.off('deps:install:status', callback); - }; - }, [channel]); - - const npmInstall = useCallback( - async (packages?: Array) => { - addLog( - 'info', - 'srcbook', - `Running ${!packages ? 'npm install' : `npm install ${packages.join(' ')}`}...`, - ); - - // NOTE: caching of the log output is required here because socket events that call callback - // functions in here hold on to old scope values - let contents = ''; - - return new Promise((resolve, reject) => { - const logCallback = ({ log }: DepsInstallLogPayloadType) => { - setOutput((old) => [...old, log]); - contents += log.data; - }; - channel.on('deps:install:log', logCallback); - - const statusCallback = (payload: DepsInstallStatusPayloadType) => { - switch (payload.status) { - case 'installing': - break; - case 'failed': - case 'complete': - channel.off('deps:install:log', logCallback); - channel.off('deps:install:status', statusCallback); - - addLog( - 'info', - 'srcbook', - `${!packages ? 'npm install' : `npm install ${packages.join(' ')}`} exited with status code ${payload.code}`, - ); - - if (payload.status === 'complete') { - resolve(); - } else { - reject(new Error(`Error running npm install: ${contents}`)); - } - break; - } - }; - channel.on('deps:install:status', statusCallback); - - setOutput([]); - setStatus('installing'); - channel.push('deps:install', { packages }); - }); - }, - [channel, addLog], - ); - - const clearNodeModules = useCallback(() => { - channel.push('deps:clear', {}); - setOutput([]); - }, [channel]); - - const context: PackageJsonContextValue = { - npmInstall, - clearNodeModules, - nodeModulesExists, - status, - installing: status === 'installing', - failed: status === 'failed', - output, - showInstallModal, - setShowInstallModal, - }; - - return {children}; -} - -export function usePackageJson() { - const context = useContext(PackageJsonContext); - - if (!context) { - throw new Error('usePackageJson must be used within a PackageJsonProvider'); - } - - return context; -} diff --git a/packages/web/src/components/apps/use-preview.tsx b/packages/web/src/components/apps/use-preview.tsx deleted file mode 100644 index 0ea327e52..000000000 --- a/packages/web/src/components/apps/use-preview.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; - -import { AppChannel } from '@/clients/websocket'; -import { PreviewStatusPayloadType } from '@srcbook/shared'; -import useEffectOnce from '@/components/use-effect-once'; -import { usePackageJson } from './use-package-json'; -import { useLogs } from './use-logs'; - -export type PreviewStatusType = 'booting' | 'connecting' | 'running' | 'stopped'; - -export interface PreviewContextValue { - url: string | null; - status: PreviewStatusType; - stop: () => void; - start: () => void; - exitCode: number | null; -} - -const PreviewContext = createContext(undefined); - -type ProviderPropsType = { - channel: AppChannel; - children: React.ReactNode; -}; - -export function PreviewProvider({ channel, children }: ProviderPropsType) { - const [url, setUrl] = useState(null); - const [status, setStatus] = useState('connecting'); - const [exitCode, setExitCode] = useState(null); - - const { npmInstall, nodeModulesExists } = usePackageJson(); - const { addLog } = useLogs(); - - useEffect(() => { - function onStatusUpdate(payload: PreviewStatusPayloadType) { - setUrl(payload.url); - setStatus(payload.status); - - switch (payload.status) { - case 'booting': - addLog('info', 'srcbook', 'Dev server is booting...'); - break; - case 'running': - addLog('info', 'srcbook', `Dev server is running at ${payload.url}`); - break; - case 'stopped': - addLog('info', 'srcbook', `Dev server exited with status ${payload.code}`); - setExitCode(payload.code); - break; - } - } - - channel.on('preview:status', onStatusUpdate); - - return () => channel.off('preview:status', onStatusUpdate); - }, [channel, addLog]); - - async function start() { - if (nodeModulesExists === false) { - await npmInstall(); - } - channel.push('preview:start', {}); - } - - const stop = useCallback(() => { - channel.push('preview:stop', {}); - }, [channel]); - - // If the node_modules directory gets deleted, then stop the preview server - useEffect(() => { - if (nodeModulesExists !== false) { - return; - } - stop(); - }, [nodeModulesExists, stop]); - - // When the page initially loads, start the vite server - useEffectOnce(() => { - start(); - }); - - return ( - - {children} - - ); -} - -export function usePreview(): PreviewContextValue { - return useContext(PreviewContext) as PreviewContextValue; -} diff --git a/packages/web/src/components/apps/use-version.tsx b/packages/web/src/components/apps/use-version.tsx deleted file mode 100644 index 978b5f595..000000000 --- a/packages/web/src/components/apps/use-version.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import { useApp } from './use-app'; -import { checkoutVersion, commitVersion, getCurrentVersion } from '@/clients/http/apps'; - -interface Version { - sha: string; - message?: string; -} - -interface VersionContextType { - currentVersion: Version | null; - createVersion: (message: string) => Promise; - checkout: (sha: string) => Promise; - fetchVersions: () => Promise; -} - -const VersionContext = createContext(undefined); - -export const VersionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { app } = useApp(); - const [currentVersion, setCurrentVersion] = useState(null); - - const fetchVersion = useCallback(async () => { - if (!app) return; - - try { - const currentVersionResponse = await getCurrentVersion(app.id); - setCurrentVersion({ sha: currentVersionResponse.sha }); - } catch (error) { - console.error('Error fetching current version:', error); - } - }, [app]); - - useEffect(() => { - fetchVersion(); - }, [fetchVersion]); - - const commitFiles = useCallback( - async (message: string) => { - if (!app) return; - - try { - const response = await commitVersion(app.id, message); - setCurrentVersion({ sha: response.sha, message }); - return response.sha; - } catch (error) { - console.error('Error committing files:', error); - } - }, - [app], - ); - - const checkout = useCallback( - async (sha: string) => { - if (!app) return; - - try { - const { sha: checkoutSha } = await checkoutVersion(app.id, sha); - setCurrentVersion({ sha: checkoutSha }); - } catch (error) { - console.error('Error checking out version:', error); - } - }, - [app], - ); - - return ( - - {children} - - ); -}; - -export const useVersion = () => { - const context = useContext(VersionContext); - if (context === undefined) { - throw new Error('useVersion must be used within a VersionProvider'); - } - return context; -}; diff --git a/packages/web/src/components/chat.tsx b/packages/web/src/components/chat.tsx deleted file mode 100644 index 1cdaaf66d..000000000 --- a/packages/web/src/components/chat.tsx +++ /dev/null @@ -1,662 +0,0 @@ -import { - Button, - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@srcbook/components'; -import Markdown from './apps/markdown.js'; -import { diffFiles } from './apps/lib/diff.js'; -import TextareaAutosize from 'react-textarea-autosize'; -import { - ArrowUp, - Minus, - Paperclip, - LoaderCircle, - History, - PanelTopOpen, - Loader, - ViewIcon, - Undo2Icon, - Redo2Icon, - GripHorizontal, - ThumbsUp, - ThumbsDown, - GitMerge, - EllipsisVertical, -} from 'lucide-react'; -import * as React from 'react'; -import { - aiEditApp, - loadHistory, - appendToHistory, - aiGenerationFeedback, -} from '@/clients/http/apps.js'; -import { AppType, randomid } from '@srcbook/shared'; -import { useFiles } from './apps/use-files'; -import { type FileType } from './apps/types'; -import type { - FileDiffType, - UserMessageType, - MessageType, - HistoryType, - CommandMessageType, - PlanMessageType, - DiffMessageType, -} from '@srcbook/shared'; -import { DiffStats } from './apps/diff-stats.js'; -import { useApp } from './apps/use-app.js'; -import { usePackageJson } from './apps/use-package-json.js'; -import { AiFeedbackModal } from './apps/AiFeedbackModal'; -import { useVersion } from './apps/use-version.js'; -import { Link } from 'react-router-dom'; - -function Chat({ - history, - loading, - onClose, - app, - fileDiffs, - diffApplied, - revertDiff, - reApplyDiff, - openDiffModal, -}: { - history: HistoryType; - loading: 'description' | 'actions' | null; - onClose: () => void; - app: AppType; - fileDiffs: FileDiffType[]; - diffApplied: boolean; - revertDiff: () => void; - reApplyDiff: () => void; - openDiffModal: () => void; -}) { - const { npmInstall } = usePackageJson(); - const messagesEndRef = React.useRef(null); - - // Tried scrolling with flex-direction: column-reverse but it didn't work - // with generated content, so fallback to using JS - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - React.useEffect(() => { - scrollToBottom(); - }, [history, loading]); - - return ( -
    -
    - Chat - - - -
    -
    -
    - {/* TODO: each message object needs a unique identifier */} - {history.map((message: MessageType, index: number) => { - if (message.type === 'user') { - return ( -

    - {message.message} -

    - ); - } else if (message.type === 'command') { - const packages = message.packages; - if (!packages) { - console.error( - 'The only supported command is `npm install `. Got:', - message.command, - ); - return; - } - return ( -
    -

    Install dependencies

    -
    -

    - {`npm install ${packages.join(' ')}`} -

    - -
    -
    - ); - } else if (message.type === 'plan') { - return ; - } else if (message.type === 'diff') { - // Calculate the incremental version, i.e. v1 for the first diffbox, v2 for the second, etc. - // This is separate from the git version number. - const diffs = history.filter((m) => m.type === 'diff'); - const currentDiffIndex = diffs.findIndex((m) => m === message); - const incrementalVersion = currentDiffIndex + 1; - - return ( - - ); - } - })} - -
    0 ? '' : 'hidden')}> - - -
    - - {loading !== null && ( -
    - {' '} -

    - {loading === 'description' - ? 'Generating plan...' - : 'Applying changes (this can take a while)...'} -

    -
    - )} - {/* empty div for scrolling */} -
    -
    -
    -
    - ); -} - -function Query({ - onSubmit, - onFocus, - isLoading, - isVisible, - setVisible, -}: { - onSubmit: (query: string) => Promise; - onFocus: () => void; - isLoading: boolean; - isVisible: boolean; - setVisible: (visible: boolean) => void; -}) { - const [query, setQuery] = React.useState(''); - - const handleSubmit = () => { - const value = query.trim(); - if (value) { - setQuery(''); - onSubmit(value); - } - }; - - return ( -
    - setQuery(e.target.value)} - onFocus={onFocus} - value={query} - onKeyDown={(e) => { - if (e.metaKey && !e.shiftKey && e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - handleSubmit(); - } - }} - /> - - - - - - - - Coming soon! - - - - -
    - ); -} - -function DiffBox({ - files, - app, - incrementalVersion, - version, - planId, -}: { - files: FileDiffType[]; - app: AppType; - incrementalVersion: number; - version: string; - planId: string; -}) { - const [showFeedbackToast, setShowFeedbackToast] = React.useState(false); - const [feedbackGiven, _setFeedbackGiven] = React.useState(null); - const [isFeedbackModalOpen, setIsFeedbackModalOpen] = React.useState(false); - - const { checkout, currentVersion } = useVersion(); - - const setFeedbackGiven = (feedback: 'positive' | 'negative') => { - setShowFeedbackToast(true); - _setFeedbackGiven(feedback); - setTimeout(() => setShowFeedbackToast(false), 2500); - }; - - const handleFeedbackSubmit = (feedbackText: string) => { - setFeedbackGiven('negative'); - aiGenerationFeedback(app.id, { planId, feedback: { type: 'negative', text: feedbackText } }); - }; - - return ( - <> -
    -
    -
    -
    - {app.name} - V{incrementalVersion} -
    - {/* We don't need this guard if we assume only new apps */} - {version && ( -
    -
    - - - #{version ? version.slice(0, 7) : 'unknown version'} - -
    - - - - - - - checkout(version)}> - Revert to this version - - alert('Coming soon!')}> - Fork this version - - - -
    - )} -
    -
    - {files.map((file) => ( -
    -
    - -

    {file.path}

    - - -
    -
    - ))} -
    -
    -
    -
    - - - {showFeedbackToast && ( -

    Thanks for the feedback!

    - )} -
    - setIsFeedbackModalOpen(false)} - onSubmit={handleFeedbackSubmit} - /> - - ); -} - -export function DraggableChatPanel(props: { children: React.ReactNode }): React.JSX.Element { - const [isDragging, setIsDragging] = React.useState(false); - const [position, setPosition] = React.useState({ x: 20, y: 20 }); - const chatRef = React.useRef(null); - const dragStartPos = React.useRef({ x: 0, y: 0 }); - const [showOverlay, setShowOverlay] = React.useState(false); - - const handleMouseDown = (e: React.MouseEvent) => { - if (e.target instanceof Element && e.target.closest('.drag-handle')) { - setIsDragging(true); - setShowOverlay(true); - dragStartPos.current = { - x: e.clientX + position.x, - y: e.clientY + position.y, - }; - } - }; - - const handleMouseMove = (e: MouseEvent) => { - if (isDragging && chatRef.current) { - const newX = dragStartPos.current.x - e.clientX; - const newY = dragStartPos.current.y - e.clientY; - - // Ensure the chat panel stays within the viewport - const maxX = window.innerWidth - chatRef.current.offsetWidth; - const maxY = window.innerHeight - chatRef.current.offsetHeight; - - setPosition({ - x: Math.max(0, Math.min(newX, maxX)), - y: Math.max(0, Math.min(newY, maxY)), - }); - } - }; - - const handleMouseUp = () => { - setIsDragging(false); - setShowOverlay(false); - }; - - React.useEffect(() => { - if (showOverlay) { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - } else { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - } - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isDragging, showOverlay]); - - // Note: we show a full screen overlay otherwise the mouse events - // don't fire correctly when hovering over the iframe. - - return ( - <> - {showOverlay && ( -
    - )} - {/* eslint-disable-next-line */} -
    -
    - - {props.children} -
    -
    - - ); -} - -type PropsType = { - triggerDiffModal: (props: { files: FileDiffType[]; onUndoAll: () => void } | null) => void; -}; - -export function ChatPanel(props: PropsType): React.JSX.Element { - const { app } = useApp(); - - const [history, setHistory] = React.useState([]); - const [fileDiffs, setFileDiffs] = React.useState([]); - const [visible, setVisible] = React.useState(false); - const [loading, setLoading] = React.useState<'description' | 'actions' | null>(null); - const [diffApplied, setDiffApplied] = React.useState(false); - const { createFile, deleteFile } = useFiles(); - const { createVersion } = useVersion(); - - // Initialize history from the DB - React.useEffect(() => { - loadHistory(app.id) - .then(({ data }) => setHistory(data)) - .catch((error) => { - console.error('Error fetching chat history:', error); - }); - }, [app]); - - const handleSubmit = async (query: string) => { - const planId = randomid(); - setLoading('description'); - setFileDiffs([]); - const userMessage = { type: 'user', message: query, planId } as UserMessageType; - setHistory((prevHistory) => [...prevHistory, userMessage]); - appendToHistory(app.id, userMessage); - setVisible(true); - - const iterable = await aiEditApp(app.id, query, planId); - - const fileUpdates: FileType[] = []; - - for await (const message of iterable) { - if (message.type === 'description') { - const planMessage = { - type: 'plan', - content: message.data.content, - planId, - } as PlanMessageType; - setHistory((prevHistory) => [...prevHistory, planMessage]); - appendToHistory(app.id, planMessage); - setLoading('actions'); - } else if (message.type === 'action') { - if (message.data.type === 'command') { - const commandMessage = { - type: 'command', - command: message.data.command, - packages: message.data.packages, - description: message.data.description, - planId, - } as CommandMessageType; - setHistory((prevHistory) => [...prevHistory, commandMessage]); - appendToHistory(app.id, commandMessage); - } else if (message.data.type === 'file') { - fileUpdates.push(message.data); - } - } else { - console.error('Unknown message type:', message); - } - } - - if (fileUpdates.length > 0) { - // Write the changes - for (const update of fileUpdates) { - createFile(update.dirname, update.basename, update.modified); - } - - // Create a new version - const version = await createVersion(`Changes for planId: ${planId}`); - - const fileDiffs: FileDiffType[] = fileUpdates.map((file: FileType) => { - const { additions, deletions } = diffFiles(file.original ?? '', file.modified); - return { - modified: file.modified, - original: file.original, - basename: file.basename, - dirname: file.dirname, - path: file.path, - additions, - deletions, - type: file.original ? 'edit' : ('create' as 'edit' | 'create'), - }; - }); - - const diffMessage = { type: 'diff', diff: fileDiffs, planId, version } as DiffMessageType; - setHistory((prevHistory) => [...prevHistory, diffMessage]); - appendToHistory(app.id, diffMessage); - - setFileDiffs(fileDiffs); - setDiffApplied(true); - } - setLoading(null); - }; - - // TODO: this closes over state that might be stale. - // This probably needs to use a ref for file diffs to - // ensure the most recent state is always referenced. - const revertDiff = () => { - for (const file of fileDiffs) { - if (file.original) { - createFile(file.dirname, file.basename, file.original); - } else { - // TODO: this needs some testing, this shows the idea only - deleteFile({ - type: 'file', - path: file.path, - dirname: file.dirname, - basename: file.basename, - }); - } - } - setDiffApplied(false); - }; - - const reApplyDiff = () => { - for (const file of fileDiffs) { - createFile(file.dirname, file.basename, file.modified); - } - setDiffApplied(true); - }; - - const handleClose = () => { - setVisible(false); - }; - - const handleFocus = () => { - if (history.length > 0) { - setVisible(true); - } - }; - - function openDiffModal() { - props.triggerDiffModal({ - files: fileDiffs, - onUndoAll: () => { - revertDiff(); - props.triggerDiffModal(null); - }, - }); - } - - return ( - -
    - {visible && ( - - )} - -
    -
    - ); -} diff --git a/packages/web/src/components/delete-app-dialog.tsx b/packages/web/src/components/delete-app-dialog.tsx deleted file mode 100644 index e4edcb69a..000000000 --- a/packages/web/src/components/delete-app-dialog.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { deleteApp } from '@/clients/http/apps'; -import { useState } from 'react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@srcbook/components/src/components/ui/dialog'; -import { Button } from '@srcbook/components/src/components/ui/button'; -import { AppType } from '@srcbook/shared'; - -type PropsType = { - app: AppType; - onClose: () => void; - onDeleted: () => void; -}; - -export default function DeleteAppModal({ app, onClose, onDeleted }: PropsType) { - const [error, setError] = useState(null); - - async function onDelete() { - try { - await deleteApp(app.id); - onDeleted(); - } catch (err) { - console.error(err); - setError('Something went wrong. Please try again.'); - setTimeout(() => setError(null), 3000); - } - } - - return ( - { - if (open === false) { - onClose(); - } - }} - > - - - Delete "{app.name}"? - -
    -

    Deleting an App cannot be undone.

    -
    -
    -
    - {error &&

    {error}

    } -
    - - -
    -
    -
    - ); -} diff --git a/packages/web/src/components/keyboard-shortcuts-dialog.tsx b/packages/web/src/components/keyboard-shortcuts-dialog.tsx index 650998ddf..0960041a0 100644 --- a/packages/web/src/components/keyboard-shortcuts-dialog.tsx +++ b/packages/web/src/components/keyboard-shortcuts-dialog.tsx @@ -38,8 +38,8 @@ export default function KeyboardShortcutsDialog({ -
    AI chat
    - +
    AI Actions
    + {!readOnly ? ( <>
    Markdown edit
    @@ -55,8 +55,6 @@ export default function KeyboardShortcutsDialog({ description="format code using Prettier" /> -
    App Builder
    - ) : null}
    diff --git a/packages/web/src/components/onboarding.tsx b/packages/web/src/components/onboarding.tsx deleted file mode 100644 index d4b573245..000000000 --- a/packages/web/src/components/onboarding.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { LayoutGridIcon, FileTextIcon } from 'lucide-react'; -import { AiSettings } from '@/routes/settings'; - -const OnboardingModal: React.FunctionComponent = () => { - return ( -
    -

    Welcome to Srcbook!

    -

    Srcbook is an AI-powered TypeScript app builder and interactive playground.

    - -
    -

    With Srcbook you can:

    -
    -
    -
    - -
    -

    App builder

    -

    Create Web Applications with the speed of thinking

    -
    - -
    -
    - -
    -

    Notebook

    -

    - Experimenting without the hassle of setting up environments -

    -
    -
    -
    - -
    - - - -
    -
    - ); -}; - -export default OnboardingModal; diff --git a/packages/web/src/components/srcbook-cards.tsx b/packages/web/src/components/srcbook-cards.tsx index 200a5a1e7..82c43168b 100644 --- a/packages/web/src/components/srcbook-cards.tsx +++ b/packages/web/src/components/srcbook-cards.tsx @@ -1,4 +1,4 @@ -import { Sparkles, Circle, PlusIcon, Trash2, Import, LayoutGrid } from 'lucide-react'; +import { Sparkles, Circle, PlusIcon, Trash2, Import } from 'lucide-react'; import { Button } from '@srcbook/components/src/components/ui/button'; import { CodeLanguageType } from '@srcbook/shared'; import { SrcbookLogo } from './logos'; @@ -175,41 +175,6 @@ export function SrcbookCard(props: SrcbookCardPropsType) { ); } -type AppCardPropsType = { - name: string; - onClick: () => void; - onDelete: () => void; -}; - -export function AppCard(props: AppCardPropsType) { - function onDelete(e: React.MouseEvent) { - e.stopPropagation(); - props.onDelete(); - } - - return ( - - - -
    {props.name}
    -
    -
    - TS - -
    -
    - ); -} - export function GenerateSrcbookButton(props: { onClick: () => void }) { return ( void }) { - return ( - props.onClick()} - className="active:translate-y-0.5 bg-[#F6EEFB80] dark:bg-[#331F4780] border-sb-purple-20 dark:border-sb-purple-80 hover:border-sb-purple-60 text-sb-purple-70 dark:text-sb-purple-20" - > -
    - -
    -
    Create App
    - - New - -
    -
    -
    - ); -} - export function ImportSrcbookButton(props: { onClick: () => void }) { return ( , errorElement: , }, - { - path: '/apps/:id', - loader: appIndex, - element: , - errorElement: , - children: [ - { - path: '', - loader: appPreview, - element: ( - - - - ), - }, - { - path: '/apps/:id/files', - loader: appPreview, - element: ( - - - - ), - }, - { - path: '/apps/:id/files/:path', - loader: appFilesShow, - element: ( - - - - ), - }, - ], - }, { path: '/', element: ( diff --git a/packages/web/src/routes/apps/context.tsx b/packages/web/src/routes/apps/context.tsx deleted file mode 100644 index 01a73f89f..000000000 --- a/packages/web/src/routes/apps/context.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Outlet, useLoaderData } from 'react-router-dom'; -import type { AppType, DirEntryType, FileType } from '@srcbook/shared'; - -import { FilesProvider } from '@/components/apps/use-files'; -import { PreviewProvider } from '@/components/apps/use-preview'; -import { LogsProvider } from '@/components/apps/use-logs'; -import { PackageJsonProvider } from '@/components/apps/use-package-json'; -import { AppProvider, useApp } from '@/components/apps/use-app'; -import { VersionProvider } from '@/components/apps/use-version'; - -export function AppContext() { - const { app } = useLoaderData() as { app: AppType }; - - return ( - - - - ); -} - -type AppLoaderDataType = { - rootDirEntries: DirEntryType; - initialOpenedFile: FileType | null; -}; - -export function AppProviders(props: { children: React.ReactNode }) { - const { initialOpenedFile, rootDirEntries } = useLoaderData() as AppLoaderDataType; - - const { channel } = useApp(); - - return ( - - - - - {props.children} - - - - - ); -} diff --git a/packages/web/src/routes/apps/files-show.tsx b/packages/web/src/routes/apps/files-show.tsx deleted file mode 100644 index c504c8326..000000000 --- a/packages/web/src/routes/apps/files-show.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useFiles } from '@/components/apps/use-files'; -import { CodeEditor } from '@/components/apps/editor'; -import AppLayout from './layout'; - -export default function AppFilesShow() { - const { openedFile, updateFile } = useFiles(); - - /* TODO: Handle 404s */ - - return ( - - {openedFile && ( - updateFile({ ...openedFile, source })} - /> - )} - - ); -} diff --git a/packages/web/src/routes/apps/files.tsx b/packages/web/src/routes/apps/files.tsx deleted file mode 100644 index 7735bd289..000000000 --- a/packages/web/src/routes/apps/files.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useNavigate } from 'react-router-dom'; -import AppLayout from './layout'; -import { getLastOpenedFile } from '@/components/apps/local-storage'; -import { useApp } from '@/components/apps/use-app'; -import { useEffect } from 'react'; - -export default function AppFiles() { - const navigateTo = useNavigate(); - - const { app } = useApp(); - - useEffect(() => { - const file = getLastOpenedFile(app.id); - if (file) { - navigateTo(`/apps/${app.id}/files/${encodeURIComponent(file.path)}`); - } - }, [app.id, navigateTo]); - - return ( - -
    - Use the file explorer to open a file for editing -
    -
    - ); -} diff --git a/packages/web/src/routes/apps/layout.tsx b/packages/web/src/routes/apps/layout.tsx deleted file mode 100644 index 601c68fbb..000000000 --- a/packages/web/src/routes/apps/layout.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import Sidebar, { type PanelType } from '@/components/apps/sidebar'; -import BottomDrawer from '@/components/apps/bottom-drawer'; -import { ChatPanel } from '@/components/chat'; -import DiffModal from '@/components/apps/diff-modal'; -import { FileDiffType } from '@srcbook/shared'; -import Header, { type HeaderTab } from '@/components/apps/header'; -import { useApp } from '@/components/apps/use-app'; -import PackageInstallToast from '@/components/apps/package-install-toast'; -import { usePackageJson } from '@/components/apps/use-package-json'; -import InstallPackageModal from '@/components/install-package-modal'; -import { useHotkeys } from 'react-hotkeys-hook'; - -export default function AppLayout(props: { - activeTab: HeaderTab; - activePanel: PanelType | null; - children: React.ReactNode; -}) { - const navigateTo = useNavigate(); - const { app } = useApp(); - - const { installing, npmInstall, output, showInstallModal, setShowInstallModal } = - usePackageJson(); - - const [diffModalProps, triggerDiffModal] = useState<{ - files: FileDiffType[]; - onUndoAll: () => void; - } | null>(null); - - useHotkeys('mod+i', () => { - setShowInstallModal(true); - }); - - return ( - <> - {diffModalProps && triggerDiffModal(null)} />} - -
    { - if (tab === 'preview') { - navigateTo(`/apps/${app.id}`); - } else { - navigateTo(`/apps/${app.id}/files`); - } - }} - className="shrink-0 h-12 max-h-12" - /> -
    - -
    -
    - -
    {props.children}
    -
    - -
    - -
    - - ); -} diff --git a/packages/web/src/routes/apps/loaders.tsx b/packages/web/src/routes/apps/loaders.tsx deleted file mode 100644 index fbbe9a09f..000000000 --- a/packages/web/src/routes/apps/loaders.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { type LoaderFunctionArgs } from 'react-router-dom'; - -import { loadApp, loadDirectory, loadFile } from '@/clients/http/apps'; - -export async function index({ params }: LoaderFunctionArgs) { - const { data: app } = await loadApp(params.id!); - return { app }; -} - -export async function preview({ params }: LoaderFunctionArgs) { - const { data: rootDirEntries } = await loadDirectory(params.id!, '.'); - return { rootDirEntries }; -} - -export async function filesShow({ params }: LoaderFunctionArgs) { - const path = decodeURIComponent(params.path!); - - const [{ data: rootDirEntries }, { data: file }] = await Promise.all([ - loadDirectory(params.id!, '.'), - loadFile(params.id!, path), - ]); - - return { initialOpenedFile: file, rootDirEntries }; -} diff --git a/packages/web/src/routes/apps/preview.tsx b/packages/web/src/routes/apps/preview.tsx deleted file mode 100644 index 2d0ae0db2..000000000 --- a/packages/web/src/routes/apps/preview.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect, useState } from 'react'; -import { usePreview } from '@/components/apps/use-preview'; -import { usePackageJson } from '@/components/apps/use-package-json'; -import { useLogs } from '@/components/apps/use-logs'; -import { Loader2Icon } from 'lucide-react'; -import { Button } from '@srcbook/components'; -import AppLayout from './layout'; - -export default function AppPreview() { - return ( - - - - ); -} - -function Preview() { - const { url, status, start, exitCode } = usePreview(); - const { nodeModulesExists } = usePackageJson(); - const { togglePane } = useLogs(); - - const [startAttempted, setStartAttempted] = useState(false); - useEffect(() => { - if (nodeModulesExists && status === 'stopped' && !startAttempted) { - setStartAttempted(true); - start(); - } - }, [nodeModulesExists, status, start, startAttempted]); - - if (nodeModulesExists === false) { - return ( -
    - Dependencies not installed -
    - ); - } - - switch (status) { - case 'connecting': - case 'booting': - return ( -
    - -
    - ); - case 'running': - if (url === null) { - return; - } - - return ( -
    -