diff --git a/src/index.html b/src/index.html index 9d10209..6239c34 100644 --- a/src/index.html +++ b/src/index.html @@ -44,6 +44,11 @@ + +
+
diff --git a/src/main.js b/src/main.js index b2e8e27..31ce357 100644 --- a/src/main.js +++ b/src/main.js @@ -43,6 +43,12 @@ function logToRenderer(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) { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); 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) { const results = []; const preMatch = html.match(/
[\s\S]*?<\/pre>/i);
@@ -113,20 +139,43 @@ function parseApacheIndex(html, baseUrl) {
       name = anchorText;
     }
     let size = '-';
+    let sizeBytes = null;
     const rest = line.slice(match.index + match[0].length).trim();
     if (rest) {
       const tokens = rest.split(/\s+/);
       const sizeToken = tokens[tokens.length - 1];
       if (sizeToken && sizeToken !== '-') {
         size = sizeToken;
+        sizeBytes = parseSizeToken(sizeToken);
       }
     }
-    results.push({ name, href: fullUrl, isDir, size });
+    results.push({ name, href: fullUrl, isDir, size, sizeBytes });
   }
   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;
   return new Promise((resolve, reject) => {
     const fileStream = fs.createWriteStream(destPath);
@@ -136,7 +185,23 @@ function downloadFile(url, destPath) {
         res.resume();
         return;
       }
+      const totalBytes = Number(res.headers['content-length']) || null;
+      let downloaded = 0;
+      let lastEmit = Date.now();
+      onProgress?.({ phase: 'start', totalBytes });
       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.close(resolve);
       });
@@ -147,19 +212,60 @@ function downloadFile(url, destPath) {
   });
 }
 
-async function downloadFolderRecursive(baseUrl, destDir) {
-  const html = await fetchUrl(baseUrl);
-  const items = parseApacheIndex(html, baseUrl);
-  for (const item of items) {
-    const targetPath = path.join(destDir, item.name.replace(/\/$/, ''));
-    if (item.isDir) {
-      fs.mkdirSync(targetPath, { recursive: true });
-      logToRenderer(`Entering ${item.href}`);
-      await downloadFolderRecursive(item.href, targetPath);
-    } else {
-      logToRenderer(`Downloading ${item.href}`);
-      await downloadFile(item.href, targetPath);
+function getRemoteBasename(url) {
+  try {
+    const urlObj = new URL(url);
+    let pathname = decodeURIComponent(urlObj.pathname);
+    if (pathname.endsWith('/')) {
+      pathname = pathname.slice(0, -1);
     }
+    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 });
   }
   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) {
       logToRenderer(`wget: ${url}`);
-      await downloadWithWget(url, destDir);
+      progressToRenderer({ phase: 'start', mode: 'wget', fileName: folderName });
+      await downloadWithWget(url, targetDir);
+      progressToRenderer({ phase: 'done' });
       return;
     }
-    await downloadFolderRecursive(url, destDir);
+    await downloadFolderWithPlan(url, targetDir);
+    progressToRenderer({ phase: 'done' });
     return;
   }
   const fileName = path.basename(new URL(url).pathname);
   const destPath = path.join(destDir, fileName);
   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' });
 });
