feature: Directory tree and multi-column file listing

This commit is contained in:
Keith Solomon
2026-01-03 12:32:23 -06:00
parent 046f08d559
commit 318ef5f9b2
4 changed files with 231 additions and 18 deletions

View File

@@ -22,7 +22,18 @@
<button id="local-browse">Browse</button> <button id="local-browse">Browse</button>
</div> </div>
</div> </div>
<div class="panel-body" id="local-list"></div> <div class="panel-body">
<div class="list" id="local-list">
<div class="list-row list-header">
<div class="col name">
Name
<span class="col-resizer" data-target="local-list"></span>
</div>
<div class="col size">Size</div>
</div>
<div class="list-body" id="local-list-body"></div>
</div>
</div>
</section> </section>
<section class="panel"> <section class="panel">
@@ -35,12 +46,24 @@
</label> </label>
</div> </div>
</div> </div>
<div class="panel-body" id="remote-list"></div> <div class="panel-body">
<div class="tree" id="remote-tree"></div>
<div class="list" id="remote-list">
<div class="list-row list-header">
<div class="col name">
Name
<span class="col-resizer" data-target="remote-list"></span>
</div>
<div class="col size">Size</div>
</div>
<div class="list-body" id="remote-list-body"></div>
</div>
</div>
</section> </section>
</main> </main>
<details class="log"> <details class="log">
<summary>Log Section (Collapsed)</summary> <summary>Session Log</summary>
<div id="log-list"></div> <div id="log-list"></div>
</details> </details>

View File

@@ -88,9 +88,11 @@ 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);
const block = preMatch ? preMatch[0] : html; const block = preMatch ? preMatch[0] : html;
const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>/gi; const preLines = preMatch ? preMatch[0].split(/\r?\n/) : block.split(/\r?\n/);
let match; const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>/i;
while ((match = linkRegex.exec(block)) !== null) { for (const line of preLines) {
const match = line.match(linkRegex);
if (!match) continue;
const href = match[1]; const href = match[1];
const anchorText = match[2]; const anchorText = match[2];
if (href === '../' || anchorText === '../') continue; if (href === '../' || anchorText === '../') continue;
@@ -110,7 +112,16 @@ function parseApacheIndex(html, baseUrl) {
} catch (err) { } catch (err) {
name = anchorText; 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; return results;
} }

View File

@@ -10,14 +10,30 @@ const el = {
remoteInput: document.querySelector('#remote-url'), remoteInput: document.querySelector('#remote-url'),
remoteSubmit: document.querySelector('#remote-submit'), remoteSubmit: document.querySelector('#remote-submit'),
remoteList: document.querySelector('#remote-list'), remoteList: document.querySelector('#remote-list'),
remoteListBody: document.querySelector('#remote-list-body'),
remoteTree: document.querySelector('#remote-tree'),
localPath: document.querySelector('#local-path'), localPath: document.querySelector('#local-path'),
localBrowse: document.querySelector('#local-browse'), localBrowse: document.querySelector('#local-browse'),
localList: document.querySelector('#local-list'), localList: document.querySelector('#local-list'),
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'),
logList: document.querySelector('#log-list'), 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) { function log(message) {
const line = document.createElement('div'); const line = document.createElement('div');
line.textContent = message; line.textContent = message;
@@ -25,26 +41,40 @@ function log(message) {
} }
function renderLocal() { function renderLocal() {
el.localList.innerHTML = ''; el.localListBody.innerHTML = '';
state.localEntries.forEach((entry) => { state.localEntries.forEach((entry) => {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'row'; row.className = 'row list-row';
row.textContent = entry.isDir ? `[${entry.name}]` : entry.name; 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 () => { row.addEventListener('dblclick', async () => {
if (!entry.isDir) return; if (!entry.isDir) return;
state.localDir = entry.path; state.localDir = entry.path;
await refreshLocal(); await refreshLocal();
}); });
el.localList.appendChild(row); el.localListBody.appendChild(row);
}); });
} }
function renderRemote() { function renderRemote() {
el.remoteList.innerHTML = ''; el.remoteListBody.innerHTML = '';
state.remoteEntries.forEach((entry) => { state.remoteEntries.forEach((entry) => {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'row'; row.className = 'row list-row';
row.textContent = entry.isDir ? `[${entry.name}]` : entry.name; 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 () => { row.addEventListener('dblclick', async () => {
if (!entry.isDir) return; if (!entry.isDir) return;
state.remoteUrl = entry.href; state.remoteUrl = entry.href;
@@ -57,7 +87,52 @@ function renderRemote() {
row.dataset.href = entry.href; row.dataset.href = entry.href;
row.dataset.isDir = entry.isDir ? '1' : '0'; 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); const entries = await window.oddl.listRemoteDir(state.remoteUrl);
state.remoteEntries = entries; state.remoteEntries = entries;
renderRemote(); 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() { async function init() {
@@ -89,6 +187,14 @@ async function init() {
state.localDir = home; state.localDir = home;
await refreshLocal(); 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 () => { el.localBrowse.addEventListener('click', async () => {

View File

@@ -71,6 +71,73 @@ body {
min-height: 0; 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 { .path-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -91,6 +158,12 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.row .size,
.list-header .size {
text-align: right;
color: #4f5765;
}
.row:hover { .row:hover {
background: #eef2f6; background: #eef2f6;
} }
@@ -111,7 +184,7 @@ body {
padding: 12px 16px; padding: 12px 16px;
font-family: Consolas, monospace; font-family: Consolas, monospace;
font-size: 12px; font-size: 12px;
max-height: 200px; height: 300px;
overflow-y: auto; overflow-y: auto;
} }