diff --git a/apps/clock/20260130-000000/TODO.txt b/apps/clock/20260130-000000/TODO.txt new file mode 100644 index 0000000..50e9c39 --- /dev/null +++ b/apps/clock/20260130-000000/TODO.txt @@ -0,0 +1,3 @@ +# clock TODO +[x] do something +[ ] do more things diff --git a/apps/code/20260130-000000/src/server/index.tsx b/apps/code/20260130-000000/src/server/index.tsx index 4b2d3d7..edafa96 100644 --- a/apps/code/20260130-000000/src/server/index.tsx +++ b/apps/code/20260130-000000/src/server/index.tsx @@ -118,12 +118,11 @@ const CodeBlock = define('CodeBlock', { margin: '20px 0', border: `1px solid ${theme('border')}`, borderRadius: '4px', - overflow: 'auto', + overflowX: 'auto', selectors: { '& pre': { margin: 0, padding: '15px', - overflow: 'auto', whiteSpace: 'pre', backgroundColor: theme('codeBg'), }, @@ -185,9 +184,17 @@ const initScript = ` document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light'); }); 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(); + setTimeout(sendHeight, 50); + setTimeout(sendHeight, 200); + setTimeout(sendHeight, 500); new ResizeObserver(sendHeight).observe(document.body); window.addEventListener('load', sendHeight); })(); @@ -280,6 +287,7 @@ function getLanguage(filename: string): string { app.get('/', async c => { const appName = c.req.query('app') + const version = c.req.query('version') || 'current' const filePath = c.req.query('file') || '' 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 { await stat(appPath) } catch { return c.html( - App "{appName}" not found + App "{appName}" (version: {version}) not found ) } @@ -309,21 +318,22 @@ app.get('/', async c => { fileStats = await stat(fullPath) } catch { return c.html( - + Path "{filePath}" not found ) } 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()) { const content = readFileSync(fullPath, 'utf-8') const language = getLanguage(basename(fullPath)) return c.html( - + ← Back {basename(fullPath)} @@ -336,12 +346,12 @@ app.get('/', async c => { const files = await listFiles(appPath, filePath) return c.html( - + {filePath && ← Back} {files.map(file => ( - + {file.isDirectory ? : } {file.name} diff --git a/apps/versions/20260130-000000/index.tsx b/apps/versions/20260130-000000/index.tsx index 5173856..35d8bf6 100644 --- a/apps/versions/20260130-000000/index.tsx +++ b/apps/versions/20260130-000000/index.tsx @@ -96,6 +96,7 @@ const VersionLink = define('VersionLink', { color: theme('link'), fontFamily: 'monospace', fontSize: '15px', + cursor: 'pointer', states: { ':hover': { 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( {versions.map(v => ( - + {formatTimestamp(v.name)} {v.isCurrent && current} diff --git a/src/client/index.tsx b/src/client/index.tsx index 7a1782c..9b10094 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,8 +1,8 @@ import { render as renderApp } from 'hono/jsx/dom' 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 { initToolIframes, updateToolIframes } from './tool-iframes' +import { initToolIframes, updateToolIframes, setNavigateToolHandler, navigateToTool } from './tool-iframes' import { initUpdate } from './update' const render = () => { @@ -19,6 +19,15 @@ initModal(render) initUpdate(render) 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 const setTheme = () => { const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches diff --git a/src/client/tool-iframes.ts b/src/client/tool-iframes.ts index 985cdc4..96fdea3 100644 --- a/src/client/tool-iframes.ts +++ b/src/client/tool-iframes.ts @@ -2,7 +2,7 @@ import { theme } from './themes' // Iframe cache - these never get recreated once loaded // Use a global to survive hot reloads -const iframes: Map = +const iframes: Map = (window as any).__toolIframes ??= new Map() // 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) const getContainer = () => document.getElementById('tool-iframes') +// Callback for tool navigation requests +let onNavigateTool: ((tool: string, params: Record) => void) | null = null + +export function setNavigateToolHandler(handler: (tool: string, params: Record) => 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 export function initToolIframes() { const container = getContainer() if (!container) return + setupMessageListener() + // Restore iframe cache from DOM if module was hot-reloaded if (iframes.size === 0) { const existingIframes = container.querySelectorAll('iframe') @@ -38,9 +78,66 @@ export function initToolIframes() { display: none; position: fixed; background: ${theme('colors-bg')}; + overflow: auto; ` } +// Build URL with params +function buildToolUrl(port: number, params: Record): 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 { + 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, + 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 export function updateToolIframes( selectedTab: string, @@ -55,7 +152,6 @@ export function updateToolIframes( const showIframe = selectedTool?.state === 'running' && selectedTool?.port if (!showIframe) { - // Only update if state changed if (currentTool !== null) { container.style.display = 'none' currentTool = null @@ -66,19 +162,20 @@ export function updateToolIframes( const tool = selectedTool! - // Build cache key for tool + app combination - const cacheKey = selectedApp ? `${tool.name}:${selectedApp}` : tool.name + // Build params and cache key + const params: Record = {} + if (selectedApp) params.app = selectedApp + const cacheKey = buildCacheKey(tool.name, params) // Skip if nothing changed if (currentTool === cacheKey) { - // Just update position in case of scroll/resize const tabContent = document.querySelector('[data-tool-target]') if (tabContent) { const rect = tabContent.getBoundingClientRect() container.style.top = `${rect.top}px` container.style.left = `${rect.left}px` container.style.width = `${rect.width}px` - container.style.height = `${rect.height}px` + container.style.height = '100vh' } return } @@ -98,35 +195,27 @@ export function updateToolIframes( top: ${rect.top}px; left: ${rect.left}px; width: ${rect.width}px; - height: ${rect.height}px; + height: calc(100vh - ${rect.top}px); background: ${theme('colors-bg')}; 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) if (!cached || cached.port !== tool.port) { - // Create new iframe (first time or port changed) const iframe = document.createElement('iframe') - const url = selectedApp - ? `http://localhost:${tool.port}?app=${encodeURIComponent(selectedApp)}` - : `http://localhost:${tool.port}` - iframe.src = url - 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; - ` + iframe.src = buildToolUrl(tool.port!, params) + iframe.dataset.toolName = tool.name + iframe.dataset.appName = selectedApp ?? '' + iframe.style.cssText = `width: 100%; border: none;` cached = { iframe, port: tool.port! } iframes.set(cacheKey, cached) - // Add to container container.appendChild(iframe) } - // Show only the selected iframe, hide others + // Show only the selected iframe for (const [key, { iframe }] of iframes) { const shouldShow = key === cacheKey if (shouldShow && iframe.parentElement !== container) { diff --git a/src/server/apps.ts b/src/server/apps.ts index 3f77a20..97618a2 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -640,10 +640,15 @@ function watchAppsDir() { const { pkg, error } = loadApp(dir) // 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.tool = pkg.toes?.tool app.error = error + // Broadcast if icon or tool changed + if (iconChanged || toolChanged) update() + // App became valid - start it if stopped if (!error && app.state === 'invalid') { app.state = 'stopped'