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>
This commit is contained in:
roberts 2026-03-14 19:13:13 -05:00
parent 4631ebc07a
commit 9c402e3726
12 changed files with 1798 additions and 20 deletions

View file

@ -11,7 +11,7 @@ from pydantic import BaseModel
from backend.auth import authenticate_user, create_token, logout from backend.auth import authenticate_user, create_token, logout
from backend.config import FRONTEND_DIR, HOST, PORT 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 from backend.routers.plugins import asi_bridge
logging.basicConfig( logging.basicConfig(
@ -87,6 +87,8 @@ app.include_router(services.router)
app.include_router(processes.router) app.include_router(processes.router)
app.include_router(settings.router) app.include_router(settings.router)
app.include_router(network.router) app.include_router(network.router)
app.include_router(packages.router)
app.include_router(updates.router)
app.include_router(asi_bridge.router) app.include_router(asi_bridge.router)

199
backend/routers/packages.py Normal file
View file

@ -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:]}

166
backend/routers/updates.py Normal file
View file

@ -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()

View file

@ -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;
}

View file

@ -50,6 +50,13 @@
font-size: 12px; font-size: 12px;
} }
.files-btn-group {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.files-action-btn { .files-action-btn {
width: 36px; width: 36px;
height: 36px; height: 36px;

View file

@ -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;
}

View file

@ -212,3 +212,75 @@
background: var(--accent-hover); background: var(--accent-hover);
color: var(--accent); 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;
}

View file

@ -21,6 +21,8 @@
<link rel="stylesheet" href="/css/apps/services.css"> <link rel="stylesheet" href="/css/apps/services.css">
<link rel="stylesheet" href="/css/apps/tasks.css"> <link rel="stylesheet" href="/css/apps/tasks.css">
<link rel="stylesheet" href="/css/apps/settings.css"> <link rel="stylesheet" href="/css/apps/settings.css">
<link rel="stylesheet" href="/css/apps/packages.css">
<link rel="stylesheet" href="/css/apps/editor.css">
</head> </head>
<body> <body>
<!-- ================================================================= --> <!-- ================================================================= -->
@ -54,6 +56,10 @@
<span class="dock-icon">🌐</span> <span class="dock-icon">🌐</span>
<span class="dock-label">Network</span> <span class="dock-label">Network</span>
</button> </button>
<button class="dock-item" data-app="packages">
<span class="dock-icon">📦</span>
<span class="dock-label">Packages</span>
</button>
<div class="dock-separator"></div> <div class="dock-separator"></div>
<button class="dock-item" data-app="asi-bridge"> <button class="dock-item" data-app="asi-bridge">
<span class="dock-icon">🔭</span> <span class="dock-icon">🔭</span>
@ -131,6 +137,9 @@
</div> </div>
</div> </div>
<!-- Update notification -->
<div class="panel-section panel-updates hidden" id="panelUpdates"></div>
<!-- Network --> <!-- Network -->
<div class="panel-section panel-network"> <div class="panel-section panel-network">
<div class="panel-section-title">NETWORK</div> <div class="panel-section-title">NETWORK</div>
@ -196,6 +205,8 @@
<script src="/js/apps/services.js"></script> <script src="/js/apps/services.js"></script>
<script src="/js/apps/tasks.js"></script> <script src="/js/apps/tasks.js"></script>
<script src="/js/apps/settings.js"></script> <script src="/js/apps/settings.js"></script>
<script src="/js/apps/packages.js"></script>
<script src="/js/apps/editor.js"></script>
<script src="/js/apps/asi_bridge.js"></script> <script src="/js/apps/asi_bridge.js"></script>
</body> </body>
</html> </html>

347
frontend/js/apps/editor.js Normal file
View file

@ -0,0 +1,347 @@
/* 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);
}
},
});
})();

View file

@ -9,6 +9,7 @@
let sidebarEl = null; let sidebarEl = null;
let selectedFiles = new Set(); let selectedFiles = new Set();
let contextMenuEl = null; let contextMenuEl = null;
let uploadInput = null;
const FILE_ICONS = { const FILE_ICONS = {
dir: '📁', 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) { async function loadDirectory(path) {
currentPath = path; currentPath = path;
selectedFiles.clear(); selectedFiles.clear();
@ -100,6 +115,7 @@
<span class="file-perms">${entry.permissions}</span> <span class="file-perms">${entry.permissions}</span>
`; `;
// Single click — navigate dir or toggle selection
row.addEventListener('click', () => { row.addEventListener('click', () => {
if (entry.is_dir) { if (entry.is_dir) {
loadDirectory(entry.path); 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 // Long-press for context menu
let pressTimer; let pressTimer;
row.addEventListener('touchstart', (e) => { row.addEventListener('touchstart', (e) => {
@ -160,19 +184,35 @@
}); });
} }
// ---- Context menu ----
function showContextMenu(e, entry) { function showContextMenu(e, entry) {
hideContextMenu(); hideContextMenu();
contextMenuEl = document.createElement('div'); contextMenuEl = document.createElement('div');
contextMenuEl.className = 'file-context-menu'; contextMenuEl.className = 'file-context-menu';
const items = [ const items = [];
{ label: 'Open', action: () => entry.is_dir ? loadDirectory(entry.path) : previewFile(entry) },
{ label: 'Rename', action: () => renameFile(entry) }, // If right-clicked on empty area or a specific entry
{ label: 'Copy', action: () => { /* clipboard */ } }, if (entry) {
{ label: 'Move', action: () => { /* clipboard */ } }, if (entry.is_dir) {
{ sep: true }, items.push({ label: 'Open', action: () => loadDirectory(entry.path) });
{ label: 'Delete', action: () => deleteFile(entry), danger: true }, } 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 => { items.forEach(item => {
if (item.sep) { 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) { async function deleteFile(entry) {
if (!confirm(`Delete "${entry.name}"?`)) return; if (!confirm(`Delete "${entry.name}"?`)) return;
await Atlus.apiFetch('/api/files/delete', { await Atlus.apiFetch('/api/files/delete', {
@ -220,6 +288,20 @@
loadDirectory(currentPath); 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) { async function renameFile(entry) {
const newName = prompt('New name:', entry.name); const newName = prompt('New name:', entry.name);
if (!newName || newName === entry.name) return; if (!newName || newName === entry.name) return;
@ -230,17 +312,26 @@
loadDirectory(currentPath); loadDirectory(currentPath);
} }
async function previewFile(entry) { async function uploadFile() {
// Simple text preview if (!uploadInput) return;
uploadInput.click();
}
async function handleUpload(file) {
const formData = new FormData();
formData.append('file', file);
try { try {
const res = await Atlus.apiFetch(`/api/files/read?path=${encodeURIComponent(entry.path)}`); await Atlus.apiFetch(`/api/files/upload?dest_dir=${encodeURIComponent(currentPath)}`, {
if (res.ok) { method: 'POST',
const data = await res.json(); body: formData,
alert(data.content.substring(0, 2000)); headers: {}, // Let browser set content-type for multipart
} });
} catch (e) {} loadDirectory(currentPath);
} catch (e) { /* ignore */ }
} }
// ---- Mounts ----
async function loadMounts() { async function loadMounts() {
try { try {
const res = await Atlus.apiFetch('/api/files/mounts'); const res = await Atlus.apiFetch('/api/files/mounts');
@ -265,6 +356,8 @@
} catch (e) {} } catch (e) {}
} }
// ---- App registration ----
Atlus.registerApp('files', { Atlus.registerApp('files', {
title: 'Files', title: 'Files',
@ -279,14 +372,62 @@
breadcrumbEl.className = 'files-breadcrumb'; breadcrumbEl.className = 'files-breadcrumb';
toolbar.appendChild(breadcrumbEl); 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'); const refreshBtn = document.createElement('button');
refreshBtn.className = 'files-action-btn'; refreshBtn.className = 'files-action-btn';
refreshBtn.textContent = '↻'; refreshBtn.textContent = '↻';
refreshBtn.title = 'Refresh'; refreshBtn.title = 'Refresh';
refreshBtn.addEventListener('click', () => loadDirectory(currentPath)); refreshBtn.addEventListener('click', () => loadDirectory(currentPath));
toolbar.appendChild(refreshBtn); btnGroup.appendChild(refreshBtn);
toolbar.appendChild(btnGroup);
container.appendChild(toolbar); container.appendChild(toolbar);
// Body // Body
@ -330,6 +471,15 @@
fileListEl = document.createElement('div'); fileListEl = document.createElement('div');
fileListEl.className = 'files-list'; 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); listPanel.appendChild(fileListEl);
body.appendChild(listPanel); body.appendChild(listPanel);
@ -346,6 +496,7 @@
fileListEl = null; fileListEl = null;
breadcrumbEl = null; breadcrumbEl = null;
sidebarEl = null; sidebarEl = null;
uploadInput = null;
currentPath = '/'; currentPath = '/';
selectedFiles.clear(); selectedFiles.clear();
}, },

View file

@ -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 = '<div class="pkg-empty">Type at least 2 characters to search packages</div>';
return;
}
listEl.innerHTML = '<div class="pkg-loading">Searching…</div>';
try {
const res = await Atlus.apiFetch(`/api/packages/search?q=${encodeURIComponent(query)}`);
if (res.status === 503) {
listEl.innerHTML = '<div class="pkg-empty">Package manager not available on this system</div>';
return;
}
if (!res.ok) throw new Error('Search failed');
const results = await res.json();
if (results.length === 0) {
listEl.innerHTML = '<div class="pkg-empty">No packages found</div>';
return;
}
renderResults(results);
updateStatus(`${results.length} result${results.length !== 1 ? 's' : ''}`);
} catch (e) {
listEl.innerHTML = `<div class="pkg-error">Error: ${e.message}</div>`;
}
}
// ---- 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 = `
<div class="pkg-row-header">
<div class="pkg-info">
<span class="pkg-name">${pkg.name}</span>
<span class="pkg-summary">${pkg.summary || ''}</span>
</div>
<span class="pkg-expand-icon"></span>
</div>
<div class="pkg-detail hidden"></div>
`;
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 = '<div class="pkg-loading">Loading…</div>';
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 = `<div class="pkg-error">Error: ${e.message}</div>`;
}
}
function renderDetail(info, detailEl) {
const installedBadge = info.installed
? `<span class="pkg-badge installed">Installed ${info.installed_version || ''}</span>`
: `<span class="pkg-badge not-installed">Not installed</span>`;
const sizeText = info.installed_size
? `${info.installed_size} KB installed`
: (info.size ? `${Atlus.formatBytes(parseInt(info.size))} download` : '');
detailEl.innerHTML = `
<div class="pkg-detail-header">
<div>
<span class="pkg-detail-version">v${info.version}</span>
${installedBadge}
</div>
<div class="pkg-detail-actions">
${info.installed
? `<button class="pkg-action-btn remove" data-name="${info.name}">Remove</button>`
: `<button class="pkg-action-btn install" data-name="${info.name}">Install</button>`
}
</div>
</div>
<div class="pkg-detail-desc">${info.description || 'No description available'}</div>
<div class="pkg-detail-meta">
${info.section ? `<span>Section: ${info.section}</span>` : ''}
${sizeText ? `<span>Size: ${sizeText}</span>` : ''}
${info.architecture ? `<span>Arch: ${info.architecture}</span>` : ''}
</div>
${info.depends ? `<div class="pkg-detail-deps"><span class="pkg-deps-label">Depends:</span> ${info.depends}</div>` : ''}
${info.homepage ? `<div class="pkg-detail-meta"><span>Homepage: ${info.homepage}</span></div>` : ''}
`;
// 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 = '<div class="pkg-empty">Type at least 2 characters to search packages</div>';
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;
},
});
})();

View file

@ -382,16 +382,88 @@
} catch (e) { /* ignore */ } } 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 = `
<div class="update-toast">
<button class="update-dismiss" title="Dismiss">&times;</button>
<div class="update-toast-title">Update available</div>
<div class="update-toast-info">${data.behind_count} commit${data.behind_count !== 1 ? 's' : ''} behind (${data.remote_hash})</div>
<button class="update-toast-btn">Install Update</button>
</div>
`;
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 // Init
// ===================================================================== // =====================================================================
loadHostname(); loadHostname();
loadPanelServices(); loadPanelServices();
connectStats(); connectStats();
checkForUpdates();
// Refresh services panel periodically // Refresh services panel periodically
setInterval(loadPanelServices, 30000); setInterval(loadPanelServices, 30000);
// Check for updates every 30 minutes
setInterval(checkForUpdates, 30 * 60 * 1000);
// Expose for app modules // Expose for app modules
window.Atlus.openApp = openApp; window.Atlus.openApp = openApp;
window.Atlus.closeApp = closeApp; window.Atlus.closeApp = closeApp;