Package Manager (new app): - Search, install, remove apt packages via web UI - Backend: apt-cache/dpkg-query/apt-get wrapper with input validation - Frontend: searchable package list with expandable detail panels Text Editor / File Viewer (new app): - Opens from file manager, supports text editing with line numbers - Image preview via authenticated blob URLs - Binary file info display with download option - Ctrl+S / Cmd+S save, dirty tracking, tab key support File Manager enhancements: - Toolbar: New File, New Folder, Upload, Delete, Refresh buttons - Context menu: New File/Folder options, Open in Editor - Double-click files to open in editor - Right-click empty area for create options Auto-update notification: - Backend checks git repo for new commits (fetch + compare) - One-click update: git pull + pip install + service restart - Toast notification in right panel with dismiss option - Polls every 30 minutes, retry logic for server restart Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
504 lines
18 KiB
JavaScript
504 lines
18 KiB
JavaScript
/* Atlus — File Manager app */
|
|
(function () {
|
|
'use strict';
|
|
|
|
let container = null;
|
|
let currentPath = '/';
|
|
let fileListEl = null;
|
|
let breadcrumbEl = null;
|
|
let sidebarEl = null;
|
|
let selectedFiles = new Set();
|
|
let contextMenuEl = null;
|
|
let uploadInput = null;
|
|
|
|
const FILE_ICONS = {
|
|
dir: '📁',
|
|
file: '📄',
|
|
image: '🖼',
|
|
video: '🎬',
|
|
audio: '🎵',
|
|
archive: '📦',
|
|
code: '📝',
|
|
text: '📝',
|
|
};
|
|
|
|
function getFileIcon(entry) {
|
|
if (entry.is_dir) return FILE_ICONS.dir;
|
|
const ext = entry.name.split('.').pop().toLowerCase();
|
|
if (['jpg','jpeg','png','gif','bmp','svg','webp','fits','fit'].includes(ext)) return FILE_ICONS.image;
|
|
if (['mp4','mkv','avi','mov','webm'].includes(ext)) return FILE_ICONS.video;
|
|
if (['mp3','flac','wav','ogg','aac'].includes(ext)) return FILE_ICONS.audio;
|
|
if (['zip','tar','gz','bz2','xz','7z','rar'].includes(ext)) return FILE_ICONS.archive;
|
|
if (['js','py','sh','css','html','json','yml','yaml','toml','rs','go','c','cpp','h'].includes(ext)) return FILE_ICONS.code;
|
|
return FILE_ICONS.file;
|
|
}
|
|
|
|
function formatSize(bytes) {
|
|
return Atlus.formatBytes(bytes);
|
|
}
|
|
|
|
function formatDate(ts) {
|
|
return new Date(ts * 1000).toLocaleString('en-US', {
|
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false,
|
|
});
|
|
}
|
|
|
|
// ---- Open file in editor ----
|
|
|
|
function openInEditor(entry) {
|
|
Atlus.openApp('editor');
|
|
// Give editor time to init, then open file
|
|
setTimeout(() => {
|
|
if (Atlus.apps.editor && Atlus.apps.editor.openFile) {
|
|
Atlus.apps.editor.openFile(entry.path, entry.mime);
|
|
}
|
|
}, 50);
|
|
}
|
|
|
|
// ---- Directory operations ----
|
|
|
|
async function loadDirectory(path) {
|
|
currentPath = path;
|
|
selectedFiles.clear();
|
|
updateBreadcrumb();
|
|
|
|
try {
|
|
const res = await Atlus.apiFetch(`/api/files/list?path=${encodeURIComponent(path)}`);
|
|
if (!res.ok) throw new Error('Failed to load directory');
|
|
const entries = await res.json();
|
|
renderFileList(entries);
|
|
} catch (e) {
|
|
fileListEl.innerHTML = `<div style="padding:16px;color:var(--status-red);font-family:var(--font-mono);font-size:13px;">Error: ${e.message}</div>`;
|
|
}
|
|
|
|
// Update sidebar active
|
|
if (sidebarEl) {
|
|
sidebarEl.querySelectorAll('.sidebar-item').forEach(item => {
|
|
item.classList.toggle('active', item.dataset.path === path);
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderFileList(entries) {
|
|
fileListEl.innerHTML = '';
|
|
|
|
// Parent directory entry
|
|
if (currentPath !== '/') {
|
|
const parentRow = document.createElement('div');
|
|
parentRow.className = 'file-row';
|
|
parentRow.innerHTML = `
|
|
<div class="file-name"><span class="file-icon dir">..</span><span>..</span></div>
|
|
<span class="file-size"></span>
|
|
<span class="file-modified"></span>
|
|
<span class="file-perms"></span>
|
|
`;
|
|
parentRow.addEventListener('click', () => {
|
|
const parent = currentPath.replace(/\/[^/]+\/?$/, '') || '/';
|
|
loadDirectory(parent);
|
|
});
|
|
fileListEl.appendChild(parentRow);
|
|
}
|
|
|
|
entries.forEach(entry => {
|
|
const row = document.createElement('div');
|
|
row.className = 'file-row';
|
|
row.dataset.path = entry.path;
|
|
|
|
const icon = getFileIcon(entry);
|
|
row.innerHTML = `
|
|
<div class="file-name">
|
|
<span class="file-icon ${entry.is_dir ? 'dir' : ''}">${icon}</span>
|
|
<span>${entry.name}</span>
|
|
</div>
|
|
<span class="file-size">${entry.is_dir ? '--' : formatSize(entry.size)}</span>
|
|
<span class="file-modified">${formatDate(entry.modified)}</span>
|
|
<span class="file-perms">${entry.permissions}</span>
|
|
`;
|
|
|
|
// Single click — navigate dir or toggle selection
|
|
row.addEventListener('click', () => {
|
|
if (entry.is_dir) {
|
|
loadDirectory(entry.path);
|
|
} else {
|
|
// Toggle selection
|
|
row.classList.toggle('selected');
|
|
if (selectedFiles.has(entry.path)) {
|
|
selectedFiles.delete(entry.path);
|
|
} else {
|
|
selectedFiles.add(entry.path);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Double click — open file in editor
|
|
row.addEventListener('dblclick', (e) => {
|
|
if (!entry.is_dir) {
|
|
e.preventDefault();
|
|
openInEditor(entry);
|
|
}
|
|
});
|
|
|
|
// Long-press for context menu
|
|
let pressTimer;
|
|
row.addEventListener('touchstart', (e) => {
|
|
pressTimer = setTimeout(() => showContextMenu(e, entry), 500);
|
|
});
|
|
row.addEventListener('touchend', () => clearTimeout(pressTimer));
|
|
row.addEventListener('touchmove', () => clearTimeout(pressTimer));
|
|
|
|
// Right-click fallback
|
|
row.addEventListener('contextmenu', (e) => {
|
|
e.preventDefault();
|
|
showContextMenu(e, entry);
|
|
});
|
|
|
|
fileListEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function updateBreadcrumb() {
|
|
breadcrumbEl.innerHTML = '';
|
|
const parts = currentPath.split('/').filter(Boolean);
|
|
|
|
// Root
|
|
const root = document.createElement('button');
|
|
root.className = 'breadcrumb-segment';
|
|
root.textContent = '/';
|
|
root.addEventListener('click', () => loadDirectory('/'));
|
|
breadcrumbEl.appendChild(root);
|
|
|
|
let path = '';
|
|
parts.forEach((part, i) => {
|
|
path += '/' + part;
|
|
const sep = document.createElement('span');
|
|
sep.className = 'breadcrumb-sep';
|
|
sep.textContent = '/';
|
|
breadcrumbEl.appendChild(sep);
|
|
|
|
const seg = document.createElement('button');
|
|
seg.className = 'breadcrumb-segment';
|
|
seg.textContent = part;
|
|
const segPath = path;
|
|
seg.addEventListener('click', () => loadDirectory(segPath));
|
|
breadcrumbEl.appendChild(seg);
|
|
});
|
|
}
|
|
|
|
// ---- Context menu ----
|
|
|
|
function showContextMenu(e, entry) {
|
|
hideContextMenu();
|
|
contextMenuEl = document.createElement('div');
|
|
contextMenuEl.className = 'file-context-menu';
|
|
|
|
const items = [];
|
|
|
|
// If right-clicked on empty area or a specific entry
|
|
if (entry) {
|
|
if (entry.is_dir) {
|
|
items.push({ label: 'Open', action: () => loadDirectory(entry.path) });
|
|
} else {
|
|
items.push({ label: 'Open in Editor', action: () => openInEditor(entry) });
|
|
}
|
|
items.push({ label: 'Rename', action: () => renameFile(entry) });
|
|
items.push({ label: 'Copy', action: () => { /* TODO */ } });
|
|
items.push({ label: 'Move', action: () => { /* TODO */ } });
|
|
items.push({ sep: true });
|
|
}
|
|
|
|
items.push({ label: 'New File', action: () => createNewFile() });
|
|
items.push({ label: 'New Folder', action: () => createNewFolder() });
|
|
|
|
if (entry) {
|
|
items.push({ sep: true });
|
|
items.push({ label: 'Delete', action: () => deleteFile(entry), danger: true });
|
|
}
|
|
|
|
items.forEach(item => {
|
|
if (item.sep) {
|
|
const sep = document.createElement('div');
|
|
sep.className = 'context-sep';
|
|
contextMenuEl.appendChild(sep);
|
|
return;
|
|
}
|
|
const btn = document.createElement('button');
|
|
btn.className = 'context-item' + (item.danger ? ' danger' : '');
|
|
btn.textContent = item.label;
|
|
btn.addEventListener('click', () => {
|
|
hideContextMenu();
|
|
item.action();
|
|
});
|
|
contextMenuEl.appendChild(btn);
|
|
});
|
|
|
|
// Position
|
|
const x = e.touches ? e.touches[0].clientX : e.clientX;
|
|
const y = e.touches ? e.touches[0].clientY : e.clientY;
|
|
contextMenuEl.style.left = x + 'px';
|
|
contextMenuEl.style.top = y + 'px';
|
|
document.body.appendChild(contextMenuEl);
|
|
|
|
// Close on outside click
|
|
setTimeout(() => {
|
|
document.addEventListener('click', hideContextMenu, { once: true });
|
|
}, 10);
|
|
}
|
|
|
|
function hideContextMenu() {
|
|
if (contextMenuEl) {
|
|
contextMenuEl.remove();
|
|
contextMenuEl = null;
|
|
}
|
|
}
|
|
|
|
// ---- File operations ----
|
|
|
|
async function createNewFile() {
|
|
const name = prompt('New file name:');
|
|
if (!name) return;
|
|
const filePath = (currentPath === '/' ? '/' : currentPath + '/') + name;
|
|
try {
|
|
await Atlus.apiFetch('/api/files/write', {
|
|
method: 'POST',
|
|
body: { path: filePath, content: '' },
|
|
});
|
|
loadDirectory(currentPath);
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
async function createNewFolder() {
|
|
const name = prompt('New folder name:');
|
|
if (!name) return;
|
|
const folderPath = (currentPath === '/' ? '/' : currentPath + '/') + name;
|
|
try {
|
|
await Atlus.apiFetch('/api/files/mkdir', {
|
|
method: 'POST',
|
|
body: { path: folderPath },
|
|
});
|
|
loadDirectory(currentPath);
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
async function deleteFile(entry) {
|
|
if (!confirm(`Delete "${entry.name}"?`)) return;
|
|
await Atlus.apiFetch('/api/files/delete', {
|
|
method: 'POST',
|
|
body: { path: entry.path },
|
|
});
|
|
loadDirectory(currentPath);
|
|
}
|
|
|
|
async function deleteSelected() {
|
|
if (selectedFiles.size === 0) return;
|
|
const count = selectedFiles.size;
|
|
if (!confirm(`Delete ${count} selected item${count > 1 ? 's' : ''}?`)) return;
|
|
for (const path of selectedFiles) {
|
|
await Atlus.apiFetch('/api/files/delete', {
|
|
method: 'POST',
|
|
body: { path },
|
|
});
|
|
}
|
|
selectedFiles.clear();
|
|
loadDirectory(currentPath);
|
|
}
|
|
|
|
async function renameFile(entry) {
|
|
const newName = prompt('New name:', entry.name);
|
|
if (!newName || newName === entry.name) return;
|
|
await Atlus.apiFetch('/api/files/rename', {
|
|
method: 'POST',
|
|
body: { old_path: entry.path, new_name: newName },
|
|
});
|
|
loadDirectory(currentPath);
|
|
}
|
|
|
|
async function uploadFile() {
|
|
if (!uploadInput) return;
|
|
uploadInput.click();
|
|
}
|
|
|
|
async function handleUpload(file) {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
try {
|
|
await Atlus.apiFetch(`/api/files/upload?dest_dir=${encodeURIComponent(currentPath)}`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: {}, // Let browser set content-type for multipart
|
|
});
|
|
loadDirectory(currentPath);
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
// ---- Mounts ----
|
|
|
|
async function loadMounts() {
|
|
try {
|
|
const res = await Atlus.apiFetch('/api/files/mounts');
|
|
if (!res.ok) return;
|
|
const mounts = await res.json();
|
|
|
|
// Add mount entries to sidebar
|
|
const heading = document.createElement('div');
|
|
heading.className = 'sidebar-heading';
|
|
heading.textContent = 'MOUNTS';
|
|
sidebarEl.appendChild(heading);
|
|
|
|
mounts.forEach(mount => {
|
|
if (mount.mountpoint === '/') return; // Already in sidebar
|
|
const item = document.createElement('button');
|
|
item.className = 'sidebar-item';
|
|
item.dataset.path = mount.mountpoint;
|
|
item.innerHTML = `<span class="sidebar-icon">💾</span>${mount.mountpoint.split('/').pop() || mount.mountpoint}`;
|
|
item.addEventListener('click', () => loadDirectory(mount.mountpoint));
|
|
sidebarEl.appendChild(item);
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
// ---- App registration ----
|
|
|
|
Atlus.registerApp('files', {
|
|
title: 'Files',
|
|
|
|
init(el) {
|
|
container = el;
|
|
container.classList.add('app-files');
|
|
|
|
// Toolbar
|
|
const toolbar = document.createElement('div');
|
|
toolbar.className = 'files-toolbar';
|
|
breadcrumbEl = document.createElement('div');
|
|
breadcrumbEl.className = 'files-breadcrumb';
|
|
toolbar.appendChild(breadcrumbEl);
|
|
|
|
// Toolbar buttons
|
|
const btnGroup = document.createElement('div');
|
|
btnGroup.className = 'files-btn-group';
|
|
|
|
// New File
|
|
const newFileBtn = document.createElement('button');
|
|
newFileBtn.className = 'files-action-btn';
|
|
newFileBtn.textContent = '📄+';
|
|
newFileBtn.title = 'New File';
|
|
newFileBtn.addEventListener('click', createNewFile);
|
|
btnGroup.appendChild(newFileBtn);
|
|
|
|
// New Folder
|
|
const newFolderBtn = document.createElement('button');
|
|
newFolderBtn.className = 'files-action-btn';
|
|
newFolderBtn.textContent = '📁+';
|
|
newFolderBtn.title = 'New Folder';
|
|
newFolderBtn.addEventListener('click', createNewFolder);
|
|
btnGroup.appendChild(newFolderBtn);
|
|
|
|
// Upload
|
|
uploadInput = document.createElement('input');
|
|
uploadInput.type = 'file';
|
|
uploadInput.style.display = 'none';
|
|
uploadInput.addEventListener('change', (e) => {
|
|
if (e.target.files.length > 0) {
|
|
handleUpload(e.target.files[0]);
|
|
uploadInput.value = '';
|
|
}
|
|
});
|
|
container.appendChild(uploadInput);
|
|
|
|
const uploadBtn = document.createElement('button');
|
|
uploadBtn.className = 'files-action-btn';
|
|
uploadBtn.textContent = '⬆';
|
|
uploadBtn.title = 'Upload';
|
|
uploadBtn.addEventListener('click', uploadFile);
|
|
btnGroup.appendChild(uploadBtn);
|
|
|
|
// Delete selected
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'files-action-btn';
|
|
deleteBtn.textContent = '🗑';
|
|
deleteBtn.title = 'Delete Selected';
|
|
deleteBtn.addEventListener('click', deleteSelected);
|
|
btnGroup.appendChild(deleteBtn);
|
|
|
|
// Refresh
|
|
const refreshBtn = document.createElement('button');
|
|
refreshBtn.className = 'files-action-btn';
|
|
refreshBtn.textContent = '↻';
|
|
refreshBtn.title = 'Refresh';
|
|
refreshBtn.addEventListener('click', () => loadDirectory(currentPath));
|
|
btnGroup.appendChild(refreshBtn);
|
|
|
|
toolbar.appendChild(btnGroup);
|
|
container.appendChild(toolbar);
|
|
|
|
// Body
|
|
const body = document.createElement('div');
|
|
body.className = 'files-body';
|
|
|
|
// Sidebar
|
|
sidebarEl = document.createElement('div');
|
|
sidebarEl.className = 'files-sidebar';
|
|
|
|
const homeHeading = document.createElement('div');
|
|
homeHeading.className = 'sidebar-heading';
|
|
homeHeading.textContent = 'PLACES';
|
|
sidebarEl.appendChild(homeHeading);
|
|
|
|
const places = [
|
|
{ icon: '🏠', label: 'Home', path: `/home/${Atlus.user}` },
|
|
{ icon: '/', label: 'Root', path: '/' },
|
|
{ icon: '📁', label: 'tmp', path: '/tmp' },
|
|
];
|
|
|
|
places.forEach(p => {
|
|
const item = document.createElement('button');
|
|
item.className = 'sidebar-item';
|
|
item.dataset.path = p.path;
|
|
item.innerHTML = `<span class="sidebar-icon">${p.icon}</span>${p.label}`;
|
|
item.addEventListener('click', () => loadDirectory(p.path));
|
|
sidebarEl.appendChild(item);
|
|
});
|
|
|
|
body.appendChild(sidebarEl);
|
|
|
|
// File list panel
|
|
const listPanel = document.createElement('div');
|
|
listPanel.className = 'files-list-panel';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'files-list-header';
|
|
header.innerHTML = '<span>Name</span><span>Size</span><span>Modified</span><span>Perms</span>';
|
|
listPanel.appendChild(header);
|
|
|
|
fileListEl = document.createElement('div');
|
|
fileListEl.className = 'files-list';
|
|
|
|
// Right-click on empty area for context menu
|
|
fileListEl.addEventListener('contextmenu', (e) => {
|
|
if (e.target === fileListEl || e.target.closest('.file-row') === null) {
|
|
e.preventDefault();
|
|
showContextMenu(e, null);
|
|
}
|
|
});
|
|
|
|
listPanel.appendChild(fileListEl);
|
|
|
|
body.appendChild(listPanel);
|
|
container.appendChild(body);
|
|
|
|
// Load initial directory
|
|
loadDirectory(`/home/${Atlus.user}`);
|
|
loadMounts();
|
|
},
|
|
|
|
destroy() {
|
|
hideContextMenu();
|
|
container = null;
|
|
fileListEl = null;
|
|
breadcrumbEl = null;
|
|
sidebarEl = null;
|
|
uploadInput = null;
|
|
currentPath = '/';
|
|
selectedFiles.clear();
|
|
},
|
|
});
|
|
})();
|