Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 74 additions & 40 deletions src/components/CodeExplorer.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import React from 'react'
import { FileExplorer } from './FileExplorer'
import { FileExplorer, type FileExplorerNode } from './FileExplorer'
import { InteractiveSandbox } from './InteractiveSandbox'
import { CodeExplorerTopBar } from './CodeExplorerTopBar'
import type { GitHubFileNode } from '~/utils/documents.server'
import { getExamplePanels, type ExamplePanel } from '~/utils/example-panel'
import type { Library } from '~/libraries'
import { twMerge } from 'tailwind-merge'
import { CodeBlock } from '~/components/markdown'
import { getCodeBlockLanguageFromFilePath } from '~/components/markdown/codeBlock.shared'

interface CodeExplorerProps {
activeTab: 'code' | 'sandbox'
activeTab: ExamplePanel
codeSandboxUrl: string
currentCode: string
currentPath: string
examplePath: string
githubContents: GitHubFileNode[] | undefined
githubContents: FileExplorerNode[] | undefined
library: Library
prefetchFileContent: (path: string) => void
setActiveTab: (tab: 'code' | 'sandbox') => void
playground?: React.ReactNode
setActiveTab: (tab: ExamplePanel) => void
setCurrentPath: (path: string) => void
stackBlitzUrl: string
}
Expand All @@ -31,12 +32,23 @@ export function CodeExplorer({
githubContents,
library,
prefetchFileContent,
playground,
setActiveTab,
setCurrentPath,
stackBlitzUrl,
}: CodeExplorerProps) {
const [isFullScreen, setIsFullScreen] = React.useState(false)
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true)
const [hasOpenedPlayground, setHasOpenedPlayground] = React.useState(
activeTab === 'playground',
)
React.useEffect(() => {
if (activeTab === 'playground') setHasOpenedPlayground(true)
}, [activeTab])
const panels = getExamplePanels({
...library,
hasPlayground: Boolean(playground),
})
const currentCodeLanguage = getCodeBlockLanguageFromFilePath(currentPath)

