Builds on the v1 watch board: - Two more layouts: row-3 (1×3) and grid-6 (2×3). A single `grid` marker class on #term now carries the shared cell chrome so it no longer enumerates each lay-*. - Ctrl+` cycles the focused quadrant (Ctrl+Shift+` reverses); matchFocusCycleKey is an exported pure helper so it's unit-tested. No-op / passthrough in single mode. - Per-quadrant ⛶ maximize: the focused cell expands to fill the grid as an absolute overlay while siblings stay live behind it; follows focus, resets on layout change / tab close. - Drag a tab from the tab bar onto a quadrant to assign it there (reuses the existing tab-drag dragIndex); grid-only. Adversarial review (3 lenses → per-finding verify, incl. a headless-Chrome repro) caught a HIGH: maximizing via `grid-column/row: 1/-1` shoved siblings into implicit rows — a strip instead of fullscreen AND a spurious resize to backgrounded live PTYs. Fixed with an absolute-overlay (`position:absolute; inset:0`), then verified in real Chromium (Playwright): maximized cell fills #term, siblings 0px size delta. Also: silence a covered pane's pending pulse under maximize; coarse-pointer target for .cell-max. Verified: typecheck + build:web clean, 1579 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
4.4 KiB
TypeScript
122 lines
4.4 KiB
TypeScript
/**
|
|
* public/main.ts — esbuild entry point for the browser frontend.
|
|
*
|
|
* Bootstraps the multi-tab app: a tab bar (#tabs) + a utility toolbar (#toolbar)
|
|
* over a stack of terminal panes (#term), each tab an independent TerminalSession.
|
|
* The mobile key bar routes input to the active tab.
|
|
*
|
|
* Per-session terminal/WS/reconnect logic lives in terminal-session.ts;
|
|
* tab management + persistence in tabs.ts.
|
|
*/
|
|
|
|
// CSS side-effect import (esbuild → public/build/main.css; typed via css.d.ts).
|
|
import '@xterm/xterm/css/xterm.css'
|
|
import { mountKeybar } from './keybar.js'
|
|
import { TabApp } from './tabs.js'
|
|
import { mountSearch } from './search.js'
|
|
import { mountQrConnect } from './qr.js'
|
|
import { mountSettings, loadSettings, type Settings } from './settings.js'
|
|
import { mountDashboard } from './dashboard.js'
|
|
import { mountHistory } from './history.js'
|
|
import { mountShortcuts } from './shortcuts.js'
|
|
import { mountShareSession } from './share.js'
|
|
import { mountGridToggle, matchFocusCycleKey } from './grid-layout.js'
|
|
|
|
const paneHost = document.getElementById('term')
|
|
const tabs = document.getElementById('tabs')
|
|
const toolbar = document.getElementById('toolbar')
|
|
if (!paneHost) throw new Error('#term element not found in DOM')
|
|
if (!tabs) throw new Error('#tabs element not found in DOM')
|
|
if (!toolbar) throw new Error('#toolbar element not found in DOM')
|
|
|
|
const app = new TabApp(paneHost, tabs)
|
|
|
|
// v0.4: a share link (?join=<id>) opens/focuses that shared session, then we
|
|
// strip the param so a refresh doesn't keep re-opening it.
|
|
const joinId = new URLSearchParams(location.search).get('join')
|
|
if (joinId) {
|
|
app.openSession(joinId)
|
|
history.replaceState(null, '', location.pathname)
|
|
}
|
|
|
|
// Apply saved theme/font (M3) to the restored tabs.
|
|
let settings: Settings = loadSettings()
|
|
app.applySettings(settings)
|
|
|
|
// Key bar (mobile + desktop) sends to whichever tab is active. onVoiceTrigger
|
|
// activates the full voice path (dictation + context-gated confirm/reject, VC)
|
|
// and un-dormants the 🎤 button (createVoiceInput / matchCommand are otherwise
|
|
// unreachable dead code without this wiring).
|
|
mountKeybar((data) => app.sendToActive(data), {
|
|
onVoiceTrigger: (action) => app.handleVoiceTrigger(action),
|
|
})
|
|
|
|
// Multi-device: when this device regains focus, re-assert the size of every
|
|
// VISIBLE pane so a shared session snaps to THIS screen (latest-writer-wins) —
|
|
// full-screen on whichever device you're using (all quadrants in a split grid).
|
|
window.addEventListener('focus', () => app.refitVisible())
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) app.refitVisible()
|
|
})
|
|
|
|
// v2: Ctrl+` cycles the focused quadrant in a split grid (Ctrl+Shift+` reverses).
|
|
// Capture phase so xterm doesn't swallow it; left untouched (no preventDefault)
|
|
// in single mode so the terminal keeps the keystroke.
|
|
document.addEventListener(
|
|
'keydown',
|
|
(e) => {
|
|
const dir = matchFocusCycleKey(e)
|
|
if (dir === null) return
|
|
if (app.getGridLayout() === 'single') return // let the terminal keep the key
|
|
e.preventDefault()
|
|
app.cycleFocus(dir)
|
|
},
|
|
{ capture: true },
|
|
)
|
|
|
|
// Toolbar utilities.
|
|
mountSearch(toolbar, {
|
|
find: (query, dir) => app.findInActive(query, dir),
|
|
clear: () => app.clearActiveSearch(),
|
|
})
|
|
mountSettings(
|
|
toolbar,
|
|
() => settings,
|
|
(s) => {
|
|
settings = s
|
|
app.applySettings(s)
|
|
},
|
|
)
|
|
mountDashboard(toolbar, {
|
|
snapshot: () => app.snapshot(),
|
|
focus: (idx) => app.focusTab(idx),
|
|
})
|
|
mountHistory(toolbar, {
|
|
resume: (cwd, id) => app.newTabForResume(cwd, id),
|
|
})
|
|
mountShortcuts(toolbar)
|
|
mountShareSession(toolbar, () => app.activeSessionId())
|
|
|
|
// Split-grid layout toggle (desktop-only; hidden below GRID_MIN_WIDTH). The
|
|
// control drives app.setGridLayout and is registered back so a programmatic
|
|
// layout change (e.g. auto-fallback on window narrow) re-syncs its pressed state.
|
|
const gridToggle = mountGridToggle(toolbar, {
|
|
getLayout: () => app.getGridLayout(),
|
|
setLayout: (layout) => app.setGridLayout(layout),
|
|
})
|
|
app.setGridToggle(gridToggle)
|
|
|
|
// Session management lives on the home Sessions chooser now (open / kill /
|
|
// new), so the standalone /manage.html page was removed.
|
|
|
|
mountQrConnect(toolbar)
|
|
|
|
// PWA: register the service worker (installable + offline shell, M4).
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', () => {
|
|
void navigator.serviceWorker.register('./sw.js').catch(() => {
|
|
// SW registration is best-effort; the app works without it.
|
|
})
|
|
})
|
|
}
|