From 318ef5f9b2d8b757f32c609cdd2cb4254ce23aa2 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 3 Jan 2026 12:32:23 -0600 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8feature:=20Directory=20tree=20and=20mu?= =?UTF-8?q?lti-column=20file=20listing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.html | 31 ++++++++++-- src/main.js | 19 ++++++-- src/renderer.js | 124 ++++++++++++++++++++++++++++++++++++++++++++---- src/styles.css | 75 ++++++++++++++++++++++++++++- 4 files changed, 231 insertions(+), 18 deletions(-) diff --git a/src/index.html b/src/index.html index adcabb6..9d10209 100644 --- a/src/index.html +++ b/src/index.html @@ -22,7 +22,18 @@ -
+
+
+
+
+ Name + +
+
Size
+
+
+
+
@@ -35,15 +46,27 @@ -
+
+
+
+
+
+ Name + +
+
Size
+
+
+
+
- Log Section (Collapsed) + Session Log
- \ No newline at end of file + diff --git a/src/main.js b/src/main.js index a9e9fa2..b2e8e27 100644 --- a/src/main.js +++ b/src/main.js @@ -88,9 +88,11 @@ function parseApacheIndex(html, baseUrl) { const results = []; const preMatch = html.match(/
[\s\S]*?<\/pre>/i);
   const block = preMatch ? preMatch[0] : html;
-  const linkRegex = /([^<]+)<\/a>/gi;
-  let match;
-  while ((match = linkRegex.exec(block)) !== null) {
+  const preLines = preMatch ? preMatch[0].split(/\r?\n/) : block.split(/\r?\n/);
+  const linkRegex = /([^<]+)<\/a>/i;
+  for (const line of preLines) {
+    const match = line.match(linkRegex);
+    if (!match) continue;
     const href = match[1];
     const anchorText = match[2];
     if (href === '../' || anchorText === '../') continue;
@@ -110,7 +112,16 @@ function parseApacheIndex(html, baseUrl) {
     } catch (err) {
       name = anchorText;
     }
-    results.push({ name, href: fullUrl, isDir });
+    let size = '-';
+    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;
+      }
+    }
+    results.push({ name, href: fullUrl, isDir, size });
   }
   return results;
 }
diff --git a/src/renderer.js b/src/renderer.js
index 8bb57c8..913d4b7 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -10,14 +10,30 @@ const el = {
   remoteInput: document.querySelector('#remote-url'),
   remoteSubmit: document.querySelector('#remote-submit'),
   remoteList: document.querySelector('#remote-list'),
+  remoteListBody: document.querySelector('#remote-list-body'),
+  remoteTree: document.querySelector('#remote-tree'),
   localPath: document.querySelector('#local-path'),
   localBrowse: document.querySelector('#local-browse'),
   localList: document.querySelector('#local-list'),
+  localListBody: document.querySelector('#local-list-body'),
   downloadBtn: document.querySelector('#download-btn'),
   useWget: document.querySelector('#use-wget'),
   logList: document.querySelector('#log-list'),
 };
 
+function formatBytes(size) {
+  if (size === 0) return '0 B';
+  if (!size) return '-';
+  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+  let value = size;
+  let unitIndex = 0;
+  while (value >= 1024 && unitIndex < units.length - 1) {
+    value /= 1024;
+    unitIndex += 1;
+  }
+  return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
+}
+
 function log(message) {
   const line = document.createElement('div');
   line.textContent = message;
@@ -25,26 +41,40 @@ function log(message) {
 }
 
 function renderLocal() {
-  el.localList.innerHTML = '';
+  el.localListBody.innerHTML = '';
   state.localEntries.forEach((entry) => {
     const row = document.createElement('div');
-    row.className = 'row';
-    row.textContent = entry.isDir ? `[${entry.name}]` : entry.name;
+    row.className = 'row list-row';
+    const name = document.createElement('div');
+    name.className = 'name';
+    name.textContent = entry.isDir ? `${entry.name}/` : entry.name;
+    const size = document.createElement('div');
+    size.className = 'size';
+    size.textContent = entry.isDir ? '-' : formatBytes(entry.size);
+    row.appendChild(name);
+    row.appendChild(size);
     row.addEventListener('dblclick', async () => {
       if (!entry.isDir) return;
       state.localDir = entry.path;
       await refreshLocal();
     });
-    el.localList.appendChild(row);
+    el.localListBody.appendChild(row);
   });
 }
 
 function renderRemote() {
-  el.remoteList.innerHTML = '';
+  el.remoteListBody.innerHTML = '';
   state.remoteEntries.forEach((entry) => {
     const row = document.createElement('div');
-    row.className = 'row';
-    row.textContent = entry.isDir ? `[${entry.name}]` : entry.name;
+    row.className = 'row list-row';
+    const name = document.createElement('div');
+    name.className = 'name';
+    name.textContent = entry.isDir ? entry.name : entry.name;
+    const size = document.createElement('div');
+    size.className = 'size';
+    size.textContent = entry.isDir ? '-' : entry.size || '-';
+    row.appendChild(name);
+    row.appendChild(size);
     row.addEventListener('dblclick', async () => {
       if (!entry.isDir) return;
       state.remoteUrl = entry.href;
@@ -57,7 +87,52 @@ function renderRemote() {
       row.dataset.href = entry.href;
       row.dataset.isDir = entry.isDir ? '1' : '0';
     });
-    el.remoteList.appendChild(row);
+    el.remoteListBody.appendChild(row);
+  });
+}
+
+function renderRemoteTree() {
+  el.remoteTree.innerHTML = '';
+  if (!state.remoteUrl) return;
+  let urlObj;
+  try {
+    urlObj = new URL(state.remoteUrl);
+  } catch (err) {
+    return;
+  }
+  const segments = urlObj.pathname.split('/').filter(Boolean);
+  const nodes = [];
+  nodes.push({ name: `${urlObj.origin}/`, url: `${urlObj.origin}/`, depth: 0, current: segments.length === 0 });
+  let currentPath = '/';
+  segments.forEach((seg, index) => {
+    currentPath += `${seg}/`;
+    nodes.push({
+      name: `${seg}/`,
+      url: `${urlObj.origin}${currentPath}`,
+      depth: index + 1,
+      current: index === segments.length - 1,
+    });
+  });
+  const childDirs = state.remoteEntries.filter((entry) => entry.isDir);
+  childDirs.forEach((entry) => {
+    nodes.push({
+      name: entry.name,
+      url: entry.href,
+      depth: segments.length + 1,
+      current: false,
+    });
+  });
+  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);
   });
 }
 
