diff --git a/public/main.ts b/public/main.ts index fd1858d..60c75f0 100644 --- a/public/main.ts +++ b/public/main.ts @@ -19,6 +19,7 @@ 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' const paneHost = document.getElementById('term') const tabs = document.getElementById('tabs') @@ -29,6 +30,14 @@ if (!toolbar) throw new Error('#toolbar element not found in DOM') const app = new TabApp(paneHost, tabs) +// v0.4: a share link (?join=) 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) @@ -57,6 +66,7 @@ mountHistory(toolbar, { resume: (cwd, id) => app.newTabForResume(cwd, id), }) mountShortcuts(toolbar) +mountShareSession(toolbar, () => app.activeSessionId()) mountQrConnect(toolbar) // PWA: register the service worker (installable + offline shell, M4). diff --git a/public/share.ts b/public/share.ts new file mode 100644 index 0000000..f64f05a --- /dev/null +++ b/public/share.ts @@ -0,0 +1,72 @@ +/** + * public/share.ts — "share this session" (v0.4 multi-device mirror). + * + * A 🔗 toolbar button shows a QR + URL of the form `/?join=` + * for the ACTIVE tab. Opening that link on another device on the same network + * joins the same live terminal (mirror: both see output, either can type). + * Reuses the .qr-* card styles. + */ + +import QRCode from 'qrcode' + +export function mountShareSession(toolbar: HTMLElement, getActiveId: () => string | null): void { + const modal = document.createElement('div') + modal.id = 'sharemodal' + modal.className = 'qrmodal' + modal.style.display = 'none' + + const card = document.createElement('div') + card.className = 'qr-card' + const title = document.createElement('div') + title.className = 'qr-title' + title.textContent = 'Share this session' + const canvas = document.createElement('canvas') + const urlEl = document.createElement('div') + urlEl.className = 'qr-url' + const note = document.createElement('div') + note.className = 'qr-note' + const close = document.createElement('button') + close.className = 'qr-close' + close.textContent = 'Close' + card.append(title, canvas, urlEl, note, close) + modal.appendChild(card) + document.body.appendChild(modal) + + const hide = (): void => { + modal.style.display = 'none' + } + modal.addEventListener('click', (e) => { + if (e.target === modal) hide() + }) + close.addEventListener('click', hide) + + const open = (): void => { + const id = getActiveId() + if (!id) { + // The active tab hasn't attached yet — no id to share. + urlEl.textContent = '' + note.textContent = 'This tab is still connecting — try again in a second.' + const ctx = canvas.getContext('2d') + if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height) + modal.style.display = 'flex' + return + } + const url = `${location.origin}/?join=${id}` + urlEl.textContent = url + void QRCode.toCanvas(canvas, url, { width: 220, margin: 1 }) + const host = location.hostname + note.textContent = + host === 'localhost' || host === '127.0.0.1' + ? 'Tip: open this page via your LAN IP (not localhost) so the code works on other devices.' + : 'Scan on a device on the same network to mirror this session.' + modal.style.display = 'flex' + } + + const toggle = document.createElement('button') + toggle.className = 'toolbtn' + toggle.textContent = '🔗' + toggle.title = 'Share this session (mirror it on another device)' + toggle.setAttribute('aria-label', 'Share this session') + toggle.addEventListener('click', open) + toolbar.appendChild(toggle) +} diff --git a/public/style.css b/public/style.css index eb0c881..3e084d1 100644 --- a/public/style.css +++ b/public/style.css @@ -368,8 +368,9 @@ body { background: var(--accent-soft); } -/* QR modal */ -#qrmodal { +/* QR + share modals */ +#qrmodal, +#sharemodal { position: fixed; inset: 0; z-index: 1200; diff --git a/public/tabs.ts b/public/tabs.ts index c886428..a6b81f7 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -133,7 +133,9 @@ export class TabApp { } catch { legacy = null } - stored = [{ id: legacy, title: null }] + // v0.2.x single-session migration only; otherwise stay empty and let + // syncLiveSessions() decide (host sessions, or a fresh tab as fallback). + if (legacy) stored = [{ id: legacy, title: null }] } for (const s of stored) this.addEntry(s.id, s.title) @@ -146,7 +148,71 @@ export class TabApp { } if (!Number.isInteger(active) || active < 0 || active >= this.tabs.length) active = 0 this.rebuild() - this.activate(active) + if (this.tabs.length > 0) this.activate(active) + + // v0.4: discover the host's running sessions and show them as tabs too. + void this.syncLiveSessions() + } + + /** + * v0.4 multi-device: fetch the host's currently-running sessions and add a tab + * for each one not already open, so opening the app on any device shows (and + * joins/mirrors) everything running on the host. Falls back to one fresh tab + * when nothing is stored and nothing is live. + */ + private async syncLiveSessions(): Promise { + let live: Array<{ id: string }> = [] + try { + const res = await fetch('/live-sessions') + const data: unknown = await res.json() + if (Array.isArray(data)) live = data as Array<{ id: string }> + } catch { + live = [] + } + + const open = new Set( + this.tabs.map((t) => t.session.id).filter((x): x is string => typeof x === 'string'), + ) + let added = false + for (const info of live) { + if (typeof info.id === 'string' && !open.has(info.id)) { + this.addEntry(info.id, null) + added = true + } + } + // Nothing restored and nothing live → start one fresh session. + if (this.tabs.length === 0) { + this.addEntry(null, null) + added = true + } + if (!added) return + + this.rebuild() + const idx = + this.activeIndex >= 0 && this.activeIndex < this.tabs.length ? this.activeIndex : 0 + this.activeIndex = idx + this.tabs.forEach((t, i) => (i === idx ? t.session.show() : t.session.hide())) + this.tabs.forEach((t) => this.refreshTab(t)) + this.updateApprovalBar() + this.persist() + } + + /** The active tab's server session id (null until it has attached). */ + activeSessionId(): string | null { + return this.tabs[this.activeIndex]?.session.id ?? null + } + + /** Focus the tab for `sessionId`, or open one that joins it (share-link target). */ + openSession(sessionId: string): void { + const existing = this.tabs.findIndex((t) => t.session.id === sessionId) + if (existing >= 0) { + this.activate(existing) + return + } + this.addEntry(sessionId, null) + this.persist() + this.rebuild() + this.activate(this.tabs.length - 1) } /* ── tab lifecycle ───────────────────────────────────────────── */