mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat(mobile): pop a session or a file preview out beside the dashboard from a native wrapper
An Android WebView wrapper has no browser pop-ups, so a foldable could not show two sessions, or a session and a file, side by side. A wrapper that can open a window of its own now exposes window.CodemanHost.openWindow(url); detachSession, detachFilePreview and openWebviewExternal hand their URL to it, mobile.css keeps the pop-out icon under html.host-windows, and a solo window closes and raises itself through the host when it offers the calls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
88e3faa456
commit
0720526b64
@@ -0,0 +1,18 @@
|
||||
---
|
||||
"aicodeman": minor
|
||||
---
|
||||
|
||||
feat(mobile): pop a session or a file preview out beside the dashboard from a native wrapper
|
||||
|
||||
A WebView app has no browser pop-ups, so "Open in a new window" had nothing to open on a
|
||||
phone, and mobile.css hid it there. An embedding app that can put a page in a window of its
|
||||
own (an Android app on a foldable or in split screen) now says so with
|
||||
`window.CodemanHost.openWindow(absoluteUrl)`, returning whether a window opened. When it is
|
||||
present, the tab pop-out, the file viewer's detach button and a web tab's "open externally"
|
||||
go through it, and the tab's pop-out icon defaults on and shows at tablet widths (phone tabs
|
||||
keep their gear + close tap zones, so the host offers the pop-out from its own chrome through
|
||||
`app.detachSession`). The dashboard
|
||||
tracks such a window over the existing window channel, the path a reloaded dashboard already
|
||||
uses, and a solo window closes and raises itself through the optional
|
||||
`CodemanHost.closeWindow()` / `CodemanHost.focusWindow()`. Browsers define none of these, so
|
||||
nothing changes there.
|
||||
+57
-2
@@ -910,6 +910,8 @@ class CodemanApp {
|
||||
// strip never flashes before handleInit selects the target session.
|
||||
this._initWindowChannel();
|
||||
if (this.isSoloWindow) document.body.classList.add('solo-mode');
|
||||
// mobile.css keeps the pop-out icon off phones unless a host can open windows.
|
||||
document.documentElement.classList.toggle('host-windows', this.hasHostWindows());
|
||||
// Initialize mobile handlers
|
||||
KeyboardHandler.init();
|
||||
SwipeHandler.init();
|
||||
@@ -1295,6 +1297,21 @@ class CodemanApp {
|
||||
// false only when we owned a now-closed window (re-dock + fall through to
|
||||
// genuinely re-open below).
|
||||
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
|
||||
// A native wrapper (an Android WebView app) has no browser pop-ups, but can
|
||||
// open the solo URL in a window of its own, beside this one on a foldable or
|
||||
// a split screen. There is no WindowProxy to poll, so the tab is tracked the
|
||||
// way a dashboard reload tracks it: the solo window's channel announcements
|
||||
// plus the roll-call liveness check.
|
||||
const hosted = this.openInHostWindow(CodemanBase.url('/session/' + encodeURIComponent(id)));
|
||||
if (hosted !== null) {
|
||||
if (!hosted) {
|
||||
this.showToast?.('Could not open a new window for this session', 'error');
|
||||
return;
|
||||
}
|
||||
this._markDetached(id, true);
|
||||
this._postWindowMessage({ type: 'detached', id });
|
||||
return;
|
||||
}
|
||||
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
|
||||
let win = null;
|
||||
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
|
||||
@@ -1309,6 +1326,32 @@ class CodemanApp {
|
||||
try { win.focus(); } catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The embedding app's window opener, when there is one. A native wrapper
|
||||
* exposes `window.CodemanHost.openWindow(absoluteUrl)` (returning whether a
|
||||
* window opened) to say it can put a page in a window of its own; browsers
|
||||
* never define it.
|
||||
* @returns {boolean} whether a host window opener is present
|
||||
*/
|
||||
hasHostWindows() {
|
||||
try {
|
||||
return typeof window !== 'undefined' && typeof window.CodemanHost?.openWindow === 'function';
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a same-origin page in a host window.
|
||||
* @param {string} url absolute or base-relative URL
|
||||
* @returns {boolean|null} null when there is no host (use window.open),
|
||||
* otherwise whether the host opened a window
|
||||
*/
|
||||
openInHostWindow(url) {
|
||||
if (!this.hasHostWindows()) return null;
|
||||
try {
|
||||
return window.CodemanHost.openWindow(new URL(url, location.href).href) !== false;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Raise the popup for an already-detached session. Returns true if the raise
|
||||
* was handled (caller should stop); false if we owned a now-closed window and
|
||||
* re-docked it (caller should fall through to inline / re-open). Unifies the
|
||||
@@ -1435,8 +1478,12 @@ class CodemanApp {
|
||||
// Roll-call has no id (broadcast to all) — answer before the id filter.
|
||||
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
|
||||
if (msg.id !== this.soloSessionId) return;
|
||||
if (msg.type === 'close-request') { try { window.close(); } catch {} }
|
||||
else if (msg.type === 'focus-request') { try { window.focus(); } catch {} }
|
||||
// A host window ignores window.close()/focus() from script it did not
|
||||
// open by window.open, so ask the host when it offers the call.
|
||||
if (msg.type === 'close-request') { this._closeSoloWindow(); }
|
||||
else if (msg.type === 'focus-request') {
|
||||
try { if (typeof window.CodemanHost?.focusWindow === 'function') window.CodemanHost.focusWindow(); else window.focus(); } catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Dashboard side.
|
||||
@@ -1488,6 +1535,14 @@ class CodemanApp {
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
/** Solo window: close itself (the re-dock button and a dashboard close-request). */
|
||||
_closeSoloWindow() {
|
||||
try {
|
||||
if (typeof window.CodemanHost?.closeWindow === 'function') window.CodemanHost.closeWindow();
|
||||
else window.close();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Solo window: select the target session and apply minimal single-session
|
||||
* chrome. Called from handleInit once the session list has loaded. */
|
||||
_applySoloMode() {
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
<span>Admin Panel</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="app._closeSoloWindow()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
</button>
|
||||
|
||||
@@ -39,8 +39,10 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* No "open in new window" (detach) on phones/tablets — popped-out browser
|
||||
windows aren't usable there. !important beats the hover/detached reveal
|
||||
rules in styles.css */
|
||||
.session-tab .tab-detach {
|
||||
rules in styles.css. A native wrapper that opens windows of its own (side
|
||||
by side on a foldable) keeps it at tablet widths: app.js sets
|
||||
html.host-windows. Phone widths hide it again in the 599px block. */
|
||||
html:not(.host-windows) .session-tab .tab-detach {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -730,6 +732,13 @@ html.mobile-init .file-browser-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The tap-zone reserve counts gear + close only (test/mobile-tab-tap-zones),
|
||||
so the pop-out icon stays off phone tabs even under a window-opening host,
|
||||
which offers the pop-out from its own chrome (app.detachSession). */
|
||||
.session-tab .tab-detach {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Gear icon on active tab - tiny, subtle */
|
||||
.session-tab.active .tab-gear {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -4203,6 +4203,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
*/
|
||||
detachFilePreview() {
|
||||
if (!this.filePreviewDetachUrl) return;
|
||||
const hosted = this.openInHostWindow?.(this.filePreviewDetachUrl) ?? null;
|
||||
if (hosted !== null) {
|
||||
if (hosted) this.closeFilePreview();
|
||||
else this.showToast('Could not open a new window for this preview', 'error');
|
||||
return;
|
||||
}
|
||||
const win = window.open(this.filePreviewDetachUrl, '_blank');
|
||||
if (!win) {
|
||||
this.showToast('Pop-up blocked: allow pop-ups for this site to detach previews', 'error');
|
||||
|
||||
@@ -464,7 +464,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||
document.getElementById('appSettingsTabRailSort').value =
|
||||
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked =
|
||||
settings.showTabDetachButton ?? (this.hasHostWindows?.() ? true : (defaults.showTabDetachButton ?? false));
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
|
||||
@@ -2662,7 +2663,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// default OFF, per-device). Mirrored as a class on <html>: styles.css hides
|
||||
// .tab-detach without it (a tab that is already detached keeps its icon as
|
||||
// the re-focus affordance for the popped-out window).
|
||||
const showTabDetach = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
// Under a host that opens windows (see hasHostWindows) popping out is the
|
||||
// way to get two panes side by side, so the button defaults on there.
|
||||
const showTabDetach =
|
||||
settings.showTabDetachButton ?? (this.hasHostWindows?.() ? true : (defaults.showTabDetachButton ?? false));
|
||||
document.documentElement.classList.toggle('tabs-show-detach', showTabDetach);
|
||||
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
|
||||
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
|
||||
|
||||
@@ -492,7 +492,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
openWebviewExternal(id) {
|
||||
const webview = this.webviews.get(id || this.activeWebviewId);
|
||||
if (webview) window.open(webview.url, '_blank', 'noopener');
|
||||
if (!webview) return;
|
||||
if (this.openInHostWindow?.(webview.url)) return;
|
||||
window.open(webview.url, '_blank', 'noopener');
|
||||
},
|
||||
|
||||
closeWebviewTab(id) {
|
||||
|
||||
@@ -128,6 +128,30 @@ describe('file viewer detach button', () => {
|
||||
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Pop-up blocked'), 'error');
|
||||
});
|
||||
|
||||
it('hands the URL to a host window opener instead of window.open', () => {
|
||||
const { app, overlay, windowStub } = loadApp();
|
||||
app.openInHostWindow = vi.fn().mockReturnValue(true);
|
||||
app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf';
|
||||
|
||||
app.detachFilePreview();
|
||||
|
||||
expect(app.openInHostWindow).toHaveBeenCalledWith('/api/sessions/s1/file-raw?path=doc.pdf');
|
||||
expect(windowStub.open).not.toHaveBeenCalled();
|
||||
expect(overlay.classList.contains('visible')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the overlay and toasts when the host could not open a window', () => {
|
||||
const { app, overlay, windowStub } = loadApp();
|
||||
app.openInHostWindow = vi.fn().mockReturnValue(false);
|
||||
app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf';
|
||||
|
||||
app.detachFilePreview();
|
||||
|
||||
expect(windowStub.open).not.toHaveBeenCalled();
|
||||
expect(overlay.classList.contains('visible')).toBe(true);
|
||||
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error');
|
||||
});
|
||||
|
||||
it('does nothing when no preview is armed', () => {
|
||||
const { app, windowStub } = loadApp();
|
||||
app.filePreviewDetachUrl = '';
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @fileoverview Pop-out through a native host's window opener.
|
||||
*
|
||||
* An Android WebView wrapper has no browser pop-ups: `window.open` there either
|
||||
* does nothing or replaces the page. On a foldable the app can still put a page
|
||||
* in a window of its own beside the dashboard, and says so by exposing
|
||||
* `window.CodemanHost.openWindow(absoluteUrl)`. When it does:
|
||||
* 1. `detachSession` hands the solo URL to the host instead of `window.open`,
|
||||
* marks the tab detached and announces it on the window channel (there is
|
||||
* no WindowProxy, so liveness is the roll-call path a reloaded dashboard
|
||||
* already uses),
|
||||
* 2. a host that refuses leaves the tab docked and toasts,
|
||||
* 3. without a host nothing changes (`openInHostWindow` returns null),
|
||||
* 4. a solo window closes and raises itself through the host when it can.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — see connection-indicator.test.ts).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
function load(host?: Record<string, unknown>) {
|
||||
const windowStub: Record<string, unknown> = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
open: vi.fn(),
|
||||
close: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
};
|
||||
if (host) windowStub.CodemanHost = host;
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
URL,
|
||||
location: { href: 'http://10.0.0.2:8095/' },
|
||||
document: { addEventListener: vi.fn() },
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: windowStub,
|
||||
MobileDetection: {},
|
||||
});
|
||||
const constants = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
app.isSoloWindow = false;
|
||||
app.sessions = new Map([['s1', {}]]);
|
||||
app.detachedSessions = new Set();
|
||||
app.detachedWindows = new Map();
|
||||
app.$ = () => null;
|
||||
app.showToast = vi.fn();
|
||||
app._postWindowMessage = vi.fn();
|
||||
app._watchDetachedWindow = vi.fn();
|
||||
return { app, windowStub };
|
||||
}
|
||||
|
||||
describe('detach through a host window opener', () => {
|
||||
it('opens the solo URL in a host window and tracks the tab over the channel', () => {
|
||||
const openWindow = vi.fn().mockReturnValue(true);
|
||||
const { app, windowStub } = load({ openWindow });
|
||||
|
||||
app.detachSession('s1');
|
||||
|
||||
expect(openWindow).toHaveBeenCalledWith('http://10.0.0.2:8095/session/s1');
|
||||
expect(windowStub.open).not.toHaveBeenCalled();
|
||||
expect(app.detachedSessions.has('s1')).toBe(true);
|
||||
expect(app.detachedWindows.size).toBe(0);
|
||||
expect(app._watchDetachedWindow).not.toHaveBeenCalled();
|
||||
expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'detached', id: 's1' });
|
||||
});
|
||||
|
||||
it('leaves the tab docked and toasts when the host opens nothing', () => {
|
||||
const { app, windowStub } = load({ openWindow: vi.fn().mockReturnValue(false) });
|
||||
|
||||
app.detachSession('s1');
|
||||
|
||||
expect(windowStub.open).not.toHaveBeenCalled();
|
||||
expect(app.detachedSessions.has('s1')).toBe(false);
|
||||
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error');
|
||||
});
|
||||
|
||||
it('treats a throwing host as a failed open', () => {
|
||||
const { app } = load({
|
||||
openWindow: () => {
|
||||
throw new Error('bridge gone');
|
||||
},
|
||||
});
|
||||
|
||||
expect(app.openInHostWindow('/session/s1')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps window.open when there is no host', () => {
|
||||
const { app, windowStub } = load();
|
||||
|
||||
expect(app.hasHostWindows()).toBe(false);
|
||||
expect(app.openInHostWindow('/session/s1')).toBeNull();
|
||||
app.detachSession('s1');
|
||||
expect(windowStub.open).toHaveBeenCalledWith('/session/s1', 'codeman-session-s1', expect.any(String));
|
||||
});
|
||||
|
||||
it('closes and raises a solo window through the host', () => {
|
||||
const closeWindow = vi.fn();
|
||||
const focusWindow = vi.fn();
|
||||
const { app, windowStub } = load({ openWindow: vi.fn(), closeWindow, focusWindow });
|
||||
app.isSoloWindow = true;
|
||||
app.soloSessionId = 's1';
|
||||
|
||||
app._onWindowMessage({ type: 'focus-request', id: 's1' });
|
||||
app._onWindowMessage({ type: 'close-request', id: 's1' });
|
||||
|
||||
expect(focusWindow).toHaveBeenCalledTimes(1);
|
||||
expect(closeWindow).toHaveBeenCalledTimes(1);
|
||||
expect(windowStub.close).not.toHaveBeenCalled();
|
||||
expect(windowStub.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user