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:
parent
4631ebc07a
commit
9c402e3726
12 changed files with 1798 additions and 20 deletions
|
|
@ -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
199
backend/routers/packages.py
Normal 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
166
backend/routers/updates.py
Normal 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()
|
||||||
195
frontend/css/apps/editor.css
Normal file
195
frontend/css/apps/editor.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
262
frontend/css/apps/packages.css
Normal file
262
frontend/css/apps/packages.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
347
frontend/js/apps/editor.js
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -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;
|
||||||
try {
|
uploadInput.click();
|
||||||
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 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() {
|
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();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
294
frontend/js/apps/packages.js
Normal file
294
frontend/js/apps/packages.js
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -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">×</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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue