Skip to content

Commit

Permalink
Ability to update app name (#388)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjreinhart authored Oct 18, 2024
1 parent 11f273a commit 2762b0a
Show file tree
Hide file tree
Showing 9 changed files with 187 additions and 46 deletions.
9 changes: 9 additions & 0 deletions packages/api/apps/app.mts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,12 @@ 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;
}
23 changes: 23 additions & 0 deletions packages/api/server/http.mts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
serializeApp,
deleteApp,
createAppWithAi,
updateApp,
} from '../apps/app.mjs';
import { toValidPackageName } from '../apps/utils.mjs';
import {
Expand Down Expand Up @@ -482,6 +483,28 @@ router.get('/apps/:id', cors(), async (req, res) => {
}
});

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;
Expand Down
15 changes: 15 additions & 0 deletions packages/web/src/clients/http/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ export async function loadApp(id: string): Promise<{ data: AppType }> {
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 });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { SrcbookLogo } from '@/components/logos';
import type { AppType } from '@srcbook/shared';

import { Button } from '@srcbook/components/src/components/ui/button';
import {
Expand All @@ -17,23 +16,47 @@ import {
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 { usePreview } from '../../use-preview';
import { usePreview } from './use-preview';
import { useApp } from './use-app';
import { Input } from '@srcbook/components';
import { useState } from 'react';

export type EditorHeaderTab = 'code' | 'preview';

type PropsType = {
app: AppType;
className?: string;
tab: EditorHeaderTab;
onChangeTab: (newTab: EditorHeaderTab) => void;
};

export default function EditorHeader(props: PropsType) {
const { app, updateApp } = useApp();
const { start: startPreview, stop: stopPreview, status: previewStatus } = usePreview();

const [nameChangeDialogOpen, setNameChangeDialogOpen] = useState(false);

return (
<>
{nameChangeDialogOpen && (
<UpdateAppNameDialog
name={app.name}
onUpdate={(name) => {
updateApp({ name });
setNameChangeDialogOpen(false);
}}
onClose={() => {
setNameChangeDialogOpen(false);
}}
/>
)}
<header
className={cn(
'w-full flex items-center justify-between bg-background z-50 text-sm border-b border-b-border relative',
Expand All @@ -45,7 +68,16 @@ export default function EditorHeader(props: PropsType) {
</Link>
<nav className="flex items-center justify-between px-2 flex-1">
<div className="flex items-center gap-2">
<h4 className="px-2 text-sm font-medium">{props.app.name}</h4>
<button
className="px-2 text-sm font-medium"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setNameChangeDialogOpen(true);
}}
>
{app.name}
</button>
</div>

<div className="absolute left-1/2 -translate-x-1/2 flex bg-inline-code h-7 rounded-sm">
Expand Down Expand Up @@ -155,3 +187,40 @@ export default function EditorHeader(props: PropsType) {
</>
);
}

function UpdateAppNameDialog(props: {
name: string;
onClose: () => void;
onUpdate: (name: string) => void;
}) {
const [name, setName] = useState(props.name);

return (
<Dialog
defaultOpen={true}
onOpenChange={(open) => {
if (!open) {
props.onClose();
}
}}
>
<DialogContent hideClose>
<DialogHeader>
<DialogTitle>Rename app</DialogTitle>
<DialogDescription className="sr-only">Rename this app</DialogDescription>
<div className="pt-2">
<Input value={name} onChange={(e) => setName(e.currentTarget.value)} />
</div>
<div className="flex w-full justify-end items-center gap-2 pt-4 bg-background">
<Button variant="secondary" onClick={props.onClose}>
Cancel
</Button>
<Button onClick={() => props.onUpdate(name)} disabled={name.trim() === ''}>
Save
</Button>
</div>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
30 changes: 30 additions & 0 deletions packages/web/src/components/apps/use-app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { createContext, useContext, useState } from 'react';
import type { AppType } from '@srcbook/shared';
import { updateApp as doUpdateApp } from '@/clients/http/apps';

export interface AppContextValue {
app: AppType;
updateApp: (attrs: { name: string }) => void;
}

const AppContext = createContext<AppContextValue | undefined>(undefined);

type ProviderPropsType = {
app: AppType;
children: React.ReactNode;
};

export function AppProvider({ app: initialApp, children }: ProviderPropsType) {
const [app, setApp] = useState(initialApp);

async function updateApp(attrs: { name: string }) {
const { data: updatedApp } = await doUpdateApp(app.id, attrs);
setApp(updatedApp);
}

return <AppContext.Provider value={{ app, updateApp }}>{children}</AppContext.Provider>;
}

export function useApp(): AppContextValue {
return useContext(AppContext) as AppContextValue;
}
8 changes: 5 additions & 3 deletions packages/web/src/components/apps/use-files.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useReducer, useRef, useState } from 'react';

import type { FileType, DirEntryType, FileEntryType, AppType } from '@srcbook/shared';
import type { FileType, DirEntryType, FileEntryType } from '@srcbook/shared';
import { AppChannel } from '@/clients/websocket';
import {
createFile as doCreateFile,
Expand All @@ -20,6 +20,7 @@ import {
updateDirNode,
updateFileNode,
} from './lib/file-tree';
import { useApp } from './use-app';

export interface FilesContextValue {
fileTree: DirEntryType;
Expand All @@ -41,20 +42,21 @@ export interface FilesContextValue {
const FilesContext = createContext<FilesContextValue | undefined>(undefined);

type ProviderPropsType = {
app: AppType;
channel: AppChannel;
children: React.ReactNode;
rootDirEntries: DirEntryType;
};

export function FilesProvider({ app, channel, rootDirEntries, children }: ProviderPropsType) {
export function FilesProvider({ channel, rootDirEntries, 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 fileTreeRef = useRef<DirEntryType>(sortTree(rootDirEntries));
const openedDirectoriesRef = useRef<Set<string>>(new Set());

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useFiles } from '../../use-files';
import { EditorHeaderTab } from './header';
import { useFiles } from '../use-files';
import { EditorHeaderTab } from '../header';
import { useEffect } from 'react';
import { Preview } from '../preview';
import { Preview } from './preview';
import { cn } from '@/lib/utils.ts';
import { CodeEditor } from '../../editor';
import { CodeEditor } from '../editor';

type EditorProps = { tab: EditorHeaderTab; onChangeTab: (newTab: EditorHeaderTab) => void };

Expand Down
20 changes: 11 additions & 9 deletions packages/web/src/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
DiffMessageType,
} from '@srcbook/shared';
import { DiffStats } from './apps/diff-stats.js';
import { useApp } from './apps/use-app.js';

function Chat({
history,
Expand Down Expand Up @@ -203,11 +204,12 @@ function DiffBox({ files, app }: { files: FileDiffType[]; app: AppType }) {
}

type PropsType = {
app: AppType;
triggerDiffModal: (props: { files: FileDiffType[]; onUndoAll: () => void } | null) => void;
};

export function ChatPanel(props: PropsType): React.JSX.Element {
const { app } = useApp();

const [history, setHistory] = React.useState<HistoryType>([]);
const [fileDiffs, setFileDiffs] = React.useState<FileDiffType[]>([]);
const [visible, setVisible] = React.useState(false);
Expand All @@ -217,25 +219,25 @@ export function ChatPanel(props: PropsType): React.JSX.Element {

// Initialize history from the DB
React.useEffect(() => {
loadHistory(props.app.id)
loadHistory(app.id)
.then(({ data }) => setHistory(data))
.catch((error) => {
console.error('Error fetching chat history:', error);
});
}, [props.app]);
}, [app]);

const handleSubmit = async (query: string) => {
setIsLoading(true);
const userMessage = { type: 'user', message: query } as UserMessageType;
setHistory((prevHistory) => [...prevHistory, userMessage]);
appendToHistory(props.app.id, userMessage);
appendToHistory(app.id, userMessage);
setVisible(true);

const { data: plan } = await aiEditApp(props.app.id, query);
const { data: plan } = await aiEditApp(app.id, query);

const planMessage = { type: 'plan', content: plan.description } as PlanMessageType;
setHistory((prevHistory) => [...prevHistory, planMessage]);
appendToHistory(props.app.id, planMessage);
appendToHistory(app.id, planMessage);

const fileUpdates = plan.actions.filter((item) => item.type === 'file');
const commandUpdates = plan.actions.filter((item) => item.type === 'command');
Expand All @@ -250,7 +252,7 @@ export function ChatPanel(props: PropsType): React.JSX.Element {
});

setHistory((prevHistory) => [...prevHistory, ...historyEntries]);
appendToHistory(props.app.id, historyEntries);
appendToHistory(app.id, historyEntries);

for (const update of fileUpdates) {
createFile(update.dirname, update.basename, update.modified);
Expand All @@ -272,7 +274,7 @@ export function ChatPanel(props: PropsType): React.JSX.Element {

const diffMessage = { type: 'diff', diff: fileDiffs } as DiffMessageType;
setHistory((prevHistory) => [...prevHistory, diffMessage]);
appendToHistory(props.app.id, diffMessage);
appendToHistory(app.id, diffMessage);

setFileDiffs(fileDiffs);
setDiffApplied(true);
Expand Down Expand Up @@ -333,7 +335,7 @@ export function ChatPanel(props: PropsType): React.JSX.Element {
history={history}
isLoading={isLoading}
onClose={handleClose}
app={props.app}
app={app}
diffApplied={diffApplied}
reApplyDiff={reApplyDiff}
revertDiff={revertDiff}
Expand Down
Loading

0 comments on commit 2762b0a

Please sign in to comment.