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 @@ +
+ +[\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;