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

@@ -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;