fix iframes, maybe

This commit is contained in:
Chris Wanstrath 2026-01-30 18:45:12 -08:00
parent d45144478d
commit ae38084440
6 changed files with 155 additions and 38 deletions

View File

@ -0,0 +1,3 @@
# clock TODO
[x] do something
[ ] do more things

View File

@ -118,12 +118,11 @@ const CodeBlock = define('CodeBlock', {
margin: '20px 0', margin: '20px 0',
border: `1px solid ${theme('border')}`, border: `1px solid ${theme('border')}`,
borderRadius: '4px', borderRadius: '4px',
overflow: 'auto', overflowX: 'auto',
selectors: { selectors: {
'& pre': { '& pre': {
margin: 0, margin: 0,
padding: '15px', padding: '15px',
overflow: 'auto',
whiteSpace: 'pre', whiteSpace: 'pre',
backgroundColor: theme('codeBg'), backgroundColor: theme('codeBg'),
}, },
@ -185,9 +184,17 @@ const initScript = `
document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light'); document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light');
}); });
function sendHeight() { function sendHeight() {
window.parent.postMessage({ type: 'resize-iframe', height: document.documentElement.scrollHeight }, '*'); // Measure the actual content container (skip script tags)
var container = document.querySelector('.Container');
if (!container) return;
var rect = container.getBoundingClientRect();
var height = rect.bottom + 20; // Add some padding
window.parent.postMessage({ type: 'resize-iframe', height: height }, '*');
} }
sendHeight(); sendHeight();
setTimeout(sendHeight, 50);
setTimeout(sendHeight, 200);
setTimeout(sendHeight, 500);
new ResizeObserver(sendHeight).observe(document.body); new ResizeObserver(sendHeight).observe(document.body);
window.addEventListener('load', sendHeight); window.addEventListener('load', sendHeight);
})(); })();
@ -280,6 +287,7 @@ function getLanguage(filename: string): string {
app.get('/', async c => { app.get('/', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file') || '' const filePath = c.req.query('file') || ''
if (!appName) { if (!appName) {
@ -290,14 +298,15 @@ app.get('/', async c => {
) )
} }
const appPath = join(APPS_DIR, appName, 'current') const appPath = join(APPS_DIR, appName, version)
const versionSuffix = version !== 'current' ? ` @ ${version}` : ''
try { try {
await stat(appPath) await stat(appPath)
} catch { } catch {
return c.html( return c.html(
<Layout title="Code Browser"> <Layout title="Code Browser">
<ErrorBox>App "{appName}" not found</ErrorBox> <ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
</Layout> </Layout>
) )
} }
@ -309,21 +318,22 @@ app.get('/', async c => {
fileStats = await stat(fullPath) fileStats = await stat(fullPath)
} catch { } catch {
return c.html( return c.html(
<Layout title="Code Browser" subtitle={appName}> <Layout title="Code Browser" subtitle={appName + versionSuffix}>
<ErrorBox>Path "{filePath}" not found</ErrorBox> <ErrorBox>Path "{filePath}" not found</ErrorBox>
</Layout> </Layout>
) )
} }
const parentPath = filePath.split('/').slice(0, -1).join('/') const parentPath = filePath.split('/').slice(0, -1).join('/')
const backUrl = `/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}` const versionParam = version !== 'current' ? `&version=${version}` : ''
const backUrl = `/?app=${appName}${versionParam}${parentPath ? `&file=${parentPath}` : ''}`
if (fileStats.isFile()) { if (fileStats.isFile()) {
const content = readFileSync(fullPath, 'utf-8') const content = readFileSync(fullPath, 'utf-8')
const language = getLanguage(basename(fullPath)) const language = getLanguage(basename(fullPath))
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`} subtitle={`${appName}/${filePath}`} highlight> <Layout title={`${appName}/${filePath}`} subtitle={`${appName}${versionSuffix}/${filePath}`} highlight>
<BackLink href={backUrl}> Back</BackLink> <BackLink href={backUrl}> Back</BackLink>
<CodeBlock> <CodeBlock>
<CodeHeader>{basename(fullPath)}</CodeHeader> <CodeHeader>{basename(fullPath)}</CodeHeader>
@ -336,12 +346,12 @@ app.get('/', async c => {
const files = await listFiles(appPath, filePath) const files = await listFiles(appPath, filePath)
return c.html( return c.html(
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`} subtitle={`${appName}${filePath ? `/${filePath}` : ''}`}> <Layout title={`${appName}${filePath ? `/${filePath}` : ''}`} subtitle={`${appName}${versionSuffix}${filePath ? `/${filePath}` : ''}`}>
{filePath && <BackLink href={backUrl}> Back</BackLink>} {filePath && <BackLink href={backUrl}> Back</BackLink>}
<FileList> <FileList>
{files.map(file => ( {files.map(file => (
<FileItem> <FileItem>
<FileLink href={`/?app=${appName}&file=${file.path}`}> <FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />} {file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
<span>{file.name}</span> <span>{file.name}</span>
</FileLink> </FileLink>

View File

@ -96,6 +96,7 @@ const VersionLink = define('VersionLink', {
color: theme('link'), color: theme('link'),
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '15px', fontSize: '15px',
cursor: 'pointer',
states: { states: {
':hover': { ':hover': {
textDecoration: 'underline', textDecoration: 'underline',
@ -230,15 +231,15 @@ app.get('/', async c => {
) )
} }
// Get the code tool's port from environment or use a relative path
const codeToolUrl = process.env.CODE_TOOL_URL || '/tool/code'
return c.html( return c.html(
<Layout title="Versions" subtitle={appName}> <Layout title="Versions" subtitle={appName}>
<VersionList> <VersionList>
{versions.map(v => ( {versions.map(v => (
<VersionItem> <VersionItem>
<VersionLink href={`${codeToolUrl}?app=${appName}&version=${v.name}`} target="_top"> <VersionLink
href="#"
onclick={`window.parent.postMessage({type:'navigate-tool',tool:'code',params:{app:'${appName}',version:'${v.name}'}}, '*'); return false;`}
>
{formatTimestamp(v.name)} {formatTimestamp(v.name)}
</VersionLink> </VersionLink>
{v.isCurrent && <Badge>current</Badge>} {v.isCurrent && <Badge>current</Badge>}

View File

@ -1,8 +1,8 @@
import { render as renderApp } from 'hono/jsx/dom' import { render as renderApp } from 'hono/jsx/dom'
import { Dashboard } from './components' import { Dashboard } from './components'
import { apps, selectedApp, selectedTab, setApps, setSelectedApp } from './state' import { apps, selectedApp, selectedTab, setApps, setSelectedApp, setSelectedTab } from './state'
import { initModal } from './components/modal' import { initModal } from './components/modal'
import { initToolIframes, updateToolIframes } from './tool-iframes' import { initToolIframes, updateToolIframes, setNavigateToolHandler, navigateToTool } from './tool-iframes'
import { initUpdate } from './update' import { initUpdate } from './update'
const render = () => { const render = () => {
@ -19,6 +19,15 @@ initModal(render)
initUpdate(render) initUpdate(render)
initToolIframes() initToolIframes()
// Handle tool-to-tool navigation via postMessage
setNavigateToolHandler((tool, params) => {
const tools = apps.filter(a => a.tool)
navigateToTool(tool, params, tools, (tab) => {
setSelectedTab(tab)
render()
})
})
// Set theme based on system preference // Set theme based on system preference
const setTheme = () => { const setTheme = () => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches

View File

@ -2,7 +2,7 @@ import { theme } from './themes'
// Iframe cache - these never get recreated once loaded // Iframe cache - these never get recreated once loaded
// Use a global to survive hot reloads // Use a global to survive hot reloads
const iframes: Map<string, { iframe: HTMLIFrameElement; port: number }> = const iframes: Map<string, { iframe: HTMLIFrameElement; port: number; contentHeight?: number }> =
(window as any).__toolIframes ??= new Map() (window as any).__toolIframes ??= new Map()
// Track current state to avoid unnecessary DOM updates // Track current state to avoid unnecessary DOM updates
@ -12,11 +12,51 @@ let currentTool: string | null = (window as any).__currentTool ?? null
// Get the stable container (outside Hono-managed DOM) // Get the stable container (outside Hono-managed DOM)
const getContainer = () => document.getElementById('tool-iframes') const getContainer = () => document.getElementById('tool-iframes')
// Callback for tool navigation requests
let onNavigateTool: ((tool: string, params: Record<string, string>) => void) | null = null
export function setNavigateToolHandler(handler: (tool: string, params: Record<string, string>) => void) {
onNavigateTool = handler
}
// Listen for messages from iframes
function setupMessageListener() {
if ((window as any).__toolIframeMessageListener) return
; (window as any).__toolIframeMessageListener = true
window.addEventListener('message', (event) => {
const { type } = event.data || {}
if (type === 'resize-iframe') {
const height = event.data.height
if (!height || typeof height !== 'number') return
// Find which iframe sent this message
for (const [, cached] of iframes) {
if (cached.iframe.contentWindow === event.source) {
cached.contentHeight = height
cached.iframe.style.height = `100vh`
return
}
}
}
if (type === 'navigate-tool') {
const { tool, params } = event.data
if (tool && onNavigateTool) {
onNavigateTool(tool, params || {})
}
}
})
}
// Initialize the container styles // Initialize the container styles
export function initToolIframes() { export function initToolIframes() {
const container = getContainer() const container = getContainer()
if (!container) return if (!container) return
setupMessageListener()
// Restore iframe cache from DOM if module was hot-reloaded // Restore iframe cache from DOM if module was hot-reloaded
if (iframes.size === 0) { if (iframes.size === 0) {
const existingIframes = container.querySelectorAll('iframe') const existingIframes = container.querySelectorAll('iframe')
@ -38,9 +78,66 @@ export function initToolIframes() {
display: none; display: none;
position: fixed; position: fixed;
background: ${theme('colors-bg')}; background: ${theme('colors-bg')};
overflow: auto;
` `
} }
// Build URL with params
function buildToolUrl(port: number, params: Record<string, string>): string {
const searchParams = new URLSearchParams(params)
const query = searchParams.toString()
return query ? `http://localhost:${port}?${query}` : `http://localhost:${port}`
}
// Build cache key from tool name and params
function buildCacheKey(toolName: string, params: Record<string, string>): string {
const parts = [toolName]
const sortedKeys = Object.keys(params).sort()
for (const key of sortedKeys) {
parts.push(`${key}=${params[key]}`)
}
return parts.join(':')
}
// Navigate to a tool with specific params (called from postMessage handler)
export function navigateToTool(
toolName: string,
params: Record<string, string>,
tools: Array<{ name: string; port?: number; state: string }>,
onSelectTab: (tab: string) => void
) {
const tool = tools.find(t => t.name === toolName)
if (!tool || tool.state !== 'running' || !tool.port) return
const container = getContainer()
if (!container) return
const cacheKey = buildCacheKey(toolName, params)
// Create iframe if needed
let cached = iframes.get(cacheKey)
if (!cached || cached.port !== tool.port) {
const iframe = document.createElement('iframe')
iframe.src = buildToolUrl(tool.port, params)
iframe.dataset.toolName = toolName
iframe.dataset.appName = params.app ?? ''
iframe.style.cssText = `width: 100%; border: none;`
cached = { iframe, port: tool.port }
iframes.set(cacheKey, cached)
container.appendChild(iframe)
}
// Switch to that tab
onSelectTab(toolName)
// Force show this specific iframe
currentTool = cacheKey
;(window as any).__currentTool = cacheKey
for (const [key, { iframe }] of iframes) {
iframe.style.display = key === cacheKey ? 'block' : 'none'
}
}
// Update which iframe is visible based on selected tab and tool state // Update which iframe is visible based on selected tab and tool state
export function updateToolIframes( export function updateToolIframes(
selectedTab: string, selectedTab: string,
@ -55,7 +152,6 @@ export function updateToolIframes(
const showIframe = selectedTool?.state === 'running' && selectedTool?.port const showIframe = selectedTool?.state === 'running' && selectedTool?.port
if (!showIframe) { if (!showIframe) {
// Only update if state changed
if (currentTool !== null) { if (currentTool !== null) {
container.style.display = 'none' container.style.display = 'none'
currentTool = null currentTool = null
@ -66,19 +162,20 @@ export function updateToolIframes(
const tool = selectedTool! const tool = selectedTool!
// Build cache key for tool + app combination // Build params and cache key
const cacheKey = selectedApp ? `${tool.name}:${selectedApp}` : tool.name const params: Record<string, string> = {}
if (selectedApp) params.app = selectedApp
const cacheKey = buildCacheKey(tool.name, params)
// Skip if nothing changed // Skip if nothing changed
if (currentTool === cacheKey) { if (currentTool === cacheKey) {
// Just update position in case of scroll/resize
const tabContent = document.querySelector('[data-tool-target]') const tabContent = document.querySelector('[data-tool-target]')
if (tabContent) { if (tabContent) {
const rect = tabContent.getBoundingClientRect() const rect = tabContent.getBoundingClientRect()
container.style.top = `${rect.top}px` container.style.top = `${rect.top}px`
container.style.left = `${rect.left}px` container.style.left = `${rect.left}px`
container.style.width = `${rect.width}px` container.style.width = `${rect.width}px`
container.style.height = `${rect.height}px` container.style.height = '100vh'
} }
return return
} }
@ -98,35 +195,27 @@ export function updateToolIframes(
top: ${rect.top}px; top: ${rect.top}px;
left: ${rect.left}px; left: ${rect.left}px;
width: ${rect.width}px; width: ${rect.width}px;
height: ${rect.height}px; height: calc(100vh - ${rect.top}px);
background: ${theme('colors-bg')}; background: ${theme('colors-bg')};
z-index: 100; z-index: 100;
overflow: auto;
` `
// Get or create the iframe for this tool + app combination // Get or create the iframe
let cached = iframes.get(cacheKey) let cached = iframes.get(cacheKey)
if (!cached || cached.port !== tool.port) { if (!cached || cached.port !== tool.port) {
// Create new iframe (first time or port changed)
const iframe = document.createElement('iframe') const iframe = document.createElement('iframe')
const url = selectedApp iframe.src = buildToolUrl(tool.port!, params)
? `http://localhost:${tool.port}?app=${encodeURIComponent(selectedApp)}` iframe.dataset.toolName = tool.name
: `http://localhost:${tool.port}` iframe.dataset.appName = selectedApp ?? ''
iframe.src = url iframe.style.cssText = `width: 100%; border: none;`
iframe.dataset.toolName = tool.name // For hot reload recovery
iframe.dataset.appName = selectedApp ?? '' // For hot reload recovery
iframe.style.cssText = `
width: 100%;
height: 100%;
border: none;
`
cached = { iframe, port: tool.port! } cached = { iframe, port: tool.port! }
iframes.set(cacheKey, cached) iframes.set(cacheKey, cached)
// Add to container
container.appendChild(iframe) container.appendChild(iframe)
} }
// Show only the selected iframe, hide others // Show only the selected iframe
for (const [key, { iframe }] of iframes) { for (const [key, { iframe }] of iframes) {
const shouldShow = key === cacheKey const shouldShow = key === cacheKey
if (shouldShow && iframe.parentElement !== container) { if (shouldShow && iframe.parentElement !== container) {

View File

@ -640,10 +640,15 @@ function watchAppsDir() {
const { pkg, error } = loadApp(dir) const { pkg, error } = loadApp(dir)
// Update icon, tool, and error from package.json // Update icon, tool, and error from package.json
const iconChanged = app.icon !== pkg.toes?.icon
const toolChanged = app.tool !== pkg.toes?.tool
app.icon = pkg.toes?.icon app.icon = pkg.toes?.icon
app.tool = pkg.toes?.tool app.tool = pkg.toes?.tool
app.error = error app.error = error
// Broadcast if icon or tool changed
if (iconChanged || toolChanged) update()
// App became valid - start it if stopped // App became valid - start it if stopped
if (!error && app.state === 'invalid') { if (!error && app.state === 'invalid') {
app.state = 'stopped' app.state = 'stopped'