Files
podman-mvp/webui/html/assets/js/tabs/files.js
T
kodi 2dfe53895b feat (ui): compacte IDE-sidebar tree view voor Files tabblad
- Vervang kaart-stijl folder-rijen door compacte, platte rijen (2px padding, geen border)
- Verwijder badge-tellers (📁 N, 📄 N) uit folder-rijen
- Voeg .btn.tiny toe voor kleine actieknoppen (+/✕) in boom
- Alle mappen standaard ingeklapt; localStorage behoudt uitgeklapte staat
- file-entry hover highlight; verwijder bottom-border per rij

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:19:04 +01:00

467 lines
16 KiB
JavaScript

let cmEditor = null;
let filesDirty = false;
let filesSuppressDirtyEvent = false;
let filesTextareaBound = false;
function filesCurrentTheme() {
const t = document.documentElement.getAttribute('data-theme');
return (t === 'light') ? 'light' : 'dark';
}
function filesCodeMirrorTheme() {
return filesCurrentTheme() === 'light' ? 'default' : 'material-darker';
}
function filesSetEditorTheme(themeName) {
if (!cmEditor) return;
const cmTheme = (themeName === 'light') ? 'default' : 'material-darker';
cmEditor.setOption('theme', cmTheme);
cmEditor.refresh();
}
window.filesSetEditorTheme = filesSetEditorTheme;
function _isFolderCollapsed(folderKey, level) {
const stored = localStorage.getItem('files_folder_collapsed:' + folderKey);
if (stored !== null) return stored !== '0';
return true; // standaard alles ingeklapt
}
function _setFolderCollapsed(folderKey, v) {
localStorage.setItem('files_folder_collapsed:' + folderKey, v ? '1' : '0');
}
// =========================
// Files tab (systemd subtree)
// =========================
const FILES_ROOT = 'systemd'; // API-root binnen WORKLOADS_DIR
let filesCurrentUiPath = ''; // zonder "systemd/"
let filesCurrentApiPath = ''; // met "systemd/"
function filesModeLabel(uiPath) {
const mode = cmModeForPath(uiPath);
if (mode === 'yaml') return 'YAML';
if (mode === 'application/json') return 'JSON';
if (mode === 'javascript') return 'JavaScript';
return 'Text';
}
function filesCursorLabel() {
if (cmEditor) {
const c = cmEditor.getCursor();
return `Ln ${c.line + 1}, Kol ${c.ch + 1}`;
}
return '';
}
function filesUpdateEditorStatus() {
const el = document.getElementById('filesEditorStatus');
if (!el) return;
if (!filesCurrentUiPath) {
el.textContent = 'Geen bestand geselecteerd';
return;
}
const dirtyTxt = filesDirty ? 'Niet opgeslagen' : 'Opgeslagen';
const parts = [
dirtyTxt,
filesModeLabel(filesCurrentUiPath),
filesCurrentUiPath,
];
const cursor = filesCursorLabel();
if (cursor) parts.push(cursor);
el.textContent = parts.join(' | ');
}
function filesUpdateTreeSelection() {
const treeEl = document.getElementById('filesTree');
if (!treeEl) return;
treeEl.querySelectorAll('.file-entry').forEach(row => {
row.classList.remove('active', 'dirty');
const state = row.querySelector('.file-entry-state');
if (state) state.textContent = '';
});
if (!filesCurrentUiPath) return;
const key = encodeURIComponent(filesCurrentUiPath);
const row = treeEl.querySelector(`.file-entry[data-file="${CSS.escape(key)}"]`);
if (!row) return;
row.classList.add('active');
if (filesDirty) {
row.classList.add('dirty');
const state = row.querySelector('.file-entry-state');
if (state) state.textContent = '●';
}
}
function filesSetDirty(v) {
filesDirty = !!v && !!filesCurrentUiPath;
filesUpdateEditorStatus();
filesUpdateTreeSelection();
}
function cmModeForPath(uiPath) {
const p = (uiPath || '').toLowerCase();
if (p.endsWith('.yaml') || p.endsWith('.yml') || p.endsWith('.kube') || p.endsWith('.container')) return 'yaml';
if (p.endsWith('.json')) return 'application/json';
if (p.endsWith('.js')) return 'javascript';
return 'text/plain';
}
function filesToApiPath(uiPath) {
let p = (uiPath || '').trim().replace(/^\/+/, '');
if (!p) return FILES_ROOT;
if (p === FILES_ROOT || p.startsWith(FILES_ROOT + '/')) return p;
return `${FILES_ROOT}/${p}`;
}
function filesToUiPath(apiPath) {
const p = (apiPath || '').trim().replace(/^\/+/, '');
return p.replace(new RegExp('^' + FILES_ROOT + '/?'), '');
}
function filesSetCurrent(uiPath) {
filesCurrentUiPath = (uiPath || '').trim().replace(/^\/+/, '');
filesCurrentApiPath = filesToApiPath(filesCurrentUiPath);
document.getElementById('filesCurrent').textContent = filesCurrentUiPath || '-';
filesSetDirty(false);
}
async function filesRefresh() {
// Files editor: CodeMirror init (alleen als textarea bestaat)
if (!cmEditor) {
const taFiles = document.getElementById('filesEditor');
if (taFiles && window.CodeMirror) {
cmEditor = CodeMirror.fromTextArea(taFiles, {
lineNumbers: true,
lineWrapping: true,
mode: 'text/plain',
theme: filesCodeMirrorTheme()
});
cmEditor.setSize('100%', 360);
cmEditor.on('change', () => {
if (filesSuppressDirtyEvent || !filesCurrentUiPath) return;
filesSetDirty(true);
});
cmEditor.on('cursorActivity', filesUpdateEditorStatus);
} else if (taFiles && !filesTextareaBound) {
filesTextareaBound = true;
taFiles.addEventListener('input', () => {
if (!filesCurrentUiPath) return;
filesSetDirty(true);
});
}
}
const treeEl = document.getElementById('filesTree');
treeEl.textContent = 'Laden...';
let data;
try {
data = await api('/files/tree', 'GET');
} catch (e) {
if (typeof window.updateNavCount === 'function') {
window.updateNavCount('countNavFiles', 0);
}
treeEl.innerHTML = (typeof window.renderStateBox === 'function')
? window.renderStateBox('error', 'Files laden mislukt', e.message || String(e))
: 'Files laden mislukt.';
filesUpdateEditorStatus();
return;
}
// Filter alleen systemd subtree
const scoped = (data || []).filter(folder => {
const p = (folder.path || '').replace(/^\/+/, '');
return p === FILES_ROOT || p.startsWith(FILES_ROOT + '/');
});
if (!scoped.length) {
if (typeof window.updateNavCount === 'function') {
window.updateNavCount('countNavFiles', 0);
}
treeEl.innerHTML = (typeof window.renderStateBox === 'function')
? window.renderStateBox('empty', 'Geen bestanden', 'Er zijn geen bestanden gevonden onder systemd.')
: 'Geen bestanden gevonden onder systemd.';
filesUpdateEditorStatus();
return;
}
let totalFiles = 0;
for (const folder of scoped) {
totalFiles += Array.isArray(folder?.files) ? folder.files.length : 0;
}
if (typeof window.updateNavCount === 'function') {
window.updateNavCount('countNavFiles', totalFiles);
}
// Bouw een geneste folder-tree uit de "platte" API response.
const folderByPath = new Map();
for (const f of scoped) {
const apiPath = (f.path || '').replace(/^\/+/, '');
folderByPath.set(apiPath, f);
}
function getOrCreateChild(parent, name) {
if (!parent.children.has(name)) {
const apiPath = parent.apiPath ? `${parent.apiPath}/${name}` : name;
parent.children.set(name, {
name,
apiPath,
uiPath: filesToUiPath(apiPath),
children: new Map(),
});
}
return parent.children.get(name);
}
const root = { name: FILES_ROOT, apiPath: FILES_ROOT, uiPath: '', children: new Map() };
// 1) Nodes aanmaken op basis van bekende folder paths
for (const apiPath of folderByPath.keys()) {
if (apiPath === FILES_ROOT) continue;
if (!apiPath.startsWith(FILES_ROOT + '/')) continue;
const rel = apiPath.slice((FILES_ROOT + '/').length);
const segs = rel.split('/').filter(Boolean);
let cur = root;
for (const s of segs) cur = getOrCreateChild(cur, s);
}
// 2) Nodes aanvullen op basis van dirs-lijsten (zodat lege tussenfolders ook verschijnen)
for (const [apiPath, folder] of folderByPath.entries()) {
if (apiPath !== FILES_ROOT && !apiPath.startsWith(FILES_ROOT + '/')) continue;
let base = root;
if (apiPath !== FILES_ROOT) {
const rel = apiPath.slice((FILES_ROOT + '/').length);
const segs = rel.split('/').filter(Boolean);
for (const s of segs) base = getOrCreateChild(base, s);
}
for (const d of (folder.dirs || [])) getOrCreateChild(base, d);
}
function renderNode(node, level) {
const folderKey = node.apiPath;
const collapsed = _isFolderCollapsed(folderKey, level);
const label = node.uiPath || 'root';
const indent = Math.max(0, level) * 14;
const folder = folderByPath.get(folderKey);
const files = (folder && folder.files) ? folder.files : [];
const childNames = Array.from(node.children.keys()).sort((a,b) => a.localeCompare(b));
const sortedFiles = (files || []).slice().sort((a,b) => a.localeCompare(b));
const out = [];
out.push(`<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="padding-left:${indent}px;">
<span class="file-folder-left">
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
<span>📂 ${esc(label)}</span>
</span>
<span class="file-folder-actions" onclick="event.stopPropagation();">
<button class="btn tiny ok" title="Nieuw bestand in ${esc(label)}" onclick="filesNewFileInFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">+</button>
<button class="btn tiny bad" title="Verwijder map (alleen als leeg)" onclick="filesDeleteFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">✕</button>
</span>
</div>`);
out.push(`<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">`);
for (const name of childNames) {
out.push(renderNode(node.children.get(name), level + 1));
}
for (const f of sortedFiles) {
const fullUi = node.uiPath ? `${node.uiPath}/${f}` : f;
const fileKey = encodeURIComponent(fullUi);
out.push(`<div class="file-entry" data-file="${fileKey}" style="padding-left:${indent + 16}px;">
<span class="mono file-entry-name" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
<span class="file-entry-state"></span>
</div>`);
}
if (!childNames.length && !sortedFiles.length) {
out.push(`<div class="muted" style="padding-left:${indent + 16}px; font-size:0.85em;">(leeg)</div>`);
}
out.push(`</div>`);
return out.join('');
}
const parts = [];
const topNames = Array.from(root.children.keys()).sort((a,b) => a.localeCompare(b));
for (const n of topNames) parts.push(renderNode(root.children.get(n), 0));
// Files direct onder "systemd/" (root) tonen bovenaan
const rootFolder = folderByPath.get(FILES_ROOT);
if (rootFolder && (rootFolder.files || []).length) {
const folderKey = FILES_ROOT;
const collapsed = _isFolderCollapsed(folderKey, 0);
parts.unshift(`<div class="mono file-folder-row" data-folder="${esc(folderKey)}">
<span class="file-folder-left">
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
<span>📂 root</span>
</span>
<span class="file-folder-actions" onclick="event.stopPropagation();">
<button class="btn tiny ok" title="Nieuw bestand in root" onclick="filesNewFileInFolder('')">+</button>
</span>
</div>
<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">
${(rootFolder.files || []).slice().sort((a,b)=>a.localeCompare(b)).map(f => {
const fileKey = encodeURIComponent(f);
return `<div class="file-entry" data-file="${fileKey}" style="padding-left:16px;">
<span class="mono file-entry-name" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
<span class="file-entry-state"></span>
</div>`;
}).join('')}
</div>`);
}
treeEl.innerHTML = parts.join('');
treeEl.onclick = (ev) => {
const row = ev.target.closest('.file-folder-row');
if (!row) return;
const folderKey = row.getAttribute('data-folder');
const isNowCollapsed = !_isFolderCollapsed(folderKey);
_setFolderCollapsed(folderKey, isNowCollapsed);
// pijltje updaten
const arrow = row.querySelector('.folder-toggle');
if (arrow) arrow.textContent = isNowCollapsed ? '▶' : '▼';
// files block tonen/verbergen
const filesBlock = treeEl.querySelector(`[data-folder-files="${CSS.escape(folderKey)}"]`);
if (filesBlock) filesBlock.style.display = isNowCollapsed ? 'none' : '';
};
filesUpdateTreeSelection();
filesUpdateEditorStatus();
}
async function filesOpen(uiPath) {
filesSetCurrent(uiPath);
const res = await api(`/files/read?path=${encodeURIComponent(filesCurrentApiPath)}`, 'GET');
const text = res.content || '';
if (cmEditor) {
cmEditor.setOption('mode', cmModeForPath(uiPath));
filesSuppressDirtyEvent = true;
cmEditor.setValue(text);
filesSuppressDirtyEvent = false;
cmEditor.refresh();
cmEditor.setCursor({ line: 0, ch: 0 });
} else {
document.getElementById('filesEditor').value = text;
}
filesSetDirty(false);
filesUpdateEditorStatus();
}
async function filesSave() {
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
return showModal('Files', 'Selecteer eerst een bestand.');
}
const content = cmEditor
? cmEditor.getValue()
: document.getElementById('filesEditor').value;
const res = await api(
`/files/save?path=${encodeURIComponent(filesCurrentApiPath)}`,
'POST',
{ content }
);
filesSetDirty(false);
showModal('Opgeslagen', JSON.stringify(res, null, 2));
await filesRefresh();
}
async function filesDelete() {
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
return showModal('Files', 'Selecteer eerst een bestand om te verwijderen.');
}
if (!confirm(`Verwijderen: ${filesCurrentUiPath}?`)) return;
const res = await api(`/files/delete?path=${encodeURIComponent(filesCurrentApiPath)}`, 'DELETE');
showModal('Verwijderd', JSON.stringify(res, null, 2));
// reset current
filesSetCurrent('');
if (cmEditor) {
filesSuppressDirtyEvent = true;
cmEditor.setValue('');
filesSuppressDirtyEvent = false;
cmEditor.refresh();
} else {
document.getElementById('filesEditor').value = '';
}
await filesRefresh();
}
async function filesNewFolder() {
const ui = prompt('Nieuwe map (onder systemd):\nVoorbeeld: mediaserver', '');
if (!ui) return;
const apiPath = filesToApiPath(ui);
const res = await api(`/files/mkdir?path=${encodeURIComponent(apiPath)}`, 'POST');
showModal('Map aangemaakt', JSON.stringify(res, null, 2));
await filesRefresh();
}
async function filesNewFile() {
const ui = prompt('Nieuw bestand (onder systemd):\nVoorbeeld: demo-web/demo-web.container', '');
if (!ui) return;
const apiPath = filesToApiPath(ui);
// altijd leeg (jouw keuze) -> leeg bestand
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "" });
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
// Open direct
filesSetCurrent(ui);
const editorEl = document.getElementById('filesEditor');
if (editorEl) editorEl.value = "";
await filesRefresh();
await filesOpen(ui);
}
async function filesNewFileInFolder(uiFolderPath) {
const base = (uiFolderPath || '').trim().replace(/^\/+/, '');
const name = prompt(`Nieuw bestand in "${base || 'root'}"\nBijv: test.yaml of demo.container`, '');
if (!name) return;
const uiFull = base ? `${base}/${name}` : name;
const apiPath = filesToApiPath(uiFull);
// altijd leeg (jouw keuze)
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "" });
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
await filesRefresh();
await filesOpen(uiFull);
}
async function filesDeleteFolder(uiFolderPath) {
const base = (uiFolderPath || '').trim().replace(/^\/+/, '');
if (!base) {
return showModal('Files', 'Root map verwijderen mag niet.');
}
if (!confirm(`Map verwijderen (alleen als leeg): ${base}?`)) return;
const apiPath = filesToApiPath(base);
try {
const res = await api(`/files/rmdir?path=${encodeURIComponent(apiPath)}`, 'DELETE');
showModal('Map verwijderd', JSON.stringify(res, null, 2));
await filesRefresh();
} catch (e) {
showModal('Kan map niet verwijderen', e.message);
}
}