Add standalone desktop workflow

This commit is contained in:
2026-04-24 11:54:33 +03:00
parent 75c6b928ae
commit 50246ada85
20 changed files with 1068 additions and 306 deletions

View File

@@ -225,6 +225,17 @@ a:hover { text-decoration: underline; }
.form-group { display: flex; flex-direction: column; gap: 5px; }
.path-input-row {
display: flex;
gap: 8px;
align-items: center;
}
.path-input-row .form-input {
flex: 1;
min-width: 0;
}
.form-label {
font-size: 13px;
font-weight: 700;
@@ -268,10 +279,25 @@ a:hover { text-decoration: underline; }
/* Checkbox list */
.source-list { display: flex; flex-direction: column; gap: 0; }
.source-tree {
padding: 8px 0;
padding: 12px;
background: linear-gradient(180deg, rgba(33, 133, 208, 0.03), rgba(34, 36, 38, 0.015));
}
.source-tree-empty {
padding: 12px 16px;
padding: 20px 16px;
border: 1px dashed var(--border);
border-radius: calc(var(--radius) + 2px);
background: rgba(255, 255, 255, 0.75);
}
.source-root-card {
margin-bottom: 12px;
overflow: hidden;
border: 1px solid rgba(33, 133, 208, 0.18);
border-radius: calc(var(--radius) + 2px);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 24px rgba(27, 28, 29, 0.04);
}
.source-root-card:last-child {
margin-bottom: 0;
}
.source-node {
border-bottom: 1px solid var(--border-lite);
@@ -284,10 +310,22 @@ a:hover { text-decoration: underline; }
align-items: center;
gap: 8px;
padding: 8px 16px;
transition: background 0.12s ease;
}
.source-row:hover {
background: rgba(33, 133, 208, 0.04);
}
.source-root-row {
padding-top: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(33, 133, 208, 0.12);
background:
linear-gradient(90deg, rgba(33, 133, 208, 0.08), rgba(33, 133, 208, 0.015) 42%, rgba(255, 255, 255, 0.96) 100%);
}
.source-root-row:hover {
background:
linear-gradient(90deg, rgba(33, 133, 208, 0.12), rgba(33, 133, 208, 0.03) 42%, rgba(255, 255, 255, 1) 100%);
}
.source-toggle {
width: 24px;
height: 24px;
@@ -318,12 +356,40 @@ a:hover { text-decoration: underline; }
min-width: 0;
flex: 1;
}
.source-root-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.source-root-badge {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 999px;
background: rgba(33, 133, 208, 0.1);
color: var(--accent-dark);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.source-item-hint {
font-size: 12px;
color: var(--muted);
}
.source-children {
padding-left: 20px;
position: relative;
padding: 6px 0 8px 0;
}
.source-children::before {
content: "";
position: absolute;
left: 26px;
top: 0;
bottom: 8px;
width: 1px;
background: linear-gradient(180deg, rgba(33, 133, 208, 0.2), rgba(33, 133, 208, 0.03));
}
.source-loading {
padding: 6px 16px 10px 48px;
@@ -418,4 +484,11 @@ a:hover { text-decoration: underline; }
.disk-grid { grid-template-columns: 1fr; }
.kv-table th { width: 130px; }
.btn-row { flex-wrap: wrap; }
.path-input-row { flex-wrap: wrap; }
.source-root-row {
align-items: flex-start;
}
.source-root-title {
flex-wrap: wrap;
}
}

View File

@@ -1,16 +1,21 @@
{{define "content"}}
<section class="panel">
<h2>Disks</h2>
<h2>Mounted Disk</h2>
<div class="panel-body">
<div id="diskSummary" class="text-muted">Loading disks...</div>
<div class="path-input-row">
<input class="form-input" type="text" id="mountPath" placeholder="/Volumes/JUKEBOX or E:\\">
<button type="button" class="button-primary" onclick="pickMountPath()">+</button>
<button type="button" class="button-secondary" onclick="refreshSelectedDisk()">Refresh</button>
</div>
<div class="form-hint">Choose the directory where the removable disk is mounted. The app works with one selected disk at a time in standalone mode.</div>
</div>
</section>
<div class="disk-grid" id="diskGrid"></div>
<div id="diskState"></div>
<script>
let disks = [];
const selectedDisk = { info: null };
const taskState = new Map();
const taskPollers = new Map();
@@ -24,16 +29,12 @@ function escapeHTML(value) {
}[char]));
}
function diskKey(disk) {
return disk.disk_id || disk.mount_path;
}
function badgeClass(state) {
return ({ absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' })[state] || 'badge-unknown';
}
function badgeLabel(state) {
return ({ absent: 'Not connected', foreign: 'Uninitialized disk', known: 'Ready' })[state] || '—';
return ({ absent: 'Directory unavailable', foreign: 'Uninitialized disk', known: 'Ready' })[state] || '—';
}
function fmtSpeed(bps) {
@@ -69,77 +70,74 @@ function taskMeta(task) {
return [fmtSpeed(task.speed_bps), task.eta_sec ? 'ETA: ' + fmtETA(task.eta_sec) : ''].filter(Boolean).join(' · ');
}
function renderDisks() {
const grid = document.getElementById('diskGrid');
const summary = document.getElementById('diskSummary');
if (!disks.length) {
summary.textContent = 'No disks found.';
grid.innerHTML = '';
function renderDisk() {
const root = document.getElementById('diskState');
const disk = selectedDisk.info;
if (!disk) {
root.innerHTML = `
<section class="panel">
<div class="panel-body text-muted">Choose a mounted disk directory to inspect it.</div>
</section>
`;
return;
}
const knownCount = disks.filter((disk) => disk.state === 'known').length;
summary.textContent = `Disks found: ${disks.length}. Ready to copy: ${knownCount}.`;
const activeTask = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
const progress = activeTask ? activeTask.progress : 0;
const message = activeTask ? (activeTask.message || 'Preparing...') : '';
const meta = activeTask ? taskMeta(activeTask) : '';
const isKnown = disk.state === 'known';
const isForeign = disk.state === 'foreign';
const hasCapacity = disk.state !== 'absent';
grid.innerHTML = disks.map((disk) => {
const activeTask = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
const progress = activeTask ? activeTask.progress : 0;
const message = activeTask ? (activeTask.message || 'Preparing...') : '';
const meta = activeTask ? taskMeta(activeTask) : '';
const isKnown = disk.state === 'known';
const isForeign = disk.state === 'foreign';
const hasCapacity = disk.state !== 'absent';
return `
<section class="panel disk-card">
<h2>${escapeHTML(disk.mount_path)}</h2>
<table class="kv-table">
<tbody>
<tr>
<th>Status</th>
<td><span class="badge ${badgeClass(disk.state)}">${badgeLabel(disk.state)}</span></td>
</tr>
<tr>
<th>Disk ID</th>
<td>${disk.disk_id ? `<span class="mono">${escapeHTML(disk.disk_id)}</span>` : '<span class="text-muted">not initialized yet</span>'}</td>
</tr>
<tr>
<th>Total capacity</th>
<td>${hasCapacity ? fmtBytes(disk.total_bytes) : '—'}</td>
</tr>
<tr>
<th>Free space</th>
<td>${hasCapacity ? fmtBytes(disk.free_bytes) : '—'}</td>
</tr>
<tr>
<th>Last copied</th>
<td>${fmtDateTime(disk.last_copied_at)}</td>
</tr>
</tbody>
</table>
${activeTask ? `
<div class="panel-body progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" style="width:${progress}%"></div>
</div>
<div class="progress-label">${escapeHTML(message)}</div>
<div class="progress-label">${escapeHTML(meta)}</div>
root.innerHTML = `
<section class="panel disk-card">
<h2>${escapeHTML(disk.mount_path)}</h2>
<table class="kv-table">
<tbody>
<tr>
<th>Status</th>
<td><span class="badge ${badgeClass(disk.state)}">${badgeLabel(disk.state)}</span></td>
</tr>
<tr>
<th>Disk ID</th>
<td>${disk.disk_id ? `<span class="mono">${escapeHTML(disk.disk_id)}</span>` : '<span class="text-muted">not initialized yet</span>'}</td>
</tr>
<tr>
<th>Total capacity</th>
<td>${hasCapacity ? fmtBytes(disk.total_bytes) : '—'}</td>
</tr>
<tr>
<th>Free space</th>
<td>${hasCapacity ? fmtBytes(disk.free_bytes) : '—'}</td>
</tr>
<tr>
<th>Last copied</th>
<td>${fmtDateTime(disk.last_copied_at)}</td>
</tr>
</tbody>
</table>
${activeTask ? `
<div class="panel-body progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" style="width:${progress}%"></div>
</div>
` : ''}
<div class="btn-row">
${isKnown ? `
<button class="button-danger" data-action="start-copy" data-mode="replace" data-disk-id="${escapeHTML(disk.disk_id)}" ${activeTask ? 'disabled' : ''}>Replace media</button>
<button class="button-primary" data-action="start-copy" data-mode="add" data-disk-id="${escapeHTML(disk.disk_id)}" ${activeTask ? 'disabled' : ''}>Add media</button>
<button class="button-danger ${activeTask ? '' : 'hidden'}" data-action="cancel-copy" data-disk-id="${escapeHTML(disk.disk_id)}">Cancel</button>
` : ''}
${isForeign ? `
<button class="button-secondary" data-action="init-disk" data-mount-path="${escapeHTML(disk.mount_path)}">Initialize disk</button>
` : ''}
<div class="progress-label">${escapeHTML(message)}</div>
<div class="progress-label">${escapeHTML(meta)}</div>
</div>
</section>
`;
}).join('');
` : ''}
<div class="btn-row">
${isKnown ? `
<button class="button-danger" data-action="start-copy" data-mode="replace" ${activeTask ? 'disabled' : ''}>Replace media</button>
<button class="button-primary" data-action="start-copy" data-mode="add" ${activeTask ? 'disabled' : ''}>Add media</button>
<button class="button-danger ${activeTask ? '' : 'hidden'}" data-action="cancel-copy">Cancel</button>
` : ''}
${isForeign ? `
<button class="button-secondary" data-action="init-disk">Initialize disk</button>
` : ''}
</div>
</section>
`;
}
function stopTaskPoll(taskID) {
@@ -154,28 +152,63 @@ function startTaskPoll(taskID) {
pollTask(taskID);
}
async function refreshDisks() {
try {
const response = await fetch('/api/disks');
if (!response.ok) return;
const payload = await response.json();
disks = payload.items || [];
renderDisks();
async function pickFolder() {
const response = await fetch('/api/system/pick-folder', { method: 'POST' });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Failed to choose folder');
}
return payload.path || '';
}
const activeTasks = new Set();
for (const disk of disks) {
if (disk.active_task_id) {
activeTasks.add(disk.active_task_id);
startTaskPoll(disk.active_task_id);
}
async function pickMountPath() {
try {
const path = await pickFolder();
if (!path) return;
document.getElementById('mountPath').value = path;
localStorage.setItem('jukebox.selectedMountPath', path);
await refreshSelectedDisk();
} catch (error) {
toast(error.message || 'Failed to choose folder', 'error');
}
}
async function refreshSelectedDisk() {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) {
selectedDisk.info = null;
renderDisk();
return;
}
localStorage.setItem('jukebox.selectedMountPath', mountPath);
try {
const response = await fetch('/api/disks/probe?mount_path=' + encodeURIComponent(mountPath));
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to inspect directory', 'error');
return;
}
for (const taskID of Array.from(taskPollers.keys())) {
if (!activeTasks.has(taskID)) {
selectedDisk.info = payload;
renderDisk();
if (payload.active_task_id) {
for (const taskID of Array.from(taskPollers.keys())) {
if (taskID !== payload.active_task_id) {
stopTaskPoll(taskID);
taskState.delete(taskID);
}
}
startTaskPoll(payload.active_task_id);
} else {
for (const taskID of Array.from(taskPollers.keys())) {
stopTaskPoll(taskID);
taskState.delete(taskID);
}
}
} catch (error) {}
} catch (error) {
toast('Network error', 'error');
}
}
async function pollTask(taskID) {
@@ -184,7 +217,7 @@ async function pollTask(taskID) {
if (!response.ok) return;
const task = await response.json();
taskState.set(taskID, task);
renderDisks();
renderDisk();
if (['success', 'failed', 'canceled'].includes(task.status)) {
stopTaskPoll(taskID);
@@ -192,17 +225,19 @@ async function pollTask(taskID) {
if (task.status === 'success') toast(task.message || 'Done', 'ok');
if (task.status === 'failed') toast('Error: ' + task.error, 'error');
if (task.status === 'canceled') toast('Copy canceled', 'error');
refreshDisks();
refreshSelectedDisk();
}
} catch (error) {}
}
async function startCopy(diskID, mode) {
async function startCopy(mode) {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) return;
try {
const response = await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/start', {
const response = await fetch('/api/disks/copy/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode })
body: JSON.stringify({ mount_path: mountPath, mode })
});
const payload = await response.json();
if (!response.ok) {
@@ -210,22 +245,25 @@ async function startCopy(diskID, mode) {
return;
}
startTaskPoll(payload.task_id);
refreshDisks();
refreshSelectedDisk();
} catch (error) {
toast('Network error', 'error');
}
}
async function cancelCopy(diskID) {
async function cancelCopy() {
if (!selectedDisk.info || !selectedDisk.info.disk_id) return;
try {
await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/cancel', { method: 'POST' });
await fetch('/api/disks/' + encodeURIComponent(selectedDisk.info.disk_id) + '/copy/cancel', { method: 'POST' });
toast('Canceling...', 'ok');
} catch (error) {
toast('Network error', 'error');
}
}
async function initDisk(mountPath) {
async function initDisk() {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) return;
try {
const response = await fetch('/api/disks/init', {
method: 'POST',
@@ -238,23 +276,28 @@ async function initDisk(mountPath) {
return;
}
toast('Disk initialized', 'ok');
refreshDisks();
refreshSelectedDisk();
} catch (error) {
toast('Network error', 'error');
}
}
document.getElementById('diskGrid').addEventListener('click', (event) => {
document.getElementById('diskState').addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
if (action === 'start-copy') startCopy(button.dataset.diskId, button.dataset.mode || 'add');
if (action === 'cancel-copy') cancelCopy(button.dataset.diskId);
if (action === 'init-disk') initDisk(button.dataset.mountPath);
if (action === 'start-copy') startCopy(button.dataset.mode || 'add');
if (action === 'cancel-copy') cancelCopy();
if (action === 'init-disk') initDisk();
});
refreshDisks();
setInterval(refreshDisks, 5000);
const savedMountPath = localStorage.getItem('jukebox.selectedMountPath');
if (savedMountPath) {
document.getElementById('mountPath').value = savedMountPath;
refreshSelectedDisk();
} else {
renderDisk();
}
</script>
{{end}}

View File

@@ -4,16 +4,17 @@
<section class="panel">
<h2>Copy Sources</h2>
<div class="panel-body">
<div class="form-hint">Select top-level folders or expand branches and choose individual nested directories.</div>
<div class="form-hint">Add one or more root folders with source files. After that, expand each root and enable or disable individual nested folders with checkboxes.</div>
</div>
<div class="btn-row">
<button type="button" class="button-primary" onclick="addSourceRoot()">Add source folder</button>
<button type="button" class="button-secondary button-sm" onclick="reloadAllSourceTrees()">Refresh trees</button>
</div>
<div class="source-list">
<div class="source-tree" id="sourceTree">
<div class="text-muted source-tree-empty">Loading...</div>
<div class="text-muted source-tree-empty">No source folders added yet.</div>
</div>
</div>
<div class="btn-row">
<button type="button" class="button-secondary button-sm" onclick="reloadSourceTree()">Refresh list</button>
</div>
</section>
<section class="panel">
@@ -77,6 +78,7 @@
const sourceTree = new Map();
const expandedNodes = new Set();
const loadingNodes = new Set();
let sourceRoots = [];
let sourceConfig = {};
function escapeHTML(value) {
@@ -89,13 +91,43 @@ function escapeHTML(value) {
}[char]));
}
function pathDepth(path) {
return path ? path.split('/').length : 0;
function pathSegments(path) {
return String(path || '').split(/[\\/]+/).filter(Boolean);
}
function nodeName(path) {
const parts = pathSegments(path);
return parts.length ? parts[parts.length - 1] : path;
}
function normalizeComparePath(path) {
return String(path || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
}
function isSamePath(a, b) {
return normalizeComparePath(a) === normalizeComparePath(b);
}
function isPathWithin(base, candidate) {
const baseNorm = normalizeComparePath(base);
const candidateNorm = normalizeComparePath(candidate);
return candidateNorm === baseNorm || candidateNorm.startsWith(baseNorm + '/');
}
function parentPath(path) {
if (!path || !path.includes('/')) return '';
return path.slice(0, path.lastIndexOf('/'));
const value = String(path || '').replace(/[\\/]+$/, '');
const slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'));
if (slash < 0) return '';
if (slash === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, slash + 1);
if (slash === 0) return value.slice(0, 1);
return value.slice(0, slash);
}
function relativeDepth(root, path) {
if (isSamePath(root, path)) return 0;
const rootParts = pathSegments(root);
const pathParts = pathSegments(path);
return Math.max(0, pathParts.length - rootParts.length);
}
function effectiveSourceState(path) {
@@ -104,37 +136,45 @@ function effectiveSourceState(path) {
if (Object.prototype.hasOwnProperty.call(sourceConfig, current)) {
return sourceConfig[current];
}
if (!current) return true;
current = parentPath(current);
if (!current) return true;
}
}
function collectSourcesForSave() {
const items = [];
const seen = new Set();
const roots = sourceTree.get('') || [];
for (const item of roots) {
items.push({ path: item.path, enabled: effectiveSourceState(item.path) });
seen.add(item.path);
}
sourceRoots.forEach((root) => {
items.push({ path: root, enabled: effectiveSourceState(root), root: true });
seen.add(normalizeComparePath(root));
});
Object.entries(sourceConfig).forEach(([path, enabled]) => {
if (seen.has(path)) return;
items.push({ path, enabled });
const key = normalizeComparePath(path);
if (seen.has(key)) return;
items.push({ path, enabled, root: false });
});
return items.sort((a, b) => a.path.localeCompare(b.path));
}
async function loadSourceChildren(path = '') {
if (loadingNodes.has(path)) return;
async function pickFolder() {
const response = await fetch('/api/system/pick-folder', { method: 'POST' });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Failed to choose folder');
}
return payload.path || '';
}
async function loadSourceChildren(path) {
if (!path || loadingNodes.has(path)) return;
loadingNodes.add(path);
renderSources();
try {
const query = path ? '?path=' + encodeURIComponent(path) : '';
const response = await fetch('/api/sources' + query);
const response = await fetch('/api/sources?path=' + encodeURIComponent(path));
if (!response.ok) return;
const payload = await response.json();
sourceTree.set(path, payload.items || []);
@@ -159,15 +199,59 @@ function toggleSource(path, checked) {
renderSources();
}
function renderSourceNodes(parent = '') {
const items = sourceTree.get(parent) || [];
function removeRoot(path) {
sourceRoots = sourceRoots.filter((root) => !isSamePath(root, path));
sourceTree.delete(path);
expandedNodes.delete(path);
loadingNodes.delete(path);
Object.keys(sourceConfig).forEach((key) => {
if (isPathWithin(path, key)) {
delete sourceConfig[key];
}
});
renderSources();
}
async function addSourceRoot() {
try {
const path = await pickFolder();
if (!path) return;
if (sourceRoots.some((root) => isSamePath(root, path))) {
toast('This source folder is already added', 'error');
return;
}
sourceRoots.push(path);
sourceRoots.sort((a, b) => a.localeCompare(b));
sourceConfig[path] = true;
expandedNodes.add(path);
await loadSourceChildren(path);
} catch (error) {
toast(error.message || 'Failed to choose folder', 'error');
}
}
async function reloadAllSourceTrees() {
const roots = [...sourceRoots];
sourceTree.clear();
for (const root of roots) {
if (expandedNodes.has(root)) {
await loadSourceChildren(root);
}
}
renderSources();
}
function renderSourceNodes(root, parentPathValue) {
const items = sourceTree.get(parentPathValue) || [];
return items.map((item) => {
const checked = effectiveSourceState(item.path);
const expanded = expandedNodes.has(item.path);
const childrenKnown = sourceTree.has(item.path);
const children = childrenKnown ? sourceTree.get(item.path) : [];
const hasChildren = !childrenKnown || children.length > 0;
const pad = 16 + pathDepth(item.path) * 20;
const pad = 16 + (relativeDepth(root, item.path) + 1) * 20;
return `
<div class="source-node">
@@ -182,11 +266,11 @@ function renderSourceNodes(parent = '') {
<input class="source-check" type="checkbox" data-action="toggle-check" data-path="${escapeHTML(item.path)}" ${checked ? 'checked' : ''}>
<div class="source-label">
<span class="source-item-name">${escapeHTML(item.name)}</span>
<span class="source-item-path">/media/${escapeHTML(item.path)}</span>
<span class="source-item-path">${escapeHTML(item.path)}</span>
</div>
</div>
${expanded && loadingNodes.has(item.path) ? '<div class="source-loading">Loading...</div>' : ''}
${expanded && childrenKnown && children.length ? `<div class="source-children">${renderSourceNodes(item.path)}</div>` : ''}
${expanded && childrenKnown && children.length ? `<div class="source-children">${renderSourceNodes(root, item.path)}</div>` : ''}
</div>
`;
}).join('');
@@ -194,24 +278,54 @@ function renderSourceNodes(parent = '') {
function renderSources() {
const el = document.getElementById('sourceTree');
const roots = sourceTree.get('');
if (loadingNodes.has('') && !roots) {
el.innerHTML = '<div class="text-muted source-tree-empty">Loading...</div>';
return;
}
if (!roots || !roots.length) {
el.innerHTML = '<div class="text-muted source-tree-empty">No folders found in /media.</div>';
if (!sourceRoots.length) {
el.innerHTML = '<div class="text-muted source-tree-empty">No source folders added yet.</div>';
return;
}
el.innerHTML = renderSourceNodes('');
el.innerHTML = sourceRoots.map((root) => {
const checked = effectiveSourceState(root);
const expanded = expandedNodes.has(root);
const childrenKnown = sourceTree.has(root);
const children = childrenKnown ? sourceTree.get(root) : [];
const hasChildren = !childrenKnown || children.length > 0;
return `
<div class="source-root-card">
<div class="source-row source-root-row">
<button
type="button"
class="source-toggle ${hasChildren ? '' : 'source-toggle-empty'}"
data-action="toggle-expand"
data-path="${escapeHTML(root)}"
${hasChildren ? '' : 'tabindex="-1" aria-hidden="true"'}
>${expanded ? '▾' : '▸'}</button>
<input class="source-check" type="checkbox" data-action="toggle-check" data-path="${escapeHTML(root)}" ${checked ? 'checked' : ''}>
<div class="source-label">
<div class="source-root-title">
<span class="source-item-name">${escapeHTML(nodeName(root))}</span>
<span class="source-root-badge">Root</span>
</div>
<span class="source-item-path">${escapeHTML(root)}</span>
</div>
<button type="button" class="button-secondary button-sm" data-action="remove-root" data-path="${escapeHTML(root)}">Remove</button>
</div>
${expanded && loadingNodes.has(root) ? '<div class="source-loading">Loading...</div>' : ''}
${expanded && childrenKnown && children.length ? `<div class="source-children">${renderSourceNodes(root, root)}</div>` : ''}
</div>
`;
}).join('');
}
async function reloadSourceTree() {
sourceTree.clear();
expandedNodes.clear();
await loadSourceChildren('');
function deriveRootsFromSources(sources) {
const explicitRoots = sources.filter((source) => source.root).map((source) => source.path);
if (explicitRoots.length) {
return explicitRoots;
}
return sources
.map((source) => source.path)
.filter((path, index, all) => !all.some((other, otherIndex) => otherIndex !== index && isPathWithin(other, path) && !isSamePath(other, path)));
}
async function loadSettings() {
@@ -229,7 +343,10 @@ async function loadSettings() {
(cfg.sources || []).forEach((source) => {
sourceConfig[source.path] = !!source.enabled;
});
renderSources();
sourceRoots = deriveRootsFromSources(cfg.sources || []).sort((a, b) => a.localeCompare(b));
expandedNodes.clear();
sourceTree.clear();
await reloadAllSourceTrees();
} catch (error) {}
}
@@ -264,16 +381,22 @@ async function saveSettings(event) {
}
document.getElementById('sourceTree').addEventListener('click', async (event) => {
const button = event.target.closest('[data-action="toggle-expand"]');
const button = event.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
const path = button.dataset.path;
if (expandedNodes.has(path)) {
expandedNodes.delete(path);
renderSources();
return;
if (action === 'toggle-expand') {
if (expandedNodes.has(path)) {
expandedNodes.delete(path);
renderSources();
return;
}
await ensureExpanded(path);
}
if (action === 'remove-root') {
removeRoot(path);
}
await ensureExpanded(path);
});
document.getElementById('sourceTree').addEventListener('change', (event) => {
@@ -283,6 +406,5 @@ document.getElementById('sourceTree').addEventListener('change', (event) => {
});
loadSettings();
loadSourceChildren('');
</script>
{{end}}