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>
</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 class="panel">
@@ -35,15 +46,27 @@
</label>
</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>
</main>
<details class="log">
<summary>Log Section (Collapsed)</summary>
<summary>Session Log</summary>
<div id="log-list"></div>
</details>
<script src="renderer.js"></script>
</body>
</html>
</html>

View File

@@ -88,9 +88,11 @@ function parseApacheIndex(html, baseUrl) {
const results = [];
const preMatch = html.match(/<pre>[\s\S]*?<\/pre>/i);
const block = preMatch ? preMatch[0] : html;
const linkRegex = /<a href="([^"]+)">([^<]+)<\/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 href="([^"]+)">([^<]+)<\/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;
}

View File

@@ -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));
init().catch((err) => log(err.message));

View File

@@ -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;
}