@@ -74,6 +149,29 @@ async function refreshRemote() {
   const entries = await window.oddl.listRemoteDir(state.remoteUrl);
   state.remoteEntries = entries;
   renderRemote();
+  renderRemoteTree();
+}
+
+function setupResizer(listEl, resizerEl) {
+  let startX = 0;
+  let startWidth = 0;
+  function onMove(event) {
+    const delta = event.clientX - startX;
+    const newWidth = Math.max(160, startWidth + delta);
+    listEl.style.setProperty('--col-name', `${newWidth}px`);
+  }
+  function onUp() {
+    window.removeEventListener('mousemove', onMove);
+    window.removeEventListener('mouseup', onUp);
+  }
+  resizerEl.addEventListener('mousedown', (event) => {
+    event.preventDefault();
+    startX = event.clientX;
+    const nameCol = listEl.querySelector('.list-header .name');
+    startWidth = nameCol ? nameCol.getBoundingClientRect().width : 240;
+    window.addEventListener('mousemove', onMove);
+    window.addEventListener('mouseup', onUp);
+  });
 }
 
 async function init() {
@@ -89,6 +187,14 @@ async function init() {
     state.localDir = home;
     await refreshLocal();
   }
+
+  document.querySelectorAll('.col-resizer').forEach((resizer) => {
+    const targetId = resizer.dataset.target;
+    const listEl = document.getElementById(targetId);
+    if (listEl) {
+      setupResizer(listEl, resizer);
+    }
+  });
 }
 
 el.localBrowse.addEventListener('click', async () => {
@@ -130,4 +236,4 @@ el.downloadBtn.addEventListener('click', async () => {
 
 window.oddl.onLog((message) => log(message));
 
-init().catch((err) => log(err.message));
\ No newline at end of file
+init().catch((err) => log(err.message));
diff --git a/src/styles.css b/src/styles.css
index 2ba9dfd..343a7c9 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -71,6 +71,73 @@ body {
   min-height: 0;
 }
 
+.list {
+  --col-name: 1fr;
+  --col-size: 140px;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+
+.list-row {
+  display: grid;
+  grid-template-columns: var(--col-name) var(--col-size);
+  gap: 12px;
+  align-items: center;
+  min-width: max-content;
+}
+
+.list-header {
+  font-weight: 600;
+  color: #2b2f36;
+  padding: 6px 8px;
+  border-bottom: 1px solid #e1e3e6;
+  position: relative;
+}
+
+.list-body {
+  display: flex;
+  flex-direction: column;
+}
+
+.col {
+  position: relative;
+}
+
+.col-resizer {
+  position: absolute;
+  top: 0;
+  right: -8px;
+  width: 12px;
+  height: 100%;
+  cursor: col-resize;
+}
+
+.tree {
+  border: 1px solid #e1e3e6;
+  border-radius: 6px;
+  padding: 8px;
+  margin-bottom: 12px;
+  background: #fbfcfe;
+  max-height: 180px;
+  overflow: auto;
+}
+
+.tree-row {
+  padding: 4px 6px;
+  border-radius: 4px;
+  cursor: pointer;
+  white-space: nowrap;
+}
+
+.tree-row:hover {
+  background: #eef2f6;
+}
+
+.tree-row.current {
+  background: #dbe7ff;
+}
+
 .path-row {
   display: flex;
   gap: 8px;
@@ -91,6 +158,12 @@ body {
   white-space: nowrap;
 }
 
+.row .size,
+.list-header .size {
+  text-align: right;
+  color: #4f5765;
+}
+
 .row:hover {
   background: #eef2f6;
 }
@@ -111,7 +184,7 @@ body {
   padding: 12px 16px;
   font-family: Consolas, monospace;
   font-size: 12px;
-  max-height: 200px;
+  height: 300px;
   overflow-y: auto;
 }