pennyrouter 0.3.40 → 0.3.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="referrer" content="no-referrer">
|
|
7
|
+
<title>Demo artifact preview · Penny Compare</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root { color-scheme: dark; font: 14px/1.4 system-ui, sans-serif; }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
html, body { margin: 0; height: 100%; background: #111116; color: #e3e3e8; }
|
|
12
|
+
body { display: flex; flex-direction: column; }
|
|
13
|
+
header { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 12px 16px;
|
|
14
|
+
background: #24242b; border-bottom: 1px solid #44444d; }
|
|
15
|
+
strong { color: #9ded83; }
|
|
16
|
+
#filename { overflow-wrap: anywhere; flex: 1; }
|
|
17
|
+
button { font: inherit; color: inherit; background: transparent; border: 1px solid #74747c;
|
|
18
|
+
padding: 5px 12px; cursor: pointer; }
|
|
19
|
+
button:disabled { opacity: .45; cursor: default; }
|
|
20
|
+
#status { margin: 0; padding: 8px 16px; color: #b8b8c3; }
|
|
21
|
+
#status:empty { display: none; }
|
|
22
|
+
iframe { flex: 1; width: 100%; min-height: 0; border: 0; background: white; }
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<header><strong id="side">PENNY COMPARE</strong><span id="filename">Artifact preview</span>
|
|
27
|
+
<button id="refresh" disabled>Refresh</button><button id="download" disabled>Download</button></header>
|
|
28
|
+
<p id="status" role="status">Connecting to your demo…</p>
|
|
29
|
+
<iframe id="preview" title="Generated HTML preview" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
|
|
30
|
+
<script>
|
|
31
|
+
let session;
|
|
32
|
+
let content;
|
|
33
|
+
let loading = false;
|
|
34
|
+
const status = document.getElementById('status');
|
|
35
|
+
const refresh = document.getElementById('refresh');
|
|
36
|
+
const download = document.getElementById('download');
|
|
37
|
+
async function load() {
|
|
38
|
+
if (!session || loading) return;
|
|
39
|
+
loading = true;
|
|
40
|
+
refresh.disabled = true;
|
|
41
|
+
status.textContent = 'Loading…';
|
|
42
|
+
try {
|
|
43
|
+
const params = new URLSearchParams({ side: session.side, path: session.path });
|
|
44
|
+
const response = await fetch(`/artifact?${params}`, {
|
|
45
|
+
headers: { Authorization: `Bearer ${session.token}` }, cache: 'no-store',
|
|
46
|
+
signal: AbortSignal.timeout(15000),
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) throw new Error('File is unavailable. It may have been moved or this demo session has ended.');
|
|
49
|
+
const artifact = await response.json();
|
|
50
|
+
content = artifact.content;
|
|
51
|
+
// This policy precedes generated markup and cannot be relaxed by its own meta tags.
|
|
52
|
+
// HTTPS resources support chart libraries; network API calls and nested frames stay disabled.
|
|
53
|
+
const policy = "default-src https: data: blob: 'unsafe-inline' 'unsafe-eval'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
54
|
+
document.getElementById('preview').srcdoc =
|
|
55
|
+
`<meta http-equiv="Content-Security-Policy" content="${policy}">` + content;
|
|
56
|
+
download.disabled = false;
|
|
57
|
+
status.textContent = '';
|
|
58
|
+
} catch (error) {
|
|
59
|
+
status.textContent = error instanceof TypeError || error.name === 'TimeoutError'
|
|
60
|
+
? 'Could not reach your demo. The session may have ended. You can still download the loaded file.'
|
|
61
|
+
: error.message || 'This demo session has ended.';
|
|
62
|
+
} finally { loading = false; refresh.disabled = false; }
|
|
63
|
+
}
|
|
64
|
+
refresh.onclick = load;
|
|
65
|
+
download.onclick = () => {
|
|
66
|
+
if (content === undefined) return;
|
|
67
|
+
const url = URL.createObjectURL(new Blob([content], { type: 'text/html;charset=utf-8' }));
|
|
68
|
+
const link = document.createElement('a');
|
|
69
|
+
link.href = url; link.download = session.path.split('/').pop(); link.click();
|
|
70
|
+
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
71
|
+
};
|
|
72
|
+
window.addEventListener('message', (event) => {
|
|
73
|
+
if (session || event.origin !== location.origin || event.source !== window.opener ||
|
|
74
|
+
event.data?.type !== 'compare-artifact-session' || !['left', 'right'].includes(event.data.side) ||
|
|
75
|
+
typeof event.data.path !== 'string' || typeof event.data.token !== 'string') return;
|
|
76
|
+
session = event.data;
|
|
77
|
+
clearInterval(handshake);
|
|
78
|
+
document.getElementById('side').textContent = session.side === 'left' ? 'NATIVE' : 'PENNY';
|
|
79
|
+
document.getElementById('filename').textContent = session.path;
|
|
80
|
+
document.title = `${session.path.split('/').pop()} · ${session.side === 'left' ? 'Native' : 'Penny'}`;
|
|
81
|
+
load();
|
|
82
|
+
});
|
|
83
|
+
const requestSession = () => window.opener?.postMessage({ type: 'compare-artifact-ready' }, location.origin);
|
|
84
|
+
const handshake = setInterval(requestSession, 500);
|
|
85
|
+
requestSession();
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
clearInterval(handshake);
|
|
88
|
+
if (!session) status.textContent = 'Open this preview from the Files list in an active demo.';
|
|
89
|
+
}, 10000);
|
|
90
|
+
</script>
|
|
91
|
+
</body>
|
|
92
|
+
</html>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, readdir, realpath } from 'node:fs/promises';
|
|
3
|
+
import { extname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const maxArtifactBytes = 5 * 1024 * 1024;
|
|
6
|
+
const excluded = new Set(['node_modules', 'vendor']);
|
|
7
|
+
// Keep the type mapping separate so additional preview formats can be added here.
|
|
8
|
+
export const artifactType = (path) => ['.html', '.htm'].includes(extname(path).toLowerCase()) ? 'html' : null;
|
|
9
|
+
const inside = (root, path) => path.startsWith(root + sep);
|
|
10
|
+
|
|
11
|
+
export async function listArtifacts(root) {
|
|
12
|
+
const files = [];
|
|
13
|
+
let inspected = 0;
|
|
14
|
+
async function walk(directory, depth) {
|
|
15
|
+
if (depth > 8 || files.length >= 200 || inspected >= 5000) return;
|
|
16
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (++inspected > 5000 || files.length >= 200) break;
|
|
19
|
+
if (entry.name.startsWith('.') || excluded.has(entry.name) || entry.isSymbolicLink()) continue;
|
|
20
|
+
const path = join(directory, entry.name);
|
|
21
|
+
if (entry.isDirectory()) await walk(path, depth + 1);
|
|
22
|
+
else if (entry.isFile() && artifactType(path)) {
|
|
23
|
+
const stat = await lstat(path).catch(() => null);
|
|
24
|
+
if (stat?.isFile() && stat.size <= maxArtifactBytes) {
|
|
25
|
+
files.push({ path: relative(root, path).split(sep).join('/'), type: artifactType(path),
|
|
26
|
+
size: stat.size, modifiedAt: stat.mtimeMs });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
await walk(root, 0);
|
|
32
|
+
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function readArtifact(root, path) {
|
|
36
|
+
if (typeof path !== 'string' || !path || path.includes('\\') || path.includes('\0') ||
|
|
37
|
+
path.split('/').some((part) => !part || part === '..' || part.startsWith('.') || excluded.has(part)) ||
|
|
38
|
+
!artifactType(path)) throw new Error('Invalid artifact path');
|
|
39
|
+
const canonicalRoot = await realpath(root);
|
|
40
|
+
const target = resolve(canonicalRoot, path);
|
|
41
|
+
if (!inside(canonicalRoot, target)) throw new Error('Invalid artifact path');
|
|
42
|
+
let current = canonicalRoot;
|
|
43
|
+
for (const part of path.split('/')) {
|
|
44
|
+
current = join(current, part);
|
|
45
|
+
if ((await lstat(current)).isSymbolicLink()) throw new Error('Invalid artifact path');
|
|
46
|
+
}
|
|
47
|
+
const canonicalTarget = await realpath(target);
|
|
48
|
+
if (!inside(canonicalRoot, canonicalTarget)) throw new Error('Invalid artifact path');
|
|
49
|
+
const handle = await open(canonicalTarget, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
50
|
+
try {
|
|
51
|
+
// On Linux, verify the opened file too, in case a directory was swapped during traversal.
|
|
52
|
+
if (process.platform === 'linux' && !inside(canonicalRoot, await realpath(`/proc/self/fd/${handle.fd}`))) {
|
|
53
|
+
throw new Error('Invalid artifact path');
|
|
54
|
+
}
|
|
55
|
+
const stat = await handle.stat();
|
|
56
|
+
if (!stat.isFile() || stat.size > maxArtifactBytes) throw new Error('Artifact is too large or unavailable');
|
|
57
|
+
const buffer = Buffer.alloc(maxArtifactBytes + 1);
|
|
58
|
+
let length = 0;
|
|
59
|
+
while (length < buffer.length) {
|
|
60
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
|
|
61
|
+
if (!bytesRead) break;
|
|
62
|
+
length += bytesRead;
|
|
63
|
+
}
|
|
64
|
+
if (length > maxArtifactBytes) throw new Error('Artifact is too large');
|
|
65
|
+
return buffer.subarray(0, length).toString('utf8');
|
|
66
|
+
} finally { await handle.close(); }
|
|
67
|
+
}
|
|
@@ -9,6 +9,7 @@ import { promisify } from 'node:util';
|
|
|
9
9
|
import { WebSocketServer } from 'ws';
|
|
10
10
|
import pty from 'node-pty';
|
|
11
11
|
import { ClaudeMeter, CodexMeter, saveReport } from './claude-meter.mjs';
|
|
12
|
+
import { listArtifacts, readArtifact } from './artifacts.mjs';
|
|
12
13
|
|
|
13
14
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const compareDir = dirname(here);
|
|
@@ -31,13 +32,33 @@ if (args.some((arg) => !['--claude', '--compare', '--compare-node', '--harness=c
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
const files = new Map([
|
|
35
|
+
['/demo-artifact-preview', ['artifact-preview.html', 'text/html; charset=utf-8']],
|
|
36
|
+
['/terminal-unicode.js', ['terminal-unicode.js', 'text/javascript; charset=utf-8']],
|
|
37
|
+
['/addon-unicode11.js', ['node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js', 'text/javascript; charset=utf-8']],
|
|
34
38
|
['/', ['terminal-preview.html', 'text/html; charset=utf-8']],
|
|
35
39
|
['/xterm.css', ['node_modules/@xterm/xterm/css/xterm.css', 'text/css; charset=utf-8']],
|
|
36
40
|
['/xterm.js', ['node_modules/@xterm/xterm/lib/xterm.js', 'text/javascript; charset=utf-8']],
|
|
37
41
|
['/addon-fit.js', ['node_modules/@xterm/addon-fit/lib/addon-fit.js', 'text/javascript; charset=utf-8']],
|
|
38
42
|
]);
|
|
43
|
+
const artifactSessions = new Map();
|
|
39
44
|
|
|
40
45
|
const server = createServer(async (request, response) => {
|
|
46
|
+
const url = new URL(request.url, 'http://localhost');
|
|
47
|
+
if (['/artifacts', '/artifact'].includes(url.pathname)) {
|
|
48
|
+
const roots = artifactSessions.get(request.headers.authorization?.replace(/^Bearer /, ''));
|
|
49
|
+
const root = roots?.get(url.searchParams.get('side'));
|
|
50
|
+
if (request.method !== 'GET' || !root) { response.writeHead(403).end(); return; }
|
|
51
|
+
try {
|
|
52
|
+
const result = url.pathname === '/artifacts'
|
|
53
|
+
? { files: await listArtifacts(root) }
|
|
54
|
+
: { content: await readArtifact(root, url.searchParams.get('path')) };
|
|
55
|
+
response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
|
56
|
+
.end(JSON.stringify(result));
|
|
57
|
+
} catch {
|
|
58
|
+
response.writeHead(404).end('Artifact unavailable');
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
41
62
|
const file = files.get(request.url);
|
|
42
63
|
if (!file) { response.writeHead(404).end(); return; }
|
|
43
64
|
try {
|
|
@@ -116,6 +137,9 @@ function launchCommand(side, state, sessionId, config) {
|
|
|
116
137
|
}
|
|
117
138
|
|
|
118
139
|
wss.on('connection', (ws) => {
|
|
140
|
+
const artifactToken = randomUUID();
|
|
141
|
+
const artifactRoots = new Map();
|
|
142
|
+
artifactSessions.set(artifactToken, artifactRoots);
|
|
119
143
|
const sessions = new Map();
|
|
120
144
|
const compareConfig = new Map();
|
|
121
145
|
const meters = new Map();
|
|
@@ -158,6 +182,7 @@ wss.on('connection', (ws) => {
|
|
|
158
182
|
const dispose = () => {
|
|
159
183
|
if (closed) return;
|
|
160
184
|
closed = true;
|
|
185
|
+
artifactSessions.delete(artifactToken);
|
|
161
186
|
for (const session of sessions.values()) {
|
|
162
187
|
try { session.kill(); } catch { /* already exited */ }
|
|
163
188
|
}
|
|
@@ -179,9 +204,13 @@ wss.on('connection', (ws) => {
|
|
|
179
204
|
}
|
|
180
205
|
compareConfig.set('left', left);
|
|
181
206
|
compareConfig.set('right', right);
|
|
207
|
+
artifactRoots.set('left', left.cwd);
|
|
208
|
+
artifactRoots.set('right', right.cwd);
|
|
209
|
+
send({ type: 'artifact-session', token: artifactToken });
|
|
182
210
|
send({ type: 'configured' });
|
|
183
211
|
} catch (error) {
|
|
184
212
|
compareConfig.clear();
|
|
213
|
+
artifactRoots.clear();
|
|
185
214
|
send({ type: 'configuration-error', error: error.message });
|
|
186
215
|
}
|
|
187
216
|
return;
|
|
@@ -242,6 +271,8 @@ wss.on('connection', (ws) => {
|
|
|
242
271
|
cwd: config.cwd, env,
|
|
243
272
|
});
|
|
244
273
|
sessions.set(message.side, session);
|
|
274
|
+
artifactRoots.set(message.side, config.cwd);
|
|
275
|
+
if (!compare) send({ type: 'artifact-session', token: artifactToken });
|
|
245
276
|
if (nodeMeter) {
|
|
246
277
|
const Meter = config.variant.includes('codex') ? CodexMeter : ClaudeMeter;
|
|
247
278
|
const role = config.variant.startsWith('penny-') ? 'PENNY' : 'NATIVE';
|
|
@@ -17,14 +17,24 @@
|
|
|
17
17
|
.pane { min-width: 0; min-height: 0; overflow: hidden; background: #16161b; }
|
|
18
18
|
.pane + .pane { border-left: 1px solid #4d4d52; }
|
|
19
19
|
.pane.focused { box-shadow: inset 0 0 0 1px #6a6a72; }
|
|
20
|
-
.
|
|
20
|
+
.pane { display: flex; flex-direction: column; }
|
|
21
|
+
.pane header { flex: none; padding: 6px 10px; background: #202027; border-bottom: 1px solid #34343c; }
|
|
22
|
+
.files { position: relative; width: fit-content; }
|
|
23
|
+
.files summary { cursor: pointer; color: #9ded83; }
|
|
24
|
+
.file-list { position: absolute; top: 100%; left: 0; z-index: 3; width: min(420px, 45vw);
|
|
25
|
+
max-height: 300px; overflow: auto; background: #202027; border: 1px solid #4d4d52; padding: 8px; }
|
|
26
|
+
.file-list p { color: #92929c; margin: 6px; }
|
|
27
|
+
.file-list button { display: block; width: 100%; text-align: left; overflow-wrap: anywhere;
|
|
28
|
+
padding: 8px; border: 0; background: none; color: #d7d7dd; font: inherit; cursor: pointer; }
|
|
29
|
+
.file-list button:hover { background: #34343c; }
|
|
30
|
+
.terminal { width: 100%; flex: 1; min-height: 0; padding: 4px 7px; }
|
|
21
31
|
.pane { position: relative; }
|
|
22
|
-
.meter { display: none; position: absolute; top:
|
|
32
|
+
.meter { display: none; position: absolute; top: 35px; right: 13px; z-index: 2;
|
|
23
33
|
width: 190px; white-space: pre; color: #9ded83; line-height: 1.15; font-size: 13px;
|
|
24
34
|
pointer-events: none; padding: 5px 8px; border-radius: 3px;
|
|
25
35
|
background: rgba(12, 12, 16, .82); }
|
|
26
36
|
.pane.metered .meter { display: block; }
|
|
27
|
-
.delta { display: none; position: absolute; top:
|
|
37
|
+
.delta { display: none; position: absolute; top: 35px; right: 220px; z-index: 2;
|
|
28
38
|
color: #9ded83; white-space: nowrap; pointer-events: none; }
|
|
29
39
|
.pane.metered .delta { display: block; }
|
|
30
40
|
.xterm { height: 100%; }
|
|
@@ -69,8 +79,8 @@
|
|
|
69
79
|
<span id="connection" style="color:#77777e">connecting…</span>
|
|
70
80
|
</div>
|
|
71
81
|
<main class="panes">
|
|
72
|
-
<section class="pane focused"><div class="meter" id="left-meter"></div><div class="terminal" id="native"></div></section>
|
|
73
|
-
<section class="pane"><div class="delta" id="delta"></div><div class="meter" id="right-meter"></div><div class="terminal" id="penny"></div></section>
|
|
82
|
+
<section class="pane focused"><header><details class="files"><summary id="left-files-count">Files · 0</summary><div class="file-list" id="left-files"><p>HTML files will appear here.</p></div></details></header><div class="meter" id="left-meter"></div><div class="terminal" id="native"></div></section>
|
|
83
|
+
<section class="pane"><header><details class="files"><summary id="right-files-count">Files · 0</summary><div class="file-list" id="right-files"><p>HTML files will appear here.</p></div></details></header><div class="delta" id="delta"></div><div class="meter" id="right-meter"></div><div class="terminal" id="penny"></div></section>
|
|
74
84
|
</main>
|
|
75
85
|
<section class="setup" id="setup">
|
|
76
86
|
<form class="setup-card" id="setup-form">
|
|
@@ -109,11 +119,13 @@
|
|
|
109
119
|
</section>
|
|
110
120
|
<script src="/xterm.js"></script>
|
|
111
121
|
<script src="/addon-fit.js"></script>
|
|
122
|
+
<script src="/addon-unicode11.js"></script>
|
|
123
|
+
<script src="/terminal-unicode.js"></script>
|
|
112
124
|
<script>
|
|
113
125
|
const options = {
|
|
114
126
|
fontFamily: 'SFMono-Regular, Menlo, Consolas, monospace', fontSize: 13,
|
|
115
127
|
lineHeight: 1.15, cursorBlink: true, cursorStyle: 'block',
|
|
116
|
-
allowTransparency: false, scrollback: 1000,
|
|
128
|
+
allowTransparency: false, scrollback: 1000, allowProposedApi: true,
|
|
117
129
|
theme: { background: '#16161b', foreground: '#d7d7dd', cursor: '#d7d7dd',
|
|
118
130
|
selectionBackground: '#4b5369', black: '#24242a', red: '#e88b8b',
|
|
119
131
|
green: '#9ded83', yellow: '#e6c583', blue: '#8eaeeb', magenta: '#c8a7e8',
|
|
@@ -121,6 +133,8 @@
|
|
|
121
133
|
};
|
|
122
134
|
const native = new Terminal(options);
|
|
123
135
|
const penny = new Terminal(options);
|
|
136
|
+
configureCompareUnicode(native, Unicode11Addon.Unicode11Addon);
|
|
137
|
+
configureCompareUnicode(penny, Unicode11Addon.Unicode11Addon);
|
|
124
138
|
const fit = { left: new FitAddon.FitAddon(), right: new FitAddon.FitAddon() };
|
|
125
139
|
native.loadAddon(fit.left);
|
|
126
140
|
penny.loadAddon(fit.right);
|
|
@@ -131,6 +145,58 @@
|
|
|
131
145
|
const panes = document.querySelectorAll('.pane');
|
|
132
146
|
const send = message => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); };
|
|
133
147
|
let socket;
|
|
148
|
+
let sessionToken;
|
|
149
|
+
let fileTimer;
|
|
150
|
+
let pollingFiles = false;
|
|
151
|
+
const previewTabs = new Map();
|
|
152
|
+
async function pollFiles() {
|
|
153
|
+
if (pollingFiles || !sessionToken) return;
|
|
154
|
+
pollingFiles = true;
|
|
155
|
+
try {
|
|
156
|
+
await Promise.all(['left', 'right'].map(async side => {
|
|
157
|
+
const list = document.getElementById(`${side}-files`);
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetch(`/artifacts?side=${side}`, {
|
|
160
|
+
headers: { Authorization: `Bearer ${sessionToken}` }, cache: 'no-store',
|
|
161
|
+
signal: AbortSignal.timeout(10000),
|
|
162
|
+
});
|
|
163
|
+
if (!response.ok) throw new Error('Files unavailable');
|
|
164
|
+
const { files } = await response.json();
|
|
165
|
+
document.getElementById(`${side}-files-count`).textContent = `Files · ${files.length}`;
|
|
166
|
+
const paths = JSON.stringify(files.map(file => file.path));
|
|
167
|
+
if (list.dataset.paths === paths) return;
|
|
168
|
+
list.dataset.paths = paths;
|
|
169
|
+
list.replaceChildren();
|
|
170
|
+
if (!files.length) {
|
|
171
|
+
const hint = document.createElement('p'); hint.textContent = 'HTML files will appear here.';
|
|
172
|
+
list.append(hint);
|
|
173
|
+
}
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
const button = document.createElement('button'); button.textContent = file.path;
|
|
176
|
+
button.title = 'Open preview in a new tab';
|
|
177
|
+
button.onclick = () => {
|
|
178
|
+
for (const tab of previewTabs.keys()) if (tab.closed) previewTabs.delete(tab);
|
|
179
|
+
const tab = window.open('/demo-artifact-preview', '_blank');
|
|
180
|
+
if (tab) previewTabs.set(tab, { side, path: file.path });
|
|
181
|
+
else document.getElementById('connection').textContent = 'Allow pop-ups to open file previews.';
|
|
182
|
+
};
|
|
183
|
+
list.append(button);
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
delete list.dataset.paths;
|
|
187
|
+
list.replaceChildren();
|
|
188
|
+
const hint = document.createElement('p'); hint.textContent = 'Files unavailable. Retrying…';
|
|
189
|
+
list.append(hint);
|
|
190
|
+
}
|
|
191
|
+
}));
|
|
192
|
+
} finally { pollingFiles = false; }
|
|
193
|
+
}
|
|
194
|
+
window.addEventListener('message', event => {
|
|
195
|
+
if (event.origin !== location.origin || event.data?.type !== 'compare-artifact-ready') return;
|
|
196
|
+
const file = previewTabs.get(event.source);
|
|
197
|
+
if (file && sessionToken) event.source.postMessage(
|
|
198
|
+
{ type: 'compare-artifact-session', ...file, token: sessionToken }, location.origin);
|
|
199
|
+
});
|
|
134
200
|
const setFocus = index => { panes.forEach((p, i) => p.classList.toggle('focused', i === index)); (index ? penny : native).focus(); };
|
|
135
201
|
const setSync = value => {
|
|
136
202
|
sync = value;
|
|
@@ -166,6 +232,12 @@
|
|
|
166
232
|
socket.addEventListener('message', event => {
|
|
167
233
|
const message = JSON.parse(event.data);
|
|
168
234
|
const terminal = message.side === 'left' ? native : penny;
|
|
235
|
+
if (message.type === 'artifact-session') {
|
|
236
|
+
sessionToken = message.token;
|
|
237
|
+
clearInterval(fileTimer);
|
|
238
|
+
void pollFiles();
|
|
239
|
+
fileTimer = setInterval(pollFiles, 3000);
|
|
240
|
+
}
|
|
169
241
|
if (message.type === 'configured' && pendingConfig) {
|
|
170
242
|
for (const [side, terminal] of [['left', native], ['right', penny]])
|
|
171
243
|
send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
|
|
@@ -232,6 +304,7 @@
|
|
|
232
304
|
}
|
|
233
305
|
});
|
|
234
306
|
socket.addEventListener('close', () => {
|
|
307
|
+
clearInterval(fileTimer);
|
|
235
308
|
if (!document.getElementById('exit').disabled) {
|
|
236
309
|
document.getElementById('connection').textContent = 'disconnected — reload to reconnect';
|
|
237
310
|
}
|
|
@@ -295,8 +368,8 @@
|
|
|
295
368
|
if (event.code === 'KeyS') { event.preventDefault(); event.stopPropagation(); setSync(!sync); }
|
|
296
369
|
if (event.code === 'KeyX') { event.preventDefault(); event.stopPropagation(); exit(); }
|
|
297
370
|
}, true);
|
|
298
|
-
|
|
299
|
-
|
|
371
|
+
document.getElementById('native').addEventListener('pointerdown', () => setFocus(0));
|
|
372
|
+
document.getElementById('penny').addEventListener('pointerdown', () => setFocus(1));
|
|
300
373
|
native.onData(data => {
|
|
301
374
|
send({ type: 'input', side: 'left', data });
|
|
302
375
|
if (sync) send({ type: 'input', side: 'right', data });
|