feat: sortierbare Spalten in der Detailansicht

Klick auf die Tabellen-Header (Name, Typ, Größe, Datum) sortiert
die Dateiliste pro Fenster; zweiter Klick wechselt die Richtung.
Ordner bleiben stets oben, Natural Sort für Dateinamen.

- app.js: sortEntries(), sortierbare th-Header mit ▲/▼, Klick-Handler
- styles.css: Header-Hover, hervorgehobene aktive Spalte
- Build auf 2026.09.12.r001 (Version + MD5 im .plg)
This commit is contained in:
2026-09-12 11:47:07 +02:00
parent 433d6aae12
commit 5b929a9ee8
5 changed files with 61 additions and 8 deletions
+43 -5
View File
@@ -126,6 +126,7 @@ function openExplorer(initialPath = state.config?.roots?.[0]?.path) {
selectedPaths: [],
selectionAnchorPath: null,
view: 'icons',
sort: { key: 'name', dir: 1 },
treeVisible: true,
treeWidth: 220,
treeExpanded: [],
@@ -349,7 +350,7 @@ function renderExplorer(body, win) {
<div class="explorer-content">
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
${data.error ? `<p class="muted explorer-message">${escapeHtml(data.error)}</p>` : ''}
${data.loading ? '<p class="muted explorer-message">Lade...</p>' : renderFileEntries(data.entries || [], data.view || 'list', selectedPathSet(win))}
${data.loading ? '<p class="muted explorer-message">Lade...</p>' : renderFileEntries(data.entries || [], data.view || 'list', selectedPathSet(win), data.sort || { key: 'name', dir: 1 })}
</div>
</div>
</div>
@@ -365,6 +366,14 @@ function renderExplorer(body, win) {
});
body.querySelector('[data-action="view-list"]').addEventListener('click', () => setExplorerView(win, 'list'));
body.querySelector('[data-action="view-icons"]').addEventListener('click', () => setExplorerView(win, 'icons'));
for (const th of body.querySelectorAll('.file-table th[data-sort-key]')) {
th.addEventListener('click', () => {
const key = th.dataset.sortKey;
const current = data.sort || { key: 'name', dir: 1 };
data.sort = current.key === key ? { key, dir: -current.dir } : { key, dir: 1 };
render();
});
}
body.querySelector('[data-action="upload-files"]').addEventListener('click', () => body.querySelector('[data-upload="files"]').click());
body.querySelector('[data-action="upload-folder"]').addEventListener('click', () => body.querySelector('[data-upload="folder"]').click());
body.querySelector('[data-action="delete-selected"]').addEventListener('click', () => deleteEntries(win));
@@ -562,14 +571,37 @@ function startTreeResize(event, win, body) {
handle.addEventListener('pointercancel', up);
}
function renderFileEntries(entries, view, selectedPaths = new Set()) {
function sortEntries(entries, sort) {
const key = sort?.key || 'name';
const dir = sort?.dir === -1 ? -1 : 1;
const typeOrder = { directory: 0, file: 1, symlink: 2, other: 3 };
const compareValue = (entry) => {
if (key === 'size') return entry.type === 'file' ? (entry.size || 0) : -1;
if (key === 'modifiedAt') return Date.parse(entry.modifiedAt) || 0;
if (key === 'type') return typeOrder[entry.type] ?? 4;
return entry.name || '';
};
const compareName = (a, b) => (a.name || '').localeCompare(b.name || undefined, undefined, { numeric: true, sensitivity: 'base' });
return [...entries].sort((a, b) => {
// Ordner immer zuerst, dann Dateien, dann Verknüpfungen/sonstige
if (a.type !== b.type) return (typeOrder[a.type] ?? 4) - (typeOrder[b.type] ?? 4);
if (key === 'name') return dir * compareName(a, b);
const valueA = compareValue(a);
const valueB = compareValue(b);
if (valueA !== valueB) return dir * (valueA < valueB ? -1 : 1);
return compareName(a, b);
});
}
function renderFileEntries(entries, view, selectedPaths = new Set(), sort = { key: 'name', dir: 1 }) {
if (!entries.length) {
return '<p class="muted">Dieser Ordner ist leer. Dateien können hier abgelegt werden.</p>';
}
return view === 'icons' ? renderFileIcons(entries, selectedPaths) : renderFileTable(entries, selectedPaths);
const sorted = sortEntries(entries, sort);
return view === 'icons' ? renderFileIcons(sorted, selectedPaths) : renderFileTable(sorted, selectedPaths, sort);
}
function renderFileTable(entries, selectedPaths) {
function renderFileTable(entries, selectedPaths, sort = { key: 'name', dir: 1 }) {
const rows = entries.map((entry) => `
<tr class="file-row ${selectedPaths.has(entry.path) ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
<td><span class="name-cell">${fileTypeIcon(entry)}<span>${escapeHtml(entry.name)}</span></span></td>
@@ -579,9 +611,15 @@ function renderFileTable(entries, selectedPaths) {
</tr>
`).join('');
const header = (key, label) => {
const active = sort.key === key;
const arrow = active ? (sort.dir === 1 ? ' <span class="sort-arrow">▲</span>' : ' <span class="sort-arrow">▼</span>') : '';
return `<th data-sort-key="${key}" class="${active ? 'sorted' : ''}" role="columnheader" aria-sort="${active ? (sort.dir === 1 ? 'ascending' : 'descending') : 'none'}" title="${active ? 'Richtung wechseln' : `Nach ${label} sortieren`}">${label}${arrow}</th>`;
};
return `
<table class="file-table">
<thead><tr><th>Name</th><th>Typ</th><th>Größe</th><th>Geändert</th></tr></thead>
<thead><tr>${header('name', 'Name')}${header('type', 'Typ')}${header('size', 'Größe')}${header('modifiedAt', 'Geändert')}</tr></thead>
<tbody>${rows}</tbody>
</table>
`;