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>
347 lines
11 KiB
JavaScript
347 lines
11 KiB
JavaScript
/* Atlus — Text Editor / File Viewer app */
|
|
(function () {
|
|
'use strict';
|
|
|
|
let container = null;
|
|
let currentFile = null; // { path, name, mime, size }
|
|
let isDirty = false;
|
|
let editorEl = null; // textarea element
|
|
let linesEl = null; // line numbers element
|
|
let titleEl = null; // filename display
|
|
let dirtyEl = null; // dirty indicator
|
|
let saveBtn = null;
|
|
let statusEl = null;
|
|
let bodyEl = null; // main content area
|
|
|
|
// MIME types we treat as text/editable
|
|
const TEXT_MIMES = [
|
|
'text/', 'application/json', 'application/javascript',
|
|
'application/xml', 'application/x-sh', 'application/x-python',
|
|
'application/toml', 'application/yaml', 'application/x-yaml',
|
|
'application/sql', 'application/x-httpd-php',
|
|
];
|
|
|
|
const IMAGE_MIMES = ['image/'];
|
|
|
|
function isTextMime(mime) {
|
|
if (!mime) return true; // Unknown = try text
|
|
return TEXT_MIMES.some(t => mime.startsWith(t));
|
|
}
|
|
|
|
function isImageMime(mime) {
|
|
if (!mime) return false;
|
|
return IMAGE_MIMES.some(t => mime.startsWith(t));
|
|
}
|
|
|
|
// ---- Line numbers ----
|
|
|
|
function updateLineNumbers() {
|
|
if (!editorEl || !linesEl) return;
|
|
const count = editorEl.value.split('\n').length;
|
|
const nums = [];
|
|
for (let i = 1; i <= count; i++) nums.push(i);
|
|
linesEl.textContent = nums.join('\n');
|
|
}
|
|
|
|
function syncScroll() {
|
|
if (linesEl && editorEl) {
|
|
linesEl.scrollTop = editorEl.scrollTop;
|
|
}
|
|
}
|
|
|
|
// ---- Dirty tracking ----
|
|
|
|
function markDirty() {
|
|
if (!isDirty) {
|
|
isDirty = true;
|
|
if (dirtyEl) dirtyEl.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
function markClean() {
|
|
isDirty = false;
|
|
if (dirtyEl) dirtyEl.classList.add('hidden');
|
|
}
|
|
|
|
// ---- Save ----
|
|
|
|
async function saveFile() {
|
|
if (!currentFile || !editorEl) return;
|
|
saveBtn.disabled = true;
|
|
saveBtn.textContent = 'Saving…';
|
|
try {
|
|
const res = await Atlus.apiFetch('/api/files/write', {
|
|
method: 'POST',
|
|
body: { path: currentFile.path, content: editorEl.value },
|
|
});
|
|
if (res.ok) {
|
|
markClean();
|
|
const data = await res.json();
|
|
updateStatus(data.size);
|
|
}
|
|
} catch (e) {
|
|
// show error inline
|
|
}
|
|
saveBtn.disabled = false;
|
|
saveBtn.textContent = 'Save';
|
|
}
|
|
|
|
// ---- Status bar ----
|
|
|
|
function updateStatus(size) {
|
|
if (!statusEl) return;
|
|
const parts = [];
|
|
if (currentFile) {
|
|
if (editorEl) {
|
|
const lines = editorEl.value.split('\n').length;
|
|
parts.push(`${lines} lines`);
|
|
}
|
|
if (size !== undefined) {
|
|
parts.push(Atlus.formatBytes(size));
|
|
}
|
|
if (currentFile.mime) {
|
|
parts.push(currentFile.mime);
|
|
}
|
|
}
|
|
statusEl.textContent = parts.join(' • ');
|
|
}
|
|
|
|
// ---- Open file ----
|
|
|
|
async function openFile(path, mime) {
|
|
const name = path.split('/').pop();
|
|
currentFile = { path, name, mime, size: 0 };
|
|
isDirty = false;
|
|
|
|
// Update title
|
|
if (titleEl) titleEl.textContent = name;
|
|
if (dirtyEl) dirtyEl.classList.add('hidden');
|
|
|
|
// Clear body
|
|
bodyEl.innerHTML = '';
|
|
|
|
if (isImageMime(mime)) {
|
|
await renderImage(path, name);
|
|
} else if (isTextMime(mime)) {
|
|
await renderTextEditor(path);
|
|
} else {
|
|
await renderBinaryInfo(path, name, mime);
|
|
}
|
|
}
|
|
|
|
async function renderTextEditor(path) {
|
|
// Create editor layout
|
|
const editorWrap = document.createElement('div');
|
|
editorWrap.className = 'editor-body';
|
|
|
|
linesEl = document.createElement('div');
|
|
linesEl.className = 'editor-lines';
|
|
|
|
editorEl = document.createElement('textarea');
|
|
editorEl.className = 'editor-textarea';
|
|
editorEl.spellcheck = false;
|
|
editorEl.autocapitalize = 'off';
|
|
editorEl.autocomplete = 'off';
|
|
|
|
editorWrap.appendChild(linesEl);
|
|
editorWrap.appendChild(editorEl);
|
|
bodyEl.appendChild(editorWrap);
|
|
|
|
// Show save button
|
|
if (saveBtn) saveBtn.classList.remove('hidden');
|
|
|
|
// Load content
|
|
try {
|
|
const res = await Atlus.apiFetch(`/api/files/read?path=${encodeURIComponent(path)}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
editorEl.value = data.content;
|
|
currentFile.size = data.content.length;
|
|
updateLineNumbers();
|
|
updateStatus(currentFile.size);
|
|
} else {
|
|
editorEl.value = `Error loading file: ${res.status}`;
|
|
}
|
|
} catch (e) {
|
|
editorEl.value = `Error: ${e.message}`;
|
|
}
|
|
|
|
// Events
|
|
editorEl.addEventListener('input', () => {
|
|
markDirty();
|
|
updateLineNumbers();
|
|
});
|
|
editorEl.addEventListener('scroll', syncScroll);
|
|
|
|
// Tab key support
|
|
editorEl.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
const start = editorEl.selectionStart;
|
|
const end = editorEl.selectionEnd;
|
|
editorEl.value = editorEl.value.substring(0, start) + ' ' + editorEl.value.substring(end);
|
|
editorEl.selectionStart = editorEl.selectionEnd = start + 4;
|
|
markDirty();
|
|
updateLineNumbers();
|
|
}
|
|
// Ctrl+S / Cmd+S
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
saveFile();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function renderImage(path, name) {
|
|
if (saveBtn) saveBtn.classList.add('hidden');
|
|
linesEl = null;
|
|
editorEl = null;
|
|
|
|
const imgWrap = document.createElement('div');
|
|
imgWrap.className = 'editor-preview';
|
|
|
|
const img = document.createElement('img');
|
|
img.className = 'editor-preview-img';
|
|
img.alt = name;
|
|
|
|
// Load via authenticated fetch → blob URL
|
|
try {
|
|
const res = await Atlus.apiFetch(`/api/files/download?path=${encodeURIComponent(path)}`);
|
|
if (res.ok) {
|
|
const blob = await res.blob();
|
|
img.src = URL.createObjectURL(blob);
|
|
currentFile.size = blob.size;
|
|
updateStatus(blob.size);
|
|
}
|
|
} catch (e) {
|
|
img.alt = 'Failed to load image';
|
|
}
|
|
|
|
imgWrap.appendChild(img);
|
|
bodyEl.appendChild(imgWrap);
|
|
}
|
|
|
|
async function renderBinaryInfo(path, name, mime) {
|
|
if (saveBtn) saveBtn.classList.add('hidden');
|
|
linesEl = null;
|
|
editorEl = null;
|
|
|
|
const infoCard = document.createElement('div');
|
|
infoCard.className = 'editor-binary-info';
|
|
|
|
// Fetch file info
|
|
try {
|
|
const res = await Atlus.apiFetch(`/api/files/info?path=${encodeURIComponent(path)}`);
|
|
if (res.ok) {
|
|
const info = await res.json();
|
|
currentFile.size = info.size;
|
|
infoCard.innerHTML = `
|
|
<div class="binary-icon">📄</div>
|
|
<div class="binary-name">${name}</div>
|
|
<div class="binary-meta">${Atlus.formatBytes(info.size)} • ${mime || 'Unknown type'}</div>
|
|
<div class="binary-meta">${info.permissions} • ${info.owner}:${info.group}</div>
|
|
<button class="editor-download-btn">Download</button>
|
|
`;
|
|
infoCard.querySelector('.editor-download-btn').addEventListener('click', async () => {
|
|
const dlRes = await Atlus.apiFetch(`/api/files/download?path=${encodeURIComponent(path)}`);
|
|
if (dlRes.ok) {
|
|
const blob = await dlRes.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = name;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
});
|
|
updateStatus(info.size);
|
|
}
|
|
} catch (e) {
|
|
infoCard.innerHTML = `<div class="binary-name">${name}</div><div class="binary-meta">Could not load file info</div>`;
|
|
}
|
|
|
|
bodyEl.appendChild(infoCard);
|
|
}
|
|
|
|
// ---- App registration ----
|
|
|
|
Atlus.registerApp('editor', {
|
|
title: 'Editor',
|
|
|
|
init(el) {
|
|
container = el;
|
|
container.classList.add('app-editor');
|
|
|
|
// Toolbar
|
|
const toolbar = document.createElement('div');
|
|
toolbar.className = 'editor-toolbar';
|
|
|
|
titleEl = document.createElement('div');
|
|
titleEl.className = 'editor-title';
|
|
titleEl.textContent = 'No file open';
|
|
|
|
dirtyEl = document.createElement('span');
|
|
dirtyEl.className = 'editor-dirty hidden';
|
|
dirtyEl.textContent = '●';
|
|
dirtyEl.title = 'Unsaved changes';
|
|
|
|
const titleWrap = document.createElement('div');
|
|
titleWrap.className = 'editor-title-wrap';
|
|
titleWrap.appendChild(titleEl);
|
|
titleWrap.appendChild(dirtyEl);
|
|
|
|
saveBtn = document.createElement('button');
|
|
saveBtn.className = 'editor-save-btn hidden';
|
|
saveBtn.textContent = 'Save';
|
|
saveBtn.addEventListener('click', saveFile);
|
|
|
|
toolbar.appendChild(titleWrap);
|
|
toolbar.appendChild(saveBtn);
|
|
container.appendChild(toolbar);
|
|
|
|
// Body
|
|
bodyEl = document.createElement('div');
|
|
bodyEl.className = 'editor-content';
|
|
container.appendChild(bodyEl);
|
|
|
|
// Status bar
|
|
statusEl = document.createElement('div');
|
|
statusEl.className = 'editor-status';
|
|
container.appendChild(statusEl);
|
|
|
|
// Check for pending file open
|
|
if (container._pendingFile) {
|
|
const { path, mime } = container._pendingFile;
|
|
delete container._pendingFile;
|
|
openFile(path, mime);
|
|
}
|
|
},
|
|
|
|
destroy() {
|
|
// Revoke any blob URLs
|
|
if (bodyEl) {
|
|
const imgs = bodyEl.querySelectorAll('img');
|
|
imgs.forEach(img => {
|
|
if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
|
|
});
|
|
}
|
|
container = null;
|
|
editorEl = null;
|
|
linesEl = null;
|
|
titleEl = null;
|
|
dirtyEl = null;
|
|
saveBtn = null;
|
|
statusEl = null;
|
|
bodyEl = null;
|
|
currentFile = null;
|
|
isDirty = false;
|
|
},
|
|
|
|
// Public API for file manager integration
|
|
openFile(path, mime) {
|
|
if (container) {
|
|
openFile(path, mime);
|
|
}
|
|
},
|
|
});
|
|
})();
|