move python clide to legacy/
Clide is being rebuilt as a Flutter desktop app. The Python Textual implementation moves wholesale into legacy/ rather than being deleted: its pane model, panel set, git skills, and panel communication design are real thought that should remain readable next to the new code while the rebuild finds its shape. Git's rename tracking preserves history, so `git log -- legacy/` still works. The Flutter rebuild lives at the repo root alongside a Go sidecar (the architecture claudian was heading toward, which folds into clide as a core component rather than a separate plugin project). Bootstrap of the new shape lands in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""clide-web: Web server for Clide TUI."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Entry point for python -m clide_web."""
|
||||
|
||||
from clide_web.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Configuration for clide-web using Pydantic Settings + DB preferences."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = Path.home() / ".clide" / "clide.db"
|
||||
|
||||
|
||||
class ClideWebSettings(BaseSettings):
|
||||
"""Web server settings.
|
||||
|
||||
Priority: env vars > .env file > DB preferences > defaults.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CLIDE_WEB_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8888
|
||||
|
||||
# Paths
|
||||
projects_dir: Path = Path("/mnt/media/Projects")
|
||||
clide_bin: str = "clide"
|
||||
|
||||
# Database
|
||||
db_path: Path = DB_PATH
|
||||
|
||||
# Sessions
|
||||
session_timeout_seconds: int = 3600
|
||||
session_cleanup_interval_seconds: int = 60
|
||||
|
||||
# Terminal
|
||||
default_cols: int = 120
|
||||
default_rows: int = 40
|
||||
term: str = "xterm-256color"
|
||||
|
||||
# UI
|
||||
font_family: str = "JetBrains Mono, monospace"
|
||||
font_size: int = 14
|
||||
default_theme: str = "summer-night"
|
||||
|
||||
|
||||
def load_settings() -> ClideWebSettings:
|
||||
"""Load settings, overlaying DB preferences onto defaults.
|
||||
|
||||
Env vars still take highest priority (Pydantic handles that).
|
||||
DB preferences override hardcoded defaults for fields not set via env.
|
||||
"""
|
||||
import os
|
||||
|
||||
# First load from env/defaults
|
||||
settings = ClideWebSettings()
|
||||
|
||||
# Then overlay DB preferences for fields not explicitly set via env
|
||||
try:
|
||||
prefs = _load_db_preferences(settings.db_path)
|
||||
except Exception:
|
||||
logger.debug("Could not load DB preferences (DB may not exist yet)")
|
||||
return settings
|
||||
|
||||
env_prefix = "CLIDE_WEB_"
|
||||
field_map = {
|
||||
"projects_dir": ("projects_dir", Path),
|
||||
"clide_bin": ("clide_bin", str),
|
||||
"port": ("port", int),
|
||||
"font_size": ("font_size", int),
|
||||
"default_theme": ("default_theme", str),
|
||||
"font_family": ("font_family", str),
|
||||
"host": ("host", str),
|
||||
}
|
||||
|
||||
for pref_key, (field_name, field_type) in field_map.items():
|
||||
env_var = f"{env_prefix}{field_name.upper()}"
|
||||
# Only apply DB pref if env var is NOT set
|
||||
if env_var not in os.environ and pref_key in prefs:
|
||||
try:
|
||||
setattr(settings, field_name, field_type(prefs[pref_key]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def _load_db_preferences(db_path: Path) -> dict[str, str]:
|
||||
"""Read UserPreference records from the database."""
|
||||
if not db_path.exists():
|
||||
return {}
|
||||
|
||||
from clide.models.db import UserPreference
|
||||
from clide.services.database import get_engine
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import select
|
||||
|
||||
engine = get_engine(db_path)
|
||||
with DBSession(engine) as db:
|
||||
stmt = select(UserPreference)
|
||||
return {p.key: p.value for p in db.exec(stmt).all()}
|
||||
Binary file not shown.
@@ -0,0 +1,169 @@
|
||||
"""PTY bridge: fork a PTY running tmux attach, async I/O to WebSocket clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import struct
|
||||
import termios
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class PtyBridge:
|
||||
"""Manages a single PTY connection to a tmux session.
|
||||
|
||||
One PtyBridge per WebSocket connection. The PTY runs `tmux attach -t <session>`.
|
||||
When the WebSocket disconnects, the PTY is killed but the tmux session persists.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tmux_session: str,
|
||||
on_output: Callable[[bytes], None],
|
||||
on_exit: Callable[[], None],
|
||||
rows: int = 40,
|
||||
cols: int = 120,
|
||||
) -> None:
|
||||
self._tmux_session = tmux_session
|
||||
self._on_output = on_output
|
||||
self._on_exit = on_exit
|
||||
self._rows = rows
|
||||
self._cols = cols
|
||||
self._pid: int | None = None
|
||||
self._master_fd: int | None = None
|
||||
self._read_task: asyncio.Task | None = None # type: ignore[type-arg]
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
if self._pid is None:
|
||||
return False
|
||||
try:
|
||||
pid, _ = os.waitpid(self._pid, os.WNOHANG)
|
||||
return pid == 0
|
||||
except ChildProcessError:
|
||||
return False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Fork a PTY and exec tmux attach."""
|
||||
pid, master_fd = pty.fork()
|
||||
|
||||
if pid == 0:
|
||||
# Child process
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
os.environ["COLORTERM"] = "truecolor"
|
||||
os.environ["COLUMNS"] = str(self._cols)
|
||||
os.environ["LINES"] = str(self._rows)
|
||||
os.execvp("tmux", ["tmux", "attach-session", "-t", self._tmux_session])
|
||||
else:
|
||||
# Parent process
|
||||
self._pid = pid
|
||||
self._master_fd = master_fd
|
||||
|
||||
# Set non-blocking I/O
|
||||
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
# Set initial PTY size
|
||||
self._set_pty_size(self._rows, self._cols)
|
||||
|
||||
# Start async read loop
|
||||
self._read_task = asyncio.create_task(self._read_output())
|
||||
|
||||
async def _read_output(self) -> None:
|
||||
"""Read PTY output and forward to callback."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
fd = self._master_fd
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Wait for data using event loop (more efficient than polling)
|
||||
await _wait_for_fd(loop, fd)
|
||||
|
||||
try:
|
||||
data = os.read(fd, 65536)
|
||||
if not data:
|
||||
break
|
||||
self._on_output(data)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
self._on_exit()
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
"""Write input data to the PTY."""
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.write(self._master_fd, data)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def resize(self, rows: int, cols: int) -> None:
|
||||
"""Resize the PTY and notify the child process."""
|
||||
self._rows = rows
|
||||
self._cols = cols
|
||||
if self._master_fd is not None:
|
||||
self._set_pty_size(rows, cols)
|
||||
|
||||
def _set_pty_size(self, rows: int, cols: int) -> None:
|
||||
"""Set PTY window size via ioctl and send SIGWINCH."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
try:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
|
||||
if self._pid is not None:
|
||||
try:
|
||||
os.kill(self._pid, signal.SIGWINCH)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Kill the PTY process and clean up."""
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
self._read_task = None
|
||||
|
||||
if self._pid is not None:
|
||||
try:
|
||||
os.kill(self._pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(self._pid, 0)
|
||||
except ChildProcessError:
|
||||
pass
|
||||
self._pid = None
|
||||
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.close(self._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._master_fd = None
|
||||
|
||||
|
||||
async def _wait_for_fd(loop: asyncio.AbstractEventLoop, fd: int) -> None:
|
||||
"""Wait until a file descriptor has data ready to read."""
|
||||
future: asyncio.Future[None] = loop.create_future()
|
||||
|
||||
def _ready() -> None:
|
||||
if not future.done():
|
||||
future.set_result(None)
|
||||
|
||||
loop.add_reader(fd, _ready)
|
||||
try:
|
||||
await future
|
||||
finally:
|
||||
loop.remove_reader(fd)
|
||||
@@ -0,0 +1,309 @@
|
||||
"""FastAPI application: HTTP routes + WebSocket terminal bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from clide.models.db import ConnectionLog
|
||||
from clide.services.database import get_db, init_db
|
||||
from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlmodel import Session as DBSession
|
||||
|
||||
from clide_web.config import ClideWebSettings, load_settings
|
||||
from clide_web.pty_bridge import PtyBridge
|
||||
from clide_web.sessions import TmuxSessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level settings and session manager (initialized in lifespan)
|
||||
_settings: ClideWebSettings | None = None
|
||||
_session_mgr: TmuxSessionManager | None = None
|
||||
_cleanup_task: asyncio.Task | None = None # type: ignore[type-arg]
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifespan
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI): # type: ignore[no-untyped-def]
|
||||
global _settings, _session_mgr, _cleanup_task
|
||||
|
||||
_settings = load_settings()
|
||||
_session_mgr = TmuxSessionManager(_settings)
|
||||
|
||||
# Initialize database
|
||||
init_db(_settings.db_path)
|
||||
logger.info("Database initialized at %s", _settings.db_path)
|
||||
|
||||
# Start periodic session cleanup
|
||||
_cleanup_task = asyncio.create_task(_periodic_cleanup())
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
if _cleanup_task:
|
||||
_cleanup_task.cancel()
|
||||
try:
|
||||
await _cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def _periodic_cleanup() -> None:
|
||||
"""Periodically sync DB session records with live tmux state."""
|
||||
assert _settings is not None
|
||||
assert _session_mgr is not None
|
||||
while True:
|
||||
await asyncio.sleep(_settings.session_cleanup_interval_seconds)
|
||||
try:
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
await _session_mgr.cleanup_dead_sessions(db)
|
||||
finally:
|
||||
try:
|
||||
next(db_gen)
|
||||
except StopIteration:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Session cleanup failed")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# App
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(title="clide-web", version="1.0.0", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Routes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
"""Redirect root to project list or serve HTML."""
|
||||
return _serve_index()
|
||||
|
||||
|
||||
@app.get("/projects/{project_name}", response_class=HTMLResponse)
|
||||
@app.head("/projects/{project_name}")
|
||||
async def project_page(project_name: str): # noqa: ARG001
|
||||
"""Serve the terminal page for a specific project."""
|
||||
return _serve_index()
|
||||
|
||||
|
||||
def _serve_index() -> FileResponse | HTMLResponse:
|
||||
index_path = STATIC_DIR / "index.html"
|
||||
if index_path.exists():
|
||||
return FileResponse(index_path, media_type="text/html")
|
||||
return HTMLResponse("<h1>clide-web</h1><p>index.html not found</p>", status_code=500)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check for reverse proxy."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
async def list_projects():
|
||||
"""List available git projects."""
|
||||
assert _session_mgr is not None
|
||||
return {"projects": _session_mgr.list_projects()}
|
||||
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def list_sessions():
|
||||
"""List active tmux sessions."""
|
||||
assert _session_mgr is not None
|
||||
sessions = await _session_mgr.list_sessions()
|
||||
return {"sessions": sessions}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket terminal bridge
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.websocket("/projects/{project}/ws")
|
||||
async def websocket_terminal(
|
||||
ws: WebSocket,
|
||||
project: str,
|
||||
db: DBSession = Depends(get_db), # noqa: B008
|
||||
):
|
||||
"""Bridge WebSocket ↔ PTY (via tmux attach).
|
||||
|
||||
Protocol:
|
||||
Prefix "0" + data → terminal I/O
|
||||
Prefix "1" + json → control messages
|
||||
Prefix "2" + C,R → resize (cols,rows)
|
||||
"""
|
||||
assert _settings is not None
|
||||
assert _session_mgr is not None
|
||||
|
||||
await ws.accept()
|
||||
|
||||
if not project:
|
||||
await ws.send_text('1{"type":"error","message":"No project specified"}')
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
|
||||
# Validate and create/attach tmux session
|
||||
try:
|
||||
tmux_name = await _session_mgr.create_session(project, db)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
await ws.send_text(f'1{json.dumps({"type": "error", "message": str(e)})}')
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
|
||||
# Log connection
|
||||
client_ip = ws.client.host if ws.client else "unknown"
|
||||
log_entry = ConnectionLog(project_name=project, client_ip=client_ip)
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
db.refresh(log_entry)
|
||||
|
||||
# Send session info
|
||||
await ws.send_text(
|
||||
f'1{json.dumps({"type": "session_info", "project": project, "tmux_session": tmux_name})}'
|
||||
)
|
||||
|
||||
# Set up PTY bridge to tmux session
|
||||
send_queue: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
|
||||
def on_output(data: bytes) -> None:
|
||||
send_queue.put_nowait(data)
|
||||
|
||||
def on_exit() -> None:
|
||||
send_queue.put_nowait(b"") # Sentinel for EOF
|
||||
|
||||
bridge = PtyBridge(
|
||||
tmux_session=tmux_name,
|
||||
on_output=on_output,
|
||||
on_exit=on_exit,
|
||||
rows=_settings.default_rows,
|
||||
cols=_settings.default_cols,
|
||||
)
|
||||
bridge.start()
|
||||
|
||||
# Task to forward PTY output → WebSocket
|
||||
async def _forward_output() -> None:
|
||||
while True:
|
||||
data = await send_queue.get()
|
||||
if not data:
|
||||
break
|
||||
try:
|
||||
await ws.send_bytes(b"0" + data)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
output_task = asyncio.create_task(_forward_output())
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await ws.receive()
|
||||
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
|
||||
if "text" in message:
|
||||
text = message["text"]
|
||||
if not text:
|
||||
continue
|
||||
prefix = text[0]
|
||||
payload = text[1:]
|
||||
|
||||
if prefix == "0":
|
||||
# Terminal input
|
||||
bridge.write(payload.encode("utf-8", errors="surrogateescape"))
|
||||
elif prefix == "1":
|
||||
# Control message
|
||||
await _handle_control(ws, payload, project)
|
||||
elif prefix == "2":
|
||||
# Resize: "2cols,rows"
|
||||
try:
|
||||
cols_str, rows_str = payload.split(",", 1)
|
||||
bridge.resize(int(rows_str), int(cols_str))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
elif "bytes" in message:
|
||||
raw = message["bytes"]
|
||||
if raw and len(raw) > 1:
|
||||
prefix = raw[0:1]
|
||||
payload_bytes = raw[1:]
|
||||
if prefix == b"0":
|
||||
bridge.write(payload_bytes)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("WebSocket error for project %s", project)
|
||||
finally:
|
||||
bridge.stop()
|
||||
output_task.cancel()
|
||||
try:
|
||||
await output_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Update connection log
|
||||
log_entry.disconnected_at = datetime.utcnow()
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
|
||||
|
||||
async def _handle_control(ws: WebSocket, payload: str, project: str) -> None: # noqa: ARG001
|
||||
"""Handle a JSON control message from the client."""
|
||||
try:
|
||||
msg = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
|
||||
msg_type = msg.get("type", "")
|
||||
|
||||
if msg_type == "list_projects":
|
||||
assert _session_mgr is not None
|
||||
projects = _session_mgr.list_projects()
|
||||
await ws.send_text(f'1{json.dumps({"type": "projects", "projects": projects})}')
|
||||
|
||||
elif msg_type == "ping":
|
||||
await ws.send_text(f'1{json.dumps({"type": "pong"})}')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the clide-web server."""
|
||||
settings = load_settings()
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
)
|
||||
logger.info("Starting clide-web on %s:%d", settings.host, settings.port)
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level="info",
|
||||
ws_ping_interval=20,
|
||||
ws_ping_timeout=20,
|
||||
)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""tmux session manager: create, attach, list, kill sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from clide.models.db import Project, Session
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from clide_web.config import ClideWebSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TmuxSessionManager:
|
||||
"""Manages tmux sessions, one per project."""
|
||||
|
||||
def __init__(self, settings: ClideWebSettings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Project discovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_projects(self) -> list[str]:
|
||||
"""List git repos in the projects directory."""
|
||||
projects_dir = self._settings.projects_dir
|
||||
if not projects_dir.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in projects_dir.iterdir() if d.is_dir() and (d / ".git").exists()
|
||||
)
|
||||
|
||||
def validate_project(self, name: str) -> Path | None:
|
||||
"""Return project path if valid, else None."""
|
||||
project_dir = self._settings.projects_dir / name
|
||||
if project_dir.is_dir() and (project_dir / ".git").exists():
|
||||
return project_dir
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# tmux operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _session_name(project: str) -> str:
|
||||
return f"clide-{project}"
|
||||
|
||||
async def session_exists(self, project: str) -> bool:
|
||||
"""Check if a tmux session exists for this project."""
|
||||
name = self._session_name(project)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
return proc.returncode == 0
|
||||
|
||||
async def create_session(self, project: str, db: DBSession) -> str:
|
||||
"""Create a new tmux session running clide for the given project.
|
||||
|
||||
Returns the tmux session name.
|
||||
"""
|
||||
name = self._session_name(project)
|
||||
project_dir = self.validate_project(project)
|
||||
if project_dir is None:
|
||||
raise ValueError(f"Project '{project}' not found")
|
||||
|
||||
# Check if session already exists
|
||||
if await self.session_exists(project):
|
||||
logger.info("tmux session %s already exists, reusing", name)
|
||||
else:
|
||||
clide_bin = _resolve_clide_bin(self._settings.clide_bin)
|
||||
env = _build_env(self._settings.term)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"new-session",
|
||||
"-d", # detached
|
||||
"-s",
|
||||
name, # session name
|
||||
"-c",
|
||||
str(project_dir), # working directory
|
||||
"-x",
|
||||
str(self._settings.default_cols),
|
||||
"-y",
|
||||
str(self._settings.default_rows),
|
||||
clide_bin, # command to run
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Failed to create tmux session: {stderr.decode().strip()}")
|
||||
|
||||
# Configure session: hide status bar, auto-respawn on exit
|
||||
# pane-died hook: kill the dead pane, clear all history, respawn clean
|
||||
respawn_cmd = (
|
||||
f"respawn-pane -k -t {name} -c {project_dir} {clide_bin} \\; "
|
||||
f"clear-history -t {name}"
|
||||
)
|
||||
for opt_args in [
|
||||
["set-option", "-t", name, "status", "off"],
|
||||
["set-option", "-t", name, "remain-on-exit", "on"],
|
||||
["set-hook", "-t", name, "pane-died", respawn_cmd],
|
||||
]:
|
||||
await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
*opt_args,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
logger.info("Created tmux session %s for project %s", name, project)
|
||||
|
||||
# Persist to DB
|
||||
_upsert_project(db, project, str(project_dir))
|
||||
_upsert_session(db, project, name)
|
||||
|
||||
return name
|
||||
|
||||
async def list_sessions(self) -> list[dict[str, str]]:
|
||||
"""List active tmux sessions matching clide-* pattern."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}:#{session_created}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
|
||||
sessions = []
|
||||
for line in stdout.decode().strip().splitlines():
|
||||
if not line.startswith("clide-"):
|
||||
continue
|
||||
parts = line.split(":", 1)
|
||||
name = parts[0]
|
||||
project = name.removeprefix("clide-")
|
||||
sessions.append({"name": name, "project": project})
|
||||
return sessions
|
||||
|
||||
async def kill_session(self, project: str) -> None:
|
||||
"""Kill a tmux session for a project."""
|
||||
name = self._session_name(project)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
logger.info("Killed tmux session %s", name)
|
||||
|
||||
async def cleanup_dead_sessions(self, db: DBSession) -> None:
|
||||
"""Sync DB session records with actual tmux state."""
|
||||
live = await self.list_sessions()
|
||||
live_names = {s["name"] for s in live}
|
||||
|
||||
stmt = select(Session).where(Session.status == "active")
|
||||
for session in db.exec(stmt).all():
|
||||
if session.tmux_session not in live_names:
|
||||
session.status = "dead"
|
||||
db.add(session)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_env(term: str) -> dict[str, str]:
|
||||
"""Build environment for tmux sessions, inheriting the current env."""
|
||||
import os
|
||||
|
||||
env = os.environ.copy()
|
||||
env["TERM"] = term
|
||||
env["COLORTERM"] = "truecolor"
|
||||
return env
|
||||
|
||||
|
||||
def _resolve_clide_bin(clide_bin: str) -> str:
|
||||
"""Resolve clide binary path to absolute if relative."""
|
||||
import shutil
|
||||
|
||||
path = Path(clide_bin)
|
||||
if path.is_absolute():
|
||||
return clide_bin
|
||||
# Relative path — resolve against cwd
|
||||
resolved = Path.cwd() / path
|
||||
if resolved.is_file():
|
||||
return str(resolved)
|
||||
# Try to find it on PATH
|
||||
found = shutil.which(clide_bin)
|
||||
if found:
|
||||
return found
|
||||
return clide_bin # Last resort: return as-is
|
||||
|
||||
|
||||
def _upsert_project(db: DBSession, name: str, path: str) -> Project:
|
||||
"""Create or update a project record."""
|
||||
stmt = select(Project).where(Project.name == name)
|
||||
project = db.exec(stmt).first()
|
||||
if project is None:
|
||||
project = Project(name=name, path=path)
|
||||
project.last_accessed = datetime.utcnow()
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
def _upsert_session(db: DBSession, project_name: str, tmux_session: str) -> Session:
|
||||
"""Create or update a session record."""
|
||||
stmt = select(Session).where(Session.tmux_session == tmux_session)
|
||||
session = db.exec(stmt).first()
|
||||
if session is None:
|
||||
session = Session(project_name=project_name, tmux_session=tmux_session)
|
||||
session.status = "active"
|
||||
session.last_activity = datetime.utcnow()
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Interactive setup wizard for clide-web configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.db import UserPreference
|
||||
from clide.services.database import get_engine, init_db
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import select
|
||||
|
||||
|
||||
def run_setup() -> None:
|
||||
"""Run the interactive setup wizard, persisting config to the database."""
|
||||
print()
|
||||
print(" ╔═══════════════════════════════════════╗")
|
||||
print(" ║ clide-web Setup Wizard ║")
|
||||
print(" ╚═══════════════════════════════════════╝")
|
||||
print()
|
||||
|
||||
db_path = Path.home() / ".clide" / "clide.db"
|
||||
init_db(db_path)
|
||||
engine = get_engine(db_path)
|
||||
|
||||
with DBSession(engine) as db:
|
||||
# Projects directory
|
||||
current = _get_pref(db, "projects_dir")
|
||||
default = current or _guess_projects_dir()
|
||||
projects_dir = _prompt("Projects directory", default)
|
||||
path = Path(projects_dir).expanduser().resolve()
|
||||
if not path.is_dir():
|
||||
print(f" Warning: '{path}' does not exist yet")
|
||||
_set_pref(db, "projects_dir", str(path))
|
||||
|
||||
# Clide binary
|
||||
current = _get_pref(db, "clide_bin")
|
||||
default = current or _find_clide_bin()
|
||||
clide_bin = _prompt("Clide binary path", default)
|
||||
_set_pref(db, "clide_bin", clide_bin)
|
||||
|
||||
# Port
|
||||
current = _get_pref(db, "port")
|
||||
port = _prompt("Server port", current or "8888")
|
||||
_set_pref(db, "port", port)
|
||||
|
||||
# Font size
|
||||
current = _get_pref(db, "font_size")
|
||||
font_size = _prompt("Terminal font size", current or "14")
|
||||
_set_pref(db, "font_size", font_size)
|
||||
|
||||
db.commit()
|
||||
|
||||
print()
|
||||
print(" Configuration saved to ~/.clide/clide.db")
|
||||
print()
|
||||
print(" Run with: clide-web")
|
||||
print()
|
||||
|
||||
|
||||
def _prompt(label: str, default: str) -> str:
|
||||
"""Prompt the user with a default value."""
|
||||
result = input(f" {label} [{default}]: ").strip()
|
||||
return result if result else default
|
||||
|
||||
|
||||
def _get_pref(db: DBSession, key: str) -> str | None:
|
||||
"""Get a preference from the database."""
|
||||
stmt = select(UserPreference).where(UserPreference.key == key)
|
||||
pref = db.exec(stmt).first()
|
||||
return pref.value if pref else None
|
||||
|
||||
|
||||
def _set_pref(db: DBSession, key: str, value: str) -> None:
|
||||
"""Set a preference in the database."""
|
||||
stmt = select(UserPreference).where(UserPreference.key == key)
|
||||
pref = db.exec(stmt).first()
|
||||
if pref is None:
|
||||
pref = UserPreference(key=key, value=value)
|
||||
else:
|
||||
pref.value = value
|
||||
db.add(pref)
|
||||
|
||||
|
||||
def _guess_projects_dir() -> str:
|
||||
"""Try to guess a sensible default for projects directory."""
|
||||
candidates = [
|
||||
Path.home() / "Projects",
|
||||
Path.home() / "projects",
|
||||
Path.home() / "src",
|
||||
Path.home() / "code",
|
||||
Path("/mnt/media/Projects"),
|
||||
]
|
||||
for p in candidates:
|
||||
if p.is_dir():
|
||||
return str(p)
|
||||
return str(Path.home() / "Projects")
|
||||
|
||||
|
||||
def _find_clide_bin() -> str:
|
||||
"""Try to find the clide binary."""
|
||||
found = shutil.which("clide")
|
||||
if found:
|
||||
return found
|
||||
# Check common venv locations
|
||||
candidates = [
|
||||
Path.cwd() / ".venv" / "bin" / "clide",
|
||||
Path.cwd().parent / ".venv" / "bin" / "clide",
|
||||
]
|
||||
for p in candidates:
|
||||
if p.exists():
|
||||
return str(p)
|
||||
return "clide"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_setup()
|
||||
@@ -0,0 +1,388 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Clide</title>
|
||||
<link rel="stylesheet" href="/static/vendor/xterm.min.css">
|
||||
<style>
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #21262f;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
/* Kill all scrollbars */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
/* Kill xterm.js internal scrollbar — TUI handles its own scrolling */
|
||||
.xterm-viewport {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
#toolbar {
|
||||
height: 36px;
|
||||
background: #292e38;
|
||||
border-bottom: 1px solid #393e48;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 12px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: #e2e8f5;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#toolbar .logo {
|
||||
font-weight: 600;
|
||||
color: #00a3d2;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#toolbar .separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #393e48;
|
||||
}
|
||||
|
||||
#toolbar select {
|
||||
background: #393e48;
|
||||
color: #e2e8f5;
|
||||
border: 1px solid #525762;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
#toolbar select:hover {
|
||||
border-color: #00a3d2;
|
||||
}
|
||||
#toolbar select:focus {
|
||||
border-color: #00a3d2;
|
||||
box-shadow: 0 0 0 1px #00a3d2;
|
||||
}
|
||||
|
||||
#toolbar label {
|
||||
font-size: 11px;
|
||||
color: #898e9a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-spacer { flex: 1; }
|
||||
|
||||
#status {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
#status .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #525762;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
#status .dot.connected { background: #00ab9a; }
|
||||
#status .dot.reconnecting { background: #d08447; animation: pulse 1s infinite; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Terminal container */
|
||||
#terminal-container {
|
||||
height: calc(100% - 36px);
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Reconnect overlay */
|
||||
#overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(33, 38, 47, 0.92);
|
||||
z-index: 100;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
color: #e2e8f5;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
#overlay .message {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
#overlay .sub {
|
||||
font-size: 13px;
|
||||
color: #898e9a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<span class="logo">Clide</span>
|
||||
<div class="separator"></div>
|
||||
<div class="toolbar-group">
|
||||
<label>Project</label>
|
||||
<select id="project-select"><option value="">Loading...</option></select>
|
||||
</div>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<div id="status">
|
||||
<div class="dot" id="status-dot"></div>
|
||||
<span id="status-text">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal-container"></div>
|
||||
<div id="overlay">
|
||||
<div class="message">Reconnecting...</div>
|
||||
<div class="sub">Session is preserved</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/vendor/xterm.min.js"></script>
|
||||
<script src="/static/vendor/addon-fit.min.js"></script>
|
||||
<script src="/static/vendor/addon-web-links.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
// --- State ---
|
||||
// Extract project from path: /projects/<name> or fallback to ?project=<name>
|
||||
function getProjectFromUrl() {
|
||||
var match = location.pathname.match(/^\/projects\/([^/]+)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
return new URLSearchParams(location.search).get("project") || "";
|
||||
}
|
||||
let currentProject = getProjectFromUrl();
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectDelay = 1000;
|
||||
|
||||
// --- DOM refs ---
|
||||
const statusDot = document.getElementById("status-dot");
|
||||
const statusText = document.getElementById("status-text");
|
||||
const overlay = document.getElementById("overlay");
|
||||
const projectSelect = document.getElementById("project-select");
|
||||
|
||||
// --- Terminal ---
|
||||
const term = new Terminal({
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: 14,
|
||||
theme: {
|
||||
background: "#21262f",
|
||||
foreground: "#e2e8f5",
|
||||
cursor: "#00a3d2",
|
||||
cursorAccent: "#21262f",
|
||||
selectionBackground: "rgba(0, 163, 210, 0.3)",
|
||||
black: "#21262f",
|
||||
red: "#f06c6f",
|
||||
green: "#00ab9a",
|
||||
yellow: "#d08447",
|
||||
blue: "#00a3d2",
|
||||
magenta: "#fa5f8b",
|
||||
cyan: "#00a9b9",
|
||||
white: "#e2e8f5",
|
||||
brightBlack: "#393e48",
|
||||
brightRed: "#f06c6f",
|
||||
brightGreen: "#00ab9a",
|
||||
brightYellow: "#d08447",
|
||||
brightBlue: "#00a3d2",
|
||||
brightMagenta: "#fa5f8b",
|
||||
brightCyan: "#00a9b9",
|
||||
brightWhite: "#e2e8f5",
|
||||
},
|
||||
cursorBlink: true,
|
||||
allowProposedApi: true,
|
||||
scrollback: 0,
|
||||
});
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon.WebLinksAddon());
|
||||
term.open(document.getElementById("terminal-container"));
|
||||
fitAddon.fit();
|
||||
|
||||
// --- Status helpers ---
|
||||
function setStatus(state, text) {
|
||||
statusDot.className = "dot " + state;
|
||||
statusText.textContent = text;
|
||||
}
|
||||
|
||||
// --- WebSocket ---
|
||||
function connect() {
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
}
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = proto + "//" + location.host + "/projects/" + encodeURIComponent(currentProject) + "/ws";
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = function() {
|
||||
setStatus("connected", currentProject || "Connected");
|
||||
overlay.style.display = "none";
|
||||
reconnectDelay = 1000;
|
||||
|
||||
// Send initial terminal size (slight delay to ensure xterm is rendered)
|
||||
setTimeout(function() {
|
||||
fitAddon.fit();
|
||||
sendResize();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
ws.onmessage = function(evt) {
|
||||
let prefix, payload;
|
||||
if (evt.data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(evt.data);
|
||||
if (bytes.length < 1) return;
|
||||
prefix = String.fromCharCode(bytes[0]);
|
||||
payload = bytes.slice(1);
|
||||
if (prefix === "0") {
|
||||
term.write(payload);
|
||||
} else if (prefix === "1") {
|
||||
handleControl(new TextDecoder().decode(payload));
|
||||
}
|
||||
} else {
|
||||
// Text frame
|
||||
prefix = evt.data[0];
|
||||
payload = evt.data.slice(1);
|
||||
if (prefix === "0") {
|
||||
term.write(payload);
|
||||
} else if (prefix === "1") {
|
||||
handleControl(payload);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
setStatus("reconnecting", "Reconnecting...");
|
||||
overlay.style.display = "flex";
|
||||
reconnectTimer = setTimeout(function() {
|
||||
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
};
|
||||
|
||||
ws.onerror = function() {
|
||||
// onclose will fire after this
|
||||
};
|
||||
}
|
||||
|
||||
// --- Terminal input → WebSocket ---
|
||||
term.onData(function(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send("0" + data);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Resize ---
|
||||
function sendResize() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const dims = fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
ws.send("2" + dims.cols + "," + dims.rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(function() {
|
||||
fitAddon.fit();
|
||||
sendResize();
|
||||
});
|
||||
resizeObserver.observe(document.getElementById("terminal-container"));
|
||||
|
||||
// --- Control messages ---
|
||||
function handleControl(jsonStr) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(jsonStr); } catch(e) { return; }
|
||||
|
||||
if (msg.type === "projects") {
|
||||
populateProjects(msg.projects);
|
||||
} else if (msg.type === "session_info") {
|
||||
setStatus("connected", msg.project);
|
||||
document.title = "Clide — " + msg.project;
|
||||
} else if (msg.type === "error") {
|
||||
term.write("\r\n\x1b[31mError: " + msg.message + "\x1b[0m\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Project management ---
|
||||
function populateProjects(projects) {
|
||||
projectSelect.innerHTML = "";
|
||||
projects.forEach(function(p) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p;
|
||||
opt.textContent = p;
|
||||
if (p === currentProject) opt.selected = true;
|
||||
projectSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
projectSelect.addEventListener("change", function() {
|
||||
currentProject = projectSelect.value;
|
||||
history.pushState(null, "", "/projects/" + encodeURIComponent(currentProject));
|
||||
document.title = "Clide — " + currentProject;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
term.clear();
|
||||
term.reset();
|
||||
connect();
|
||||
});
|
||||
|
||||
// --- Load projects and connect ---
|
||||
fetch("/api/projects")
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
populateProjects(data.projects);
|
||||
// If no project selected, pick first available
|
||||
if (!currentProject && data.projects.length > 0) {
|
||||
currentProject = data.projects[0];
|
||||
projectSelect.value = currentProject;
|
||||
history.replaceState(null, "", "/projects/" + encodeURIComponent(currentProject));
|
||||
}
|
||||
if (currentProject) {
|
||||
connect();
|
||||
} else {
|
||||
setStatus("", "No projects found");
|
||||
term.write("\r\nNo projects found in configured projects directory.\r\n");
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
setStatus("reconnecting", "Server unreachable");
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-fit@0.11.0/lib/addon-fit.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-web-links@0.12.0/lib/addon-web-links.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={490:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(490),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
|
||||
//# sourceMappingURL=addon-web-links.js.map
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Minified by jsDelivr using clean-css v5.3.3.
|
||||
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
|
||||
/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user