-
+ |
{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)} />}
+
+ |