diff --git a/src/preload.js b/src/preload.js
index 66765e0..b60b6fd 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -7,4 +7,5 @@ contextBridge.exposeInMainWorld('oddl', {
   listRemoteDir: (url) => ipcRenderer.invoke('list-remote-dir', url),
   downloadItem: (payload) => ipcRenderer.invoke('download-item', payload),
   onLog: (callback) => ipcRenderer.on('log', (_event, message) => callback(message)),
-});
\ No newline at end of file
+  onProgress: (callback) => ipcRenderer.on('progress', (_event, payload) => callback(payload)),
+});
diff --git a/src/renderer.js b/src/renderer.js
index 913d4b7..24cce60 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -4,6 +4,12 @@ const state = {
   localEntries: [],
   remoteEntries: [],
   hasWget: false,
+  remoteTree: {
+    rootUrl: '',
+    currentUrl: '',
+    nodes: new Map(),
+  },
+  transfer: null,
 };
 
 const el = {
@@ -18,6 +24,10 @@ const el = {
   localListBody: document.querySelector('#local-list-body'),
   downloadBtn: document.querySelector('#download-btn'),
   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'),
 };
 
@@ -34,6 +44,65 @@ function formatBytes(size) {
   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) {
   const line = document.createElement('div');
   line.textContent = message;
@@ -69,10 +138,13 @@ function renderRemote() {
     row.className = 'row list-row';
     const name = document.createElement('div');
     name.className = 'name';
-    name.textContent = entry.isDir ? entry.name : entry.name;
+    name.textContent = entry.name;
     const size = document.createElement('div');
     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(size);
     row.addEventListener('dblclick', async () => {
@@ -91,49 +163,146 @@ function renderRemote() {
   });
 }
 
-function renderRemoteTree() {
-  el.remoteTree.innerHTML = '';
-  if (!state.remoteUrl) return;
+function ensureRemoteNode(url, name) {
+  const normalized = normalizeDirUrl(url);
+  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;
   try {
-    urlObj = new URL(state.remoteUrl);
+    urlObj = new URL(url);
   } catch (err) {
     return;
   }
+  const rootUrl = `${urlObj.origin}/`;
+  state.remoteTree.rootUrl = rootUrl;
+  const root = ensureRemoteNode(rootUrl, `${urlObj.origin}/`);
   const segments = urlObj.pathname.split('/').filter(Boolean);
-  const nodes = [];
-  nodes.push({ name: `${urlObj.origin}/`, url: `${urlObj.origin}/`, depth: 0, current: segments.length === 0 });
+  let parent = root;
   let currentPath = '/';
-  segments.forEach((seg, index) => {
+  root.expanded = true;
+  segments.forEach((seg) => {
     currentPath += `${seg}/`;
-    nodes.push({
-      name: `${seg}/`,
-      url: `${urlObj.origin}${currentPath}`,
-      depth: index + 1,
-      current: index === segments.length - 1,
-    });
+    const nodeUrl = `${urlObj.origin}${currentPath}`;
+    const node = ensureRemoteNode(nodeUrl, `${seg}/`);
+    linkChild(parent, node);
+    parent.expanded = true;
+    parent = node;
   });
-  const childDirs = state.remoteEntries.filter((entry) => entry.isDir);
+  parent.expanded = true;
+  state.remoteTree.currentUrl = parent.url;
+
+  const childDirs = entries.filter((entry) => entry.isDir);
+  parent.children = [];
   childDirs.forEach((entry) => {
-    nodes.push({
-      name: entry.name,
-      url: entry.href,
-      depth: segments.length + 1,
-      current: false,
-    });
+    const child = ensureRemoteNode(entry.href, entry.name);
+    linkChild(parent, child);
   });
-  nodes.forEach((node) => {
-    const row = document.createElement('div');
-    row.className = `tree-row${node.current ? ' current' : ''}`;
-    row.textContent = node.name;
-    row.style.paddingLeft = `${node.depth * 16}px`;
-    row.addEventListener('click', async () => {
-      state.remoteUrl = node.url;
-      el.remoteInput.value = state.remoteUrl;
-      await refreshRemote();
-    });
-    el.remoteTree.appendChild(row);
+  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);
   });
+  node.loaded = true;
+  node.loading = false;
+}
+
+function renderTreeNode(node, depth, container) {
+  const row = document.createElement('div');
+  const current = node.url === state.remoteTree.currentUrl;
+  row.className = `tree-node${current ? ' current' : ''}`;
+  row.style.paddingLeft = `${depth * 16}px`;
+
+  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;
+    el.remoteInput.value = state.remoteUrl;
+    await refreshRemote();
+  });
+
+  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() {
@@ -146,9 +315,11 @@ async function refreshLocal() {
 
 async function refreshRemote() {
   if (!state.remoteUrl) return;
+  state.remoteUrl = normalizeDirUrl(state.remoteUrl);
   const entries = await window.oddl.listRemoteDir(state.remoteUrl);
   state.remoteEntries = entries;
   renderRemote();
+  syncTreeWithCurrent(state.remoteUrl, entries);
   renderRemoteTree();
 }
 
@@ -182,10 +353,24 @@ async function init() {
     el.useWget.checked = false;
   }
 
-  const home = await window.oddl.selectLocalDir();
-  if (home) {
-    state.localDir = home;
-    await refreshLocal();
+  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();
+    if (home) {
+      state.localDir = home;
+      localStorage.setItem('oddl.localDir', home);
+      await refreshLocal();
+    }
   }
 
   document.querySelectorAll('.col-resizer').forEach((resizer) => {
@@ -201,6 +386,7 @@ el.localBrowse.addEventListener('click', async () => {
   const dir = await window.oddl.selectLocalDir();
   if (!dir) return;
   state.localDir = dir;
+  localStorage.setItem('oddl.localDir', dir);
   await refreshLocal();
 });
 
@@ -235,5 +421,155 @@ el.downloadBtn.addEventListener('click', async () => {
 });
 
 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));
diff --git a/src/styles.css b/src/styles.css
index 343a7c9..d36318b 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -123,25 +123,48 @@ body {
   overflow: auto;
 }
 
-.tree-row {
+.tree-node {
   padding: 4px 6px;
   border-radius: 4px;
   cursor: pointer;
   white-space: nowrap;
+  display: flex;
+  align-items: center;
+  gap: 6px;
 }
 
-.tree-row:hover {
+.tree-node:hover {
   background: #eef2f6;
 }
 
-.tree-row.current {
+.tree-node.current {
   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 {
   display: flex;
   gap: 8px;
   align-items: center;
+  flex-wrap: wrap;
 }
 
 .path-row input {
@@ -178,13 +201,14 @@ body {
   border-radius: 6px;
   background: #ffffff;
   flex-shrink: 0;
+  padding: 8px 16px;
 }
 
 #log-list {
   padding: 12px 16px;
   font-family: Consolas, monospace;
   font-size: 12px;
-  height: 300px;
+  height: 200px;
   overflow-y: auto;
 }
 
@@ -195,6 +219,48 @@ body {
   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) {
   .grid {
     grid-template-columns: 1fr;