diff --git a/packages/web/src/components/apps/bottom-drawer.tsx b/packages/web/src/components/apps/bottom-drawer.tsx index 9ad42e09..6a1e646c 100644 --- a/packages/web/src/components/apps/bottom-drawer.tsx +++ b/packages/web/src/components/apps/bottom-drawer.tsx @@ -63,12 +63,12 @@ export default function BottomDrawer() { style={{ maxHeight: open ? `${maxHeightInPx}px` : '2rem' }} >
- togglePane()} - className="text-sm font-medium ml-2 select-none cursor-pointer" + className="px-2 text-sm font-medium h-6 select-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > Logs - +
{open && logs.length > 0 && ( @@ -98,12 +98,12 @@ export default function BottomDrawer() { {logs.map((log, index) => ( - + {log.timestamp.toISOString()} - + {log.source} diff --git a/packages/web/src/components/apps/header.tsx b/packages/web/src/components/apps/header.tsx index ac8cd698..5535733f 100644 --- a/packages/web/src/components/apps/header.tsx +++ b/packages/web/src/components/apps/header.tsx @@ -36,13 +36,12 @@ import { exportApp } from '@/clients/http/apps'; import { toast } from 'sonner'; import { useLogs } from './use-logs'; -export type EditorHeaderTab = 'code' | 'preview'; +export type HeaderTab = 'code' | 'preview'; type PropsType = { className?: string; - tab: EditorHeaderTab; - onChangeTab: (newTab: EditorHeaderTab) => void; - onShowPackagesPanel: () => void; + tab: HeaderTab; + onChangeTab: (newTab: HeaderTab) => void; }; export default function EditorHeader(props: PropsType) { diff --git a/packages/web/src/components/apps/local-storage.ts b/packages/web/src/components/apps/local-storage.ts new file mode 100644 index 00000000..55342087 --- /dev/null +++ b/packages/web/src/components/apps/local-storage.ts @@ -0,0 +1,15 @@ +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/sidebar.tsx b/packages/web/src/components/apps/sidebar.tsx index 9dec69ae..dd82aa53 100644 --- a/packages/web/src/components/apps/sidebar.tsx +++ b/packages/web/src/components/apps/sidebar.tsx @@ -38,19 +38,19 @@ function getTitleForPanel(panel: PanelType | null): string | null { } type SidebarProps = { - panel: PanelType | null; - onChangePanel: (newPanel: PanelType | null) => void; + initialPanel: PanelType | null; }; -export default function Sidebar({ panel, onChangePanel }: SidebarProps) { +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); - const { status } = usePackageJson(); function setPanel(nextPanel: PanelType) { - onChangePanel(nextPanel === panel ? null : nextPanel); + _setPanel(nextPanel === panel ? null : nextPanel); } return ( diff --git a/packages/web/src/components/apps/use-app.tsx b/packages/web/src/components/apps/use-app.tsx index 3a5c7375..e89a9f42 100644 --- a/packages/web/src/components/apps/use-app.tsx +++ b/packages/web/src/components/apps/use-app.tsx @@ -1,9 +1,11 @@ -import { createContext, useContext, useState } from 'react'; +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; } @@ -17,12 +19,33 @@ type ProviderPropsType = { export function AppProvider({ app: initialApp, children }: ProviderPropsType) { const [app, setApp] = useState(initialApp); + const channelRef = useRef(AppChannel.create(app.id)); + + // This is only meant to be run one time, when the component mounts. + useEffect(() => { + channelRef.current.subscribe(); + return () => channelRef.current.unsubscribe(); + }, []); + + useEffect(() => { + if (app.id === channelRef.current.appId) { + return; + } + + channelRef.current.unsubscribe(); + channelRef.current = AppChannel.create(app.id); + }, [app.id]); + async function updateApp(attrs: { name: string }) { const { data: updatedApp } = await doUpdateApp(app.id, attrs); setApp(updatedApp); } - return {children}; + return ( + + {children} + + ); } export function useApp(): AppContextValue { diff --git a/packages/web/src/components/apps/use-files.tsx b/packages/web/src/components/apps/use-files.tsx index 7ed9671e..fc42c8e7 100644 --- a/packages/web/src/components/apps/use-files.tsx +++ b/packages/web/src/components/apps/use-files.tsx @@ -1,4 +1,12 @@ -import React, { createContext, useCallback, useContext, useReducer, useRef, useState } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useReducer, + useRef, + useState, +} from 'react'; import type { FileType, DirEntryType, FileEntryType } from '@srcbook/shared'; import { AppChannel } from '@/clients/websocket'; @@ -10,7 +18,6 @@ import { deleteDirectory, renameDirectory, loadDirectory, - loadFile, } from '@/clients/http/apps'; import { createNode, @@ -21,11 +28,13 @@ import { 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) => Promise; + openFile: (entry: FileEntryType) => void; createFile: (dirname: string, basename: string, source?: string) => Promise; updateFile: (file: FileType, attrs: Partial) => void; renameFile: (entry: FileEntryType, name: string) => Promise; @@ -44,10 +53,16 @@ const FilesContext = createContext(undefined); type ProviderPropsType = { channel: AppChannel; children: React.ReactNode; + initialOpenedFile: FileType | null; rootDirEntries: DirEntryType; }; -export function FilesProvider({ channel, rootDirEntries, children }: ProviderPropsType) { +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. // @@ -56,18 +71,43 @@ export function FilesProvider({ channel, rootDirEntries, children }: ProviderPro 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 [openedFile, setOpenedFile] = useState(null); + 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], + ); + + 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( - async (entry: FileEntryType) => { - const { data: file } = await loadFile(app.id, entry.path); - setOpenedFile(file); + (entry: FileEntryType) => { + navigateToFile(entry); }, - [app.id], + [navigateToFile], ); const createFile = useCallback( @@ -85,10 +125,10 @@ export function FilesProvider({ channel, rootDirEntries, children }: ProviderPro (file: FileType, attrs: Partial) => { const updatedFile: FileType = { ...file, ...attrs }; channel.push('file:updated', { file: updatedFile }); - setOpenedFile(updatedFile); + setOpenedFile(() => updatedFile); forceComponentRerender(); }, - [channel], + [channel, setOpenedFile], ); const deleteFile = useCallback( @@ -103,7 +143,7 @@ export function FilesProvider({ channel, rootDirEntries, children }: ProviderPro fileTreeRef.current = deleteNode(fileTreeRef.current, entry.path); forceComponentRerender(); // required }, - [app.id], + [app.id, setOpenedFile], ); const renameFile = useCallback( @@ -118,7 +158,7 @@ export function FilesProvider({ channel, rootDirEntries, children }: ProviderPro fileTreeRef.current = updateFileNode(fileTreeRef.current, entry, newEntry); forceComponentRerender(); // required }, - [app.id], + [app.id, setOpenedFile], ); const isFolderOpen = useCallback((entry: DirEntryType) => { @@ -176,7 +216,7 @@ export function FilesProvider({ channel, rootDirEntries, children }: ProviderPro fileTreeRef.current = deleteNode(fileTreeRef.current, entry.path); forceComponentRerender(); // required }, - [app.id], + [app.id, setOpenedFile], ); const renameFolder = useCallback( @@ -199,7 +239,7 @@ export function FilesProvider({ channel, rootDirEntries, children }: ProviderPro forceComponentRerender(); // required }, - [app.id], + [app.id, setOpenedFile], ); const context: FilesContextValue = { diff --git a/packages/web/src/components/apps/workspace/editor.tsx b/packages/web/src/components/apps/workspace/editor.tsx deleted file mode 100644 index a442fc1e..00000000 --- a/packages/web/src/components/apps/workspace/editor.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useFiles } from '../use-files'; -import { EditorHeaderTab } from '../header'; -import { useEffect } from 'react'; -import { Preview } from './preview'; -import { cn } from '@/lib/utils.ts'; -import { CodeEditor } from '../editor'; -import PackageInstallToast from '../package-install-toast'; - -type EditorProps = { - tab: EditorHeaderTab; - onChangeTab: (newTab: EditorHeaderTab) => void; - onShowPackagesPanel: () => void; -}; - -export function Editor({ tab, onChangeTab, onShowPackagesPanel }: EditorProps) { - const { openedFile, updateFile } = useFiles(); - - useEffect(() => { - if (!openedFile) { - return; - } - onChangeTab('code'); - }, [openedFile, onChangeTab]); - - return ( -
- - - {tab === 'code' ? ( - /* Careful to ensure this div always consumes full height of parent container and only overflows via scroll */ -
- {openedFile ? ( - updateFile(openedFile, { source })} - /> - ) : ( -
- Use the file explorer to open a file for editing -
- )} -
- ) : null} - - {/* - NOTE: applying hidden conditional like this keeps the iframe from getting mounted/unmounted - and causing a flash of unstyled content - */} -
- -
-
- ); -} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 2bdc4e5c..0e4bad09 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -5,7 +5,15 @@ import './index.css'; import Layout, { loader as configLoader } from './Layout'; import LayoutNavbar from './LayoutNavbar'; import Home, { loader as homeLoader } from './routes/home'; -import Apps from './routes/apps'; +import { AppContext, AppProviders } from './routes/apps/context'; +import AppPreview from './routes/apps/preview'; +import AppFiles from './routes/apps/files'; +import AppFilesShow from './routes/apps/files-show'; +import { + index as appIndex, + preview as appPreview, + filesShow as appFilesShow, +} from './routes/apps/loaders'; import Session from './routes/session'; import Settings from './routes/settings'; import Secrets from './routes/secrets'; @@ -52,9 +60,38 @@ const router = createBrowserRouter([ }, { path: '/apps/:id', - loader: Apps.loader, - element: , + 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: '/', diff --git a/packages/web/src/routes/apps.tsx b/packages/web/src/routes/apps.tsx deleted file mode 100644 index fadf8370..00000000 --- a/packages/web/src/routes/apps.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useLoaderData, type LoaderFunctionArgs } from 'react-router-dom'; - -import type { AppType, DirEntryType } from '@srcbook/shared'; - -import { loadApp, loadDirectory } from '@/clients/http/apps'; -import Sidebar, { PanelType } from '@/components/apps/sidebar'; -import { useEffect, useRef, useState } from 'react'; -import BottomDrawer from '@/components/apps/bottom-drawer'; -import { AppChannel } from '@/clients/websocket'; -import { FilesProvider, useFiles } from '@/components/apps/use-files'; -import { Editor } from '@/components/apps/workspace/editor'; -import { PreviewProvider } from '@/components/apps/use-preview'; -import { LogsProvider } from '@/components/apps/use-logs'; -import { PackageJsonProvider, usePackageJson } from '@/components/apps/use-package-json'; -import { ChatPanel } from '@/components/chat'; -import DiffModal from '@/components/apps/diff-modal'; -import { FileDiffType } from '@srcbook/shared'; -import EditorHeader, { EditorHeaderTab } from '@/components/apps/header'; -import { AppProvider } from '@/components/apps/use-app'; -import InstallPackageModal from '@/components/install-package-modal'; -import { useHotkeys } from 'react-hotkeys-hook'; - -async function loader({ params }: LoaderFunctionArgs) { - const [{ data: app }, { data: rootDirEntries }] = await Promise.all([ - loadApp(params.id!), - loadDirectory(params.id!, '.'), - ]); - - return { app, rootDirEntries }; -} - -type AppLoaderDataType = { - app: AppType; - rootDirEntries: DirEntryType; -}; - -export function AppsPage() { - const { app, rootDirEntries } = useLoaderData() as AppLoaderDataType; - - const channelRef = useRef(AppChannel.create(app.id)); - - // This is only meant to be run one time, when the component mounts. - useEffect(() => { - channelRef.current.subscribe(); - return () => channelRef.current.unsubscribe(); - }, []); - - useEffect(() => { - if (app.id === channelRef.current.appId) { - return; - } - - channelRef.current.unsubscribe(); - channelRef.current = AppChannel.create(app.id); - }, [app.id]); - - return ( - - - - - - - - - - - - ); -} - -function Apps() { - const [tab, setTab] = useState('code'); - - const { openedFile } = useFiles(); - const [panel, setPanel] = useState(openedFile === null ? 'explorer' : null); - 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)} />} - - - setPanel('packages')} - /> -
- -
- setPanel('packages')} /> - -
- -
- - ); -} - -AppsPage.loader = loader; -export default AppsPage; diff --git a/packages/web/src/routes/apps/context.tsx b/packages/web/src/routes/apps/context.tsx new file mode 100644 index 00000000..780b6cc8 --- /dev/null +++ b/packages/web/src/routes/apps/context.tsx @@ -0,0 +1,43 @@ +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'; + +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 new file mode 100644 index 00000000..0830c7d1 --- /dev/null +++ b/packages/web/src/routes/apps/files-show.tsx @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000..7735bd28 --- /dev/null +++ b/packages/web/src/routes/apps/files.tsx @@ -0,0 +1,26 @@ +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 new file mode 100644 index 00000000..601c68fb --- /dev/null +++ b/packages/web/src/routes/apps/layout.tsx @@ -0,0 +1,69 @@ +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 new file mode 100644 index 00000000..fbbe9a09 --- /dev/null +++ b/packages/web/src/routes/apps/loaders.tsx @@ -0,0 +1,24 @@ +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/components/apps/workspace/preview.tsx b/packages/web/src/routes/apps/preview.tsx similarity index 59% rename from packages/web/src/components/apps/workspace/preview.tsx rename to packages/web/src/routes/apps/preview.tsx index a44f4768..26d82354 100644 --- a/packages/web/src/components/apps/workspace/preview.tsx +++ b/packages/web/src/routes/apps/preview.tsx @@ -1,37 +1,35 @@ -import { cn } from '@/lib/utils'; -import { usePreview } from '../use-preview'; 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 { useLogs } from '../use-logs'; -import { Button } from '@srcbook/components/src/components/ui/button'; -import { usePackageJson } from '../use-package-json'; +import { Button } from '@srcbook/components'; +import AppLayout from './layout'; -type PropsType = { - isActive?: boolean; - onShowPackagesPanel: () => void; - className?: string; -}; +export default function AppPreview() { + return ( + + + + ); +} -export function Preview(props: PropsType) { +function Preview() { const { url, status, start, lastStoppedError } = usePreview(); const { nodeModulesExists } = usePackageJson(); const { togglePane } = useLogs(); - const isActive = props.isActive ?? true; - const [startAttempted, setStartAttempted] = useState(false); useEffect(() => { - if (isActive && nodeModulesExists && status === 'stopped' && !startAttempted) { + if (nodeModulesExists && status === 'stopped' && !startAttempted) { setStartAttempted(true); start(); - } else if (!isActive) { - setStartAttempted(false); } - }, [isActive, nodeModulesExists, status, start, startAttempted]); + }, [nodeModulesExists, status, start, startAttempted]); if (nodeModulesExists === false) { return ( -
+
Dependencies not installed
); @@ -41,7 +39,7 @@ export function Preview(props: PropsType) { case 'connecting': case 'booting': return ( -
+
); @@ -51,13 +49,13 @@ export function Preview(props: PropsType) { } return ( -
+