atlus/frontend/js/apps/editor.js
roberts 9c402e3726 Add package manager, text editor, file manager enhancements, auto-updates
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>
2026-03-14 19:13:13 -05:00

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);
}
},
});
})();