feature: Add folder creation for recursive transfers and transfer progress indicator

This commit is contained in:
Keith Solomon
2026-01-03 19:06:28 -06:00
parent 318ef5f9b2
commit 6164906d7a
5 changed files with 590 additions and 59 deletions

View File

@@ -44,6 +44,11 @@
<label class="checkbox"> <label class="checkbox">
<input id="use-wget" type="checkbox" /> Use wget if available <input id="use-wget" type="checkbox" /> Use wget if available
</label> </label>
<div class="progress" id="transfer-progress" aria-hidden="true">
<div class="progress-bar" id="transfer-bar"></div>
</div>
<div class="progress-meta" id="transfer-meta"></div>
<div class="progress-meta secondary" id="transfer-meta-secondary"></div>
</div> </div>
</div> </div>
<div class="panel-body"> <div class="panel-body">

View File

@@ -43,6 +43,12 @@ function logToRenderer(message) {
windows[0].webContents.send('log', message); windows[0].webContents.send('log', message);
} }
function progressToRenderer(payload) {
const windows = BrowserWindow.getAllWindows();
if (!windows.length) return;
windows[0].webContents.send('progress', payload);
}
function listLocalDir(dirPath) { function listLocalDir(dirPath) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const entries = fs.readdirSync(dirPath, { withFileTypes: true });
return entries.map((entry) => { return entries.map((entry) => {
@@ -84,6 +90,26 @@ function fetchUrl(url) {
}); });
} }
function parseSizeToken(token) {
if (!token || token === '-') return null;
const match = token.trim().match(/^([\d.]+)\s*([KMGTP]?)(?:B)?$/i);
if (!match) return null;
const value = Number(match[1]);
if (Number.isNaN(value)) return null;
const unit = match[2] ? match[2].toUpperCase() : '';
const multipliers = {
'': 1,
K: 1024,
M: 1024 ** 2,
G: 1024 ** 3,
T: 1024 ** 4,
P: 1024 ** 5,
};
const multiplier = multipliers[unit];
if (!multiplier) return null;
return Math.round(value * multiplier);
}
function parseApacheIndex(html, baseUrl) { function parseApacheIndex(html, baseUrl) {
const results = []; const results = [];
const preMatch = html.match(/<pre>[\s\S]*?<\/pre>/i); const preMatch = html.match(/<pre>[\s\S]*?<\/pre>/i);
@@ -113,20 +139,43 @@ function parseApacheIndex(html, baseUrl) {
name = anchorText; name = anchorText;
} }
let size = '-'; let size = '-';
let sizeBytes = null;
const rest = line.slice(match.index + match[0].length).trim(); const rest = line.slice(match.index + match[0].length).trim();
if (rest) { if (rest) {
const tokens = rest.split(/\s+/); const tokens = rest.split(/\s+/);
const sizeToken = tokens[tokens.length - 1]; const sizeToken = tokens[tokens.length - 1];
if (sizeToken && sizeToken !== '-') { if (sizeToken && sizeToken !== '-') {
size = sizeToken; size = sizeToken;
sizeBytes = parseSizeToken(sizeToken);
} }
} }
results.push({ name, href: fullUrl, isDir, size }); results.push({ name, href: fullUrl, isDir, size, sizeBytes });
} }
return results; return results;
} }
function downloadFile(url, destPath) { async function collectRemoteFiles(baseUrl, relDir) {
const html = await fetchUrl(baseUrl);
const items = parseApacheIndex(html, baseUrl);
const files = [];
for (const item of items) {
const cleanName = item.name.replace(/\/$/, '');
const nextRel = relDir ? path.join(relDir, cleanName) : cleanName;
if (item.isDir) {
const childFiles = await collectRemoteFiles(item.href, nextRel);
files.push(...childFiles);
} else {
files.push({
url: item.href,
relPath: nextRel,
sizeBytes: item.sizeBytes ?? null,
});
}
}
return files;
}
function downloadFile(url, destPath, onProgress) {
const client = url.startsWith('https') ? https : http; const client = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const fileStream = fs.createWriteStream(destPath); const fileStream = fs.createWriteStream(destPath);
@@ -136,7 +185,23 @@ function downloadFile(url, destPath) {
res.resume(); res.resume();
return; return;
} }
const totalBytes = Number(res.headers['content-length']) || null;
let downloaded = 0;
let lastEmit = Date.now();
onProgress?.({ phase: 'start', totalBytes });
res.pipe(fileStream); res.pipe(fileStream);
res.on('data', (chunk) => {
downloaded += chunk.length;
const now = Date.now();
if (onProgress && (now - lastEmit > 200 || downloaded === totalBytes)) {
lastEmit = now;
onProgress({
phase: 'progress',
downloadedBytes: downloaded,
totalBytes,
});
}
});
fileStream.on('finish', () => { fileStream.on('finish', () => {
fileStream.close(resolve); fileStream.close(resolve);
}); });
@@ -147,19 +212,60 @@ function downloadFile(url, destPath) {
}); });
} }
async function downloadFolderRecursive(baseUrl, destDir) { function getRemoteBasename(url) {
const html = await fetchUrl(baseUrl); try {
const items = parseApacheIndex(html, baseUrl); const urlObj = new URL(url);
for (const item of items) { let pathname = decodeURIComponent(urlObj.pathname);
const targetPath = path.join(destDir, item.name.replace(/\/$/, '')); if (pathname.endsWith('/')) {
if (item.isDir) { pathname = pathname.slice(0, -1);
fs.mkdirSync(targetPath, { recursive: true });
logToRenderer(`Entering ${item.href}`);
await downloadFolderRecursive(item.href, targetPath);
} else {
logToRenderer(`Downloading ${item.href}`);
await downloadFile(item.href, targetPath);
} }
const name = pathname.split('/').pop();
return name || 'download';
} catch (err) {
return 'download';
}
}
async function downloadFolderWithPlan(baseUrl, destDir) {
progressToRenderer({ phase: 'scan-start' });
const files = await collectRemoteFiles(baseUrl, '');
progressToRenderer({
phase: 'scan-done',
fileCount: files.length,
});
const totalBytes = files.every((file) => typeof file.sizeBytes === 'number')
? files.reduce((sum, file) => sum + file.sizeBytes, 0)
: null;
progressToRenderer({
phase: 'aggregate-start',
totalBytes,
fileCount: files.length,
});
let completedBytes = 0;
for (const file of files) {
const targetPath = path.join(destDir, file.relPath);
const targetDir = path.dirname(targetPath);
fs.mkdirSync(targetDir, { recursive: true });
logToRenderer(`Downloading ${file.url}`);
progressToRenderer({ phase: 'file-start', fileName: file.relPath });
await downloadFile(file.url, targetPath, (progress) => {
const current = progress.downloadedBytes || 0;
const aggregate = completedBytes + current;
progressToRenderer({
phase: 'file-progress',
fileName: file.relPath,
downloadedBytes: current,
totalBytes: progress.totalBytes,
});
progressToRenderer({
phase: 'aggregate-progress',
downloadedBytes: aggregate,
totalBytes,
});
});
completedBytes += file.sizeBytes ?? 0;
progressToRenderer({ phase: 'file-done', fileName: file.relPath });
} }
} }
@@ -216,16 +322,33 @@ ipcMain.handle('download-item', async (_event, payload) => {
fs.mkdirSync(destDir, { recursive: true }); fs.mkdirSync(destDir, { recursive: true });
} }
if (isDir) { if (isDir) {
const folderName = getRemoteBasename(url);
const targetDir = path.join(destDir, folderName);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
if (useWget && state.hasWget) { if (useWget && state.hasWget) {
logToRenderer(`wget: ${url}`); logToRenderer(`wget: ${url}`);
await downloadWithWget(url, destDir); progressToRenderer({ phase: 'start', mode: 'wget', fileName: folderName });
await downloadWithWget(url, targetDir);
progressToRenderer({ phase: 'done' });
return; return;
} }
await downloadFolderRecursive(url, destDir); await downloadFolderWithPlan(url, targetDir);
progressToRenderer({ phase: 'done' });
return; return;
} }
const fileName = path.basename(new URL(url).pathname); const fileName = path.basename(new URL(url).pathname);
const destPath = path.join(destDir, fileName); const destPath = path.join(destDir, fileName);
logToRenderer(`Downloading ${url}`); logToRenderer(`Downloading ${url}`);
await downloadFile(url, destPath); progressToRenderer({ phase: 'start', fileName });
await downloadFile(url, destPath, (progress) => {
progressToRenderer({
phase: 'progress',
fileName,
downloadedBytes: progress.downloadedBytes,
totalBytes: progress.totalBytes,
});
});
progressToRenderer({ phase: 'done' });
}); });

View File

@@ -7,4 +7,5 @@ contextBridge.exposeInMainWorld('oddl', {
listRemoteDir: (url) => ipcRenderer.invoke('list-remote-dir', url), listRemoteDir: (url) => ipcRenderer.invoke('list-remote-dir', url),
downloadItem: (payload) => ipcRenderer.invoke('download-item', payload), downloadItem: (payload) => ipcRenderer.invoke('download-item', payload),
onLog: (callback) => ipcRenderer.on('log', (_event, message) => callback(message)), onLog: (callback) => ipcRenderer.on('log', (_event, message) => callback(message)),
onProgress: (callback) => ipcRenderer.on('progress', (_event, payload) => callback(payload)),
}); });

View File

@@ -4,6 +4,12 @@ const state = {
localEntries: [], localEntries: [],
remoteEntries: [], remoteEntries: [],
hasWget: false, hasWget: false,
remoteTree: {
rootUrl: '',
currentUrl: '',
nodes: new Map(),
},
transfer: null,
}; };
const el = { const el = {
@@ -18,6 +24,10 @@ const el = {
localListBody: document.querySelector('#local-list-body'), localListBody: document.querySelector('#local-list-body'),
downloadBtn: document.querySelector('#download-btn'), downloadBtn: document.querySelector('#download-btn'),
useWget: document.querySelector('#use-wget'), useWget: document.querySelector('#use-wget'),
progress: document.querySelector('#transfer-progress'),
progressBar: document.querySelector('#transfer-bar'),
progressMeta: document.querySelector('#transfer-meta'),
progressMetaSecondary: document.querySelector('#transfer-meta-secondary'),
logList: document.querySelector('#log-list'), logList: document.querySelector('#log-list'),
}; };
@@ -34,6 +44,65 @@ function formatBytes(size) {
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
} }
function formatDuration(seconds) {
if (!seconds || !Number.isFinite(seconds)) return '';
const rounded = Math.max(0, Math.round(seconds));
const mins = Math.floor(rounded / 60);
const secs = rounded % 60;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
}
function showProgress({ percent, label, indeterminate }) {
el.progress.classList.add('active');
el.progress.classList.toggle('indeterminate', Boolean(indeterminate));
el.progressBar.style.width = indeterminate ? '40%' : `${percent}%`;
el.progressMeta.textContent = label || '';
}
function setSecondaryProgress(text) {
el.progressMetaSecondary.textContent = text || '';
}
function hideProgress() {
el.progress.classList.remove('active', 'indeterminate');
el.progressBar.style.width = '0%';
el.progressMeta.textContent = '';
el.progressMetaSecondary.textContent = '';
}
function parseSizeToBytes(token) {
if (!token || token === '-') return null;
const match = token.trim().match(/^([\d.]+)\s*([KMGTP]?)(?:B)?$/i);
if (!match) return null;
const value = Number(match[1]);
if (Number.isNaN(value)) return null;
const unit = match[2] ? match[2].toUpperCase() : '';
const multipliers = {
'': 1,
K: 1024,
M: 1024 ** 2,
G: 1024 ** 3,
T: 1024 ** 4,
P: 1024 ** 5,
};
const multiplier = multipliers[unit];
if (!multiplier) return null;
return Math.round(value * multiplier);
}
function normalizeDirUrl(url) {
try {
const urlObj = new URL(url);
if (!urlObj.pathname.endsWith('/')) {
urlObj.pathname += '/';
}
return urlObj.toString();
} catch (err) {
return url;
}
}
function log(message) { function log(message) {
const line = document.createElement('div'); const line = document.createElement('div');
line.textContent = message; line.textContent = message;
@@ -69,10 +138,13 @@ function renderRemote() {
row.className = 'row list-row'; row.className = 'row list-row';
const name = document.createElement('div'); const name = document.createElement('div');
name.className = 'name'; name.className = 'name';
name.textContent = entry.isDir ? entry.name : entry.name; name.textContent = entry.name;
const size = document.createElement('div'); const size = document.createElement('div');
size.className = 'size'; size.className = 'size';
size.textContent = entry.isDir ? '-' : entry.size || '-'; const sizeBytes = entry.sizeBytes !== undefined && entry.sizeBytes !== null
? entry.sizeBytes
: parseSizeToBytes(entry.size);
size.textContent = entry.isDir ? '-' : formatBytes(sizeBytes);
row.appendChild(name); row.appendChild(name);
row.appendChild(size); row.appendChild(size);
row.addEventListener('dblclick', async () => { row.addEventListener('dblclick', async () => {
@@ -91,49 +163,146 @@ function renderRemote() {
}); });
} }
function renderRemoteTree() { function ensureRemoteNode(url, name) {
el.remoteTree.innerHTML = ''; const normalized = normalizeDirUrl(url);
if (!state.remoteUrl) return; let node = state.remoteTree.nodes.get(normalized);
if (!node) {
node = {
url: normalized,
name: name || normalized,
parentUrl: null,
children: [],
expanded: false,
loaded: false,
loading: false,
};
state.remoteTree.nodes.set(normalized, node);
}
if (name) {
node.name = name;
}
return node;
}
function linkChild(parent, child) {
if (!parent.children.includes(child.url)) {
parent.children.push(child.url);
}
child.parentUrl = parent.url;
}
function syncTreeWithCurrent(url, entries) {
if (!url) return;
let urlObj; let urlObj;
try { try {
urlObj = new URL(state.remoteUrl); urlObj = new URL(url);
} catch (err) { } catch (err) {
return; return;
} }
const rootUrl = `${urlObj.origin}/`;
state.remoteTree.rootUrl = rootUrl;
const root = ensureRemoteNode(rootUrl, `${urlObj.origin}/`);
const segments = urlObj.pathname.split('/').filter(Boolean); const segments = urlObj.pathname.split('/').filter(Boolean);
const nodes = []; let parent = root;
nodes.push({ name: `${urlObj.origin}/`, url: `${urlObj.origin}/`, depth: 0, current: segments.length === 0 });
let currentPath = '/'; let currentPath = '/';
segments.forEach((seg, index) => { root.expanded = true;
segments.forEach((seg) => {
currentPath += `${seg}/`; currentPath += `${seg}/`;
nodes.push({ const nodeUrl = `${urlObj.origin}${currentPath}`;
name: `${seg}/`, const node = ensureRemoteNode(nodeUrl, `${seg}/`);
url: `${urlObj.origin}${currentPath}`, linkChild(parent, node);
depth: index + 1, parent.expanded = true;
current: index === segments.length - 1, parent = node;
}); });
}); parent.expanded = true;
const childDirs = state.remoteEntries.filter((entry) => entry.isDir); state.remoteTree.currentUrl = parent.url;
const childDirs = entries.filter((entry) => entry.isDir);
parent.children = [];
childDirs.forEach((entry) => { childDirs.forEach((entry) => {
nodes.push({ const child = ensureRemoteNode(entry.href, entry.name);
name: entry.name, linkChild(parent, child);
url: entry.href,
depth: segments.length + 1,
current: false,
}); });
parent.loaded = true;
parent.loading = false;
}
async function loadNodeChildren(node) {
if (node.loading) return;
node.loading = true;
renderRemoteTree();
let entries = [];
try {
entries = await window.oddl.listRemoteDir(node.url);
} catch (err) {
log(err.message);
node.loading = false;
return;
}
node.children = [];
entries.filter((entry) => entry.isDir).forEach((entry) => {
const child = ensureRemoteNode(entry.href, entry.name);
linkChild(node, child);
}); });
nodes.forEach((node) => { node.loaded = true;
node.loading = false;
}
function renderTreeNode(node, depth, container) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = `tree-row${node.current ? ' current' : ''}`; const current = node.url === state.remoteTree.currentUrl;
row.textContent = node.name; row.className = `tree-node${current ? ' current' : ''}`;
row.style.paddingLeft = `${node.depth * 16}px`; row.style.paddingLeft = `${depth * 16}px`;
row.addEventListener('click', async () => {
const toggle = document.createElement('span');
toggle.className = 'tree-toggle';
const hasChildren = node.children.length > 0 || !node.loaded;
toggle.textContent = hasChildren ? (node.expanded ? 'v' : '>') : '';
toggle.addEventListener('click', async (event) => {
event.stopPropagation();
if (!hasChildren && node.loaded) return;
node.expanded = !node.expanded;
if (node.expanded && !node.loaded) {
await loadNodeChildren(node);
}
renderRemoteTree();
});
const label = document.createElement('span');
label.className = 'tree-label';
label.textContent = node.name;
label.addEventListener('click', async () => {
state.remoteUrl = node.url; state.remoteUrl = node.url;
el.remoteInput.value = state.remoteUrl; el.remoteInput.value = state.remoteUrl;
await refreshRemote(); await refreshRemote();
}); });
el.remoteTree.appendChild(row);
}); row.appendChild(toggle);
row.appendChild(label);
container.appendChild(row);
if (node.loading) {
const loadingRow = document.createElement('div');
loadingRow.className = 'tree-loading';
loadingRow.textContent = 'loading...';
loadingRow.style.paddingLeft = `${(depth + 1) * 16}px`;
container.appendChild(loadingRow);
}
if (node.expanded) {
const children = node.children
.map((url) => state.remoteTree.nodes.get(url))
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name));
children.forEach((child) => renderTreeNode(child, depth + 1, container));
}
}
function renderRemoteTree() {
el.remoteTree.innerHTML = '';
const root = state.remoteTree.nodes.get(state.remoteTree.rootUrl);
if (!root) return;
renderTreeNode(root, 0, el.remoteTree);
} }
async function refreshLocal() { async function refreshLocal() {
@@ -146,9 +315,11 @@ async function refreshLocal() {
async function refreshRemote() { async function refreshRemote() {
if (!state.remoteUrl) return; if (!state.remoteUrl) return;
state.remoteUrl = normalizeDirUrl(state.remoteUrl);
const entries = await window.oddl.listRemoteDir(state.remoteUrl); const entries = await window.oddl.listRemoteDir(state.remoteUrl);
state.remoteEntries = entries; state.remoteEntries = entries;
renderRemote(); renderRemote();
syncTreeWithCurrent(state.remoteUrl, entries);
renderRemoteTree(); renderRemoteTree();
} }
@@ -182,11 +353,25 @@ async function init() {
el.useWget.checked = false; el.useWget.checked = false;
} }
const savedDir = localStorage.getItem('oddl.localDir');
if (savedDir) {
state.localDir = savedDir;
try {
await refreshLocal();
} catch (err) {
log('Saved local folder missing, please select a new folder.');
state.localDir = '';
localStorage.removeItem('oddl.localDir');
}
}
if (!state.localDir) {
const home = await window.oddl.selectLocalDir(); const home = await window.oddl.selectLocalDir();
if (home) { if (home) {
state.localDir = home; state.localDir = home;
localStorage.setItem('oddl.localDir', home);
await refreshLocal(); await refreshLocal();
} }
}
document.querySelectorAll('.col-resizer').forEach((resizer) => { document.querySelectorAll('.col-resizer').forEach((resizer) => {
const targetId = resizer.dataset.target; const targetId = resizer.dataset.target;
@@ -201,6 +386,7 @@ el.localBrowse.addEventListener('click', async () => {
const dir = await window.oddl.selectLocalDir(); const dir = await window.oddl.selectLocalDir();
if (!dir) return; if (!dir) return;
state.localDir = dir; state.localDir = dir;
localStorage.setItem('oddl.localDir', dir);
await refreshLocal(); await refreshLocal();
}); });
@@ -235,5 +421,155 @@ el.downloadBtn.addEventListener('click', async () => {
}); });
window.oddl.onLog((message) => log(message)); window.oddl.onLog((message) => log(message));
window.oddl.onProgress((payload) => {
if (payload.phase === 'scan-start') {
state.progressMode = 'aggregate';
showProgress({ percent: 100, label: 'Scanning remote folders...', indeterminate: true });
setSecondaryProgress('');
return;
}
if (payload.phase === 'scan-done') {
const count = payload.fileCount || 0;
showProgress({ percent: 0, label: `Found ${count} files`, indeterminate: false });
setSecondaryProgress('');
return;
}
if (payload.phase === 'aggregate-start') {
state.progressMode = 'aggregate';
if (payload.totalBytes) {
showProgress({
percent: 0,
label: `0 / ${formatBytes(payload.totalBytes)}`,
});
} else {
showProgress({ percent: 100, label: 'Downloading...', indeterminate: true });
}
setSecondaryProgress('');
return;
}
if (payload.phase === 'aggregate-progress') {
const downloaded = payload.downloadedBytes || 0;
const total = payload.totalBytes || 0;
if (total > 0) {
const percent = Math.min(100, Math.round((downloaded / total) * 100));
const label = `${formatBytes(downloaded)} / ${formatBytes(total)}`;
showProgress({ percent, label });
} else {
showProgress({ percent: 100, label: `${formatBytes(downloaded)} downloaded`, indeterminate: true });
}
return;
}
if (payload.phase === 'start') {
state.progressMode = 'single';
if (payload.mode === 'wget') {
showProgress({
percent: 100,
label: `wget running ${payload.fileName ? `(${payload.fileName})` : ''}`,
indeterminate: true,
});
setSecondaryProgress('');
return;
}
state.transfer = {
fileName: payload.fileName || '',
startTime: Date.now(),
lastBytes: 0,
lastTime: Date.now(),
};
showProgress({ percent: 0, label: `Starting ${payload.fileName || 'download'}` });
setSecondaryProgress('');
return;
}
if (payload.phase === 'progress') {
const downloaded = payload.downloadedBytes || 0;
const total = payload.totalBytes || 0;
const now = Date.now();
const transfer = state.transfer || { lastBytes: 0, lastTime: now };
const deltaBytes = Math.max(0, downloaded - transfer.lastBytes);
const deltaTime = Math.max(0.5, (now - transfer.lastTime) / 1000);
const speed = deltaBytes / deltaTime;
transfer.lastBytes = downloaded;
transfer.lastTime = now;
state.transfer = transfer;
if (total > 0) {
const percent = Math.min(100, Math.round((downloaded / total) * 100));
const remaining = Math.max(0, total - downloaded);
const eta = speed > 0 ? remaining / speed : null;
const label = `${formatBytes(downloaded)} / ${formatBytes(total)}${eta ? ` • ETA ${formatDuration(eta)}` : ''}`;
showProgress({ percent, label });
} else {
showProgress({
percent: 100,
label: `${formatBytes(downloaded)} downloaded`,
indeterminate: true,
});
}
return;
}
if (payload.phase === 'file-start') {
state.transfer = {
fileName: payload.fileName || '',
startTime: Date.now(),
lastBytes: 0,
lastTime: Date.now(),
};
if (state.progressMode === 'aggregate') {
setSecondaryProgress(`Downloading ${payload.fileName || 'file'}`);
} else {
showProgress({ percent: 0, label: `Downloading ${payload.fileName || 'file'}` });
setSecondaryProgress('');
}
return;
}
if (payload.phase === 'file-progress') {
const downloaded = payload.downloadedBytes || 0;
const total = payload.totalBytes || 0;
const now = Date.now();
const transfer = state.transfer || { lastBytes: 0, lastTime: now };
const deltaBytes = Math.max(0, downloaded - transfer.lastBytes);
const deltaTime = Math.max(0.5, (now - transfer.lastTime) / 1000);
const speed = deltaBytes / deltaTime;
transfer.lastBytes = downloaded;
transfer.lastTime = now;
state.transfer = transfer;
if (total > 0) {
const percent = Math.min(100, Math.round((downloaded / total) * 100));
const remaining = Math.max(0, total - downloaded);
const eta = speed > 0 ? remaining / speed : null;
const label = `${payload.fileName || 'file'}${formatBytes(downloaded)} / ${formatBytes(total)}${eta ? ` • ETA ${formatDuration(eta)}` : ''}`;
if (state.progressMode === 'aggregate') {
setSecondaryProgress(label);
} else {
showProgress({ percent, label });
}
} else {
const label = `${payload.fileName || 'file'}${formatBytes(downloaded)} downloaded`;
if (state.progressMode === 'aggregate') {
setSecondaryProgress(label);
} else {
showProgress({
percent: 100,
label,
indeterminate: true,
});
}
}
return;
}
if (payload.phase === 'file-done') {
if (state.progressMode === 'aggregate') {
setSecondaryProgress(`Finished ${payload.fileName || 'file'}`);
} else {
showProgress({ percent: 100, label: `Finished ${payload.fileName || 'file'}` });
}
return;
}
if (payload.phase === 'done') {
state.transfer = null;
state.progressMode = null;
hideProgress();
}
});
init().catch((err) => log(err.message)); init().catch((err) => log(err.message));

View File

@@ -123,25 +123,48 @@ body {
overflow: auto; overflow: auto;
} }
.tree-row { .tree-node {
padding: 4px 6px; padding: 4px 6px;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
} }
.tree-row:hover { .tree-node:hover {
background: #eef2f6; background: #eef2f6;
} }
.tree-row.current { .tree-node.current {
background: #dbe7ff; background: #dbe7ff;
} }
.tree-toggle {
display: inline-flex;
width: 12px;
justify-content: center;
color: #5a6475;
font-size: 12px;
}
.tree-label {
flex: 1;
}
.tree-loading {
padding: 4px 6px;
font-size: 12px;
color: #6a7282;
white-space: nowrap;
}
.path-row { .path-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.path-row input { .path-row input {
@@ -178,13 +201,14 @@ body {
border-radius: 6px; border-radius: 6px;
background: #ffffff; background: #ffffff;
flex-shrink: 0; flex-shrink: 0;
padding: 8px 16px;
} }
#log-list { #log-list {
padding: 12px 16px; padding: 12px 16px;
font-family: Consolas, monospace; font-family: Consolas, monospace;
font-size: 12px; font-size: 12px;
height: 300px; height: 200px;
overflow-y: auto; overflow-y: auto;
} }
@@ -195,6 +219,48 @@ body {
font-size: 12px; font-size: 12px;
} }
.progress {
position: relative;
width: 200px;
height: 10px;
border-radius: 999px;
background: #e6e9ef;
overflow: hidden;
display: none;
}
.progress.active {
display: block;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #5c87ff, #7aa2ff);
transition: width 0.2s ease;
}
.progress.indeterminate .progress-bar {
width: 40%;
position: absolute;
animation: progress-slide 1s infinite ease-in-out;
}
.progress-meta {
font-size: 12px;
color: #4f5765;
min-width: 140px;
}
.progress-meta.secondary {
color: #6a7282;
}
@keyframes progress-slide {
0% { left: -40%; }
100% { left: 100%; }
}
@media (max-width: 900px) { @media (max-width: 900px) {
.grid { .grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;