simplify extension ui

This commit is contained in:
2026-05-28 00:59:37 +08:00
parent 3f86aeaf0c
commit 8e8d3d6da0
10 changed files with 1416 additions and 536 deletions

View File

@@ -2,12 +2,12 @@ import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { File } from 'node:buffer';
import { handleClipboardAndOpen, singletonPanel } from './shared';
import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
export function openApplovinUpload(_context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.applovinUpload', 'AppLovin Demo Upload');
const { panel, isNew } = singletonPanel(store, 'hplToolbox.applovinUpload', 'AppLovin Playable Preview');
if (!isNew) return;
panel.webview.html = getHtml();
@@ -15,6 +15,24 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
try {
if (handleClipboardAndOpen(msg)) return;
if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select Folder',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked[0]) {
const files = await collectHtmlFiles(picked[0].fsPath);
panel.webview.postMessage({
type: 'filesSelected',
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
});
}
return;
}
if (msg.type === 'pickFiles') {
const picked = await vscode.window.showOpenDialog({
canSelectMany: true,
@@ -175,6 +193,30 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
return vscode.Uri.file(root);
}
async function collectHtmlFiles(root: string): Promise<string[]> {
const out: string[] = [];
const stack: string[] = [root];
while (stack.length) {
const dir = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === '.git') continue;
stack.push(full);
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
out.push(full);
}
}
}
return out;
}
function qrBaseName(name: string): string {
return (name || 'qr').replace(/\.html?$/i, '').replace(/[\\/:*?"<>|]/g, '_') || 'qr';
}
@@ -243,54 +285,87 @@ function getHtml(): string {
<head>
<meta charset="UTF-8" />
<style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
h2 { margin-top: 0; }
button {
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
${getToolWebviewStyles()}
.selected-files { margin-top: 10px; }
.qr-cell { text-align: center; }
.qr-cell img {
width: 104px;
height: 104px;
max-width: 100%;
padding: 6px;
border-radius: var(--tool-radius);
background: #fff;
}
button:hover { background: var(--vscode-button-hoverBackground); }
button.secondary {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
.result-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.qr-cell img { cursor: zoom-in; }
.qr-modal {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.72);
z-index: 10;
}
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
.file-cell { display: flex; align-items: center; gap: 8px; }
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
.actions { margin-top: 12px; display: flex; gap: 8px; }
#status { margin-top: 12px; min-height: 20px; }
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
.result {
padding: 10px;
background: var(--vscode-textBlockQuote-background);
border-left: 3px solid var(--vscode-textBlockQuote-border);
word-break: break-all;
.qr-modal.open { display: flex; }
.qr-modal img {
width: min(70vw, 520px);
height: min(70vw, 520px);
padding: 18px;
border-radius: var(--tool-radius);
background: #fff;
}
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
#results { margin-top: 12px; display: grid; grid-template-columns: repeat(auto-fill,minmax(280px,1fr)); gap: 12px; }
.qr-results:not(.has-errors) .error-col { display: none; }
.remove-selected {
min-width: 28px;
width: 28px;
padding: 3px 0;
color: var(--vscode-errorForeground);
font-size: 16px;
line-height: 1;
}
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style>
</head>
<body>
<h2>AppLovin Playable Preview (QR)</h2>
<p style="opacity:0.8;font-size:12px;margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app (iOS/Android).</p>
<main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">AppLovin Playable Preview</h2>
<p class="tool-description">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
</header>
<div class="file-cell" style="margin-bottom:8px;">
<button id="pick" class="secondary">Add files...</button>
<button id="clear" class="secondary" style="display:none;">Clear</button>
<span class="file-name" id="fileName">(no files)</span>
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
</div>
<div class="panel-body">
<div class="control-group">
<div class="file-row">
<button id="pickFolder" class="secondary">Select Folder</button>
<button id="pick" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div class="file-name" id="fileName" style="margin-top:8px;">(no files selected)</div>
<div id="fileList" class="selected-files"></div>
</div>
<div class="action-row">
<button id="upload">Upload</button>
<button id="saveAll" class="secondary" disabled>Save All</button>
<button id="regenAll" class="secondary" disabled>Regenerate</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="outputPanel" class="is-hidden">
<div id="results" class="results-panel"></div>
</div>
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
<div class="actions">
<button id="upload">Upload to AppLovin</button>
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
</div>
<div id="status"></div>
<div id="results"></div>
<div id="qrModal" class="qr-modal" title="Click to close"><img alt="Expanded QR" /></div>
</main>
<script>
const vscode = acquireVsCodeApi();
const pickFolderBtn = document.getElementById('pickFolder');
const pickBtn = document.getElementById('pick');
const clearBtn = document.getElementById('clear');
const uploadBtn = document.getElementById('upload');
@@ -298,9 +373,13 @@ function getHtml(): string {
const fileListEl = document.getElementById('fileList');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const outputPanel = document.getElementById('outputPanel');
const qrModal = document.getElementById('qrModal');
const saveAllBtn = document.getElementById('saveAll');
const regenAllBtn = document.getElementById('regenAll');
const PAGE_SIZE = 5;
let selectedPaths = [];
let selectedPage = 0;
let resultItems = [];
function basename(p) {
@@ -308,15 +387,51 @@ function getHtml(): string {
return i >= 0 ? p.slice(i + 1) : p;
}
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!selectedPaths.length) {
fileNameEl.textContent = '(no files)';
fileNameEl.textContent = '(no files selected)';
fileListEl.innerHTML = '';
clearBtn.style.display = 'none';
return;
}
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
clearBtn.style.display = '';
const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visiblePaths = selectedPaths.slice(start, start + PAGE_SIZE);
fileListEl.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
'<tbody>' +
visiblePaths.map((p, offset) => {
const i = start + offset;
return (
'<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td>' +
'<td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>'
);
}).join('') +
'</tbody>' +
'</table>' +
'</div>';
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
selectedPaths.splice(Number(btn.dataset.index), 1);
renderSelection();
});
});
if (selectedPaths.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
fileListEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
saveAllBtn.addEventListener('click', () => {
@@ -331,24 +446,30 @@ function getHtml(): string {
img.src = base + '&_=' + Date.now();
});
});
qrModal.addEventListener('click', () => {
qrModal.classList.remove('open');
});
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' }));
clearBtn.addEventListener('click', () => {
selectedPaths = [];
selectedPage = 0;
renderSelection();
});
uploadBtn.addEventListener('click', () => {
statusEl.textContent = '';
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return;
}
uploadBtn.disabled = true;
resultItems = [];
saveAllBtn.style.display = 'none';
regenAllBtn.style.display = 'none';
saveAllBtn.disabled = true;
regenAllBtn.disabled = true;
vscode.postMessage({ type: 'upload', paths: selectedPaths });
});
@@ -356,20 +477,34 @@ function getHtml(): string {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function ensureResultsTable() {
let tbody = resultsEl.querySelector('tbody');
if (tbody) return tbody;
resultsEl.innerHTML =
'<table class="data-table qr-results">' +
'<thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
outputPanel.classList.remove('is-hidden');
return resultsEl.querySelector('tbody');
}
window.addEventListener('message', (e) => {
const m = e.data;
if (m.type === 'filesSelected') {
const added = m.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
selectedPaths = selectedPaths.concat(added);
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
renderSelection();
} else if (m.type === 'start') {
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
resultItems = [];
saveAllBtn.style.display = 'none';
regenAllBtn.style.display = 'none';
saveAllBtn.disabled = true;
regenAllBtn.disabled = true;
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = '';
statusEl.className = 'status-panel';
} else if (m.type === 'error') {
const div = document.createElement('div');
div.className = 'err';
@@ -378,20 +513,32 @@ function getHtml(): string {
statusEl.appendChild(div);
uploadBtn.disabled = false;
} else if (m.type === 'fileError') {
const wrap = document.createElement('div');
wrap.className = 'result';
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
resultsEl.appendChild(wrap);
const tbody = ensureResultsTable();
resultsEl.querySelector('table').classList.add('has-errors');
const row = document.createElement('tr');
row.innerHTML =
'<td class="mono wrap">' + escapeHtml(m.name) + '</td>' +
'<td class="muted">-</td>' +
'<td class="muted">-</td>' +
'<td class="err wrap error-col">' + escapeHtml(m.message) + '</td>';
tbody.appendChild(row);
} else if (m.type === 'fileResult') {
const wrap = document.createElement('div');
wrap.className = 'result';
wrap.innerHTML =
'<div style="font-weight:600;margin-bottom:8px;word-break:break-all;">' + escapeHtml(m.name) + '</div>' +
'<div style="text-align:center;margin-bottom:8px;">' +
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
'</div>' +
'<div style="font-size:11px;opacity:0.8;word-break:break-all;">Hash: ' + escapeHtml(m.hash) + '</div>';
const tbody = ensureResultsTable();
const row = document.createElement('tr');
const file = document.createElement('td');
file.className = 'mono wrap';
file.textContent = m.name;
const qr = document.createElement('td');
qr.className = 'qr-cell';
qr.innerHTML = '<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" />';
qr.querySelector('img').addEventListener('click', (event) => {
qrModal.querySelector('img').src = event.currentTarget.src;
qrModal.classList.add('open');
});
const actionCell = document.createElement('td');
const error = document.createElement('td');
error.className = 'error-col';
error.innerHTML = '<span class="muted">-</span>';
const actions = document.createElement('div');
actions.className = 'result-actions';
const openPreview = document.createElement('button');
@@ -404,13 +551,12 @@ function getHtml(): string {
saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name });
actions.appendChild(openPreview);
actions.appendChild(saveQr);
wrap.appendChild(actions);
resultsEl.appendChild(wrap);
actionCell.appendChild(actions);
row.append(file, qr, actionCell, error);
tbody.appendChild(row);
resultItems.push({ name: m.name, url: m.qrImg });
if (resultItems.length >= 2) {
saveAllBtn.style.display = '';
regenAllBtn.style.display = '';
}
saveAllBtn.disabled = resultItems.length === 0;
regenAllBtn.disabled = resultItems.length === 0;
} else if (m.type === 'done') {
statusEl.innerHTML = '<span class="ok">Done.</span>';
uploadBtn.disabled = false;

View File

@@ -1,12 +1,12 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { singletonPanel } from './shared';
import { getToolWebviewStyles, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
export function openBase64Scanner(_context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.base64Scanner', 'Base64 Asset Scanner');
const { panel, isNew } = singletonPanel(store, 'hplToolbox.base64Scanner', 'Base64 Scanner');
if (!isNew) return;
panel.webview.html = getHtml();
@@ -16,7 +16,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
if (ws) {
const distPath = path.join(ws.uri.fsPath, 'dist');
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
const files = await collectHtmlFiles(distPath);
panel.webview.postMessage({ type: 'filesPicked', paths: files });
}
}
} else if (msg.type === 'pickFolder') {
@@ -27,7 +28,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
openLabel: 'Select Folder',
});
if (picked && picked[0]) {
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
const files = await collectHtmlFiles(picked[0].fsPath);
panel.webview.postMessage({ type: 'filesPicked', paths: files });
}
} else if (msg.type === 'pickFiles') {
const picked = await vscode.window.showOpenDialog({
@@ -144,46 +146,125 @@ function getHtml(): string {
<head>
<meta charset="UTF-8" />
<style>
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); }
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
button:hover { background: var(--vscode-button-hoverBackground); }
.row-result { display: flex; gap: 8px; padding: 2px 0; font-family: var(--vscode-editor-font-family); font-size: 12px; }
.mark { width: 14px; flex-shrink: 0; font-weight: bold; }
.ok { color: #3fb950; }
.bad { color: #f85149; }
.file-name { word-break: break-all; }
.assets { color: var(--vscode-descriptionForeground); margin-left: 6px; word-break: break-all; }
.summary { margin-top: 12px; opacity: 0.8; }
${getToolWebviewStyles()}
.asset-list { margin: 0; padding-left: 16px; }
.asset-list li + li { margin-top: 3px; }
.selected-list { margin-top: 8px; }
.remove-selected {
min-width: 28px;
width: 28px;
padding: 3px 0;
color: var(--vscode-errorForeground);
font-size: 16px;
line-height: 1;
}
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style>
</head>
<body>
<h2>Base64 Asset Scanner</h2>
<div class="row">
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
<button id="pick">Browse Folder...</button>
<button id="pickFiles">Pick Files...</button>
<button id="scan">Scan</button>
<main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">Base64 Scanner</h2>
<p class="tool-description">Scan folders or selected HTML files for asset references that are not embedded as base64 data URIs.</p>
</header>
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
</div>
<div class="panel-body">
<div class="control-group">
<div class="field-row">
<input id="folder" type="hidden" />
<button id="pick" class="secondary">Select Folder</button>
<button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div id="selection" class="selected-list"></div>
</div>
<div class="action-row">
<button id="scan">Scan</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="outputPanel" class="is-hidden">
<div id="results" class="results-panel"></div>
</div>
<div id="selection" class="summary"></div>
<div id="status"></div>
<div id="results"></div>
</main>
<script>
const vscode = acquireVsCodeApi();
vscode.postMessage({ type: 'ready' });
const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const outputPanel = document.getElementById('outputPanel');
const selectionEl = document.getElementById('selection');
const PAGE_SIZE = 5;
let pickedFolder = '';
let pickedFiles = [];
let selectedPage = 0;
function clearFiles() {
pickedFiles = [];
selectionEl.textContent = '';
function basename(p) {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
return i >= 0 ? p.slice(i + 1) : p;
}
folderEl.addEventListener('input', clearFiles);
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
const items = pickedFiles.length
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
if (!items.length) {
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
return;
}
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
selectionEl.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
'<tbody>' +
visibleItems.map(item =>
'<tr><td class="mono wrap">' + escapeHtml(item.label) + '</td>' +
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
).join('') +
'</tbody></table>' +
'</div>';
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.type === 'folder') {
pickedFolder = '';
folderEl.value = '';
} else {
pickedFiles.splice(Number(btn.dataset.index), 1);
}
renderSelection();
});
});
if (items.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
selectionEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
renderSelection();
document.getElementById('pick').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' });
@@ -191,9 +272,20 @@ function getHtml(): string {
document.getElementById('pickFiles').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFiles' });
});
document.getElementById('clear').addEventListener('click', () => {
folderEl.value = '';
pickedFiles = [];
pickedFolder = '';
selectedPage = 0;
renderSelection();
statusEl.textContent = '';
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
});
document.getElementById('scan').addEventListener('click', () => {
statusEl.textContent = 'Scanning...';
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
if (pickedFiles.length) {
vscode.postMessage({ type: 'scan', files: pickedFiles });
} else {
@@ -207,35 +299,63 @@ function getHtml(): string {
const msg = event.data;
if (msg.type === 'folderPicked') {
folderEl.value = msg.path;
clearFiles();
pickedFolder = msg.path;
pickedFiles = [];
selectedPage = 0;
renderSelection();
} else if (msg.type === 'filesPicked') {
pickedFiles = msg.paths;
const existing = new Set(pickedFiles);
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
folderEl.value = '';
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
pickedFolder = '';
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection();
} else if (msg.type === 'results') {
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
if (msg.error) {
statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden');
return;
}
const total = msg.results.length;
const flagged = msg.results.filter(r => !r.ok).length;
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
resultsEl.innerHTML = '';
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
if (!total) {
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
return;
}
outputPanel.classList.remove('is-hidden');
resultsEl.innerHTML =
'<table class="data-table">' +
'<thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
const tbody = resultsEl.querySelector('tbody');
for (const r of msg.results) {
const row = document.createElement('div');
row.className = 'row-result';
const mark = document.createElement('span');
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
mark.textContent = r.ok ? '✓' : '✗';
const name = document.createElement('span');
name.className = 'file-name';
const row = document.createElement('tr');
const status = document.createElement('td');
status.innerHTML = r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>';
const name = document.createElement('td');
name.className = 'mono wrap';
name.textContent = r.file;
row.appendChild(mark);
row.appendChild(name);
const count = document.createElement('td');
count.textContent = String(r.assets.length);
const assets = document.createElement('td');
assets.className = 'wrap';
if (!r.ok) {
const a = document.createElement('span');
a.className = 'assets';
a.textContent = '— ' + r.assets.join(', ');
row.appendChild(a);
const list = document.createElement('ul');
list.className = 'asset-list mono';
r.assets.forEach(asset => {
const item = document.createElement('li');
item.textContent = asset;
list.appendChild(item);
});
assets.appendChild(list);
} else {
assets.innerHTML = '<span class="muted">None</span>';
}
resultsEl.appendChild(row);
row.append(status, name, count, assets);
tbody.appendChild(row);
}
}
});

View File

@@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { singletonPanel } from './shared';
import { getToolWebviewStyles, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
@@ -50,7 +50,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
if (ws) {
const distPath = path.join(ws.uri.fsPath, 'dist');
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
const files = (await collectHtmlFiles(distPath)).filter(isInSupportedMraidFolderPath);
panel.webview.postMessage({ type: 'filesPicked', paths: files });
}
}
} else if (msg.type === 'pickFolder') {
@@ -61,7 +62,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
openLabel: 'Select Folder',
});
if (picked && picked[0]) {
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
const files = (await collectHtmlFiles(picked[0].fsPath)).filter(isInSupportedMraidFolderPath);
panel.webview.postMessage({ type: 'filesPicked', paths: files });
}
} else if (msg.type === 'pickFiles') {
const picked = await vscode.window.showOpenDialog({
@@ -384,53 +386,76 @@ function getHtml(): string {
<head>
<meta charset="UTF-8" />
<style>
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); }
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
button:hover { background: var(--vscode-button-hoverBackground); }
.summary { margin-top: 12px; opacity: 0.85; }
.docs { color: var(--vscode-descriptionForeground); margin-bottom: 12px; }
.row-result { padding: 8px 0; border-bottom: 1px solid var(--vscode-panel-border); }
.file-head { display: flex; gap: 8px; align-items: baseline; font-family: var(--vscode-editor-font-family); font-size: 12px; }
.mark { width: 18px; flex-shrink: 0; font-weight: bold; }
.ok { color: #3fb950; }
.bad { color: #f85149; }
.file-name { word-break: break-all; }
.counts { color: var(--vscode-descriptionForeground); margin-left: auto; white-space: nowrap; }
.issues { margin: 6px 0 0 26px; padding: 0; }
.issue { margin: 6px 0; list-style: none; }
.badge { display: inline-block; min-width: 82px; margin-right: 6px; padding: 1px 5px; border-radius: 3px; font-size: 11px; text-align: center; }
.requirement { color: #ffb4ad; background: rgba(248,81,73,0.18); }
.best-practice { color: #ffd580; background: rgba(210,153,34,0.18); }
${getToolWebviewStyles()}
.issues { margin: 0; padding: 0; }
.issue { list-style: none; margin: 0 0 8px; }
.issue:last-child { margin-bottom: 0; }
.severity-badge {
display: inline-flex;
min-width: 82px;
justify-content: center;
margin-right: 6px;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--tool-border);
font-size: 11px;
}
.requirement { color: var(--vscode-errorForeground); background: var(--vscode-inputValidation-errorBackground, rgba(248,81,73,0.14)); }
.best-practice { color: var(--vscode-editorWarning-foreground, #d29922); background: var(--vscode-inputValidation-warningBackground, rgba(210,153,34,0.14)); }
.issue-title { font-weight: 600; }
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
.selected-list { margin-top: 8px; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style>
</head>
<body>
<h2>MRAID Checker</h2>
<div class="docs">Static checks for AppLovin, ironSource, and Unity folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</div>
<div class="row">
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
<button id="pick">Browse Folder...</button>
<button id="pickFiles">Pick Files...</button>
<button id="scan">Scan</button>
<main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">MRAID Checker</h2>
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
</header>
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
</div>
<div class="panel-body">
<div class="control-group">
<div class="field-row">
<input id="folder" type="hidden" />
<button id="pick" class="secondary">Select Folder</button>
<button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div id="selection" class="selected-list"></div>
</div>
<div class="action-row">
<button id="scan">Scan</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="outputPanel" class="is-hidden">
<div id="results" class="results-panel"></div>
</div>
<div id="selection" class="summary"></div>
<div id="status"></div>
<div id="results"></div>
</main>
<script>
const vscode = acquireVsCodeApi();
vscode.postMessage({ type: 'ready' });
const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const outputPanel = document.getElementById('outputPanel');
const selectionEl = document.getElementById('selection');
const PAGE_SIZE = 5;
let pickedFolder = '';
let pickedFiles = [];
let selectedPage = 0;
function clearFiles() {
pickedFiles = [];
selectionEl.textContent = '';
function basename(p) {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
return i >= 0 ? p.slice(i + 1) : p;
}
function escapeText(value) {
@@ -443,7 +468,57 @@ function getHtml(): string {
}[ch]));
}
folderEl.addEventListener('input', clearFiles);
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
const items = pickedFiles.length
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
if (!items.length) {
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
return;
}
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
selectionEl.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
'<tbody>' +
visibleItems.map(item =>
'<tr><td class="mono wrap">' + escapeText(item.label) + '</td>' +
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
).join('') +
'</tbody>' +
'</table>' +
'</div>';
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.type === 'folder') {
pickedFolder = '';
folderEl.value = '';
} else {
pickedFiles.splice(Number(btn.dataset.index), 1);
}
renderSelection();
});
});
if (items.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
selectionEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
renderSelection();
document.getElementById('pick').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' });
@@ -451,9 +526,20 @@ function getHtml(): string {
document.getElementById('pickFiles').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFiles' });
});
document.getElementById('clear').addEventListener('click', () => {
folderEl.value = '';
pickedFolder = '';
pickedFiles = [];
selectedPage = 0;
renderSelection();
statusEl.textContent = '';
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
});
document.getElementById('scan').addEventListener('click', () => {
statusEl.textContent = 'Scanning...';
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
if (pickedFiles.length) {
vscode.postMessage({ type: 'scan', files: pickedFiles });
} else {
@@ -467,33 +553,48 @@ function getHtml(): string {
const msg = event.data;
if (msg.type === 'folderPicked') {
folderEl.value = msg.path;
clearFiles();
pickedFolder = msg.path;
pickedFiles = [];
selectedPage = 0;
renderSelection();
} else if (msg.type === 'filesPicked') {
pickedFiles = msg.paths;
const existing = new Set(pickedFiles);
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
folderEl.value = '';
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
pickedFolder = '';
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection();
} else if (msg.type === 'results') {
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
if (msg.error) {
statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden');
return;
}
const total = msg.results.length;
const withIssues = msg.results.filter(r => !r.ok).length;
const requirements = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
const bestPractices = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
const skipped = Number(msg.skipped || 0);
const skippedReason = msg.skippedReason || 'file(s).';
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>';
resultsEl.innerHTML = '';
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
if (!total) {
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
return;
}
outputPanel.classList.remove('is-hidden');
resultsEl.innerHTML =
'<table class="data-table">' +
'<thead><tr><th>File</th><th>Results</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
const tbody = resultsEl.querySelector('tbody');
for (const r of msg.results) {
const requirementsForFile = r.issues.filter(i => i.severity === 'requirement').length;
const bestPracticesForFile = r.issues.filter(i => i.severity === 'best-practice').length;
const row = document.createElement('div');
row.className = 'row-result';
row.innerHTML =
'<div class="file-head">' +
'<span class="mark ' + (r.ok ? 'ok' : 'bad') + '">' + (r.ok ? 'OK' : 'X') + '</span>' +
'<span class="file-name">' + escapeText(r.file) + '</span>' +
'<span class="counts">' + requirementsForFile + ' req / ' + bestPracticesForFile + ' bp</span>' +
'</div>';
const row = document.createElement('tr');
const file = document.createElement('td');
file.className = 'mono wrap';
file.textContent = r.file;
const issues = document.createElement('td');
issues.className = 'wrap';
if (!r.ok) {
const list = document.createElement('ul');
list.className = 'issues';
@@ -502,14 +603,17 @@ function getHtml(): string {
item.className = 'issue';
const line = i.line ? ' line ' + i.line + ':' : '';
item.innerHTML =
'<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
'<span class="severity-badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
'<span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span>' +
'<div class="issue-detail">' + escapeText(i.detail) + '</div>';
list.appendChild(item);
}
row.appendChild(list);
issues.appendChild(list);
} else {
issues.innerHTML = '<span class="badge ok">OK</span>';
}
resultsEl.appendChild(row);
row.append(file, issues);
tbody.appendChild(row);
}
}
});

View File

@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as cp from 'child_process';
import { singletonPanel } from './shared';
import { getToolWebviewStyles, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
@@ -324,119 +324,98 @@ function getHtml(): string {
];
const checkboxRows = networks.map(n => `
<label class="net-row">
<input type="checkbox" class="net-cb" data-tag="${n.tag}" checked />
<span class="net-label">${n.label} <span class="net-tag">${n.tag}</span></span>
<span class="net-note">${n.note}</span>
</label>`).join('');
<tr>
<td style="width:70px;"><input type="checkbox" class="net-cb" data-tag="${n.tag}"${n.tag === 'tt' ? '' : ' checked'} /></td>
<td style="width:160px;"><span class="net-label">${n.label}</span> <span class="net-tag">${n.tag}</span></td>
<td><span class="net-note">${n.note}</span></td>
</tr>`).join('');
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 10px; font-size: 13px; }
h2 { font-size: 14px; margin: 0 0 14px 0; font-weight: 600; }
.section { margin-bottom: 14px; }
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.65; margin-bottom: 5px; }
.row { display: flex; gap: 6px; align-items: center; }
input[type=text] {
flex: 1; padding: 4px 7px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, #555);
border-radius: 2px; font-size: 12px;
}
button {
padding: 4px 10px; cursor: pointer; font-size: 12px; border-radius: 2px;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: 1px solid var(--vscode-panel-border, #444);
}
button:hover { background: var(--vscode-button-secondaryHoverBackground); }
button.primary {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none; padding: 6px 18px; font-size: 13px;
}
button.primary:hover { background: var(--vscode-button-hoverBackground); }
button:disabled { opacity: 0.45; cursor: default; }
.net-grid { display: flex; flex-direction: column; gap: 4px; }
.net-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; user-select: none; }
${getToolWebviewStyles()}
.net-label { min-width: 100px; font-weight: 500; }
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
.net-note { font-size: 11px; opacity: 0.6; }
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
.toggle-row button { font-size: 11px; padding: 2px 8px; }
.actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; }
#status { margin-top: 12px; font-size: 12px; opacity: 0.75; min-height: 16px; }
.results { margin-top: 10px; display: flex; flex-direction: column; gap: 3px; }
.res-row { display: flex; align-items: center; gap: 8px; font-size: 12px; font-family: var(--vscode-editor-font-family); }
.mark { font-weight: bold; width: 14px; flex-shrink: 0; }
.ok { color: #3fb950; }
.bad { color: #f85149; }
.res-net { min-width: 70px; font-weight: 500; }
.res-file { opacity: 0.65; word-break: break-all; }
.res-err { color: #f85149; opacity: 0.9; }
hr { border: none; border-top: 1px solid var(--vscode-panel-border, #444); margin: 14px 0; }
</style>
</head>
<body>
<h2>Playworks Converter</h2>
<main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">Playworks Converter</h2>
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
</header>
<div class="section">
<div class="section-label">Source HTML (Playworks export)</div>
<div class="row">
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly />
<button id="pickSrc">Browse...</button>
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
</div>
</div>
<div class="section">
<div class="section-label">Base Filename</div>
<div class="row">
<input id="baseName" type="text" placeholder="Base filename..." />
<div class="panel-body">
<div class="control-group">
<div class="control-label">Source HTML (Playworks export)</div>
<div class="field-row">
<input id="src" type="hidden" />
<button id="pickSrc" class="secondary">Select File</button>
<span id="srcName" class="file-name">(no file)</span>
</div>
</div>
<div class="control-group">
<div class="control-label">Base Filename</div>
<div class="field-row">
<input id="baseName" type="text" placeholder="Base filename..." />
</div>
</div>
<div class="control-group">
<div class="control-label">Output Folder</div>
<div class="field-row">
<input id="outDir" type="text" placeholder="Output folder..." />
<button id="pickOut" class="secondary">Browse...</button>
</div>
</div>
<div class="control-group">
<div class="control-label">Networks to Generate</div>
<div class="toggle-row">
<button id="selectAll" class="secondary">Select All</button>
<button id="selectNone" class="secondary">Select None</button>
</div>
<div class="results-panel" style="margin-top:0;">
<table class="data-table">
<thead><tr><th style="width:70px;">Use</th><th style="width:160px;">Network</th><th>Output behavior</th></tr></thead>
<tbody>${checkboxRows}</tbody>
</table>
</div>
</div>
<div class="action-row">
<button id="convert" disabled>Convert</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="outputPanel" class="is-hidden">
<div id="results" class="results-panel"></div>
<div class="action-row">
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
</div>
</div>
<div class="section">
<div class="section-label">Output Folder</div>
<div class="row">
<input id="outDir" type="text" placeholder="Output folder..." />
<button id="pickOut">Browse...</button>
</div>
</div>
<hr />
<div class="section">
<div class="section-label">Networks to generate</div>
<div class="toggle-row">
<button id="selectAll">All</button>
<button id="selectNone">None</button>
</div>
<div class="net-grid">${checkboxRows}</div>
</div>
<div class="actions">
<button class="primary" id="convert" disabled>Convert</button>
<button id="openOut" style="display:none">Open Output Folder</button>
</div>
<div id="status"></div>
<div class="results" id="results"></div>
</main>
<script>
const vscode = acquireVsCodeApi();
let outputDir = '';
const srcEl = document.getElementById('src');
const srcNameEl = document.getElementById('srcName');
const outEl = document.getElementById('outDir');
const baseNameEl = document.getElementById('baseName');
const convertBtn = document.getElementById('convert');
const openOutBtn = document.getElementById('openOut');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const outputPanel = document.getElementById('outputPanel');
function updateConvertBtn() {
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
@@ -446,7 +425,7 @@ function getHtml(): string {
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
document.getElementById('selectAll').addEventListener('click', () => {
document.querySelectorAll('.net-cb').forEach(c => c.checked = true);
document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
updateConvertBtn();
});
document.getElementById('selectNone').addEventListener('click', () => {
@@ -472,6 +451,7 @@ function getHtml(): string {
statusEl.textContent = 'Converting...';
resultsEl.innerHTML = '';
openOutBtn.style.display = 'none';
outputPanel.classList.add('is-hidden');
convertBtn.disabled = true;
vscode.postMessage({
type: 'convert',
@@ -488,6 +468,7 @@ function getHtml(): string {
const msg = event.data;
if (msg.type === 'sourcePicked') {
srcEl.value = msg.path;
srcNameEl.textContent = msg.path.split(/[\\\\/]/).filter(Boolean).pop() || msg.path;
baseNameEl.value = msg.baseName || '';
if (!outEl.value && msg.dir) {
outEl.value = msg.dir;
@@ -503,31 +484,46 @@ function getHtml(): string {
updateConvertBtn();
if (msg.error) {
statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden');
return;
}
const results = msg.results || [];
const ok = results.filter(r => r.ok).length;
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
resultsEl.innerHTML = '';
if (!results.length) {
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
return;
}
outputPanel.classList.remove('is-hidden');
resultsEl.innerHTML =
'<table class="data-table">' +
'<thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th style="width:90px;">Output</th><th>Path</th><th>Error</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
const tbody = resultsEl.querySelector('tbody');
for (const r of results) {
const row = document.createElement('div');
row.className = 'res-row';
const mark = document.createElement('span');
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
mark.textContent = r.ok ? '✓' : '✗';
const net = document.createElement('span');
net.className = 'res-net';
const row = document.createElement('tr');
const net = document.createElement('td');
net.textContent = r.network;
const detail = document.createElement('span');
const status = document.createElement('td');
status.innerHTML = r.ok ? '<span class="badge ok">Done</span>' : '<span class="badge err">Failed</span>';
const outputType = document.createElement('td');
const detail = document.createElement('td');
detail.className = 'mono wrap';
const error = document.createElement('td');
error.className = 'err wrap';
if (r.ok) {
detail.className = 'res-file';
outputType.textContent = /\\.zip$/i.test(r.file) ? 'ZIP' : 'HTML';
detail.textContent = outputDisplayName(r.file);
} else {
detail.className = 'res-err';
detail.textContent = r.error || 'Unknown error';
outputType.textContent = '-';
detail.innerHTML = '<span class="muted">-</span>';
error.textContent = r.error || 'Unknown error';
}
row.append(mark, net, detail);
resultsEl.appendChild(row);
if (r.ok) error.innerHTML = '<span class="muted">-</span>';
row.append(net, status, outputType, detail, error);
tbody.appendChild(row);
}
if (ok > 0) {
outputDir = outEl.value;

View File

@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { File } from 'node:buffer';
import { handleClipboardAndOpen, singletonPanel } from './shared';
import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
@@ -15,6 +15,24 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
try {
if (handleClipboardAndOpen(msg)) return;
if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select Folder',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked[0]) {
const files = await collectHtmlFiles(picked[0].fsPath);
panel.webview.postMessage({
type: 'filesSelected',
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
});
}
return;
}
if (msg.type === 'pickFile') {
const picked = await vscode.window.showOpenDialog({
canSelectMany: true,
@@ -159,78 +177,100 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
return vscode.Uri.file(root);
}
async function collectHtmlFiles(root: string): Promise<string[]> {
const out: string[] = [];
const stack: string[] = [root];
while (stack.length) {
const dir = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === '.git') continue;
stack.push(full);
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
out.push(full);
}
}
}
return out;
}
function getHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
h2 { margin-top: 0; }
table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
th, td { padding: 6px 8px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--vscode-panel-border); }
th { font-weight: 600; font-size: 12px; text-transform: uppercase; opacity: 0.7; }
input[type=text] {
width: 100%; padding: 4px 6px;
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, transparent);
border-radius: 2px;
}
button {
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
}
button:hover { background: var(--vscode-button-hoverBackground); }
button.secondary {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
.file-cell { display: flex; align-items: center; gap: 8px; }
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
${getToolWebviewStyles()}
#rows input[type=text] { width: 100%; }
.file-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }
.row-error { color: var(--vscode-errorForeground); font-size: 12px; margin-top: 4px; }
.actions { margin-top: 12px; display: flex; gap: 8px; }
#status { margin-top: 12px; min-height: 20px; }
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
.result {
margin-top: 12px; padding: 10px;
background: var(--vscode-textBlockQuote-background);
border-left: 3px solid var(--vscode-textBlockQuote-border);
word-break: break-all;
.pick { min-width: 82px; }
.remove-btn {
min-width: 28px;
width: 28px;
padding: 3px 0;
color: var(--vscode-errorForeground);
font-size: 16px;
line-height: 1;
}
.result a { color: var(--vscode-textLink-foreground); }
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
.remove-btn { background: transparent; color: var(--vscode-foreground); padding: 4px 8px; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style>
</head>
<body>
<h2>PLEC Upload</h2>
<p style="opacity:0.8;font-size:12px;margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p>
<main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">PLEC Upload</h2>
<p class="tool-description">Pick HTML files, give each an iteration name, then upload them to PLEC.</p>
</header>
<table id="rows">
<thead>
<tr>
<th style="width:45%">HTML File</th>
<th style="width:45%">Iteration Name</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="actions">
<button id="addRow" class="secondary">+ Add Row</button>
<button id="upload">Upload</button>
</div>
<div id="status"></div>
<div id="results"></div>
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
</div>
<div class="panel-body">
<div class="control-group">
<div class="file-row">
<button id="pickFolder" class="secondary">Select Folder</button>
<button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
<div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
<table id="rows" class="data-table">
<thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="action-row">
<button id="upload">Upload</button>
<button id="openResult" class="secondary" disabled>Open</button>
<button id="copyResult" class="secondary" disabled>Copy Link</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
</main>
<script>
const vscode = acquireVsCodeApi();
const PAGE_SIZE = 5;
let nextId = 1;
let rows = [];
let page = 0;
let previewUrl = '';
const tbody = document.querySelector('#rows tbody');
const rowsPanel = document.getElementById('rowsPanel');
const emptySelection = document.getElementById('emptySelection');
function parseIterationName(filename) {
const base = filename.replace(/\\.html?$/i, '');
@@ -241,87 +281,143 @@ function getHtml(): string {
return base;
}
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const openResultBtn = document.getElementById('openResult');
const copyResultBtn = document.getElementById('copyResult');
function addRow() {
const id = 'r' + (nextId++);
const tr = document.createElement('tr');
tr.dataset.id = id;
tr.innerHTML = \`
<td>
<div class="file-cell">
<button class="pick secondary">Choose...</button>
<span class="file-name" data-name>(no file)</span>
</div>
<input type="hidden" data-path />
<div class="row-error" data-error></div>
</td>
<td><input type="text" data-iter placeholder="iteration name" /></td>
<td><button class="remove-btn" title="Remove">×</button></td>
\`;
tr.querySelector('.pick').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFile', rowId: id });
});
tr.querySelector('.remove-btn').addEventListener('click', () => {
tr.remove();
if (!tbody.children.length) addRow();
});
tbody.appendChild(tr);
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
document.getElementById('addRow').addEventListener('click', addRow);
function addFiles(files) {
const existing = new Set(rows.map(r => r.path));
files.forEach(f => {
if (existing.has(f.path)) return;
rows.push({
id: 'r' + (nextId++),
path: f.path,
name: f.name,
iteration: parseIterationName(f.name),
error: ''
});
existing.add(f.path);
});
normalizeDuplicateIterations(files.length);
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
renderRows();
}
function normalizeDuplicateIterations(addedCount) {
if (addedCount <= 1) return;
const added = rows.slice(Math.max(0, rows.length - addedCount));
if (!added.length) return;
const first = added[0].iteration;
if (first && added.every(r => r.iteration === first)) {
added.forEach((r, i) => {
r.iteration = String(i + 1).padStart(2, '0') + '_' + first;
});
}
}
function renderRows() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
const maxPage = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
page = Math.min(page, maxPage);
rowsPanel.classList.toggle('is-hidden', rows.length === 0);
emptySelection.classList.toggle('is-hidden', rows.length !== 0);
if (!rows.length) {
tbody.innerHTML = '';
return;
}
const start = page * PAGE_SIZE;
const pageRows = rows.slice(start, start + PAGE_SIZE);
tbody.innerHTML = pageRows.map((row, offset) => {
const i = start + offset;
return \`
<tr data-id="\${row.id}">
<td>
<span class="mono wrap">\${escapeHtml(row.name)}</span>
<div class="row-error" data-error>\${escapeHtml(row.error || '')}</div>
</td>
<td><input type="text" data-iter data-index="\${i}" value="\${escapeHtml(row.iteration)}" /></td>
<td><button class="remove-btn danger" data-remove="\${i}" title="Remove">×</button></td>
</tr>
\`;
}).join('');
tbody.querySelectorAll('[data-iter]').forEach(input => {
input.addEventListener('input', () => {
rows[Number(input.dataset.index)].iteration = input.value;
});
});
tbody.querySelectorAll('[data-remove]').forEach(btn => {
btn.addEventListener('click', () => {
rows.splice(Number(btn.dataset.remove), 1);
renderRows();
});
});
if (rows.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (page === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (page + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (page === maxPage ? ' disabled' : '') + '>Next</button>';
document.querySelector('#rows').parentElement.after(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { page--; renderRows(); });
pager.querySelector('#nextPage').addEventListener('click', () => { page++; renderRows(); });
}
}
function setPreview(url) {
previewUrl = url || '';
openResultBtn.disabled = !previewUrl;
copyResultBtn.disabled = !previewUrl;
}
document.getElementById('pickFolder').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' });
});
document.getElementById('pickFiles').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFile' });
});
document.getElementById('clear').addEventListener('click', () => {
rows = [];
page = 0;
setPreview('');
renderRows();
statusEl.textContent = '';
});
document.getElementById('upload').addEventListener('click', () => {
clearErrors();
statusEl.textContent = '';
resultsEl.innerHTML = '';
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
id: tr.dataset.id,
path: tr.querySelector('[data-path]').value,
iteration: tr.querySelector('[data-iter]').value,
})).filter(r => r.path || r.iteration);
setPreview('');
if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>';
statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
return;
}
vscode.postMessage({ type: 'upload', rows });
});
openResultBtn.addEventListener('click', () => {
if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl });
});
copyResultBtn.addEventListener('click', () => {
if (previewUrl) vscode.postMessage({ type: 'copy', text: previewUrl });
});
function clearErrors() {
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
rows.forEach(row => row.error = '');
renderRows();
}
window.addEventListener('message', (e) => {
const m = e.data;
if (m.type === 'filesSelected') {
const firstTr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
const affected = [];
m.files.forEach(function(f, i) {
let tr;
if (i === 0) {
tr = firstTr;
} else {
addRow();
tr = tbody.lastElementChild;
}
if (!tr) return;
tr.querySelector('[data-path]').value = f.path;
tr.querySelector('[data-name]').textContent = f.name;
const iter = tr.querySelector('[data-iter]');
if (!iter.value) iter.value = parseIterationName(f.name);
affected.push(tr);
});
if (affected.length > 1) {
const iters = affected.map(r => r.querySelector('[data-iter]').value);
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
affected.forEach((r, i) => {
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
});
}
}
addFiles(m.files || []);
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = '';
statusEl.className = 'status-panel';
} else if (m.type === 'error') {
statusEl.innerHTML = '';
const div = document.createElement('div');
@@ -329,47 +425,17 @@ function getHtml(): string {
div.textContent = m.message;
statusEl.appendChild(div);
} else if (m.type === 'rowError') {
const tr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
if (tr) tr.querySelector('[data-error]').textContent = m.message;
const row = rows.find(r => r.id === m.rowId);
if (row) row.error = m.message;
renderRows();
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
} else if (m.type === 'result') {
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
const wrap = document.createElement('div');
wrap.className = 'result';
if (m.preview) {
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + m.preview + '">' + m.preview + '</a></div>';
const actions = document.createElement('div');
actions.className = 'result-actions';
const copy = document.createElement('button');
copy.textContent = 'Copy';
copy.onclick = () => vscode.postMessage({ type: 'copy', text: m.preview });
const open = document.createElement('button');
open.textContent = 'Open';
open.className = 'secondary';
open.onclick = () => vscode.postMessage({ type: 'open', text: m.preview });
const showRaw = document.createElement('button');
showRaw.textContent = 'Show server response';
showRaw.className = 'secondary';
showRaw.onclick = () => {
const pre = document.createElement('pre');
pre.style.marginTop = '8px';
pre.style.whiteSpace = 'pre-wrap';
pre.textContent = m.rawText || JSON.stringify(m.raw, null, 2);
wrap.appendChild(pre);
showRaw.disabled = true;
};
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
wrap.appendChild(actions);
} else {
wrap.innerHTML = '<div>No preview field in response. Raw JSON:</div><pre>' +
(m.rawText || JSON.stringify(m.raw, null, 2)).replace(/</g, '&lt;') + '</pre>';
}
resultsEl.innerHTML = '';
resultsEl.appendChild(wrap);
setPreview(m.preview);
}
});
addRow();
renderRows();
</script>
</body>
</html>`;

View File

@@ -4,7 +4,7 @@ import * as path from 'path';
import * as http from 'http';
import * as os from 'os';
import * as crypto from 'crypto';
import { handleClipboardAndOpen } from './shared';
import { getToolWebviewStyles, handleClipboardAndOpen } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
@@ -12,7 +12,11 @@ interface ActiveShare {
server: http.Server;
port: number;
token: string;
fileBuf: Buffer;
files: SharedFile[];
}
interface SharedFile {
buf: Buffer;
filename: string;
}
@@ -57,19 +61,35 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
try {
if (handleClipboardAndOpen(msg)) return;
if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select Folder',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked[0]) {
const files = await collectHtmlFiles(picked[0].fsPath);
panel.webview.postMessage({
type: 'fileSelected',
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
});
}
return;
}
if (msg.type === 'pickFile') {
const picked = await vscode.window.showOpenDialog({
canSelectMany: false,
canSelectMany: true,
filters: { HTML: ['html', 'htm'] },
openLabel: 'Select',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked[0]) {
const fp = picked[0].fsPath;
if (picked && picked.length) {
panel.webview.postMessage({
type: 'fileSelected',
path: fp,
name: path.basename(fp),
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
});
}
return;
@@ -77,13 +97,19 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
if (msg.type === 'startShare') {
stopActive();
const filePath: string = msg.path;
if (!filePath || !fs.existsSync(filePath)) {
const filePaths: string[] = Array.isArray(msg.paths) ? msg.paths : (msg.path ? [msg.path] : []);
if (!filePaths.length) {
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
return;
}
const fileBuf = fs.readFileSync(filePath);
const filename = path.basename(filePath);
const files: SharedFile[] = [];
for (const filePath of filePaths) {
if (!filePath || !fs.existsSync(filePath)) {
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
return;
}
files.push({ buf: fs.readFileSync(filePath), filename: path.basename(filePath) });
}
const token = crypto.randomBytes(6).toString('base64url');
const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile');
@@ -93,8 +119,7 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
server: http.createServer(),
port: 0,
token,
fileBuf,
filename,
files,
};
share.server.on('request', (req, res) => handleRequest(req, res, share));
share.server.on('error', (err) => {
@@ -125,7 +150,11 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
iface: ip.iface,
url: `http://${ip.address}:${share.port}/s/${token}/`,
}));
panel.webview.postMessage({ type: 'sharing', urls, filename });
panel.webview.postMessage({
type: 'sharing',
urls,
filename: files.length === 1 ? files[0].filename : `${files.length} files`,
});
});
return;
}
@@ -141,6 +170,30 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
});
}
async function collectHtmlFiles(root: string): Promise<string[]> {
const out: string[] = [];
const stack: string[] = [root];
while (stack.length) {
const dir = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === '.git') continue;
stack.push(full);
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
out.push(full);
}
}
}
return out;
}
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, share: ActiveShare) {
if (req.method !== 'GET' || !req.url) {
res.statusCode = 404;
@@ -156,7 +209,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
const rest = req.url.slice(prefix.length).replace(/\?.*$/, '');
if (rest === '' || rest === '/') {
const body = chooserPage(share.filename);
const body = chooserPage(share.files);
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
@@ -165,24 +218,40 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
return;
}
if (rest === '/view') {
const viewMatch = rest.match(/^\/view\/(\d+)$/);
if (rest === '/view' || viewMatch) {
const index = viewMatch ? Number(viewMatch[1]) : 0;
const file = share.files[index];
if (!file) {
res.statusCode = 404;
res.end();
return;
}
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(share.fileBuf);
res.end(file.buf);
return;
}
if (rest === '/file') {
const safeName = share.filename.replace(/[^A-Za-z0-9._-]/g, '_');
const fileMatch = rest.match(/^\/file\/(\d+)$/);
if (rest === '/file' || fileMatch) {
const index = fileMatch ? Number(fileMatch[1]) : 0;
const file = share.files[index];
if (!file) {
res.statusCode = 404;
res.end();
return;
}
const safeName = file.filename.replace(/[^A-Za-z0-9._-]/g, '_');
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${safeName}"`,
'Content-Length': String(share.fileBuf.length),
'Content-Length': String(file.buf.length),
'Cache-Control': 'no-store',
});
res.end(share.fileBuf);
res.end(file.buf);
return;
}
@@ -190,10 +259,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
res.end();
}
function chooserPage(filename: string): string {
const escName = filename.replace(/[&<>"]/g, (c) =>
function chooserPage(files: SharedFile[]): string {
const esc = (value: string) => value.replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] as string)
);
const items = files.map((file, index) => {
const escName = esc(file.filename);
return `<div class="item">
<div class="file">${escName}</div>
<a class="btn view" href="view/${index}">View in browser</a>
<a class="btn dl" href="file/${index}" download="${escName}">Download .html</a>
</div>`;
}).join('');
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
@@ -203,7 +280,8 @@ function chooserPage(filename: string): string {
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 24px; background: #111; color: #eee; }
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; }
.item { margin-bottom: 26px; }
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; word-break: break-all; }
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
a.view { background: #2d7dff; color: white; }
@@ -211,9 +289,7 @@ function chooserPage(filename: string): string {
</style></head>
<body>
<h1>HPL Toolbox</h1>
<div class="file">${escName}</div>
<a class="btn view" href="view">View in browser</a>
<a class="btn dl" href="file" download="${escName}">Download .html</a>
${items}
</body></html>`;
}
@@ -250,26 +326,8 @@ function getHtml(qrScriptUri: string): string {
<head>
<meta charset="UTF-8" />
<style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
h2 { margin-top: 0; }
.row { margin-bottom: 14px; }
.file-cell { display: flex; align-items: center; gap: 8px; }
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
button {
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
}
button:hover { background: var(--vscode-button-hoverBackground); }
button.secondary {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.iface-list { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
.iface-list label { font-size: 12px; cursor: pointer; }
.iface-list .meta { opacity: 0.6; margin-left: 6px; }
#qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: 4px; }
${getToolWebviewStyles()}
#qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: var(--tool-radius); }
#qr svg, #qr img { display: block; }
.url {
margin-top: 10px; padding: 8px;
@@ -277,99 +335,168 @@ function getHtml(qrScriptUri: string): string {
border-left: 3px solid var(--vscode-textBlockQuote-border);
font-family: var(--vscode-editor-font-family, monospace); font-size: 12px; word-break: break-all;
}
.url-actions { margin-top: 6px; display: flex; gap: 6px; }
#status { margin-top: 12px; min-height: 18px; }
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
.hint { font-size: 12px; opacity: 0.7; margin-top: 8px; }
.url-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
.share-panel { padding: 12px; }
.iface-radio { text-align: center; }
#qr { margin-top: 0; }
.selected-list { margin-top: 10px; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style>
<script src="${qrScriptUri}"></script>
</head>
<body>
<h2>Send To Mobile</h2>
<p style="opacity:0.8;font-size:12px;margin-top:0;">
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
</p>
<main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">Send To Mobile</h2>
<p class="tool-description">Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi. The phone gets View and Download options.</p>
</header>
<div class="row">
<div class="file-cell">
<button id="pick" class="secondary">Choose...</button>
<span class="file-name" id="fileName">(no file)</span>
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
</div>
<input type="hidden" id="filePath" />
</div>
<div class="row">
<button id="start">Start sharing</button>
<button id="stop" class="secondary" disabled>Stop</button>
</div>
<div id="ifaceWrap" class="row" style="display:none;">
<div style="font-size:12px;opacity:0.8;">Multiple network interfaces found. Pick the one your phone can reach:</div>
<div class="iface-list" id="ifaceList"></div>
</div>
<div id="shareInfo" style="display:none;">
<div id="qr"></div>
<div class="url" id="urlText"></div>
<div class="url-actions">
<button id="copyUrl" class="secondary">Copy URL</button>
<button id="openUrl" class="secondary">Open in browser</button>
<div class="panel-body">
<div class="control-group">
<div class="file-row">
<button id="pickFolder" class="secondary">Select Folder</button>
<button id="pick" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div class="file-name" id="fileName" style="margin-top:8px;">(no files selected)</div>
<div id="fileList" class="selected-list"></div>
</div>
<div class="action-row">
<button id="start">Start</button>
<button id="stop" class="secondary" disabled>Stop</button>
</div>
<div id="status" class="status-panel"></div>
</div>
<div class="hint">
First run on Windows may show a firewall prompt — allow access for "Private networks".
Sharing stops automatically when this panel is closed.
</div>
</div>
</section>
<div id="status"></div>
<div id="outputPanel" class="is-hidden">
<div id="ifaceWrap" class="control-group" style="display:none;">
<div class="control-label">Network Interfaces</div>
<p class="section-description">Pick the interface your phone can reach.</p>
<div class="iface-list" id="ifaceList"></div>
</div>
<div id="shareInfo" class="result-card share-panel" style="display:none;">
<div id="qr"></div>
<div class="muted" style="margin-top:8px;font-size:12px;">
First run on Windows may show a firewall prompt - allow access for "Private networks".
Sharing stops automatically when this panel is closed.
</div>
</div>
</div>
</main>
<script>
const vscode = acquireVsCodeApi();
const pickFolderBtn = document.getElementById('pickFolder');
const pickBtn = document.getElementById('pick');
const clearBtn = document.getElementById('clear');
const startBtn = document.getElementById('start');
const stopBtn = document.getElementById('stop');
const fileNameEl = document.getElementById('fileName');
const filePathEl = document.getElementById('filePath');
const fileListEl = document.getElementById('fileList');
const statusEl = document.getElementById('status');
const outputPanel = document.getElementById('outputPanel');
const shareInfo = document.getElementById('shareInfo');
const ifaceWrap = document.getElementById('ifaceWrap');
const ifaceList = document.getElementById('ifaceList');
const qrEl = document.getElementById('qr');
const urlTextEl = document.getElementById('urlText');
const PAGE_SIZE = 5;
let currentUrls = [];
let selectedIdx = 0;
let selectedPaths = [];
let selectedFiles = [];
let selectedPage = 0;
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFile' }));
clearBtn.addEventListener('click', () => {
selectedPaths = [];
selectedFiles = [];
selectedPage = 0;
renderSelection();
statusEl.textContent = '';
});
startBtn.addEventListener('click', () => {
statusEl.innerHTML = '';
const path = filePathEl.value;
if (!path) {
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
outputPanel.classList.add('is-hidden');
if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return;
}
startBtn.disabled = true;
statusEl.textContent = 'Starting server...';
vscode.postMessage({ type: 'startShare', path });
vscode.postMessage({ type: 'startShare', paths: selectedPaths });
});
stopBtn.addEventListener('click', () => {
vscode.postMessage({ type: 'stopShare' });
});
document.getElementById('copyUrl').addEventListener('click', () => {
vscode.postMessage({ type: 'copy', text: currentUrls[selectedIdx]?.url || '' });
});
document.getElementById('openUrl').addEventListener('click', () => {
vscode.postMessage({ type: 'open', text: currentUrls[selectedIdx]?.url || '' });
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!selectedFiles.length) {
fileNameEl.textContent = '(no files selected)';
fileListEl.innerHTML = '';
return;
}
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visibleFiles = selectedFiles.slice(start, start + PAGE_SIZE);
fileListEl.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
'<tbody>' +
visibleFiles.map((file, offset) => {
const i = start + offset;
return (
'<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td>' +
'<td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>'
);
}).join('') +
'</tbody>' +
'</table>' +
'</div>';
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
const index = Number(btn.dataset.index);
selectedFiles.splice(index, 1);
selectedPaths.splice(index, 1);
renderSelection();
});
});
if (selectedFiles.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
fileListEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
renderSelection();
function renderQr(url) {
qrEl.innerHTML = '';
urlTextEl.textContent = url;
try {
const qr = qrcode(0, 'M');
qr.addData(url);
@@ -389,29 +516,42 @@ function getHtml(qrScriptUri: string): string {
return;
}
ifaceWrap.style.display = '';
ifaceList.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead>' +
'<tbody></tbody>' +
'</table>' +
'</div>';
const tbody = ifaceList.querySelector('tbody');
urls.forEach((u, i) => {
const id = 'iface_' + i;
const label = document.createElement('label');
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
(i === selectedIdx ? ' checked' : '') + ' /> ' +
u.ip + '<span class="meta">(' + u.iface + ')</span>';
label.querySelector('input').addEventListener('change', () => {
const row = document.createElement('tr');
row.innerHTML =
'<td class="iface-radio"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
'<td class="mono wrap">' + u.ip + '</td>' +
'<td class="wrap">' + u.iface + '</td>';
row.querySelector('input').addEventListener('change', () => {
selectedIdx = i;
renderQr(urls[i].url);
});
ifaceList.appendChild(label);
tbody.appendChild(row);
});
}
window.addEventListener('message', (e) => {
const m = e.data;
if (m.type === 'fileSelected') {
filePathEl.value = m.path;
fileNameEl.textContent = m.name;
const existing = new Set(selectedFiles.map(f => f.path));
selectedFiles = selectedFiles.concat((m.files || []).filter(f => !existing.has(f.path)));
selectedPaths = selectedFiles.map(f => f.path);
selectedPage = 0;
renderSelection();
} else if (m.type === 'sharing') {
currentUrls = m.urls;
selectedIdx = 0;
statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>';
outputPanel.classList.remove('is-hidden');
shareInfo.style.display = '';
renderIfaces(m.urls);
renderQr(m.urls[0].url);
@@ -420,6 +560,8 @@ function getHtml(qrScriptUri: string): string {
} else if (m.type === 'stopped') {
statusEl.textContent = 'Stopped.';
shareInfo.style.display = 'none';
ifaceWrap.style.display = 'none';
outputPanel.classList.add('is-hidden');
currentUrls = [];
startBtn.disabled = false;
stopBtn.disabled = true;

View File

@@ -13,6 +13,288 @@ export function handleClipboardAndOpen(msg: any): boolean {
return false;
}
export function getToolWebviewStyles(): string {
return `
:root {
color-scheme: dark;
--tool-gap-xs: 4px;
--tool-gap-sm: 8px;
--tool-gap-md: 12px;
--tool-gap-lg: 16px;
--tool-radius: 4px;
--tool-border: var(--vscode-panel-border, rgba(128, 128, 128, 0.35));
--tool-panel: var(--vscode-editorWidget-background, var(--vscode-sideBar-background));
--tool-panel-soft: var(--vscode-textBlockQuote-background, rgba(128, 128, 128, 0.08));
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 0;
background: var(--vscode-editor-background);
color: var(--vscode-foreground);
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
.tool-page {
max-width: 1100px;
padding: 18px;
}
.tool-header {
margin-bottom: var(--tool-gap-lg);
}
.tool-title {
margin: 0;
font-size: 18px;
line-height: 1.25;
font-weight: 600;
}
.tool-description,
.section-description,
.muted {
color: var(--vscode-descriptionForeground);
}
.tool-description {
margin: 5px 0 0;
max-width: 780px;
font-size: 12px;
line-height: 1.45;
}
.tool-section {
margin-bottom: var(--tool-gap-lg);
}
.tool-panel {
margin-bottom: var(--tool-gap-lg);
border: 1px solid var(--tool-border);
border-radius: var(--tool-radius);
background: var(--tool-panel-soft);
overflow: hidden;
}
.is-hidden,
.tool-panel.is-hidden {
display: none;
}
.panel-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--tool-gap-md);
padding: 10px 12px;
border-bottom: 1px solid var(--tool-border);
background: var(--tool-panel);
}
.panel-title {
margin: 0;
color: var(--vscode-foreground);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.45px;
text-transform: uppercase;
}
.panel-body {
padding: 12px;
}
.control-group + .control-group {
margin-top: var(--tool-gap-md);
}
.control-label {
margin: 0 0 6px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.section-title {
margin: 0 0 6px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.section-description {
margin: -2px 0 8px;
font-size: 12px;
line-height: 1.45;
}
.field-row,
.file-row,
.action-row {
display: flex;
align-items: center;
gap: var(--tool-gap-sm);
flex-wrap: wrap;
}
.field-row + .field-row {
margin-top: var(--tool-gap-sm);
}
input[type=text],
input[type=date],
select,
textarea {
min-width: 0;
padding: 6px 8px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, var(--tool-border));
border-radius: var(--tool-radius);
font: inherit;
}
input[type=text]:focus,
input[type=date]:focus,
select:focus,
textarea:focus {
outline: 1px solid var(--vscode-focusBorder);
border-color: var(--vscode-focusBorder);
}
.field-row input[type=text] {
flex: 1 1 260px;
}
button {
min-height: 28px;
padding: 5px 12px;
border: 1px solid transparent;
border-radius: var(--tool-radius);
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
font: inherit;
cursor: pointer;
}
button:hover {
background: var(--vscode-button-hoverBackground);
}
button.secondary {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border-color: var(--tool-border);
}
button.secondary:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
button.danger {
background: transparent;
color: var(--vscode-errorForeground);
border-color: var(--tool-border);
}
button.danger:hover {
background: var(--vscode-inputValidation-errorBackground, rgba(248, 81, 73, 0.16));
}
.remove-selected {
min-width: 28px;
width: 28px;
padding: 3px 0;
color: var(--vscode-errorForeground);
font-size: 16px;
line-height: 1;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-row {
margin-top: var(--tool-gap-md);
}
.status-panel,
.results-panel,
.result-card {
border: 1px solid var(--tool-border);
border-radius: var(--tool-radius);
background: var(--tool-panel-soft);
}
.status-panel {
min-height: 30px;
margin-top: var(--tool-gap-md);
padding: 8px 10px;
white-space: pre-wrap;
}
.status-panel:empty {
display: none;
}
.results-panel {
margin-top: var(--tool-gap-md);
overflow: auto;
}
.results-panel:empty {
display: none;
}
.output-panel .results-panel {
margin-top: 0;
}
.result-card {
padding: 10px;
word-break: break-word;
}
.data-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.data-table th,
.data-table td {
padding: 8px 10px;
text-align: left;
vertical-align: top;
border-bottom: 1px solid var(--tool-border);
}
.data-table th {
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
background: var(--tool-panel);
}
.data-table tr:last-child td {
border-bottom: none;
}
.mono {
font-family: var(--vscode-editor-font-family, monospace);
font-size: 12px;
}
.wrap {
overflow-wrap: anywhere;
word-break: break-word;
}
.file-name {
color: var(--vscode-descriptionForeground);
font-size: 12px;
overflow-wrap: anywhere;
}
.ok {
color: var(--vscode-testing-iconPassed, #3fb950);
}
.err {
color: var(--vscode-errorForeground, #f85149);
}
.warn {
color: var(--vscode-editorWarning-foreground, #d29922);
}
.badge {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--tool-border);
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
}
.badge.ok {
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #3fb950) 45%, transparent);
background: rgba(63, 185, 80, 0.12);
}
.badge.err {
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f85149) 45%, transparent);
background: var(--vscode-inputValidation-errorBackground, rgba(248, 81, 73, 0.12));
}
.badge.warn {
border-color: color-mix(in srgb, var(--vscode-editorWarning-foreground, #d29922) 45%, transparent);
background: var(--vscode-inputValidation-warningBackground, rgba(210, 153, 34, 0.12));
}
`;
}
export function singletonPanel(
store: { panel: vscode.WebviewPanel | null },
viewType: string,