Skip to content

Commit

Permalink
Refurbish bottom drawer (#400)
Browse files Browse the repository at this point in the history
* feat: rename statusbar to bottom drawer

* feat: add preview:log event to send logs from vite to the client

* feat: add new addLog method to useLogs to append log messages in the new format

* feat: add button to open / close bototm drawer from header

* Committing progress

* Add tooltip, reuse modal from notebooks for installing apps

* Format

* Fix up styles for layout + log table. Adjust copy on packages panel

* Ability to click on 'Logs' to toggle the pane

* Fix deps array

---------

Co-authored-by: Nicholas Charriere <[email protected]>
  • Loading branch information
1egoman and nichochar authored Oct 21, 2024
1 parent 02e2500 commit 9953112
Show file tree
Hide file tree
Showing 15 changed files with 365 additions and 203 deletions.
14 changes: 14 additions & 0 deletions packages/api/server/channels/app.mts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ async function previewStart(
console.log(encodedData);
bufferedLogs.push(encodedData);

conn.reply(`app:${app.externalId}`, 'preview:log', {
log: {
type: 'stdout',
data: encodedData,
},
});

const potentialPortMatch = VITE_PORT_REGEX.exec(encodedData);
if (potentialPortMatch) {
const portString = potentialPortMatch[1]!;
Expand All @@ -93,6 +100,13 @@ async function previewStart(
const encodedData = data.toString('utf8');
console.error(encodedData);
bufferedLogs.push(encodedData);

conn.reply(`app:${app.externalId}`, 'preview:log', {
log: {
type: 'stderr',
data: encodedData,
},
});
},
onExit: (code) => {
processMetadata.delete(app.externalId);
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/schemas/websockets.mts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ export const PreviewStatusPayloadSchema = z.union([
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() }),
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/types/websockets.mts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
DepsClearPayloadSchema,
DepsStatusPayloadSchema,
DepsStatusResponsePayloadSchema,
PreviewLogPayloadSchema,
} from '../schemas/websockets.mjs';

export type CellExecPayloadType = z.infer<typeof CellExecPayloadSchema>;
Expand Down Expand Up @@ -109,5 +110,6 @@ export type FileDeletedPayloadType = z.infer<typeof FileDeletedPayloadSchema>;
export type PreviewStatusPayloadType = z.infer<typeof PreviewStatusPayloadSchema>;
export type PreviewStartPayloadType = z.infer<typeof PreviewStartPayloadSchema>;
export type PreviewStopPayloadType = z.infer<typeof PreviewStopPayloadSchema>;
export type PreviewLogPayloadType = z.infer<typeof PreviewLogPayloadSchema>;
export type DepsInstallLogPayloadType = z.infer<typeof DepsInstallLogPayloadSchema>;
export type DepsInstallStatusPayloadType = z.infer<typeof DepsInstallStatusPayloadSchema>;
2 changes: 2 additions & 0 deletions packages/web/src/clients/websocket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
DepsClearPayloadSchema,
DepsStatusResponsePayloadSchema,
DepsStatusPayloadSchema,
PreviewLogPayloadSchema,
} from '@srcbook/shared';
import Channel from '@/clients/websocket/channel';
import WebSocketClient from '@/clients/websocket/client';
Expand Down Expand Up @@ -99,6 +100,7 @@ export class SessionChannel extends Channel<
const IncomingAppEvents = {
file: FilePayloadSchema,
'preview:status': PreviewStatusPayloadSchema,
'preview:log': PreviewLogPayloadSchema,
'deps:install:log': DepsInstallLogPayloadSchema,
'deps:install:status': DepsInstallStatusPayloadSchema,
'deps:status:response': DepsStatusResponsePayloadSchema,
Expand Down
132 changes: 132 additions & 0 deletions packages/web/src/components/apps/bottom-drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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 maxHeightInPx = 320;

export default function BottomDrawer() {
const { logs, clearLogs, open, togglePane, closePane } = useLogs();

useHotkeys('mod+shift+y', () => {
togglePane();
});

const scrollWrapperRef = useRef<HTMLDivElement | null>(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 (
<div
className={cn(
'flex flex-col w-full overflow-hidden transition-all duration-200 ease-in-out',
open ? 'flex-grow' : 'flex-shrink-0 h-8',
)}
style={{ maxHeight: open ? `${maxHeightInPx}px` : '2rem' }}
>
<div className="flex-shrink-0 flex items-center justify-between border-t border-b h-8 px-1 w-full bg-muted">
<span

Check failure on line 66 in packages/web/src/components/apps/bottom-drawer.tsx

View workflow job for this annotation

GitHub Actions / Build and Test (18.x)

Visible, non-interactive elements with click handlers must have at least one keyboard listener

Check failure on line 66 in packages/web/src/components/apps/bottom-drawer.tsx

View workflow job for this annotation

GitHub Actions / Build and Test (18.x)

Avoid non-native interactive elements. If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element

Check failure on line 66 in packages/web/src/components/apps/bottom-drawer.tsx

View workflow job for this annotation

GitHub Actions / Build and Test (20.x)

Visible, non-interactive elements with click handlers must have at least one keyboard listener

Check failure on line 66 in packages/web/src/components/apps/bottom-drawer.tsx

View workflow job for this annotation

GitHub Actions / Build and Test (20.x)

Avoid non-native interactive elements. If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element

Check failure on line 66 in packages/web/src/components/apps/bottom-drawer.tsx

View workflow job for this annotation

GitHub Actions / Build and Test (22.x)

Visible, non-interactive elements with click handlers must have at least one keyboard listener

Check failure on line 66 in packages/web/src/components/apps/bottom-drawer.tsx

View workflow job for this annotation

GitHub Actions / Build and Test (22.x)

Avoid non-native interactive elements. If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element
onClick={() => togglePane()}
className="text-sm font-medium ml-2 select-none cursor-pointer"
>
Logs
</span>

<div className="flex items-center gap-1">
{open && logs.length > 0 && (
<Button
size="sm"
variant="icon"
onClick={clearLogs}
className="active:translate-y-0 w-6 px-0"
>
<BanIcon size={14} />
</Button>
)}
<Button
size="sm"
variant="icon"
onClick={() => closePane()}
className="active:translate-y-0 w-6 px-0"
>
<XIcon size={16} />
</Button>
</div>
</div>

{open && (
<div className="flex-grow overflow-auto p-2" ref={scrollWrapperRef}>
<table className="w-full border-collapse text-xs">
<tbody>
{logs.map((log, index) => (
<tr key={index}>
<td className="align-top whitespace-nowrap select-none pointer-events-none whitespace-nowrap w-0 pr-4">
<span className="font-mono text-tertiary-foreground/80">
{log.timestamp.toISOString()}
</span>
</td>
<td className="align-top whitespace-nowrap select-none pointer-events-none whitespace-nowrap w-0 pr-4">
<span className="font-mono text-tertiary-foreground">{log.source}</span>
</td>
<td className="align-top">
<pre
className={cn('font-mono cursor-text whitespace-pre-wrap', {
'text-red-300': log.type === 'stderr',
'text-tertiary-foreground': log.type === 'info',
})}
>
{log.message}
</pre>
</td>
</tr>
))}
</tbody>
</table>
{logs.length === 0 && (
<div className="w-full h-full flex items-center justify-center">
<span className="text-tertiary-foreground">No logs</span>
</div>
)}
</div>
)}
</div>
);
}
30 changes: 19 additions & 11 deletions packages/web/src/components/apps/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import {
ShareIcon,
PlayIcon,
StopCircleIcon,
EllipsisIcon,
PlayCircleIcon,
Code2Icon,
Loader2Icon,
CircleAlertIcon,
PanelBottomOpenIcon,
PanelBottomCloseIcon,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { SrcbookLogo } from '@/components/logos';
Expand All @@ -32,6 +34,7 @@ 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 EditorHeaderTab = 'code' | 'preview';

Expand All @@ -47,6 +50,7 @@ export default function EditorHeader(props: PropsType) {
const { 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);

Expand Down Expand Up @@ -198,17 +202,17 @@ export default function EditorHeader(props: PropsType) {
<Button
variant="icon"
size="icon"
onClick={handleExport}
onClick={togglePane}
className="active:translate-y-0"
>
{isExporting ? (
<Loader2Icon size={18} className="animate-spin" />
) : (
<ShareIcon size={18} />
)}
{panelIcon === 'default' && !open ? <PanelBottomOpenIcon size={18} /> : null}
{panelIcon === 'default' && open ? <PanelBottomCloseIcon size={18} /> : null}
{panelIcon === 'error' ? (
<CircleAlertIcon size={18} className="text-red-600" />
) : null}
</Button>
</TooltipTrigger>
<TooltipContent>Export app</TooltipContent>
<TooltipContent>Open logs</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
Expand All @@ -217,13 +221,17 @@ export default function EditorHeader(props: PropsType) {
<Button
variant="icon"
size="icon"
onClick={() => alert('More options')}
onClick={handleExport}
className="active:translate-y-0"
>
<EllipsisIcon size={18} />
{isExporting ? (
<Loader2Icon size={18} className="animate-spin" />
) : (
<ShareIcon size={18} />
)}
</Button>
</TooltipTrigger>
<TooltipContent>More options</TooltipContent>
<TooltipContent>Export app</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
Expand Down
98 changes: 53 additions & 45 deletions packages/web/src/components/apps/panels/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,65 @@
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 { status, output, npmInstall, clearNodeModules, nodeModulesExists } = usePackageJson();
const { setShowInstallModal, npmInstall, clearNodeModules, nodeModulesExists, status } =
usePackageJson();

return (
<div className="flex flex-col gap-4 px-5 w-[360px]">
<p className="text-sm text-tertiary-foreground">
Clear your node_modules, re-install packages and inspect the output logs from{' '}
<pre>npm install</pre>
</p>
<div>
<Button onClick={() => npmInstall()} disabled={status === 'installing'}>
Run npm install
</Button>
<div className="flex flex-col gap-6 px-5 w-[360px]">
<div className="flex flex-col gap-2">
<p className="text-sm text-tertiary-foreground">
To add packages, you can simply ask the AI in chat, or use the button below.
</p>

<div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => setShowInstallModal(true)} className="gap-1">
<PackagePlus size={16} />
Add a package
</Button>
</TooltipTrigger>
<TooltipContent>
Install packages <Shortcut keys={['mod', 'i']} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>

{status !== 'idle' ? (
<>
<h3 className="text-sm font-medium">Logs</h3>
<pre className="font-mono text-xs bg-tertiary p-2 overflow-auto rounded-md border">
{/* FIXME: disambiguate between stdout and stderr in here using n.type! */}
{output.map((n) => n.data).join('\n')}
</pre>
</>
) : null}
<div className="flex flex-col gap-2">
<p className="text-sm text-tertiary-foreground">
If you suspect your node_modules are corrupted, you can clear them and reinstall all
packages.
</p>
<div>
<Button onClick={() => clearNodeModules()} disabled={nodeModulesExists !== true}>
Clear all packages
</Button>
</div>
</div>

{process.env.NODE_ENV !== 'production' && (
<>
<span>
Status: <code>{status}</code>
</span>
<div>
<Button
onClick={() => npmInstall(['uuid'])}
variant="secondary"
disabled={status === 'installing'}
>
Run npm install uuid
</Button>
</div>
<div>
exists={JSON.stringify(nodeModulesExists)}
<Button
onClick={() => clearNodeModules()}
variant="secondary"
disabled={nodeModulesExists !== true}
>
Clear node_modules
</Button>
</div>
</>
)}
<div className="flex flex-col gap-2">
<p className="text-sm text-tertiary-foreground">
Re-run <code className="code">npm install</code>. This will run against the package.json
from the project root.
</p>
<div>
<Button onClick={() => npmInstall()} disabled={status === 'installing'}>
Re-install all packages
</Button>
</div>
</div>
</div>
);
}
Loading

0 comments on commit 9953112

Please sign in to comment.