diff --git a/.changeset/thin-dots-rest.md b/.changeset/thin-dots-rest.md new file mode 100644 index 00000000..43fec97c --- /dev/null +++ b/.changeset/thin-dots-rest.md @@ -0,0 +1,7 @@ +--- +'@srcbook/shared': minor +'@srcbook/api': minor +'@srcbook/web': minor +--- + +Add Prettier Support to Code Notebook diff --git a/packages/api/exec.mts b/packages/api/exec.mts index ec0555a1..84aea713 100644 --- a/packages/api/exec.mts +++ b/packages/api/exec.mts @@ -15,6 +15,7 @@ export type NodeRequestType = BaseExecRequestType & { export type NPMInstallRequestType = BaseExecRequestType & { packages?: Array; + args?: Array; }; type SpawnCallRequestType = { @@ -132,10 +133,17 @@ export function tsx(options: NodeRequestType) { */ export function npmInstall(options: NPMInstallRequestType) { const { cwd, stdout, stderr, onExit } = options; - const args = options.packages - ? ['install', '--include=dev', ...options.packages] - : ['install', '--include=dev']; + ? ['install', '--include=dev', ...(options.args || []), ...options.packages] + : ['install', '--include=dev', ...(options.args || [])]; - return spawnCall({ command: 'npm', cwd, args, stdout, stderr, onExit, env: process.env }); + return spawnCall({ + command: 'npm', + cwd, + args, + stdout, + stderr, + onExit, + env: process.env, + }); } diff --git a/packages/api/server/ws.mts b/packages/api/server/ws.mts index f8c975ca..33548318 100644 --- a/packages/api/server/ws.mts +++ b/packages/api/server/ws.mts @@ -11,6 +11,7 @@ import { removeCell, updateCodeCellFilename, addCell, + formatAndUpdateCodeCell, } from '../session.mjs'; import { getSecrets } from '../config.mjs'; import type { SessionType } from '../types.mjs'; @@ -25,6 +26,7 @@ import type { DepsValidatePayloadType, CellStopPayloadType, CellUpdatePayloadType, + CellFormatPayloadType, TsServerStartPayloadType, TsServerStopPayloadType, CellDeletePayloadType, @@ -42,6 +44,7 @@ import { CellUpdatedPayloadSchema, CellRenamePayloadSchema, CellDeletePayloadSchema, + CellFormatPayloadSchema, CellExecPayloadSchema, CellStopPayloadSchema, AiGenerateCellPayloadSchema, @@ -60,6 +63,7 @@ import { TsServerCellSuggestionsPayloadSchema, TsServerQuickInfoRequestPayloadSchema, TsServerQuickInfoResponsePayloadSchema, + CellFormattedPayloadSchema, } from '@srcbook/shared'; import tsservers from '../tsservers.mjs'; import { TsServer } from '../tsserver/tsserver.mjs'; @@ -417,6 +421,43 @@ async function cellFixDiagnostics(payload: AiFixDiagnosticsPayloadType) { }); } +async function cellFormat(payload: CellFormatPayloadType) { + const session = await findSession(payload.sessionId); + if (!session) { + throw new Error(`No session exists for session '${payload.sessionId}'`); + } + const cellBeforeUpdate = findCell(session, payload.cellId); + + if (!cellBeforeUpdate || cellBeforeUpdate.type !== 'code') { + throw new Error( + `No cell exists or not a code cell for session '${payload.sessionId}' and cell '${payload.cellId}'`, + ); + } + const result = await formatAndUpdateCodeCell(session, cellBeforeUpdate); + if (!result.success) { + wss.broadcast(`session:${session.id}`, 'cell:output', { + cellId: payload.cellId, + output: { type: 'stderr', data: result.errors }, + }); + sendCellUpdateError(session, payload.cellId, [ + { + message: + 'An error occurred while formatting the code. Please check the "stderr" for more details.', + attribute: 'formatting', + }, + ]); + } else { + const cell = result.cell as CodeCellType; + + wss.broadcast(`session:${session.id}`, 'cell:formatted', { + cellId: payload.cellId, + cell, + }); + + refreshCodeCellDiagnostics(session, cell); + } +} + async function cellUpdate(payload: CellUpdatePayloadType) { const session = await findSession(payload.sessionId); @@ -431,7 +472,6 @@ async function cellUpdate(payload: CellUpdatePayloadType) { `No cell exists for session '${payload.sessionId}' and cell '${payload.cellId}'`, ); } - const result = await updateCell(session, cellBeforeUpdate, payload.updates); if (!result.success) { @@ -440,19 +480,7 @@ async function cellUpdate(payload: CellUpdatePayloadType) { const cell = result.cell as CodeCellType; - if (session.language === 'typescript' && cell.type === 'code' && tsservers.has(session.id)) { - const tsserver = tsservers.get(session.id); - - // This isn't intended for renaming, so the filenames - // and their resulting paths are expected to be the same - reopenFileInTsServer(tsserver, session, { - openFilename: cell.filename, - closeFilename: cell.filename, - source: cell.source, - }); - - requestAllDiagnostics(tsserver, session); - } + refreshCodeCellDiagnostics(session, cell); } async function cellRename(payload: CellRenamePayloadType) { @@ -725,6 +753,21 @@ async function tsserverQuickInfo(payload: TsServerQuickInfoRequestPayloadType) { }); } +function refreshCodeCellDiagnostics(session: SessionType, cell: CodeCellType) { + if (session.language === 'typescript' && cell.type === 'code' && tsservers.has(session.id)) { + const tsserver = tsservers.get(session.id); + + // This isn't intended for renaming, so the filenames + // and their resulting paths are expected to be the same + reopenFileInTsServer(tsserver, session, { + openFilename: cell.filename, + closeFilename: cell.filename, + source: cell.source, + }); + + requestAllDiagnostics(tsserver, session); + } +} wss .channel('session:*') .incoming('cell:exec', CellExecPayloadSchema, cellExec) @@ -733,6 +776,7 @@ wss .incoming('cell:update', CellUpdatePayloadSchema, cellUpdate) .incoming('cell:rename', CellRenamePayloadSchema, cellRename) .incoming('cell:delete', CellDeletePayloadSchema, cellDelete) + .incoming('cell:format', CellFormatPayloadSchema, cellFormat) .incoming('ai:generate', AiGenerateCellPayloadSchema, cellGenerate) .incoming('ai:fix_diagnostics', AiFixDiagnosticsPayloadSchema, cellFixDiagnostics) .incoming('deps:install', DepsInstallPayloadSchema, depsInstall) @@ -747,6 +791,7 @@ wss ) .outgoing('tsserver:cell:quickinfo:response', TsServerQuickInfoResponsePayloadSchema) .outgoing('cell:updated', CellUpdatedPayloadSchema) + .outgoing('cell:formatted', CellFormattedPayloadSchema) .outgoing('cell:error', CellErrorPayloadSchema) .outgoing('cell:output', CellOutputPayloadSchema) .outgoing('ai:generated', AiGeneratedCellPayloadSchema) diff --git a/packages/api/session.mts b/packages/api/session.mts index 0e0b6c57..c6abc950 100644 --- a/packages/api/session.mts +++ b/packages/api/session.mts @@ -29,6 +29,8 @@ import { import { fileExists } from './fs-utils.mjs'; import { validFilename } from '@srcbook/shared'; import { pathToCodeFile } from './srcbook/path.mjs'; +import { exec } from 'node:child_process'; +import { npmInstall } from './exec.mjs'; const sessions: Record = {}; @@ -296,7 +298,70 @@ export function updateCell(session: SessionType, cell: CellType, updates: CellUp return updateCodeCell(session, cell, updates); } } - +async function ensurePrettierInstalled(dir: string): Promise { + const prettierPath = Path.join(dir, 'node_modules', 'prettier'); + try { + // check if prettier is installed + await fs.access(prettierPath); + return true; + } catch (error) { + return new Promise((resolve) => { + try { + npmInstall({ + cwd: dir, + packages: ['prettier'], + args: ['--save-dev'], + stdout: () => {}, + stderr: (err) => console.error(err), + onExit: (exitCode) => { + if (exitCode === 0) { + resolve(true); + } else { + console.error('Failed to install Prettier:', exitCode); + resolve(false); + } + }, + }); + } catch (installError) { + console.error('Failed to initiate Prettier installation:', installError); + resolve(false); + } + }); + } +} +export async function formatCode(dir: string, fileName: string) { + try { + await ensurePrettierInstalled(dir); + + const codeFilePath = pathToCodeFile(dir, fileName); + const command = `npx prettier ${codeFilePath}`; + + return new Promise((resolve, reject) => { + exec(command, async (_, stdout, stderr) => { + if (stderr) { + console.error(`exec error: ${stderr}`); + reject(stderr); + return; + } + resolve(stdout); + }); + }); + } catch (error) { + console.error('Formatting error:', error); + throw error; + } +} +export async function formatAndUpdateCodeCell(session: SessionType, cell: CodeCellType) { + try { + const formattedCode = await formatCode(session.dir, cell.filename); + return updateCodeCell(session, cell, { source: formattedCode } as { source: string }); + } catch (error) { + return Promise.resolve({ + success: false, + errors: error, + } as UpdateResultType); + } +} export function sessionToResponse(session: SessionType) { const result: Pick = { id: session.id, diff --git a/packages/api/srcbook/config.mts b/packages/api/srcbook/config.mts index 8873bfde..5ad9d778 100644 --- a/packages/api/srcbook/config.mts +++ b/packages/api/srcbook/config.mts @@ -1,7 +1,13 @@ export function buildJSPackageJson() { return { type: 'module', - dependencies: {}, + devDependencies: { + prettier: 'latest', + }, + prettier: { + semi: true, + singleQuote: true, + }, }; } @@ -13,6 +19,13 @@ export function buildTSPackageJson() { typescript: 'latest', '@types/node': 'latest', }, + devDependencies: { + prettier: 'latest', + }, + prettier: { + semi: true, + singleQuote: true, + }, }; } diff --git a/packages/shared/src/schemas/websockets.mts b/packages/shared/src/schemas/websockets.mts index 1204c4d2..3a329542 100644 --- a/packages/shared/src/schemas/websockets.mts +++ b/packages/shared/src/schemas/websockets.mts @@ -35,6 +35,11 @@ export const CellUpdatePayloadSchema = z.object({ updates: CellUpdateAttrsSchema, }); +export const CellFormatPayloadSchema = z.object({ + sessionId: z.string(), + cellId: z.string(), +}); + export const AiGenerateCellPayloadSchema = z.object({ sessionId: z.string(), cellId: z.string(), @@ -73,6 +78,10 @@ export const CellUpdatedPayloadSchema = z.object({ cell: CellSchema, }); +export const CellFormattedPayloadSchema = z.object({ + cellId: z.string(), + cell: CellSchema, +}); export const AiGeneratedCellPayloadSchema = z.object({ cellId: z.string(), output: z.string(), diff --git a/packages/shared/src/types/websockets.mts b/packages/shared/src/types/websockets.mts index b1b0822a..722eb275 100644 --- a/packages/shared/src/types/websockets.mts +++ b/packages/shared/src/types/websockets.mts @@ -6,6 +6,7 @@ import { CellCreatePayloadSchema, CellUpdatePayloadSchema, CellUpdatedPayloadSchema, + CellFormatPayloadSchema, CellRenamePayloadSchema, CellDeletePayloadSchema, AiGenerateCellPayloadSchema, @@ -24,12 +25,14 @@ import { TsServerCellSuggestionsPayloadSchema, TsServerQuickInfoRequestPayloadSchema, TsServerQuickInfoResponsePayloadSchema, + CellFormattedPayloadSchema, } from '../schemas/websockets.mjs'; export type CellExecPayloadType = z.infer; export type CellStopPayloadType = z.infer; export type CellCreatePayloadType = z.infer; export type CellUpdatePayloadType = z.infer; +export type CellFormatPayloadType = z.infer; export type CellUpdatedPayloadType = z.infer; export type CellRenamePayloadType = z.infer; export type CellDeletePayloadType = z.infer; @@ -43,6 +46,7 @@ export type DepsValidateResponsePayloadType = z.infer; export type CellErrorPayloadType = z.infer; +export type CellFormattedPayloadType = z.infer; export type TsServerStartPayloadType = z.infer; export type TsServerStopPayloadType = z.infer; diff --git a/packages/web/src/clients/websocket/index.ts b/packages/web/src/clients/websocket/index.ts index 2a2b4abd..c31f4887 100644 --- a/packages/web/src/clients/websocket/index.ts +++ b/packages/web/src/clients/websocket/index.ts @@ -4,6 +4,8 @@ import { AiGenerateCellPayloadSchema, AiGeneratedCellPayloadSchema, CellUpdatedPayloadSchema, + CellFormattedPayloadSchema, + CellFormatPayloadSchema, DepsValidateResponsePayloadSchema, CellExecPayloadSchema, CellStopPayloadSchema, @@ -35,6 +37,7 @@ const IncomingSessionEvents = { 'cell:output': CellOutputPayloadSchema, 'cell:error': CellErrorPayloadSchema, 'cell:updated': CellUpdatedPayloadSchema, + 'cell:formatted': CellFormattedPayloadSchema, 'deps:validate:response': DepsValidateResponsePayloadSchema, 'tsserver:cell:diagnostics': TsServerCellDiagnosticsPayloadSchema, 'tsserver:cell:suggestions': TsServerCellSuggestionsPayloadSchema, @@ -50,6 +53,7 @@ const OutgoingSessionEvents = { 'cell:update': CellUpdatePayloadSchema, 'cell:rename': CellRenamePayloadSchema, 'cell:delete': CellDeletePayloadSchema, + 'cell:format': CellFormatPayloadSchema, 'ai:generate': AiGenerateCellPayloadSchema, 'ai:fix_diagnostics': AiFixDiagnosticsPayloadSchema, 'deps:install': DepsInstallPayloadSchema, diff --git a/packages/web/src/components/cell-output.tsx b/packages/web/src/components/cell-output.tsx index ca14704e..1d605be9 100644 --- a/packages/web/src/components/cell-output.tsx +++ b/packages/web/src/components/cell-output.tsx @@ -5,7 +5,7 @@ import { CodeCellType, PackageJsonCellType, TsServerDiagnosticType } from '@srcb import { cn } from '@/lib/utils'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/underline-flat-tabs'; import { useCells } from '@/components/use-cell'; -import { OutputType, StdoutOutputType, StderrOutputType } from '@/types'; +import { OutputType, StdoutOutputType, StderrOutputType, CellModeType } from '@/types'; import { Button } from './ui/button'; type PropsType = { @@ -13,7 +13,7 @@ type PropsType = { show: boolean; setShow: (show: boolean) => void; fixDiagnostics: (diagnostics: string) => void; - cellMode: 'off' | 'generating' | 'reviewing' | 'prompting' | 'fixing'; + cellMode: CellModeType; setFullscreen: (fullscreen: boolean) => void; fullscreen: boolean; }; @@ -217,7 +217,7 @@ function TsServerDiagnostics({ }: { diagnostics: TsServerDiagnosticType[]; fixDiagnostics: (diagnostics: string) => void; - cellMode: 'off' | 'generating' | 'reviewing' | 'prompting' | 'fixing'; + cellMode: CellModeType; }) { const { aiEnabled } = useSettings(); const formattedDiagnostics = diagnostics.map(formatDiagnostic).join('\n'); @@ -248,7 +248,7 @@ function TsServerSuggestions({ }: { suggestions: TsServerDiagnosticType[]; fixSuggestions: (suggestions: string) => void; - cellMode: 'off' | 'generating' | 'reviewing' | 'prompting' | 'fixing'; + cellMode: CellModeType; }) { const { aiEnabled } = useSettings(); const formattedSuggestions = suggestions.map(formatDiagnostic).join('\n'); diff --git a/packages/web/src/components/cells/code.tsx b/packages/web/src/components/cells/code.tsx index df5fbe6a..2ca12226 100644 --- a/packages/web/src/components/cells/code.tsx +++ b/packages/web/src/components/cells/code.tsx @@ -19,6 +19,7 @@ import { LoaderCircle, Maximize, Minimize, + PaintbrushVertical, } from 'lucide-react'; import TextareaAutosize from 'react-textarea-autosize'; import AiGenerateTipsDialog from '@/components/ai-generate-tips-dialog'; @@ -27,12 +28,13 @@ import { CodeCellType, CodeCellUpdateAttrsType, CellErrorPayloadType, + CellFormattedPayloadType, AiGeneratedCellPayloadType, TsServerDiagnosticType, } from '@srcbook/shared'; import { useSettings } from '@/components/use-settings'; import { cn } from '@/lib/utils'; -import { SessionType } from '@/types'; +import { CellModeType, SessionType } from '@/types'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import DeleteCellWithConfirmation from '@/components/delete-cell-dialog'; @@ -47,9 +49,9 @@ import { unifiedMergeView } from '@codemirror/merge'; import { type Diagnostic, linter } from '@codemirror/lint'; import { tsHover } from './hover'; import { mapTsServerLocationToCM } from './util'; +import { toast } from 'sonner'; const DEBOUNCE_DELAY = 500; -type CellModeType = 'off' | 'generating' | 'reviewing' | 'prompting' | 'fixing'; export default function CodeCell(props: { session: SessionType; @@ -66,9 +68,7 @@ export default function CodeCell(props: { const [prompt, setPrompt] = useState(''); const [newSource, setNewSource] = useState(''); const [fullscreen, setFullscreen] = useState(false); - const { aiEnabled } = useSettings(); - useHotkeys( 'mod+enter', () => { @@ -110,6 +110,12 @@ export default function CodeCell(props: { if (filenameError) { setFilenameError(filenameError.message); } + + const formattingError = payload.errors.find((e) => e.attribute === 'formatting'); + if (formattingError) { + toast.error(formattingError.message); + setCellMode('off'); + } } channel.on('cell:error', callback); @@ -117,6 +123,18 @@ export default function CodeCell(props: { return () => channel.off('cell:error', callback); }, [cell.id, channel]); + useEffect(() => { + function callback(payload: CellFormattedPayloadType) { + if (payload.cellId === cell.id) { + updateCellOnClient({ ...payload.cell }); + setCellMode('off'); + } + } + + channel.on('cell:formatted', callback); + return () => channel.off('cell:formatted', callback); + }, [cell.id, channel, updateCellOnClient]); + function updateFilename(filename: string) { updateCellOnClient({ ...cell, filename }); channel.push('cell:rename', { @@ -190,6 +208,13 @@ export default function CodeCell(props: { setCellMode('off'); } + function formatCell() { + setCellMode('formatting'); + channel.push('cell:format', { + sessionId: session.id, + cellId: cell.id, + }); + } return (
@@ -224,6 +249,7 @@ export default function CodeCell(props: { setShowStdio={setShowStdio} onAccept={onAcceptDiff} onRevert={onRevertDiff} + formatCell={formatCell} /> {cellMode === 'reviewing' ? ( @@ -237,8 +263,11 @@ export default function CodeCell(props: { session={session} cell={cell} runCell={runCell} + formatCell={formatCell} updateCellOnServer={updateCellOnServer} - readOnly={['generating', 'prompting', 'fixing'].includes(cellMode)} + readOnly={['generating', 'prompting', 'fixing', 'formatting'].includes( + cellMode, + )} />
@@ -291,6 +320,7 @@ export default function CodeCell(props: { setShowStdio={setShowStdio} onAccept={onAcceptDiff} onRevert={onRevertDiff} + formatCell={formatCell} /> {cellMode === 'reviewing' ? ( @@ -303,8 +333,9 @@ export default function CodeCell(props: { session={session} cell={cell} runCell={runCell} + formatCell={formatCell} updateCellOnServer={updateCellOnServer} - readOnly={['generating', 'prompting'].includes(cellMode)} + readOnly={['generating', 'prompting', 'formatting'].includes(cellMode)} /> void; onAccept: () => void; onRevert: () => void; + formatCell: () => void; }) { const { cell, @@ -359,6 +391,7 @@ function Header(props: { prompt, setPrompt, stopCell, + formatCell, } = props; const { aiEnabled } = useSettings(); @@ -404,6 +437,26 @@ function Header(props: { )} >
+ + + + {cellMode === 'formatting' ? ( + + ) : ( + + )} + + Format + + @@ -478,7 +531,7 @@ function Header(props: {
)} - {cellMode === 'off' && ( + {['off', 'formatting'].includes(cellMode) && ( <> {cell.status === 'running' && (