From 9c402e3726d6d1f75f9872468591e79a2e23ca02 Mon Sep 17 00:00:00 2001 From: roberts Date: Sat, 14 Mar 2026 19:13:13 -0500 Subject: [PATCH] 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 --- backend/main.py | 4 +- backend/routers/packages.py | 199 +++++++++++++++++++ backend/routers/updates.py | 166 ++++++++++++++++ frontend/css/apps/editor.css | 195 ++++++++++++++++++ frontend/css/apps/files.css | 7 + frontend/css/apps/packages.css | 262 +++++++++++++++++++++++++ frontend/css/panel.css | 72 +++++++ frontend/desktop.html | 11 ++ frontend/js/apps/editor.js | 347 +++++++++++++++++++++++++++++++++ frontend/js/apps/files.js | 189 ++++++++++++++++-- frontend/js/apps/packages.js | 294 ++++++++++++++++++++++++++++ frontend/js/atlus.js | 72 +++++++ 12 files changed, 1798 insertions(+), 20 deletions(-) create mode 100644 backend/routers/packages.py create mode 100644 backend/routers/updates.py create mode 100644 frontend/css/apps/editor.css create mode 100644 frontend/css/apps/packages.css create mode 100644 frontend/js/apps/editor.js create mode 100644 frontend/js/apps/packages.js diff --git a/backend/main.py b/backend/main.py index dd4236d..f7eb2fb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,7 +11,7 @@ from pydantic import BaseModel from backend.auth import authenticate_user, create_token, logout from backend.config import FRONTEND_DIR, HOST, PORT -from backend.routers import stats, terminal, files, services, processes, settings, network +from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates from backend.routers.plugins import asi_bridge logging.basicConfig( @@ -87,6 +87,8 @@ app.include_router(services.router) app.include_router(processes.router) app.include_router(settings.router) app.include_router(network.router) +app.include_router(packages.router) +app.include_router(updates.router) app.include_router(asi_bridge.router) diff --git a/backend/routers/packages.py b/backend/routers/packages.py new file mode 100644 index 0000000..8a6fc72 --- /dev/null +++ b/backend/routers/packages.py @@ -0,0 +1,199 @@ +"""Package management — apt-get / dpkg wrapper for Debian-based systems.""" + +import asyncio +import os +import re +import shutil + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from backend.auth import get_current_user + +router = APIRouter(prefix="/api/packages", tags=["packages"]) + +# --------------------------------------------------------------------------- +# Guard +# --------------------------------------------------------------------------- + +_HAS_APT = bool(shutil.which("apt-cache")) + + +def _require_apt(): + if not _HAS_APT: + raise HTTPException(503, "apt package manager not available on this system") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_PKG_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9.+\-:]+$") + + +def _validate_name(name: str) -> str: + name = name.strip() + if not _PKG_NAME_RE.match(name): + raise HTTPException(400, f"Invalid package name: {name}") + return name + + +async def _apt(*args: str, timeout: float = 30) -> str: + """Run an apt/dpkg command and return stdout.""" + _require_apt() + cmd = list(args) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "LC_ALL": "C", "DEBIAN_FRONTEND": "noninteractive"}, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise HTTPException(504, "Package operation timed out") + if proc.returncode != 0: + msg = stderr.decode().strip() or stdout.decode().strip() + raise HTTPException(500, f"Package error: {msg}") + return stdout.decode() + + +async def _apt_nofail(*args: str, timeout: float = 30) -> tuple[int, str, str]: + """Run an apt/dpkg command, return (returncode, stdout, stderr) without raising.""" + _require_apt() + cmd = list(args) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "LC_ALL": "C", "DEBIAN_FRONTEND": "noninteractive"}, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return 1, "", "Timed out" + return proc.returncode, stdout.decode(), stderr.decode() + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +class PackageAction(BaseModel): + name: str + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@router.get("/search") +async def search_packages( + q: str = Query(..., min_length=2, description="Search query"), + _user: str = Depends(get_current_user), +): + """Search for packages matching a query.""" + output = await _apt("apt-cache", "search", q) + results = [] + for line in output.strip().splitlines(): + if not line.strip(): + continue + parts = line.split(" - ", 1) + if len(parts) == 2: + results.append({"name": parts[0].strip(), "summary": parts[1].strip()}) + if len(results) >= 100: + break + return results + + +@router.get("/info/{name}") +async def package_info( + name: str, + _user: str = Depends(get_current_user), +): + """Get detailed information about a package.""" + name = _validate_name(name) + + # Get package info from apt-cache + output = await _apt("apt-cache", "show", name) + + # Parse key-value fields + info = {} + current_key = None + current_val = "" + for line in output.splitlines(): + if line.startswith(" "): + # Continuation of previous field + current_val += "\n" + line.strip() + elif ": " in line: + if current_key: + info[current_key] = current_val + current_key, current_val = line.split(": ", 1) + elif line == "": + if current_key: + info[current_key] = current_val + current_key = None + current_val = "" + break # Only parse first stanza + if current_key: + info[current_key] = current_val + + # Check installed status + rc, dpkg_out, _ = await _apt_nofail( + "dpkg-query", "-W", "-f=${Status}\t${Version}\n", name + ) + installed_version = None + is_installed = False + if rc == 0 and dpkg_out.strip(): + parts = dpkg_out.strip().split("\t") + if len(parts) >= 2 and "install ok installed" in parts[0]: + is_installed = True + installed_version = parts[1] + + return { + "name": info.get("Package", name), + "version": info.get("Version", ""), + "installed": is_installed, + "installed_version": installed_version, + "description": info.get("Description", info.get("Description-en", "")), + "size": info.get("Size", ""), + "installed_size": info.get("Installed-Size", ""), + "depends": info.get("Depends", ""), + "section": info.get("Section", ""), + "maintainer": info.get("Maintainer", ""), + "homepage": info.get("Homepage", ""), + "architecture": info.get("Architecture", ""), + } + + +@router.post("/install") +async def install_package( + req: PackageAction, + _user: str = Depends(get_current_user), +): + """Install a package via apt-get.""" + name = _validate_name(req.name) + output = await _apt("apt-get", "install", "-y", name, timeout=300) + return {"success": True, "package": name, "output": output[-500:]} + + +@router.post("/remove") +async def remove_package( + req: PackageAction, + _user: str = Depends(get_current_user), +): + """Remove a package via apt-get.""" + name = _validate_name(req.name) + output = await _apt("apt-get", "remove", "-y", name, timeout=120) + return {"success": True, "package": name, "output": output[-500:]} + + +@router.post("/update-cache") +async def update_cache(_user: str = Depends(get_current_user)): + """Run apt-get update to refresh package lists.""" + output = await _apt("apt-get", "update", timeout=120) + return {"success": True, "output": output[-500:]} diff --git a/backend/routers/updates.py b/backend/routers/updates.py new file mode 100644 index 0000000..f1e6946 --- /dev/null +++ b/backend/routers/updates.py @@ -0,0 +1,166 @@ +"""Self-update — check Gitea repo for new commits and apply updates.""" + +import asyncio +import os +import shutil + +from fastapi import APIRouter, Depends, HTTPException + +from backend.auth import get_current_user +from backend.config import BASE_DIR + +router = APIRouter(prefix="/api/updates", tags=["updates"]) + +# --------------------------------------------------------------------------- +# Guard +# --------------------------------------------------------------------------- + +_IS_GIT = (BASE_DIR / ".git").is_dir() +_HAS_GIT = bool(shutil.which("git")) + + +def _require_git(): + if not _HAS_GIT: + raise HTTPException(503, "git is not installed") + if not _IS_GIT: + raise HTTPException(503, "Atlus was not installed via git — updates unavailable") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _git(*args: str, timeout: float = 30) -> str: + """Run a git command in the Atlus install directory.""" + _require_git() + cmd = ["git", "-C", str(BASE_DIR)] + list(args) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "LC_ALL": "C"}, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise HTTPException(504, "git operation timed out") + if proc.returncode != 0: + msg = stderr.decode().strip() or stdout.decode().strip() + raise HTTPException(500, f"git error: {msg}") + return stdout.decode().strip() + + +async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]: + """Run a git command, return (returncode, stdout) without raising.""" + _require_git() + cmd = ["git", "-C", str(BASE_DIR)] + list(args) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "LC_ALL": "C"}, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return 1, "" + return proc.returncode, stdout.decode().strip() + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@router.get("/check") +async def check_for_updates(_user: str = Depends(get_current_user)): + """Check if there are new commits on the remote.""" + # Get current local HEAD + local_hash = await _git("rev-parse", "HEAD") + + # Fetch latest from remote (may take a few seconds) + try: + await _git("fetch", "origin", timeout=30) + except HTTPException: + # Fetch failed (no network, etc.) — report no update + return { + "available": False, + "local_hash": local_hash[:8], + "remote_hash": local_hash[:8], + "behind_count": 0, + "error": "Could not reach remote repository", + } + + # Get remote HEAD + rc, remote_hash = await _git_nofail("rev-parse", "origin/main") + if rc != 0: + # Try origin/master as fallback + rc, remote_hash = await _git_nofail("rev-parse", "origin/master") + if rc != 0: + return { + "available": False, + "local_hash": local_hash[:8], + "remote_hash": "", + "behind_count": 0, + "error": "Could not determine remote branch", + } + + # Count commits behind + rc, count_str = await _git_nofail("rev-list", "--count", f"HEAD..{remote_hash}") + behind_count = int(count_str) if rc == 0 and count_str.isdigit() else 0 + + return { + "available": behind_count > 0, + "local_hash": local_hash[:8], + "remote_hash": remote_hash[:8], + "behind_count": behind_count, + } + + +@router.post("/apply") +async def apply_update(_user: str = Depends(get_current_user)): + """Pull latest changes and schedule a service restart.""" + # Pull latest + pull_output = await _git("pull", "--ff-only", timeout=60) + + # Reinstall Python dependencies + pip_bin = str(BASE_DIR / "venv" / "bin" / "pip") + if not os.path.exists(pip_bin): + # Fallback: try system pip + pip_bin = shutil.which("pip3") or shutil.which("pip") or "pip3" + + req_file = str(BASE_DIR / "backend" / "requirements.txt") + if os.path.exists(req_file): + proc = await asyncio.create_subprocess_exec( + pip_bin, "install", "-r", req_file, "-q", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + await asyncio.wait_for(proc.communicate(), timeout=120) + except asyncio.TimeoutError: + pass # Non-fatal — deps might already be satisfied + + # Schedule restart after response is sent + if shutil.which("systemctl"): + asyncio.create_task(_delayed_restart()) + + return { + "success": True, + "message": "Update applied. Restarting service...", + "pull_output": pull_output[-500:], + } + + +async def _delayed_restart(delay: float = 2.0): + """Wait briefly then restart the atlus systemd service.""" + await asyncio.sleep(delay) + proc = await asyncio.create_subprocess_exec( + "systemctl", "restart", "atlus", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.wait() diff --git a/frontend/css/apps/editor.css b/frontend/css/apps/editor.css new file mode 100644 index 0000000..cf0bdba --- /dev/null +++ b/frontend/css/apps/editor.css @@ -0,0 +1,195 @@ +/* Editor / File Viewer app styles */ +.app-editor { + display: flex; + flex-direction: column; + height: 100%; +} + +.editor-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 12px; + background: var(--bg-titlebar); + border-bottom: 1px solid var(--border-structural); + min-height: 44px; +} + +.editor-title-wrap { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.editor-title { + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.editor-dirty { + color: var(--status-amber); + font-size: 12px; + flex-shrink: 0; +} + +.editor-save-btn { + height: 32px; + padding: 0 16px; + background: var(--accent); + border: none; + border-radius: var(--radius-sm); + color: #fff; + font-family: var(--font-ui); + font-size: 13px; + font-weight: 500; + cursor: pointer; + flex-shrink: 0; +} + +.editor-save-btn:hover { + opacity: 0.9; +} + +.editor-save-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Main content area */ +.editor-content { + flex: 1; + display: flex; + overflow: hidden; +} + +/* Text editor body */ +.editor-body { + display: flex; + flex: 1; + overflow: hidden; +} + +.editor-lines { + width: 48px; + padding: 12px 8px 12px 4px; + background: var(--bg-dock); + border-right: 1px solid var(--border-structural); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.5; + color: var(--text-muted); + text-align: right; + overflow: hidden; + white-space: pre; + user-select: none; + -webkit-user-select: none; +} + +.editor-textarea { + flex: 1; + padding: 12px; + background: var(--bg-stage); + border: none; + outline: none; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.5; + resize: none; + tab-size: 4; + -moz-tab-size: 4; + white-space: pre; + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +.editor-textarea::selection { + background: var(--accent-dim); +} + +/* Image preview */ +.editor-preview { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + overflow: auto; + -webkit-overflow-scrolling: touch; + background: var(--bg-stage); +} + +.editor-preview-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: var(--radius-md); + box-shadow: 0 4px 24px rgba(0,0,0,0.3); +} + +/* Binary file info */ +.editor-binary-info { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px; + background: var(--bg-stage); +} + +.binary-icon { + font-size: 48px; +} + +.binary-name { + font-family: var(--font-mono); + font-size: 16px; + font-weight: 500; + color: var(--text-primary); +} + +.binary-meta { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); +} + +.editor-download-btn { + margin-top: 12px; + height: 40px; + padding: 0 24px; + background: var(--accent); + border: none; + border-radius: var(--radius-md); + color: #fff; + font-family: var(--font-ui); + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.editor-download-btn:hover { + opacity: 0.9; +} + +/* Status bar */ +.editor-status { + padding: 4px 12px; + background: var(--bg-titlebar); + border-top: 1px solid var(--border-structural); + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + min-height: 24px; + display: flex; + align-items: center; +} diff --git a/frontend/css/apps/files.css b/frontend/css/apps/files.css index 239bddb..a90a452 100644 --- a/frontend/css/apps/files.css +++ b/frontend/css/apps/files.css @@ -50,6 +50,13 @@ font-size: 12px; } +.files-btn-group { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + .files-action-btn { width: 36px; height: 36px; diff --git a/frontend/css/apps/packages.css b/frontend/css/apps/packages.css new file mode 100644 index 0000000..fdb1b93 --- /dev/null +++ b/frontend/css/apps/packages.css @@ -0,0 +1,262 @@ +/* Package Manager app styles */ +.app-packages { + display: flex; + flex-direction: column; + height: 100%; +} + +.pkg-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-titlebar); + border-bottom: 1px solid var(--border-structural); + min-height: 48px; +} + +.pkg-search { + flex: 1; + height: 36px; + padding: 0 12px; + background: var(--bg-input); + border: 1px solid var(--border-structural); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + outline: none; +} + +.pkg-search:focus { + border-color: var(--accent); +} + +.pkg-search::placeholder { + color: var(--text-muted); +} + +.pkg-cache-btn { + height: 36px; + padding: 0 14px; + background: var(--bg-titlebar); + border: 1px solid var(--border-structural); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-ui); + font-size: 13px; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} + +.pkg-cache-btn:hover { + background: var(--accent-hover); +} + +.pkg-cache-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Results list */ +.pkg-list { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.pkg-empty, +.pkg-loading, +.pkg-error { + padding: 24px; + text-align: center; + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-muted); +} + +.pkg-error { + color: var(--status-red); +} + +/* Package row */ +.pkg-row { + border-bottom: 1px solid var(--border-structural); +} + +.pkg-row-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + cursor: pointer; + transition: background var(--transition-fast); + min-height: 48px; + gap: 12px; +} + +.pkg-row-header:hover { + background: var(--accent-hover); +} + +.pkg-row.expanded .pkg-row-header { + background: var(--accent-dim); +} + +.pkg-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.pkg-name { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.pkg-summary { + font-family: var(--font-ui); + font-size: 12px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pkg-expand-icon { + color: var(--text-muted); + font-size: 12px; + flex-shrink: 0; + width: 16px; + text-align: center; +} + +/* Package detail */ +.pkg-detail { + padding: 0 16px 16px; + background: var(--bg-dock); + border-top: 1px solid var(--border-structural); +} + +.pkg-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + flex-wrap: wrap; +} + +.pkg-detail-version { + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-secondary); +} + +.pkg-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + margin-left: 8px; +} + +.pkg-badge.installed { + background: rgba(58, 184, 106, 0.15); + color: var(--status-green); +} + +.pkg-badge.not-installed { + background: var(--border-structural); + color: var(--text-muted); +} + +.pkg-detail-actions { + flex-shrink: 0; +} + +.pkg-action-btn { + height: 32px; + padding: 0 16px; + border: none; + border-radius: var(--radius-sm); + font-family: var(--font-ui); + font-size: 13px; + font-weight: 500; + cursor: pointer; +} + +.pkg-action-btn.install { + background: var(--accent); + color: #fff; +} + +.pkg-action-btn.install:hover { + opacity: 0.9; +} + +.pkg-action-btn.remove { + background: var(--status-red); + color: #fff; +} + +.pkg-action-btn.remove:hover { + opacity: 0.9; +} + +.pkg-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pkg-detail-desc { + font-family: var(--font-ui); + font-size: 13px; + color: var(--text-primary); + line-height: 1.5; + padding: 8px 0; + white-space: pre-wrap; +} + +.pkg-detail-meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + padding: 4px 0; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); +} + +.pkg-detail-deps { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + padding: 4px 0; + word-break: break-word; +} + +.pkg-deps-label { + color: var(--text-muted); + font-weight: 500; +} + +/* Status bar */ +.pkg-status { + padding: 6px 16px; + background: var(--bg-titlebar); + border-top: 1px solid var(--border-structural); + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + min-height: 28px; + display: flex; + align-items: center; +} diff --git a/frontend/css/panel.css b/frontend/css/panel.css index db6374e..def26b3 100644 --- a/frontend/css/panel.css +++ b/frontend/css/panel.css @@ -212,3 +212,75 @@ background: var(--accent-hover); color: var(--accent); } + +/* Update toast */ +.panel-updates { + transition: all var(--transition-base); +} + +.update-toast { + position: relative; + padding: 12px; + background: var(--accent-dim); + border-left: 3px solid var(--accent); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +.update-dismiss { + position: absolute; + top: 4px; + right: 4px; + width: 24px; + height: 24px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); +} + +.update-dismiss:hover { + background: var(--accent-hover); + color: var(--text-primary); +} + +.update-toast-title { + font-family: var(--font-ui); + font-size: 13px; + font-weight: 500; + color: var(--accent); + margin-bottom: 4px; +} + +.update-toast-info { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 10px; +} + +.update-toast-btn { + width: 100%; + height: 30px; + background: var(--accent); + border: none; + border-radius: var(--radius-sm); + color: #fff; + font-family: var(--font-ui); + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.update-toast-btn:hover { + opacity: 0.9; +} + +.update-toast-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/frontend/desktop.html b/frontend/desktop.html index 595afa2..c7fadeb 100644 --- a/frontend/desktop.html +++ b/frontend/desktop.html @@ -21,6 +21,8 @@ + + @@ -54,6 +56,10 @@ 🌐 Network +
+ `; + 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 = `
${name}
Could not load file info
`; + } + + 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); + } + }, + }); +})(); diff --git a/frontend/js/apps/files.js b/frontend/js/apps/files.js index 56ead96..a2c9f50 100644 --- a/frontend/js/apps/files.js +++ b/frontend/js/apps/files.js @@ -9,6 +9,7 @@ let sidebarEl = null; let selectedFiles = new Set(); let contextMenuEl = null; + let uploadInput = null; const FILE_ICONS = { dir: '📁', @@ -42,6 +43,20 @@ }); } + // ---- 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(); @@ -100,6 +115,7 @@ ${entry.permissions} `; + // Single click — navigate dir or toggle selection row.addEventListener('click', () => { if (entry.is_dir) { loadDirectory(entry.path); @@ -114,6 +130,14 @@ } }); + // 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) => { @@ -160,19 +184,35 @@ }); } + // ---- Context menu ---- + function showContextMenu(e, entry) { hideContextMenu(); contextMenuEl = document.createElement('div'); contextMenuEl.className = 'file-context-menu'; - const items = [ - { label: 'Open', action: () => entry.is_dir ? loadDirectory(entry.path) : previewFile(entry) }, - { label: 'Rename', action: () => renameFile(entry) }, - { label: 'Copy', action: () => { /* clipboard */ } }, - { label: 'Move', action: () => { /* clipboard */ } }, - { sep: true }, - { label: 'Delete', action: () => deleteFile(entry), danger: true }, - ]; + 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) { @@ -211,6 +251,34 @@ } } + // ---- 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', { @@ -220,6 +288,20 @@ 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; @@ -230,17 +312,26 @@ loadDirectory(currentPath); } - async function previewFile(entry) { - // Simple text preview - try { - const res = await Atlus.apiFetch(`/api/files/read?path=${encodeURIComponent(entry.path)}`); - if (res.ok) { - const data = await res.json(); - alert(data.content.substring(0, 2000)); - } - } catch (e) {} + 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'); @@ -265,6 +356,8 @@ } catch (e) {} } + // ---- App registration ---- + Atlus.registerApp('files', { title: 'Files', @@ -279,14 +372,62 @@ breadcrumbEl.className = 'files-breadcrumb'; toolbar.appendChild(breadcrumbEl); - // Refresh button + // 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)); - toolbar.appendChild(refreshBtn); + btnGroup.appendChild(refreshBtn); + toolbar.appendChild(btnGroup); container.appendChild(toolbar); // Body @@ -330,6 +471,15 @@ 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); @@ -346,6 +496,7 @@ fileListEl = null; breadcrumbEl = null; sidebarEl = null; + uploadInput = null; currentPath = '/'; selectedFiles.clear(); }, diff --git a/frontend/js/apps/packages.js b/frontend/js/apps/packages.js new file mode 100644 index 0000000..d5b8fd6 --- /dev/null +++ b/frontend/js/apps/packages.js @@ -0,0 +1,294 @@ +/* Atlus — Package Manager app */ +(function () { + 'use strict'; + + let container = null; + let searchInput = null; + let listEl = null; + let statusEl = null; + let searchTimeout = null; + let expandedPkg = null; // currently expanded package name + + // ---- Search ---- + + async function searchPackages(query) { + if (!query || query.length < 2) { + listEl.innerHTML = '
Type at least 2 characters to search packages
'; + return; + } + + listEl.innerHTML = '
Searching…
'; + + try { + const res = await Atlus.apiFetch(`/api/packages/search?q=${encodeURIComponent(query)}`); + if (res.status === 503) { + listEl.innerHTML = '
Package manager not available on this system
'; + return; + } + if (!res.ok) throw new Error('Search failed'); + const results = await res.json(); + + if (results.length === 0) { + listEl.innerHTML = '
No packages found
'; + return; + } + + renderResults(results); + updateStatus(`${results.length} result${results.length !== 1 ? 's' : ''}`); + } catch (e) { + listEl.innerHTML = `
Error: ${e.message}
`; + } + } + + // ---- Render results ---- + + function renderResults(results) { + listEl.innerHTML = ''; + + results.forEach(pkg => { + const row = document.createElement('div'); + row.className = 'pkg-row'; + row.dataset.name = pkg.name; + + row.innerHTML = ` +
+
+ ${pkg.name} + ${pkg.summary || ''} +
+ +
+ + `; + + const header = row.querySelector('.pkg-row-header'); + const detail = row.querySelector('.pkg-detail'); + + header.addEventListener('click', () => { + if (expandedPkg === pkg.name) { + // Collapse + detail.classList.add('hidden'); + row.classList.remove('expanded'); + row.querySelector('.pkg-expand-icon').textContent = '▸'; + expandedPkg = null; + } else { + // Collapse previous + if (expandedPkg) { + const prev = listEl.querySelector(`.pkg-row[data-name="${expandedPkg}"]`); + if (prev) { + prev.querySelector('.pkg-detail').classList.add('hidden'); + prev.classList.remove('expanded'); + prev.querySelector('.pkg-expand-icon').textContent = '▸'; + } + } + // Expand this + expandedPkg = pkg.name; + row.classList.add('expanded'); + row.querySelector('.pkg-expand-icon').textContent = '▾'; + loadPackageDetail(pkg.name, detail); + } + }); + + listEl.appendChild(row); + }); + } + + // ---- Package detail ---- + + async function loadPackageDetail(name, detailEl) { + detailEl.classList.remove('hidden'); + detailEl.innerHTML = '
Loading…
'; + + try { + const res = await Atlus.apiFetch(`/api/packages/info/${encodeURIComponent(name)}`); + if (!res.ok) throw new Error('Failed to load package info'); + const info = await res.json(); + renderDetail(info, detailEl); + } catch (e) { + detailEl.innerHTML = `
Error: ${e.message}
`; + } + } + + function renderDetail(info, detailEl) { + const installedBadge = info.installed + ? `Installed ${info.installed_version || ''}` + : `Not installed`; + + const sizeText = info.installed_size + ? `${info.installed_size} KB installed` + : (info.size ? `${Atlus.formatBytes(parseInt(info.size))} download` : ''); + + detailEl.innerHTML = ` +
+
+ v${info.version} + ${installedBadge} +
+
+ ${info.installed + ? `` + : `` + } +
+
+
${info.description || 'No description available'}
+
+ ${info.section ? `Section: ${info.section}` : ''} + ${sizeText ? `Size: ${sizeText}` : ''} + ${info.architecture ? `Arch: ${info.architecture}` : ''} +
+ ${info.depends ? `
Depends: ${info.depends}
` : ''} + ${info.homepage ? `
Homepage: ${info.homepage}
` : ''} + `; + + // Action button handler + const actionBtn = detailEl.querySelector('.pkg-action-btn'); + if (actionBtn) { + actionBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const pkgName = actionBtn.dataset.name; + if (actionBtn.classList.contains('install')) { + installPackage(pkgName, actionBtn, detailEl); + } else { + removePackage(pkgName, actionBtn, detailEl); + } + }); + } + } + + // ---- Install / Remove ---- + + async function installPackage(name, btn, detailEl) { + btn.disabled = true; + btn.textContent = 'Installing…'; + updateStatus(`Installing ${name}…`); + + try { + const res = await Atlus.apiFetch('/api/packages/install', { + method: 'POST', + body: { name }, + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Install failed'); + } + updateStatus(`${name} installed successfully`); + // Reload detail to reflect new status + await loadPackageDetail(name, detailEl); + } catch (e) { + btn.disabled = false; + btn.textContent = 'Install'; + updateStatus(`Error: ${e.message}`); + } + } + + async function removePackage(name, btn, detailEl) { + if (!confirm(`Remove package "${name}"?`)) return; + btn.disabled = true; + btn.textContent = 'Removing…'; + updateStatus(`Removing ${name}…`); + + try { + const res = await Atlus.apiFetch('/api/packages/remove', { + method: 'POST', + body: { name }, + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Remove failed'); + } + updateStatus(`${name} removed successfully`); + // Reload detail to reflect new status + await loadPackageDetail(name, detailEl); + } catch (e) { + btn.disabled = false; + btn.textContent = 'Remove'; + updateStatus(`Error: ${e.message}`); + } + } + + // ---- Refresh cache ---- + + async function refreshCache(btn) { + btn.disabled = true; + btn.textContent = 'Updating…'; + updateStatus('Updating package cache…'); + + try { + const res = await Atlus.apiFetch('/api/packages/update-cache', { + method: 'POST', + }); + if (!res.ok) throw new Error('Cache update failed'); + updateStatus('Package cache updated'); + } catch (e) { + updateStatus(`Error: ${e.message}`); + } + btn.disabled = false; + btn.textContent = 'Refresh Cache'; + } + + // ---- Status ---- + + function updateStatus(text) { + if (statusEl) statusEl.textContent = text; + } + + // ---- App registration ---- + + Atlus.registerApp('packages', { + title: 'Packages', + + init(el) { + container = el; + container.classList.add('app-packages'); + + // Toolbar + const toolbar = document.createElement('div'); + toolbar.className = 'pkg-toolbar'; + + searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.className = 'pkg-search'; + searchInput.placeholder = 'Search packages…'; + searchInput.addEventListener('input', () => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + searchPackages(searchInput.value.trim()); + }, 300); + }); + toolbar.appendChild(searchInput); + + const cacheBtn = document.createElement('button'); + cacheBtn.className = 'pkg-cache-btn'; + cacheBtn.textContent = 'Refresh Cache'; + cacheBtn.addEventListener('click', () => refreshCache(cacheBtn)); + toolbar.appendChild(cacheBtn); + + container.appendChild(toolbar); + + // Results list + listEl = document.createElement('div'); + listEl.className = 'pkg-list'; + listEl.innerHTML = '
Type at least 2 characters to search packages
'; + container.appendChild(listEl); + + // Status bar + statusEl = document.createElement('div'); + statusEl.className = 'pkg-status'; + statusEl.textContent = 'Ready'; + container.appendChild(statusEl); + + // Focus search + setTimeout(() => searchInput.focus(), 100); + }, + + destroy() { + clearTimeout(searchTimeout); + container = null; + searchInput = null; + listEl = null; + statusEl = null; + expandedPkg = null; + }, + }); +})(); diff --git a/frontend/js/atlus.js b/frontend/js/atlus.js index 9f46587..18813bd 100644 --- a/frontend/js/atlus.js +++ b/frontend/js/atlus.js @@ -382,16 +382,88 @@ } catch (e) { /* ignore */ } } + // ===================================================================== + // Panel — Update checker + // ===================================================================== + let updateDismissed = false; + + async function checkForUpdates() { + if (updateDismissed) return; + try { + const res = await Atlus.apiFetch('/api/updates/check'); + if (!res.ok) return; + const data = await res.json(); + const panel = $('#panelUpdates'); + if (!panel) return; + + if (data.available && data.behind_count > 0) { + showUpdateToast(panel, data); + } else { + panel.classList.add('hidden'); + } + } catch (e) { /* ignore */ } + } + + function showUpdateToast(panel, data) { + panel.classList.remove('hidden'); + panel.innerHTML = ` +
+ +
Update available
+
${data.behind_count} commit${data.behind_count !== 1 ? 's' : ''} behind (${data.remote_hash})
+ +
+ `; + + panel.querySelector('.update-dismiss').addEventListener('click', () => { + panel.classList.add('hidden'); + updateDismissed = true; + }); + + panel.querySelector('.update-toast-btn').addEventListener('click', async (e) => { + const btn = e.target; + btn.disabled = true; + btn.textContent = 'Updating…'; + + try { + const res = await Atlus.apiFetch('/api/updates/apply', { method: 'POST' }); + if (res.ok) { + btn.textContent = 'Restarting…'; + // Server will restart — try to reload after a delay + setTimeout(() => attemptReload(0), 4000); + } else { + btn.textContent = 'Update failed'; + btn.disabled = false; + } + } catch (e) { + // Connection lost = server restarting + btn.textContent = 'Restarting…'; + setTimeout(() => attemptReload(0), 4000); + } + }); + } + + function attemptReload(attempt) { + if (attempt > 10) return; // Give up after ~30s + fetch('/desktop', { method: 'HEAD' }) + .then(() => window.location.reload()) + .catch(() => setTimeout(() => attemptReload(attempt + 1), 3000)); + } + // ===================================================================== // Init // ===================================================================== loadHostname(); loadPanelServices(); connectStats(); + checkForUpdates(); // Refresh services panel periodically setInterval(loadPanelServices, 30000); + // Check for updates every 30 minutes + setInterval(checkForUpdates, 30 * 60 * 1000); + // Expose for app modules window.Atlus.openApp = openApp; window.Atlus.closeApp = closeApp;