// Add escape key handler
Expand All @@ -60,6 +72,7 @@ export function CodeExplorer({
>
<CodeExplorerTopBar
activeTab={activeTab}
panels={panels}
setActiveTab={setActiveTab}
isFullScreen={isFullScreen}
setIsFullScreen={setIsFullScreen}
Expand All @@ -68,45 +81,66 @@ export function CodeExplorer({
/>

<div className="relative flex-1">
<div
className={`absolute inset-0 flex ${
activeTab === 'code' ? '' : 'hidden'
}`}
>
<FileExplorer
currentPath={currentPath}
files={githubContents}
isSidebarOpen={isSidebarOpen}
libraryColor={library.bgStyle}
onSidebarClose={() => setIsSidebarOpen(false)}
prefetchFileContent={prefetchFileContent}
setCurrentPath={setCurrentPath}
/>
{playground && (hasOpenedPlayground || activeTab === 'playground') ? (
<div
className={`absolute inset-0 min-h-0 flex-col ${activeTab === 'playground' ? 'flex' : 'hidden'}`}
>
{playground}
</div>
) : null}
{!playground ? (
<div
className={twMerge(
'flex-1 overflow-auto relative',
isFullScreen ? 'max-h-[90dvh]' : 'max-h-[80dvh]',
)}
className={`absolute inset-0 flex ${
activeTab === 'code' ? '' : 'hidden'
}`}
>
<CodeBlock
className="h-full border-0"
isEmbedded
showTypeCopyButton={false}
<FileExplorer
currentPath={currentPath}
files={githubContents}
isSidebarOpen={isSidebarOpen}
libraryColor={library.bgStyle}
onSidebarClose={() => setIsSidebarOpen(false)}
prefetchFileContent={prefetchFileContent}
setCurrentPath={setCurrentPath}
/>
<div
className={twMerge(
'flex-1 overflow-auto relative',
isFullScreen ? 'max-h-[90dvh]' : 'max-h-[80dvh]',
)}
>
<code className={`language-${currentCodeLanguage}`}>
{currentCode}
</code>
</CodeBlock>
<CodeBlock
className="h-full border-0"
isEmbedded
showTypeCopyButton={false}
>
<code className={`language-${currentCodeLanguage}`}>
{currentCode}
</code>
</CodeBlock>
</div>
</div>
</div>
<InteractiveSandbox
isActive={activeTab === 'sandbox'}
codeSandboxUrl={codeSandboxUrl}
stackBlitzUrl={stackBlitzUrl}
examplePath={examplePath}
libraryName={library.name}
embedEditor={library.embedEditor || 'stackblitz'}
/>
) : null}
{!library.hideStackblitzUrl ? (
<InteractiveSandbox
isActive={activeTab === 'stackblitz'}
codeSandboxUrl={codeSandboxUrl}
stackBlitzUrl={stackBlitzUrl}
examplePath={examplePath}
libraryName={library.name}
embedEditor="stackblitz"
/>
) : null}
{!library.hideCodesandboxUrl ? (
<InteractiveSandbox
isActive={activeTab === 'codesandbox'}
codeSandboxUrl={codeSandboxUrl}
stackBlitzUrl={stackBlitzUrl}
examplePath={examplePath}
libraryName={library.name}
embedEditor="codesandbox"
/>
) : null}
</div>
</div>
)
Expand Down
56 changes: 21 additions & 35 deletions src/components/CodeExplorerTopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,21 @@ import {
TextAlignLeftIcon,
} from '@phosphor-icons/react'
import { Tooltip } from '~/ui'
import type { ExamplePanel, ExamplePanelOption } from '~/utils/example-panel'

interface CodeExplorerTopBarProps {
activeTab: 'code' | 'sandbox'
activeTab: ExamplePanel
panels: ReadonlyArray<ExamplePanelOption>
isFullScreen: boolean
isSidebarOpen: boolean
setActiveTab: (tab: 'code' | 'sandbox') => void
setActiveTab: (tab: ExamplePanel) => void
setIsFullScreen: React.Dispatch<React.SetStateAction<boolean>>
setIsSidebarOpen: (isOpen: boolean) => void
}

export function CodeExplorerTopBar({
activeTab,
panels,
isFullScreen,
isSidebarOpen,
setActiveTab,
Expand All @@ -27,7 +30,7 @@ export function CodeExplorerTopBar({
}: CodeExplorerTopBarProps) {
return (
<div className="flex items-center justify-between gap-2 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 px-1">
<div className="flex min-w-0 items-center gap-1 overflow-x-auto px-1">
{activeTab === 'code' ? (
isSidebarOpen ? (
<Tooltip content="Hide files" side="bottom">
Expand Down Expand Up @@ -62,38 +65,21 @@ export function CodeExplorerTopBar({
/>
</div>
)}
<button
onClick={() => setActiveTab('code')}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === 'code'
? 'text-gray-900 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<span className="hidden sm:inline">Code Explorer</span>
<span className="sm:hidden">Code</span>
{activeTab === 'code' ? (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500" />
) : (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-transparent" />
)}
</button>
<button
onClick={() => setActiveTab('sandbox')}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === 'sandbox'
? 'text-gray-900 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<span className="hidden sm:inline">Interactive Sandbox</span>
<span className="sm:hidden">Sandbox</span>
{activeTab === 'sandbox' ? (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500" />
) : (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-transparent" />
)}
</button>
{panels.map((panel) => (
<button
key={panel.id}
type="button"
onClick={() => setActiveTab(panel.id)}
aria-pressed={activeTab === panel.id}
className={`shrink-0 px-3 py-2 text-sm font-medium transition-colors border-b-2 ${
activeTab === panel.id
? 'border-blue-500 text-gray-900 dark:text-white'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
{panel.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<button
Expand Down
64 changes: 57 additions & 7 deletions src/components/InteractiveSandbox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import React from 'react'
import { ArrowSquareOutIcon } from '@phosphor-icons/react'
import { Button } from '~/ui'
import { stackBlitzIframeProps } from '~/utils/stackblitz-embed'

interface InteractiveSandboxProps {
Expand All @@ -19,20 +21,68 @@ export function InteractiveSandbox({
embedEditor,
}: InteractiveSandboxProps) {
const isStackBlitz = embedEditor === 'stackblitz'
const [hasOpened, setHasOpened] = React.useState(isActive)
React.useEffect(() => {
if (isActive) setHasOpened(true)
}, [isActive])
if (!isActive && !hasOpened) return null

const provider = isStackBlitz ? 'StackBlitz' : 'CodeSandbox'
const embedUrl = isStackBlitz ? stackBlitzUrl : codeSandboxUrl
const externalUrl = new URL(embedUrl)
externalUrl.searchParams.delete('embed')

// CodeSandbox's challenge responses forbid framing. Keep its editor usable
// through a top-level navigation instead of showing a blocked iframe.
if (!isStackBlitz) {
return (
<div
className={`absolute inset-0 items-center justify-center bg-background-default p-6 ${isActive ? 'flex' : 'hidden'}`}
aria-hidden={!isActive}
>
<div className="max-w-sm space-y-4 text-center">
<p className="text-sm text-text-muted">
Open this example in a new tab to use CodeSandbox's editor and
preview.
</p>
<Button
as="a"
href={externalUrl.href}
target="_blank"
rel="noreferrer"
>
Open in CodeSandbox
<ArrowSquareOutIcon className="size-4" aria-hidden="true" />
</Button>
</div>
</div>
)
}

return (
<div
className={`absolute inset-0 ${
isActive ? '' : 'pointer-events-none opacity-0'
}`}
className={`absolute inset-0 flex-col ${isActive ? 'flex' : 'hidden'}`}
aria-hidden={!isActive}
>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-background-default px-4 py-2 text-sm">
<span className="text-text-muted">
If the embedded editor does not load, open it in a new tab.
</span>
<a
href={externalUrl.href}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:underline dark:text-blue-400"
>
Open in {provider}
</a>
</div>
<iframe
src={isStackBlitz ? stackBlitzUrl : codeSandboxUrl}
title={`${libraryName} | ${examplePath}`}
{...(isStackBlitz ? stackBlitzIframeProps : {})}
src={embedUrl}
title={`${provider} | ${libraryName} | ${examplePath}`}
{...stackBlitzIframeProps}
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
className="w-full h-full min-h-[80dvh] overflow-hidden shadow-lg bg-white dark:bg-black"
className="w-full min-h-0 flex-1 overflow-hidden bg-white dark:bg-black"
/>
</div>
)
Expand Down
38 changes: 6 additions & 32 deletions src/components/examples/ExampleWorkbench.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
XIcon,
} from '@phosphor-icons/react'
import { ButtonGroup } from '~/components/ButtonGroup'
import { FileExplorer, type FileExplorerNode } from '~/components/FileExplorer'
import { FileExplorer } from '~/components/FileExplorer'
import { createExampleFileTree } from '~/utils/example-file-tree'
import { useTheme } from '~/components/ThemeProvider'
import {
Button,
Expand Down Expand Up @@ -2783,7 +2784,10 @@ export function ExampleWorkbench({
.sort(),
[definition.hiddenFiles, workspace.files],
)
const fileTree = React.useMemo(() => createFileTree(filePaths), [filePaths])
const fileTree = React.useMemo(
() => createExampleFileTree(filePaths),
[filePaths],
)
const activeBuilderPane = getActiveBuilderWorkbenchPane(builderTabs)
const activeBuilderTab = getActiveBuilderWorkbenchTab(builderTabs)
const visibleBuilderPanes =
Expand Down Expand Up @@ -4611,36 +4615,6 @@ function getConsoleColor(level: ExampleConsoleLevel) {
}
}

function createFileTree(paths: Array<string>) {
const root: Array<FileExplorerNode> = []

for (const path of paths) {
const segments = path.split('/').filter(Boolean)
let children = root

for (const [index, name] of segments.entries()) {
const nodePath = `/${segments.slice(0, index + 1).join('/')}`
const last = index === segments.length - 1
let node = children.find((candidate) => candidate.path === nodePath)

if (!node) {
node = {
children: last ? undefined : [],
depth: index,
name,
path: nodePath,
type: last ? 'file' : 'dir',
}
children.push(node)
}

if (node.children) children = node.children
}
}

return root
}

function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
Expand Down
Loading
Loading