kokokoasd commited on
Commit
914be63
·
verified ·
1 Parent(s): dfb6537

Upload 9 files

Browse files
Files changed (9) hide show
  1. Dockerfile +42 -0
  2. README.md +19 -10
  3. app.py +151 -0
  4. requirements.txt +5 -0
  5. static/app.js +611 -0
  6. static/index.html +200 -0
  7. static/style.css +723 -0
  8. terminal.py +146 -0
  9. zones.py +156 -0
Dockerfile ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Cài system dependencies
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ curl wget git build-essential procps htop nano vim \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ # Cài Node.js 20
9
+ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
10
+ && apt-get install -y nodejs \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Cài Go 1.22
14
+ RUN wget -q https://go.dev/dl/go1.22.5.linux-amd64.tar.gz \
15
+ && tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz \
16
+ && rm go1.22.5.linux-amd64.tar.gz
17
+ ENV PATH="/usr/local/go/bin:${PATH}"
18
+ ENV GOPATH="/data/go"
19
+
20
+ # Tạo user (HF Spaces yêu cầu user 1000)
21
+ RUN useradd -m -u 1000 user
22
+ ENV HOME=/home/user
23
+ ENV PATH="/home/user/.local/bin:${PATH}"
24
+
25
+ WORKDIR /app
26
+
27
+ # Cài Python dependencies
28
+ COPY requirements.txt .
29
+ RUN pip install --no-cache-dir -r requirements.txt
30
+
31
+ # Copy source code
32
+ COPY . .
33
+
34
+ # Tạo thư mục data
35
+ RUN mkdir -p /data/zones && chown -R user:user /data
36
+
37
+ # Mở port 7860 (mặc định HF Spaces)
38
+ EXPOSE 7860
39
+
40
+ USER user
41
+
42
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,19 @@
1
- ---
2
- title: Huhuh
3
- emoji: 🚀
4
- colorFrom: blue
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: HugPanel
3
+ emoji: 🖥️
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # HugPanel
11
+
12
+ Panel quản lý đa vùng (zones) với file manager và terminal riêng cho từng zone.
13
+
14
+ ## Tính năng
15
+ - Tạo/xoá các zone (vùng con) độc lập
16
+ - File manager cho từng zone (tạo, sửa, xoá, upload, download file)
17
+ - Terminal riêng cho từng zone (xterm.js + WebSocket PTY)
18
+ - Cài sẵn Node.js, Go, Python
19
+ - Persistent storage
app.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import shutil
4
+ from pathlib import Path
5
+ from contextlib import asynccontextmanager
6
+
7
+ import uvicorn
8
+ from fastapi import FastAPI, WebSocket, UploadFile, File, Form, HTTPException, Query
9
+ from fastapi.staticfiles import StaticFiles
10
+ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
11
+
12
+ import zones
13
+ from terminal import terminal_ws, kill_terminal, active_terminals
14
+
15
+
16
+ @asynccontextmanager
17
+ async def lifespan(app: FastAPI):
18
+ yield
19
+ # Cleanup terminals khi shutdown
20
+ for zone_name in list(active_terminals.keys()):
21
+ kill_terminal(zone_name)
22
+
23
+
24
+ app = FastAPI(title="HugPanel", lifespan=lifespan)
25
+
26
+
27
+ # ── Zone API ──────────────────────────────────────────────
28
+
29
+ @app.get("/api/zones")
30
+ def api_list_zones():
31
+ return zones.list_zones()
32
+
33
+
34
+ @app.post("/api/zones")
35
+ def api_create_zone(name: str = Form(...), description: str = Form("")):
36
+ try:
37
+ result = zones.create_zone(name, description)
38
+ return result
39
+ except ValueError as e:
40
+ raise HTTPException(400, str(e))
41
+
42
+
43
+ @app.delete("/api/zones/{zone_name}")
44
+ def api_delete_zone(zone_name: str):
45
+ try:
46
+ kill_terminal(zone_name)
47
+ zones.delete_zone(zone_name)
48
+ return {"ok": True}
49
+ except ValueError as e:
50
+ raise HTTPException(400, str(e))
51
+
52
+
53
+ # ── File Manager API ─────────────────────────────────────
54
+
55
+ @app.get("/api/zones/{zone_name}/files")
56
+ def api_list_files(zone_name: str, path: str = Query("")):
57
+ try:
58
+ return zones.list_files(zone_name, path)
59
+ except ValueError as e:
60
+ raise HTTPException(400, str(e))
61
+
62
+
63
+ @app.get("/api/zones/{zone_name}/files/read")
64
+ def api_read_file(zone_name: str, path: str = Query(...)):
65
+ try:
66
+ content = zones.read_file(zone_name, path)
67
+ return {"content": content, "path": path}
68
+ except ValueError as e:
69
+ raise HTTPException(400, str(e))
70
+
71
+
72
+ @app.get("/api/zones/{zone_name}/files/download")
73
+ def api_download_file(zone_name: str, path: str = Query(...)):
74
+ try:
75
+ zone_path = zones.get_zone_path(zone_name)
76
+ target = zones._safe_path(zone_path, path)
77
+ if not target.is_file():
78
+ raise HTTPException(404, "File không tồn tại")
79
+ return FileResponse(target, filename=target.name)
80
+ except ValueError as e:
81
+ raise HTTPException(400, str(e))
82
+
83
+
84
+ @app.post("/api/zones/{zone_name}/files/write")
85
+ def api_write_file(zone_name: str, path: str = Form(...), content: str = Form(...)):
86
+ try:
87
+ zones.write_file(zone_name, path, content)
88
+ return {"ok": True}
89
+ except ValueError as e:
90
+ raise HTTPException(400, str(e))
91
+
92
+
93
+ @app.post("/api/zones/{zone_name}/files/mkdir")
94
+ def api_create_folder(zone_name: str, path: str = Form(...)):
95
+ try:
96
+ zones.create_folder(zone_name, path)
97
+ return {"ok": True}
98
+ except ValueError as e:
99
+ raise HTTPException(400, str(e))
100
+
101
+
102
+ @app.post("/api/zones/{zone_name}/files/upload")
103
+ async def api_upload_file(zone_name: str, path: str = Form(""), file: UploadFile = File(...)):
104
+ try:
105
+ zone_path = zones.get_zone_path(zone_name)
106
+ dest = zones._safe_path(zone_path, os.path.join(path, file.filename))
107
+ dest.parent.mkdir(parents=True, exist_ok=True)
108
+ content = await file.read()
109
+ dest.write_bytes(content)
110
+ return {"ok": True, "path": str(dest.relative_to(zone_path))}
111
+ except ValueError as e:
112
+ raise HTTPException(400, str(e))
113
+
114
+
115
+ @app.delete("/api/zones/{zone_name}/files")
116
+ def api_delete_file(zone_name: str, path: str = Query(...)):
117
+ try:
118
+ zones.delete_file(zone_name, path)
119
+ return {"ok": True}
120
+ except ValueError as e:
121
+ raise HTTPException(400, str(e))
122
+
123
+
124
+ @app.post("/api/zones/{zone_name}/files/rename")
125
+ def api_rename_file(zone_name: str, old_path: str = Form(...), new_name: str = Form(...)):
126
+ try:
127
+ zones.rename_item(zone_name, old_path, new_name)
128
+ return {"ok": True}
129
+ except ValueError as e:
130
+ raise HTTPException(400, str(e))
131
+
132
+
133
+ # ── Terminal WebSocket ────────────────────────────────────
134
+
135
+ @app.websocket("/ws/terminal/{zone_name}")
136
+ async def ws_terminal(websocket: WebSocket, zone_name: str):
137
+ await terminal_ws(websocket, zone_name)
138
+
139
+
140
+ # ── Static Files & SPA ──────────────────────────────────
141
+
142
+ app.mount("/static", StaticFiles(directory="static"), name="static")
143
+
144
+
145
+ @app.get("/")
146
+ def index():
147
+ return FileResponse("static/index.html")
148
+
149
+
150
+ if __name__ == "__main__":
151
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ websockets==14.1
4
+ python-multipart==0.0.18
5
+ aiofiles==24.1.0
static/app.js ADDED
@@ -0,0 +1,611 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ═══════════════════════════════════════════════
2
+ // HugPanel — Frontend Controller
3
+ // ═══════════════════════════════════════════════
4
+
5
+ // ── State ─────────────────────────────────────
6
+ let currentZone = null;
7
+ let currentPath = "";
8
+ let currentEditFile = null;
9
+ let cmEditor = null; // CodeMirror instance
10
+ let term = null;
11
+ let termSocket = null;
12
+ let fitAddon = null;
13
+ let termDataDisposable = null;
14
+ let termResizeDisposable = null;
15
+ let promptResolve = null;
16
+
17
+ // ── Init ──────────────────────────────────────
18
+ document.addEventListener("DOMContentLoaded", () => {
19
+ lucide.createIcons();
20
+ loadZones();
21
+ initResizers();
22
+ bindEvents();
23
+ });
24
+
25
+ // ── API ───────────────────────────────────────
26
+ async function api(url, opts = {}) {
27
+ const res = await fetch(url, opts);
28
+ if (!res.ok) {
29
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
30
+ throw new Error(err.detail || "API Error");
31
+ }
32
+ return res.json();
33
+ }
34
+
35
+ // ── Toast ─────────────────────────────────────
36
+ function toast(message, type = "info") {
37
+ const container = document.getElementById("toast-container");
38
+ const icons = {
39
+ success: "check-circle-2",
40
+ error: "alert-circle",
41
+ info: "info",
42
+ };
43
+ const el = document.createElement("div");
44
+ el.className = `toast toast-${type}`;
45
+ el.innerHTML = `<i data-lucide="${icons[type] || 'info'}"></i><span>${escapeHtml(message)}</span>`;
46
+ container.appendChild(el);
47
+ lucide.createIcons({ nodes: [el] });
48
+ setTimeout(() => { el.style.opacity = "0"; setTimeout(() => el.remove(), 200); }, 3000);
49
+ }
50
+
51
+ // ── Zone Management ──────────────────────────
52
+ async function loadZones() {
53
+ const zones = await api("/api/zones");
54
+ const list = document.getElementById("zone-list");
55
+ if (zones.length === 0) {
56
+ list.innerHTML = `<li class="empty-hint" style="color:var(--text-3);font-size:12px;padding:8px 10px;cursor:default;opacity:0.6">No zones yet</li>`;
57
+ return;
58
+ }
59
+ list.innerHTML = zones.map(z => `
60
+ <li data-zone="${escapeAttr(z.name)}" class="${currentZone === z.name ? 'active' : ''}" onclick="openZone('${escapeAttr(z.name)}')">
61
+ <span class="zone-icon"><i data-lucide="box"></i></span>
62
+ <span class="zone-name">${escapeHtml(z.name)}</span>
63
+ </li>
64
+ `).join("");
65
+ lucide.createIcons({ nodes: [list] });
66
+ }
67
+
68
+ async function openZone(name) {
69
+ currentZone = name;
70
+ currentPath = "";
71
+ currentEditFile = null;
72
+
73
+ document.getElementById("zone-title").textContent = name;
74
+ document.getElementById("welcome").classList.remove("active");
75
+ document.getElementById("workspace").classList.add("active");
76
+
77
+ // Reset editor
78
+ const editorContainer = document.getElementById("editor-container");
79
+ editorContainer.innerHTML = `<div class="editor-empty"><i data-lucide="mouse-pointer-click"></i><p>Double-click a file to open</p></div>`;
80
+ document.getElementById("editor-tabs").innerHTML = `<span class="tab-placeholder"><i data-lucide="code-2"></i> No file open</span>`;
81
+ lucide.createIcons({ nodes: [editorContainer, document.getElementById("editor-tabs")] });
82
+ cmEditor = null;
83
+
84
+ // Highlight
85
+ document.querySelectorAll(".zone-list li").forEach(li => {
86
+ li.classList.toggle("active", li.dataset.zone === name);
87
+ });
88
+
89
+ await loadFiles();
90
+
91
+ // Auto-connect terminal
92
+ setTimeout(() => connectTerminal(), 200);
93
+ }
94
+
95
+ async function createZone() {
96
+ const name = document.getElementById("input-zone-name").value.trim();
97
+ const desc = document.getElementById("input-zone-desc").value.trim();
98
+ if (!name) return;
99
+ const form = new FormData();
100
+ form.append("name", name);
101
+ form.append("description", desc);
102
+ try {
103
+ await api("/api/zones", { method: "POST", body: form });
104
+ closeModal("modal-overlay");
105
+ document.getElementById("input-zone-name").value = "";
106
+ document.getElementById("input-zone-desc").value = "";
107
+ toast(`Zone "${name}" created`, "success");
108
+ await loadZones();
109
+ openZone(name);
110
+ } catch (e) {
111
+ toast(e.message, "error");
112
+ }
113
+ }
114
+
115
+ async function deleteZone() {
116
+ if (!currentZone) return;
117
+ if (!confirm(`Delete zone "${currentZone}"? All data will be lost!`)) return;
118
+ try {
119
+ await api(`/api/zones/${currentZone}`, { method: "DELETE" });
120
+ disconnectTerminal();
121
+ toast(`Zone "${currentZone}" deleted`, "info");
122
+ currentZone = null;
123
+ document.getElementById("workspace").classList.remove("active");
124
+ document.getElementById("welcome").classList.add("active");
125
+ await loadZones();
126
+ } catch (e) {
127
+ toast(e.message, "error");
128
+ }
129
+ }
130
+
131
+ // ── File Manager ─────────────────────────────
132
+ async function loadFiles() {
133
+ if (!currentZone) return;
134
+ const files = await api(`/api/zones/${currentZone}/files?path=${encodeURIComponent(currentPath)}`);
135
+ renderBreadcrumb();
136
+ renderFiles(files);
137
+ }
138
+
139
+ function renderBreadcrumb() {
140
+ const bc = document.getElementById("breadcrumb");
141
+ const parts = currentPath ? currentPath.split("/").filter(Boolean) : [];
142
+ let html = `<span onclick="navigateTo('')">~</span>`;
143
+ let path = "";
144
+ for (const part of parts) {
145
+ path += (path ? "/" : "") + part;
146
+ html += `<span class="sep">/</span>`;
147
+ html += `<span onclick="navigateTo('${escapeAttr(path)}')">${escapeHtml(part)}</span>`;
148
+ }
149
+ bc.innerHTML = html;
150
+ }
151
+
152
+ function renderFiles(files) {
153
+ const list = document.getElementById("file-list");
154
+ if (files.length === 0 && !currentPath) {
155
+ list.innerHTML = `<div class="empty-state"><i data-lucide="folder-open"></i><p>Empty zone — create a file or upload</p></div>`;
156
+ lucide.createIcons({ nodes: [list] });
157
+ return;
158
+ }
159
+ let html = "";
160
+ if (currentPath) {
161
+ html += `<div class="file-item" ondblclick="navigateUp()">
162
+ <span class="fi-icon fi-icon-back"><i data-lucide="corner-left-up"></i></span>
163
+ <span class="fi-name">..</span>
164
+ </div>`;
165
+ }
166
+ const sorted = [...files].sort((a, b) => {
167
+ if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
168
+ return a.name.localeCompare(b.name);
169
+ });
170
+ for (const f of sorted) {
171
+ const relPath = currentPath ? `${currentPath}/${f.name}` : f.name;
172
+ const iconClass = f.is_dir ? "fi-icon-folder" : fileIconClass(f.name);
173
+ const iconName = f.is_dir ? "folder" : "file-text";
174
+ const size = f.is_dir ? "" : formatSize(f.size);
175
+
176
+ html += `<div class="file-item" ondblclick="${f.is_dir ? `navigateTo('${escapeAttr(relPath)}')` : `editFile('${escapeAttr(relPath)}')`}">
177
+ <span class="fi-icon ${iconClass}"><i data-lucide="${iconName}"></i></span>
178
+ <span class="fi-name">${escapeHtml(f.name)}</span>
179
+ <span class="fi-size">${size}</span>
180
+ <span class="fi-actions">
181
+ ${!f.is_dir ? `<button title="Download" onclick="event.stopPropagation();downloadFile('${escapeAttr(relPath)}')"><i data-lucide="download"></i></button>` : ''}
182
+ <button title="Rename" onclick="event.stopPropagation();renameFile('${escapeAttr(relPath)}','${escapeAttr(f.name)}')"><i data-lucide="pencil"></i></button>
183
+ <button class="fi-del" title="Delete" onclick="event.stopPropagation();deleteFile('${escapeAttr(relPath)}')"><i data-lucide="trash-2"></i></button>
184
+ </span>
185
+ </div>`;
186
+ }
187
+ list.innerHTML = html;
188
+ lucide.createIcons({ nodes: [list] });
189
+ }
190
+
191
+ function navigateTo(path) { currentPath = path; loadFiles(); }
192
+
193
+ function navigateUp() {
194
+ const parts = currentPath.split("/").filter(Boolean);
195
+ parts.pop();
196
+ currentPath = parts.join("/");
197
+ loadFiles();
198
+ }
199
+
200
+ // ── Editor (CodeMirror) ──────────────────────
201
+ function getMode(filename) {
202
+ const ext = filename.split(".").pop().toLowerCase();
203
+ const modes = {
204
+ js: "javascript", mjs: "javascript", jsx: "javascript",
205
+ ts: "text/typescript", tsx: "text/typescript",
206
+ py: "python",
207
+ go: "go",
208
+ rs: "rust",
209
+ html: "htmlmixed", htm: "htmlmixed",
210
+ css: "css", scss: "css", less: "css",
211
+ json: { name: "javascript", json: true },
212
+ md: "markdown",
213
+ sh: "shell", bash: "shell", zsh: "shell",
214
+ yml: "yaml", yaml: "yaml",
215
+ toml: "toml",
216
+ xml: "xml", svg: "xml",
217
+ dockerfile: "dockerfile",
218
+ };
219
+ // Special filename matches
220
+ if (filename.toLowerCase() === "dockerfile") return "dockerfile";
221
+ return modes[ext] || "text/plain";
222
+ }
223
+
224
+ async function editFile(relPath) {
225
+ try {
226
+ const data = await api(`/api/zones/${currentZone}/files/read?path=${encodeURIComponent(relPath)}`);
227
+ currentEditFile = relPath;
228
+ const filename = relPath.split("/").pop();
229
+
230
+ // Update tab
231
+ document.getElementById("editor-tabs").innerHTML = `
232
+ <span class="pane-tab">
233
+ <i data-lucide="file-text"></i>
234
+ <span class="tab-name">${escapeHtml(filename)}</span>
235
+ <span class="tab-dot" id="editor-modified" style="display:none"></span>
236
+ </span>`;
237
+ lucide.createIcons({ nodes: [document.getElementById("editor-tabs")] });
238
+
239
+ // Init CodeMirror
240
+ const container = document.getElementById("editor-container");
241
+ container.innerHTML = "";
242
+ cmEditor = CodeMirror(container, {
243
+ value: data.content,
244
+ mode: getMode(filename),
245
+ theme: "material-darker",
246
+ lineNumbers: true,
247
+ lineWrapping: false,
248
+ indentWithTabs: false,
249
+ indentUnit: 4,
250
+ tabSize: 4,
251
+ matchBrackets: true,
252
+ autoCloseBrackets: true,
253
+ styleActiveLine: true,
254
+ extraKeys: {
255
+ "Ctrl-S": () => saveFile(),
256
+ "Cmd-S": () => saveFile(),
257
+ "Tab": (cm) => {
258
+ if (cm.somethingSelected()) cm.indentSelection("add");
259
+ else cm.replaceSelection(" ", "end");
260
+ },
261
+ }
262
+ });
263
+
264
+ // Track modifications
265
+ cmEditor.on("change", () => {
266
+ const dot = document.getElementById("editor-modified");
267
+ if (dot) dot.style.display = "block";
268
+ });
269
+
270
+ // Focus editor
271
+ setTimeout(() => cmEditor.refresh(), 50);
272
+ } catch (e) {
273
+ toast(e.message, "error");
274
+ }
275
+ }
276
+
277
+ async function saveFile() {
278
+ if (!currentEditFile || !currentZone || !cmEditor) return;
279
+ const content = cmEditor.getValue();
280
+ const form = new FormData();
281
+ form.append("path", currentEditFile);
282
+ form.append("content", content);
283
+ try {
284
+ await api(`/api/zones/${currentZone}/files/write`, { method: "POST", body: form });
285
+ const dot = document.getElementById("editor-modified");
286
+ if (dot) dot.style.display = "none";
287
+ toast("File saved", "success");
288
+ } catch (e) {
289
+ toast("Save failed: " + e.message, "error");
290
+ }
291
+ }
292
+
293
+ function downloadFile(relPath) {
294
+ window.open(`/api/zones/${currentZone}/files/download?path=${encodeURIComponent(relPath)}`);
295
+ }
296
+
297
+ async function deleteFile(relPath) {
298
+ if (!confirm(`Delete "${relPath}"?`)) return;
299
+ try {
300
+ await api(`/api/zones/${currentZone}/files?path=${encodeURIComponent(relPath)}`, { method: "DELETE" });
301
+ toast("Deleted", "info");
302
+ await loadFiles();
303
+ } catch (e) {
304
+ toast(e.message, "error");
305
+ }
306
+ }
307
+
308
+ async function renameFile(oldPath, oldName) {
309
+ const newName = await promptUser("Rename", oldName);
310
+ if (!newName || newName === oldName) return;
311
+ const form = new FormData();
312
+ form.append("old_path", oldPath);
313
+ form.append("new_name", newName);
314
+ try {
315
+ await api(`/api/zones/${currentZone}/files/rename`, { method: "POST", body: form });
316
+ toast("Renamed", "success");
317
+ await loadFiles();
318
+ } catch (e) {
319
+ toast(e.message, "error");
320
+ }
321
+ }
322
+
323
+ async function createNewFile() {
324
+ const name = await promptUser("New file name", "");
325
+ if (!name) return;
326
+ const path = currentPath ? `${currentPath}/${name}` : name;
327
+ const form = new FormData();
328
+ form.append("path", path);
329
+ form.append("content", "");
330
+ try {
331
+ await api(`/api/zones/${currentZone}/files/write`, { method: "POST", body: form });
332
+ toast("File created", "success");
333
+ await loadFiles();
334
+ } catch (e) {
335
+ toast(e.message, "error");
336
+ }
337
+ }
338
+
339
+ async function createNewFolder() {
340
+ const name = await promptUser("New folder name", "");
341
+ if (!name) return;
342
+ const path = currentPath ? `${currentPath}/${name}` : name;
343
+ const form = new FormData();
344
+ form.append("path", path);
345
+ try {
346
+ await api(`/api/zones/${currentZone}/files/mkdir`, { method: "POST", body: form });
347
+ toast("Folder created", "success");
348
+ await loadFiles();
349
+ } catch (e) {
350
+ toast(e.message, "error");
351
+ }
352
+ }
353
+
354
+ function uploadFiles() {
355
+ document.getElementById("file-upload-input").click();
356
+ }
357
+
358
+ async function handleUpload(event) {
359
+ const files = event.target.files;
360
+ if (!files.length) return;
361
+ for (const file of files) {
362
+ const form = new FormData();
363
+ form.append("path", currentPath);
364
+ form.append("file", file);
365
+ try {
366
+ await api(`/api/zones/${currentZone}/files/upload`, { method: "POST", body: form });
367
+ toast(`Uploaded ${file.name}`, "success");
368
+ } catch (e) {
369
+ toast(`Upload ${file.name} failed: ${e.message}`, "error");
370
+ }
371
+ }
372
+ event.target.value = "";
373
+ await loadFiles();
374
+ }
375
+
376
+ // ── Terminal ─────────────────────────────────
377
+ function connectTerminal() {
378
+ if (!currentZone) return;
379
+
380
+ if (!term) {
381
+ term = new window.Terminal({
382
+ cursorBlink: true,
383
+ fontSize: 13,
384
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
385
+ theme: {
386
+ background: "#09090b",
387
+ foreground: "#fafafa",
388
+ cursor: "#3b82f6",
389
+ selectionBackground: "rgba(59,130,246,0.3)",
390
+ black: "#27272a",
391
+ red: "#ef4444",
392
+ green: "#22c55e",
393
+ yellow: "#f59e0b",
394
+ blue: "#3b82f6",
395
+ magenta: "#a855f7",
396
+ cyan: "#06b6d4",
397
+ white: "#e4e4e7",
398
+ brightBlack: "#52525b",
399
+ brightRed: "#f87171",
400
+ brightGreen: "#4ade80",
401
+ brightYellow: "#fbbf24",
402
+ brightBlue: "#60a5fa",
403
+ brightMagenta: "#c084fc",
404
+ brightCyan: "#22d3ee",
405
+ brightWhite: "#fafafa",
406
+ }
407
+ });
408
+ fitAddon = new window.FitAddon.FitAddon();
409
+ term.loadAddon(fitAddon);
410
+ term.loadAddon(new window.WebLinksAddon.WebLinksAddon());
411
+ const container = document.getElementById("terminal-container");
412
+ container.innerHTML = "";
413
+ term.open(container);
414
+ fitAddon.fit();
415
+ }
416
+
417
+ if (termSocket) { termSocket.close(); termSocket = null; }
418
+ if (termDataDisposable) { termDataDisposable.dispose(); termDataDisposable = null; }
419
+ if (termResizeDisposable) { termResizeDisposable.dispose(); termResizeDisposable = null; }
420
+
421
+ term.reset();
422
+
423
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
424
+ termSocket = new WebSocket(`${proto}//${location.host}/ws/terminal/${currentZone}`);
425
+ termSocket.binaryType = "arraybuffer";
426
+
427
+ termSocket.onopen = () => {
428
+ termSocket.send(JSON.stringify({ type: "resize", rows: term.rows, cols: term.cols }));
429
+ };
430
+
431
+ termSocket.onmessage = (event) => {
432
+ if (event.data instanceof ArrayBuffer) term.write(new Uint8Array(event.data));
433
+ else term.write(event.data);
434
+ };
435
+
436
+ termSocket.onclose = () => {
437
+ term.writeln("\r\n\x1b[2m── disconnected ──\x1b[0m");
438
+ };
439
+
440
+ termSocket.onerror = () => {
441
+ term.writeln("\r\n\x1b[31m── connection error ──\x1b[0m");
442
+ };
443
+
444
+ termDataDisposable = term.onData((data) => {
445
+ if (termSocket && termSocket.readyState === WebSocket.OPEN) {
446
+ termSocket.send(JSON.stringify({ type: "input", data }));
447
+ }
448
+ });
449
+
450
+ termResizeDisposable = term.onResize(({ rows, cols }) => {
451
+ if (termSocket && termSocket.readyState === WebSocket.OPEN) {
452
+ termSocket.send(JSON.stringify({ type: "resize", rows, cols }));
453
+ }
454
+ });
455
+ }
456
+
457
+ function disconnectTerminal() {
458
+ if (termSocket) { termSocket.close(); termSocket = null; }
459
+ }
460
+
461
+ function reconnectTerminal() {
462
+ disconnectTerminal();
463
+ connectTerminal();
464
+ }
465
+
466
+ // ── Resizable Panels ─────────────────────────
467
+ function initResizers() {
468
+ // Vertical resizer (file panel width)
469
+ const rv = document.getElementById("resizer-v");
470
+ const panelFiles = document.getElementById("panel-files");
471
+ if (rv && panelFiles) {
472
+ let startX, startW;
473
+ rv.addEventListener("mousedown", (e) => {
474
+ e.preventDefault();
475
+ startX = e.clientX;
476
+ startW = panelFiles.offsetWidth;
477
+ rv.classList.add("active");
478
+ const onMove = (e) => {
479
+ const w = Math.max(180, Math.min(500, startW + e.clientX - startX));
480
+ panelFiles.style.width = w + "px";
481
+ };
482
+ const onUp = () => {
483
+ rv.classList.remove("active");
484
+ document.removeEventListener("mousemove", onMove);
485
+ document.removeEventListener("mouseup", onUp);
486
+ if (fitAddon) fitAddon.fit();
487
+ if (cmEditor) cmEditor.refresh();
488
+ };
489
+ document.addEventListener("mousemove", onMove);
490
+ document.addEventListener("mouseup", onUp);
491
+ });
492
+ }
493
+
494
+ // Horizontal resizer (terminal height)
495
+ const rh = document.getElementById("resizer-h");
496
+ const paneTerminal = document.getElementById("pane-terminal");
497
+ if (rh && paneTerminal) {
498
+ let startY, startH;
499
+ rh.addEventListener("mousedown", (e) => {
500
+ e.preventDefault();
501
+ startY = e.clientY;
502
+ startH = paneTerminal.offsetHeight;
503
+ rh.classList.add("active");
504
+ const onMove = (e) => {
505
+ const h = Math.max(100, Math.min(600, startH - (e.clientY - startY)));
506
+ paneTerminal.style.height = h + "px";
507
+ paneTerminal.style.flex = "none";
508
+ };
509
+ const onUp = () => {
510
+ rh.classList.remove("active");
511
+ document.removeEventListener("mousemove", onMove);
512
+ document.removeEventListener("mouseup", onUp);
513
+ if (fitAddon) fitAddon.fit();
514
+ if (cmEditor) cmEditor.refresh();
515
+ };
516
+ document.addEventListener("mousemove", onMove);
517
+ document.addEventListener("mouseup", onUp);
518
+ });
519
+ }
520
+ }
521
+
522
+ // ── Modal / Prompt ───────────────────────────
523
+ function showModal(id) {
524
+ document.getElementById(id).classList.remove("hidden");
525
+ lucide.createIcons({ nodes: [document.getElementById(id)] });
526
+ }
527
+
528
+ function closeModal(id) {
529
+ document.getElementById(id).classList.add("hidden");
530
+ }
531
+
532
+ function promptUser(title, defaultValue) {
533
+ return new Promise((resolve) => {
534
+ document.getElementById("prompt-title").textContent = title;
535
+ document.getElementById("prompt-input").value = defaultValue || "";
536
+ showModal("modal-prompt");
537
+ setTimeout(() => document.getElementById("prompt-input").focus(), 100);
538
+ promptResolve = resolve;
539
+ });
540
+ }
541
+
542
+ // ── Utility ──────────────────────────────────
543
+ function escapeHtml(str) {
544
+ const div = document.createElement("div");
545
+ div.textContent = str;
546
+ return div.innerHTML;
547
+ }
548
+
549
+ function escapeAttr(str) {
550
+ return str.replace(/'/g, "\\'").replace(/"/g, "&quot;");
551
+ }
552
+
553
+ function formatSize(bytes) {
554
+ if (bytes === 0) return "0 B";
555
+ const units = ["B", "KB", "MB", "GB"];
556
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
557
+ return (bytes / Math.pow(1024, i)).toFixed(i ? 1 : 0) + " " + units[i];
558
+ }
559
+
560
+ function fileIconClass(name) {
561
+ const ext = name.split(".").pop().toLowerCase();
562
+ const classes = {
563
+ js: "fi-icon-js", mjs: "fi-icon-js", jsx: "fi-icon-js",
564
+ ts: "fi-icon-ts", tsx: "fi-icon-ts",
565
+ py: "fi-icon-py",
566
+ go: "fi-icon-go",
567
+ html: "fi-icon-html", htm: "fi-icon-html",
568
+ css: "fi-icon-css", scss: "fi-icon-css",
569
+ json: "fi-icon-json",
570
+ md: "fi-icon-md",
571
+ png: "fi-icon-img", jpg: "fi-icon-img", jpeg: "fi-icon-img", gif: "fi-icon-img", svg: "fi-icon-img",
572
+ yml: "fi-icon-config", yaml: "fi-icon-config", toml: "fi-icon-config",
573
+ };
574
+ return classes[ext] || "fi-icon-file";
575
+ }
576
+
577
+ // ── Event Binding ────────────────────────────
578
+ function bindEvents() {
579
+ document.getElementById("btn-add-zone").addEventListener("click", () => showModal("modal-overlay"));
580
+ document.getElementById("form-create-zone").addEventListener("submit", (e) => { e.preventDefault(); createZone(); });
581
+ document.getElementById("btn-delete-zone").addEventListener("click", deleteZone);
582
+ document.getElementById("btn-new-file").addEventListener("click", createNewFile);
583
+ document.getElementById("btn-new-folder").addEventListener("click", createNewFolder);
584
+ document.getElementById("btn-upload").addEventListener("click", uploadFiles);
585
+ document.getElementById("file-upload-input").addEventListener("change", handleUpload);
586
+ document.getElementById("btn-save-file").addEventListener("click", saveFile);
587
+ document.getElementById("btn-reconnect").addEventListener("click", reconnectTerminal);
588
+
589
+ document.getElementById("btn-cancel-prompt").addEventListener("click", () => {
590
+ closeModal("modal-prompt");
591
+ if (promptResolve) { promptResolve(null); promptResolve = null; }
592
+ });
593
+ document.getElementById("form-prompt").addEventListener("submit", (e) => {
594
+ e.preventDefault();
595
+ const val = document.getElementById("prompt-input").value;
596
+ closeModal("modal-prompt");
597
+ if (promptResolve) { promptResolve(val); promptResolve = null; }
598
+ });
599
+
600
+ document.addEventListener("keydown", (e) => {
601
+ if ((e.ctrlKey || e.metaKey) && e.key === "s") {
602
+ e.preventDefault();
603
+ saveFile();
604
+ }
605
+ });
606
+
607
+ window.addEventListener("resize", () => {
608
+ if (fitAddon) fitAddon.fit();
609
+ if (cmEditor) cmEditor.refresh();
610
+ });
611
+ }
static/index.html ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>HugPanel</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ <!-- Lucide Icons -->
9
+ <script src="https://cdn.jsdelivr.net/npm/lucide@0.344.0/dist/umd/lucide.min.js"></script>
10
+ <!-- xterm.js -->
11
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
12
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
13
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
14
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
15
+ <!-- CodeMirror 5 -->
16
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.css">
17
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/theme/material-darker.css">
18
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.js"></script>
19
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/javascript/javascript.min.js"></script>
20
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/python/python.min.js"></script>
21
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/go/go.min.js"></script>
22
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/markdown/markdown.min.js"></script>
23
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/shell/shell.min.js"></script>
24
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/css/css.min.js"></script>
25
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
26
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/xml/xml.min.js"></script>
27
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/yaml/yaml.min.js"></script>
28
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/toml/toml.min.js"></script>
29
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/dockerfile/dockerfile.min.js"></script>
30
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/rust/rust.min.js"></script>
31
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/edit/closebrackets.min.js"></script>
32
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/edit/matchbrackets.min.js"></script>
33
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/selection/active-line.min.js"></script>
34
+ </head>
35
+ <body>
36
+ <div id="app">
37
+ <!-- Sidebar -->
38
+ <aside id="sidebar">
39
+ <div class="sidebar-brand">
40
+ <div class="brand-icon"><i data-lucide="layout-dashboard"></i></div>
41
+ <span class="brand-text">HugPanel</span>
42
+ </div>
43
+
44
+ <nav class="sidebar-nav">
45
+ <div class="nav-group">
46
+ <div class="nav-group-header">
47
+ <span>ZONES</span>
48
+ <button id="btn-add-zone" class="icon-btn-sm" title="Tạo zone mới">
49
+ <i data-lucide="plus"></i>
50
+ </button>
51
+ </div>
52
+ <ul id="zone-list" class="zone-list"></ul>
53
+ </div>
54
+ </nav>
55
+
56
+ <div class="sidebar-bottom">
57
+ <div class="env-badges">
58
+ <span class="badge badge-green">Node 20</span>
59
+ <span class="badge badge-cyan">Go 1.22</span>
60
+ <span class="badge badge-yellow">Python 3.11</span>
61
+ </div>
62
+ </div>
63
+ </aside>
64
+
65
+ <!-- Main -->
66
+ <main id="main">
67
+ <!-- Welcome -->
68
+ <div id="welcome" class="view active">
69
+ <div class="welcome-hero">
70
+ <div class="welcome-glow"></div>
71
+ <div class="welcome-icon"><i data-lucide="server"></i></div>
72
+ <h1>HugPanel</h1>
73
+ <p>Multi-zone workspace with file manager, code editor &amp; terminal.</p>
74
+ <button class="btn btn-accent btn-lg" onclick="document.getElementById('btn-add-zone').click()">
75
+ <i data-lucide="plus"></i> Create Zone
76
+ </button>
77
+ </div>
78
+ </div>
79
+
80
+ <!-- Workspace -->
81
+ <div id="workspace" class="view">
82
+ <div class="topbar">
83
+ <div class="topbar-left">
84
+ <span class="zone-indicator" id="zone-badge">
85
+ <i data-lucide="box"></i>
86
+ <span id="zone-title"></span>
87
+ </span>
88
+ <span class="topbar-sep"></span>
89
+ <div class="breadcrumb" id="breadcrumb"></div>
90
+ </div>
91
+ <div class="topbar-right">
92
+ <button id="btn-delete-zone" class="icon-btn icon-btn-danger" title="Delete zone">
93
+ <i data-lucide="trash-2"></i>
94
+ </button>
95
+ </div>
96
+ </div>
97
+
98
+ <div class="workspace-body">
99
+ <!-- File Panel -->
100
+ <div class="panel panel-files" id="panel-files">
101
+ <div class="panel-header">
102
+ <span class="panel-title"><i data-lucide="folder-open"></i> Explorer</span>
103
+ <div class="panel-actions">
104
+ <button class="icon-btn-sm" id="btn-new-file" title="New File"><i data-lucide="file-plus"></i></button>
105
+ <button class="icon-btn-sm" id="btn-new-folder" title="New Folder"><i data-lucide="folder-plus"></i></button>
106
+ <button class="icon-btn-sm" id="btn-upload" title="Upload"><i data-lucide="upload"></i></button>
107
+ </div>
108
+ </div>
109
+ <div id="file-list" class="file-tree"></div>
110
+ <input type="file" id="file-upload-input" style="display:none" multiple>
111
+ </div>
112
+
113
+ <div class="resizer-v" id="resizer-v"></div>
114
+
115
+ <!-- Right: Editor + Terminal -->
116
+ <div class="panel panel-right" id="panel-right">
117
+ <div class="pane pane-editor" id="pane-editor">
118
+ <div class="pane-header">
119
+ <div class="pane-tabs" id="editor-tabs">
120
+ <span class="tab-placeholder"><i data-lucide="code-2"></i> No file open</span>
121
+ </div>
122
+ <div class="pane-actions">
123
+ <button class="icon-btn-sm" id="btn-save-file" title="Save (Ctrl+S)"><i data-lucide="save"></i></button>
124
+ </div>
125
+ </div>
126
+ <div id="editor-container" class="editor-container">
127
+ <div class="editor-empty">
128
+ <i data-lucide="mouse-pointer-click"></i>
129
+ <p>Double-click a file to open</p>
130
+ </div>
131
+ </div>
132
+ </div>
133
+
134
+ <div class="resizer-h" id="resizer-h"></div>
135
+
136
+ <div class="pane pane-terminal" id="pane-terminal">
137
+ <div class="pane-header">
138
+ <span class="pane-title"><i data-lucide="terminal-square"></i> Terminal</span>
139
+ <div class="pane-actions">
140
+ <button class="icon-btn-sm" id="btn-reconnect" title="Reconnect"><i data-lucide="refresh-cw"></i></button>
141
+ </div>
142
+ </div>
143
+ <div id="terminal-container" class="terminal-container"></div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </main>
149
+ </div>
150
+
151
+ <!-- Toasts -->
152
+ <div id="toast-container" class="toast-container"></div>
153
+
154
+ <!-- Modal: Create Zone -->
155
+ <div id="modal-overlay" class="modal-overlay hidden">
156
+ <div class="modal">
157
+ <div class="modal-header">
158
+ <h3><i data-lucide="plus-circle"></i> Create Zone</h3>
159
+ <button class="icon-btn-sm" onclick="closeModal('modal-overlay')"><i data-lucide="x"></i></button>
160
+ </div>
161
+ <form id="form-create-zone">
162
+ <div class="form-group">
163
+ <label for="input-zone-name">Zone name</label>
164
+ <input type="text" id="input-zone-name" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" required autocomplete="off">
165
+ <span class="form-hint">Only a-z, 0-9, hyphens and underscores</span>
166
+ </div>
167
+ <div class="form-group">
168
+ <label for="input-zone-desc">Description <span class="optional">(optional)</span></label>
169
+ <input type="text" id="input-zone-desc" placeholder="Short description...">
170
+ </div>
171
+ <div class="modal-footer">
172
+ <button type="button" class="btn btn-ghost" onclick="closeModal('modal-overlay')">Cancel</button>
173
+ <button type="submit" class="btn btn-accent"><i data-lucide="plus"></i> Create</button>
174
+ </div>
175
+ </form>
176
+ </div>
177
+ </div>
178
+
179
+ <!-- Modal: Prompt -->
180
+ <div id="modal-prompt" class="modal-overlay hidden">
181
+ <div class="modal modal-sm">
182
+ <div class="modal-header">
183
+ <h3 id="prompt-title">Enter name</h3>
184
+ <button class="icon-btn-sm" onclick="closeModal('modal-prompt');if(promptResolve){promptResolve(null);promptResolve=null}"><i data-lucide="x"></i></button>
185
+ </div>
186
+ <form id="form-prompt">
187
+ <div class="form-group">
188
+ <input type="text" id="prompt-input" required autocomplete="off">
189
+ </div>
190
+ <div class="modal-footer">
191
+ <button type="button" class="btn btn-ghost" id="btn-cancel-prompt">Cancel</button>
192
+ <button type="submit" class="btn btn-accent">OK</button>
193
+ </div>
194
+ </form>
195
+ </div>
196
+ </div>
197
+
198
+ <script src="/static/app.js"></script>
199
+ </body>
200
+ </html>
static/style.css ADDED
@@ -0,0 +1,723 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════
2
+ HugPanel — Modern IDE-style Dashboard
3
+ ═══════════════════════════════════════════════ */
4
+
5
+ * { margin: 0; padding: 0; box-sizing: border-box; }
6
+
7
+ :root {
8
+ --bg-0: #09090b;
9
+ --bg-1: #0f0f12;
10
+ --bg-2: #18181b;
11
+ --bg-3: #1e1e22;
12
+ --bg-4: #27272a;
13
+ --bg-hover: #2a2a2f;
14
+ --bg-active: #323238;
15
+
16
+ --border: #27272a;
17
+ --border-subtle: #1e1e22;
18
+ --border-focus: #3b82f6;
19
+
20
+ --text: #fafafa;
21
+ --text-2: #a1a1aa;
22
+ --text-3: #71717a;
23
+
24
+ --accent: #3b82f6;
25
+ --accent-hover: #60a5fa;
26
+ --accent-glow: rgba(59, 130, 246, 0.15);
27
+ --danger: #ef4444;
28
+ --danger-hover: #f87171;
29
+ --success: #22c55e;
30
+ --warning: #f59e0b;
31
+
32
+ --radius-sm: 4px;
33
+ --radius: 8px;
34
+ --radius-lg: 12px;
35
+ --radius-xl: 16px;
36
+
37
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
38
+ --shadow: 0 4px 16px rgba(0,0,0,0.4);
39
+ --shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
40
+
41
+ --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
42
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
43
+
44
+ --sidebar-w: 240px;
45
+ --topbar-h: 44px;
46
+ --panel-header-h: 36px;
47
+
48
+ --transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
49
+ }
50
+
51
+ html, body { height: 100%; overflow: hidden; }
52
+
53
+ body {
54
+ background: var(--bg-0);
55
+ color: var(--text);
56
+ font-family: var(--font);
57
+ font-size: 13px;
58
+ line-height: 1.5;
59
+ -webkit-font-smoothing: antialiased;
60
+ }
61
+
62
+ /* ── Scrollbar ──────────────── */
63
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
64
+ ::-webkit-scrollbar-track { background: transparent; }
65
+ ::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 3px; }
66
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
67
+
68
+ /* ── Layout ─────────────────── */
69
+ #app { display: flex; height: 100vh; }
70
+
71
+ /* ── Sidebar ────────────────── */
72
+ #sidebar {
73
+ width: var(--sidebar-w);
74
+ min-width: var(--sidebar-w);
75
+ background: var(--bg-1);
76
+ border-right: 1px solid var(--border);
77
+ display: flex;
78
+ flex-direction: column;
79
+ z-index: 10;
80
+ }
81
+
82
+ .sidebar-brand {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 10px;
86
+ padding: 16px 16px 12px;
87
+ }
88
+
89
+ .brand-icon {
90
+ width: 32px; height: 32px;
91
+ background: linear-gradient(135deg, var(--accent), #8b5cf6);
92
+ border-radius: var(--radius);
93
+ display: flex; align-items: center; justify-content: center;
94
+ color: #fff;
95
+ }
96
+
97
+ .brand-icon svg { width: 18px; height: 18px; }
98
+ .brand-text { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
99
+
100
+ .sidebar-nav { flex: 1; overflow-y: auto; padding: 4px 0; }
101
+
102
+ .nav-group { padding: 0 8px; }
103
+
104
+ .nav-group-header {
105
+ display: flex;
106
+ justify-content: space-between;
107
+ align-items: center;
108
+ padding: 8px 8px 6px;
109
+ font-size: 11px;
110
+ font-weight: 600;
111
+ letter-spacing: 0.8px;
112
+ color: var(--text-3);
113
+ }
114
+
115
+ .zone-list { list-style: none; }
116
+
117
+ .zone-list li {
118
+ display: flex;
119
+ align-items: center;
120
+ gap: 8px;
121
+ padding: 7px 10px;
122
+ margin: 1px 0;
123
+ border-radius: var(--radius-sm);
124
+ cursor: pointer;
125
+ color: var(--text-2);
126
+ font-size: 13px;
127
+ transition: all var(--transition);
128
+ }
129
+
130
+ .zone-list li:hover { background: var(--bg-hover); color: var(--text); }
131
+
132
+ .zone-list li.active {
133
+ background: var(--accent-glow);
134
+ color: var(--accent-hover);
135
+ }
136
+
137
+ .zone-list li .zone-icon {
138
+ width: 18px; height: 18px; flex-shrink: 0;
139
+ opacity: 0.6;
140
+ }
141
+
142
+ .zone-list li.active .zone-icon { opacity: 1; color: var(--accent); }
143
+
144
+ .zone-list li .zone-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
145
+ .zone-list li .zone-desc { font-size: 11px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
146
+
147
+ .sidebar-bottom {
148
+ padding: 12px 16px;
149
+ border-top: 1px solid var(--border);
150
+ }
151
+
152
+ .env-badges { display: flex; gap: 6px; flex-wrap: wrap; }
153
+
154
+ .badge {
155
+ padding: 2px 8px;
156
+ border-radius: 20px;
157
+ font-size: 10px;
158
+ font-weight: 600;
159
+ letter-spacing: 0.3px;
160
+ }
161
+
162
+ .badge-green { background: rgba(34,197,94,0.12); color: #4ade80; }
163
+ .badge-cyan { background: rgba(56,189,248,0.12); color: #38bdf8; }
164
+ .badge-yellow { background: rgba(245,158,11,0.12); color: #fbbf24; }
165
+
166
+ /* ── Icon Buttons ──────────── */
167
+ .icon-btn-sm {
168
+ width: 26px; height: 26px;
169
+ display: inline-flex; align-items: center; justify-content: center;
170
+ background: transparent;
171
+ border: none;
172
+ border-radius: var(--radius-sm);
173
+ color: var(--text-3);
174
+ cursor: pointer;
175
+ transition: all var(--transition);
176
+ }
177
+
178
+ .icon-btn-sm svg { width: 14px; height: 14px; }
179
+ .icon-btn-sm:hover { background: var(--bg-hover); color: var(--text); }
180
+
181
+ .icon-btn {
182
+ width: 32px; height: 32px;
183
+ display: inline-flex; align-items: center; justify-content: center;
184
+ background: transparent;
185
+ border: 1px solid var(--border);
186
+ border-radius: var(--radius);
187
+ color: var(--text-2);
188
+ cursor: pointer;
189
+ transition: all var(--transition);
190
+ }
191
+
192
+ .icon-btn svg { width: 16px; height: 16px; }
193
+ .icon-btn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--text-3); }
194
+ .icon-btn-danger:hover { background: rgba(239,68,68,0.1); color: var(--danger); border-color: var(--danger); }
195
+
196
+ /* ── Buttons ───────────────── */
197
+ .btn {
198
+ display: inline-flex; align-items: center; gap: 6px;
199
+ padding: 8px 16px;
200
+ border: none;
201
+ border-radius: var(--radius);
202
+ font-size: 13px;
203
+ font-weight: 500;
204
+ cursor: pointer;
205
+ transition: all var(--transition);
206
+ white-space: nowrap;
207
+ }
208
+
209
+ .btn svg { width: 14px; height: 14px; }
210
+
211
+ .btn-accent {
212
+ background: var(--accent);
213
+ color: #fff;
214
+ box-shadow: 0 0 20px var(--accent-glow);
215
+ }
216
+
217
+ .btn-accent:hover { background: var(--accent-hover); box-shadow: 0 0 30px var(--accent-glow); }
218
+
219
+ .btn-ghost {
220
+ background: transparent;
221
+ color: var(--text-2);
222
+ border: 1px solid var(--border);
223
+ }
224
+
225
+ .btn-ghost:hover { background: var(--bg-hover); color: var(--text); }
226
+
227
+ .btn-lg { padding: 12px 24px; font-size: 14px; border-radius: var(--radius-lg); }
228
+ .btn-lg svg { width: 16px; height: 16px; }
229
+
230
+ /* ── Main ──────────────────── */
231
+ #main {
232
+ flex: 1;
233
+ display: flex;
234
+ flex-direction: column;
235
+ overflow: hidden;
236
+ background: var(--bg-0);
237
+ }
238
+
239
+ .view { display: none; flex: 1; flex-direction: column; overflow: hidden; }
240
+ .view.active { display: flex; }
241
+
242
+ /* ── Welcome ───────────────── */
243
+ .welcome-hero {
244
+ flex: 1;
245
+ display: flex;
246
+ flex-direction: column;
247
+ align-items: center;
248
+ justify-content: center;
249
+ position: relative;
250
+ gap: 16px;
251
+ }
252
+
253
+ .welcome-glow {
254
+ position: absolute;
255
+ width: 300px; height: 300px;
256
+ background: radial-gradient(circle, var(--accent-glow) 0%, transparent 70%);
257
+ border-radius: 50%;
258
+ filter: blur(60px);
259
+ pointer-events: none;
260
+ }
261
+
262
+ .welcome-icon {
263
+ width: 80px; height: 80px;
264
+ background: var(--bg-2);
265
+ border: 1px solid var(--border);
266
+ border-radius: var(--radius-xl);
267
+ display: flex; align-items: center; justify-content: center;
268
+ color: var(--accent);
269
+ position: relative;
270
+ }
271
+
272
+ .welcome-icon svg { width: 36px; height: 36px; }
273
+
274
+ .welcome-hero h1 {
275
+ font-size: 28px;
276
+ font-weight: 700;
277
+ letter-spacing: -0.5px;
278
+ }
279
+
280
+ .welcome-hero p {
281
+ color: var(--text-2);
282
+ font-size: 15px;
283
+ max-width: 360px;
284
+ text-align: center;
285
+ }
286
+
287
+ /* ── Topbar ────────────────── */
288
+ .topbar {
289
+ height: var(--topbar-h);
290
+ display: flex;
291
+ align-items: center;
292
+ justify-content: space-between;
293
+ padding: 0 12px;
294
+ border-bottom: 1px solid var(--border);
295
+ background: var(--bg-1);
296
+ flex-shrink: 0;
297
+ }
298
+
299
+ .topbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
300
+ .topbar-right { display: flex; align-items: center; gap: 6px; }
301
+
302
+ .zone-indicator {
303
+ display: flex; align-items: center; gap: 6px;
304
+ padding: 4px 10px;
305
+ background: var(--accent-glow);
306
+ border-radius: var(--radius-sm);
307
+ color: var(--accent-hover);
308
+ font-weight: 600;
309
+ font-size: 12px;
310
+ flex-shrink: 0;
311
+ }
312
+
313
+ .zone-indicator svg { width: 13px; height: 13px; }
314
+
315
+ .topbar-sep {
316
+ width: 1px; height: 16px;
317
+ background: var(--border);
318
+ flex-shrink: 0;
319
+ }
320
+
321
+ .breadcrumb {
322
+ display: flex; align-items: center; gap: 2px;
323
+ font-size: 12px;
324
+ overflow-x: auto;
325
+ min-width: 0;
326
+ }
327
+
328
+ .breadcrumb span {
329
+ color: var(--text-3);
330
+ cursor: pointer;
331
+ padding: 2px 5px;
332
+ border-radius: var(--radius-sm);
333
+ white-space: nowrap;
334
+ transition: all var(--transition);
335
+ }
336
+
337
+ .breadcrumb span:hover { color: var(--text); background: var(--bg-hover); }
338
+ .breadcrumb .sep { cursor: default; color: var(--text-3); padding: 0 1px; }
339
+ .breadcrumb .sep:hover { background: none; color: var(--text-3); }
340
+
341
+ /* ── Workspace Body ────────── */
342
+ .workspace-body {
343
+ flex: 1;
344
+ display: flex;
345
+ overflow: hidden;
346
+ }
347
+
348
+ /* ── Panels ────────────────── */
349
+ .panel { display: flex; flex-direction: column; overflow: hidden; }
350
+
351
+ .panel-files {
352
+ width: 260px;
353
+ min-width: 180px;
354
+ max-width: 500px;
355
+ border-right: 1px solid var(--border);
356
+ background: var(--bg-1);
357
+ }
358
+
359
+ .panel-right { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
360
+
361
+ .panel-header {
362
+ height: var(--panel-header-h);
363
+ display: flex;
364
+ align-items: center;
365
+ justify-content: space-between;
366
+ padding: 0 10px;
367
+ border-bottom: 1px solid var(--border);
368
+ flex-shrink: 0;
369
+ }
370
+
371
+ .panel-title {
372
+ display: flex; align-items: center; gap: 6px;
373
+ font-size: 11px;
374
+ font-weight: 600;
375
+ text-transform: uppercase;
376
+ letter-spacing: 0.5px;
377
+ color: var(--text-3);
378
+ }
379
+
380
+ .panel-title svg { width: 13px; height: 13px; }
381
+
382
+ .panel-actions { display: flex; gap: 2px; }
383
+
384
+ /* ── File Tree ─────────────── */
385
+ .file-tree {
386
+ flex: 1;
387
+ overflow-y: auto;
388
+ padding: 4px 0;
389
+ }
390
+
391
+ .file-item {
392
+ display: flex;
393
+ align-items: center;
394
+ padding: 4px 12px;
395
+ gap: 8px;
396
+ cursor: pointer;
397
+ transition: background var(--transition);
398
+ height: 30px;
399
+ }
400
+
401
+ .file-item:hover { background: var(--bg-hover); }
402
+
403
+ .file-item .fi-icon {
404
+ width: 16px; height: 16px;
405
+ flex-shrink: 0;
406
+ display: flex; align-items: center; justify-content: center;
407
+ }
408
+
409
+ .file-item .fi-icon svg { width: 14px; height: 14px; }
410
+
411
+ .fi-icon-folder { color: var(--accent); }
412
+ .fi-icon-file { color: var(--text-3); }
413
+ .fi-icon-js { color: #f7df1e; }
414
+ .fi-icon-ts { color: #3178c6; }
415
+ .fi-icon-py { color: #3572a5; }
416
+ .fi-icon-go { color: #00add8; }
417
+ .fi-icon-html { color: #e34c26; }
418
+ .fi-icon-css { color: #563d7c; }
419
+ .fi-icon-json { color: #f59e0b; }
420
+ .fi-icon-md { color: var(--text-2); }
421
+ .fi-icon-img { color: #22c55e; }
422
+ .fi-icon-config { color: var(--text-3); }
423
+ .fi-icon-back { color: var(--text-3); }
424
+
425
+ .file-item .fi-name {
426
+ flex: 1;
427
+ font-size: 13px;
428
+ overflow: hidden;
429
+ text-overflow: ellipsis;
430
+ white-space: nowrap;
431
+ color: var(--text-2);
432
+ }
433
+
434
+ .file-item:hover .fi-name { color: var(--text); }
435
+
436
+ .file-item .fi-size {
437
+ font-size: 11px;
438
+ color: var(--text-3);
439
+ white-space: nowrap;
440
+ font-family: var(--font-mono);
441
+ }
442
+
443
+ .file-item .fi-actions {
444
+ display: flex; gap: 1px;
445
+ opacity: 0;
446
+ transition: opacity var(--transition);
447
+ }
448
+
449
+ .file-item:hover .fi-actions { opacity: 1; }
450
+
451
+ .fi-actions button {
452
+ width: 22px; height: 22px;
453
+ display: flex; align-items: center; justify-content: center;
454
+ background: none; border: none;
455
+ color: var(--text-3);
456
+ cursor: pointer;
457
+ border-radius: var(--radius-sm);
458
+ transition: all var(--transition);
459
+ }
460
+
461
+ .fi-actions button svg { width: 12px; height: 12px; }
462
+ .fi-actions button:hover { background: var(--bg-active); color: var(--text); }
463
+ .fi-actions button.fi-del:hover { color: var(--danger); }
464
+
465
+ /* ── Resizers ──────────────── */
466
+ .resizer-v {
467
+ width: 4px;
468
+ cursor: col-resize;
469
+ background: transparent;
470
+ transition: background var(--transition);
471
+ flex-shrink: 0;
472
+ position: relative;
473
+ }
474
+
475
+ .resizer-v:hover, .resizer-v.active { background: var(--accent); }
476
+
477
+ .resizer-h {
478
+ height: 4px;
479
+ cursor: row-resize;
480
+ background: transparent;
481
+ transition: background var(--transition);
482
+ flex-shrink: 0;
483
+ }
484
+
485
+ .resizer-h:hover, .resizer-h.active { background: var(--accent); }
486
+
487
+ /* ── Panes ─────────────────── */
488
+ .pane { display: flex; flex-direction: column; overflow: hidden; }
489
+ .pane-editor { flex: 1; min-height: 100px; }
490
+ .pane-terminal { height: 260px; min-height: 100px; }
491
+
492
+ .pane-header {
493
+ height: var(--panel-header-h);
494
+ display: flex;
495
+ align-items: center;
496
+ justify-content: space-between;
497
+ padding: 0 10px;
498
+ background: var(--bg-2);
499
+ border-bottom: 1px solid var(--border);
500
+ flex-shrink: 0;
501
+ }
502
+
503
+ .pane-tabs { display: flex; align-items: center; gap: 2px; min-width: 0; flex: 1; overflow-x: auto; }
504
+
505
+ .pane-tab {
506
+ display: flex; align-items: center; gap: 5px;
507
+ padding: 4px 12px;
508
+ border-radius: var(--radius-sm);
509
+ background: var(--bg-3);
510
+ color: var(--text-2);
511
+ font-size: 12px;
512
+ white-space: nowrap;
513
+ max-width: 200px;
514
+ }
515
+
516
+ .pane-tab svg { width: 12px; height: 12px; flex-shrink: 0; }
517
+ .pane-tab .tab-name { overflow: hidden; text-overflow: ellipsis; }
518
+
519
+ .pane-tab .tab-dot {
520
+ width: 6px; height: 6px;
521
+ border-radius: 50%;
522
+ background: var(--accent);
523
+ flex-shrink: 0;
524
+ }
525
+
526
+ .tab-placeholder {
527
+ display: flex; align-items: center; gap: 6px;
528
+ color: var(--text-3);
529
+ font-size: 12px;
530
+ }
531
+
532
+ .tab-placeholder svg { width: 13px; height: 13px; }
533
+
534
+ .pane-title {
535
+ display: flex; align-items: center; gap: 6px;
536
+ font-size: 11px;
537
+ font-weight: 600;
538
+ text-transform: uppercase;
539
+ letter-spacing: 0.5px;
540
+ color: var(--text-3);
541
+ }
542
+
543
+ .pane-title svg { width: 13px; height: 13px; }
544
+
545
+ .pane-actions { display: flex; gap: 2px; }
546
+
547
+ /* ── Editor ────────────────── */
548
+ .editor-container { flex: 1; overflow: hidden; position: relative; }
549
+
550
+ .editor-empty {
551
+ position: absolute;
552
+ inset: 0;
553
+ display: flex;
554
+ flex-direction: column;
555
+ align-items: center;
556
+ justify-content: center;
557
+ gap: 8px;
558
+ color: var(--text-3);
559
+ }
560
+
561
+ .editor-empty svg { width: 28px; height: 28px; opacity: 0.3; }
562
+ .editor-empty p { font-size: 13px; }
563
+
564
+ /* CodeMirror overrides */
565
+ .editor-container .CodeMirror {
566
+ height: 100%;
567
+ font-family: var(--font-mono);
568
+ font-size: 13px;
569
+ line-height: 1.6;
570
+ background: var(--bg-0);
571
+ }
572
+
573
+ .CodeMirror-gutters {
574
+ background: var(--bg-1) !important;
575
+ border-right: 1px solid var(--border) !important;
576
+ }
577
+
578
+ .CodeMirror-linenumber { color: var(--text-3) !important; }
579
+ .CodeMirror-activeline-background { background: var(--bg-hover) !important; }
580
+ .CodeMirror-selected { background: rgba(59,130,246,0.2) !important; }
581
+ .CodeMirror-cursor { border-left-color: var(--accent) !important; }
582
+
583
+ /* ── Terminal ──────────────── */
584
+ .terminal-container {
585
+ flex: 1;
586
+ background: var(--bg-0);
587
+ overflow: hidden;
588
+ }
589
+
590
+ .terminal-container .xterm { padding: 4px 0 4px 4px; }
591
+
592
+ /* ── Empty State ───────────── */
593
+ .empty-state {
594
+ display: flex;
595
+ flex-direction: column;
596
+ align-items: center;
597
+ justify-content: center;
598
+ padding: 40px 20px;
599
+ color: var(--text-3);
600
+ gap: 8px;
601
+ }
602
+
603
+ .empty-state svg { width: 32px; height: 32px; opacity: 0.3; }
604
+ .empty-state p { font-size: 13px; }
605
+
606
+ /* ── Modal ─────────────────── */
607
+ .modal-overlay {
608
+ position: fixed;
609
+ inset: 0;
610
+ background: rgba(0, 0, 0, 0.6);
611
+ backdrop-filter: blur(4px);
612
+ display: flex;
613
+ align-items: center;
614
+ justify-content: center;
615
+ z-index: 1000;
616
+ animation: fadeIn 150ms ease;
617
+ }
618
+
619
+ .modal-overlay.hidden { display: none; }
620
+
621
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
622
+ @keyframes slideUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
623
+
624
+ .modal {
625
+ background: var(--bg-2);
626
+ border: 1px solid var(--border);
627
+ border-radius: var(--radius-lg);
628
+ padding: 0;
629
+ width: 420px;
630
+ max-width: 90vw;
631
+ box-shadow: var(--shadow-lg);
632
+ animation: slideUp 200ms ease;
633
+ }
634
+
635
+ .modal-sm { width: 360px; }
636
+
637
+ .modal-header {
638
+ display: flex;
639
+ align-items: center;
640
+ justify-content: space-between;
641
+ padding: 16px 20px 12px;
642
+ border-bottom: 1px solid var(--border);
643
+ }
644
+
645
+ .modal-header h3 {
646
+ display: flex; align-items: center; gap: 8px;
647
+ font-size: 15px;
648
+ font-weight: 600;
649
+ }
650
+
651
+ .modal-header h3 svg { width: 18px; height: 18px; color: var(--accent); }
652
+
653
+ .modal form { padding: 16px 20px; }
654
+
655
+ .form-group { margin-bottom: 14px; }
656
+
657
+ .form-group label {
658
+ display: block;
659
+ font-size: 12px;
660
+ font-weight: 500;
661
+ color: var(--text-2);
662
+ margin-bottom: 5px;
663
+ }
664
+
665
+ .form-group .optional { color: var(--text-3); font-weight: 400; }
666
+
667
+ .form-group input[type="text"] {
668
+ width: 100%;
669
+ padding: 8px 12px;
670
+ background: var(--bg-0);
671
+ border: 1px solid var(--border);
672
+ border-radius: var(--radius);
673
+ color: var(--text);
674
+ font-size: 13px;
675
+ outline: none;
676
+ transition: border-color var(--transition);
677
+ font-family: var(--font);
678
+ }
679
+
680
+ .form-group input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
681
+
682
+ .form-hint { font-size: 11px; color: var(--text-3); margin-top: 4px; display: block; }
683
+
684
+ .modal-footer {
685
+ display: flex;
686
+ justify-content: flex-end;
687
+ gap: 8px;
688
+ padding-top: 8px;
689
+ }
690
+
691
+ /* ── Toast ─────────────────── */
692
+ .toast-container {
693
+ position: fixed;
694
+ bottom: 20px;
695
+ right: 20px;
696
+ display: flex;
697
+ flex-direction: column;
698
+ gap: 8px;
699
+ z-index: 2000;
700
+ pointer-events: none;
701
+ }
702
+
703
+ .toast {
704
+ display: flex; align-items: center; gap: 8px;
705
+ padding: 10px 16px;
706
+ background: var(--bg-3);
707
+ border: 1px solid var(--border);
708
+ border-radius: var(--radius);
709
+ box-shadow: var(--shadow);
710
+ font-size: 13px;
711
+ color: var(--text);
712
+ animation: slideUp 200ms ease;
713
+ pointer-events: auto;
714
+ max-width: 360px;
715
+ }
716
+
717
+ .toast svg { width: 16px; height: 16px; flex-shrink: 0; }
718
+ .toast-success svg { color: var(--success); }
719
+ .toast-error svg { color: var(--danger); }
720
+ .toast-info svg { color: var(--accent); }
721
+
722
+ /* ── Utility ───────────────── */
723
+ .hidden { display: none !important; }
terminal.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import fcntl
3
+ import os
4
+ import pty
5
+ import select
6
+ import struct
7
+ import termios
8
+ from fastapi import WebSocket, WebSocketDisconnect
9
+ from zones import get_zone_path
10
+
11
+ # Lưu các terminal process đang chạy: {zone_name: {fd, pid}}
12
+ active_terminals: dict[str, dict] = {}
13
+
14
+
15
+ def _spawn_shell(zone_name: str) -> dict:
16
+ """Tạo PTY shell mới cho zone."""
17
+ zone_path = get_zone_path(zone_name)
18
+ master_fd, slave_fd = pty.openpty()
19
+
20
+ child_pid = os.fork()
21
+ if child_pid == 0:
22
+ # Child process
23
+ os.setsid()
24
+ os.dup2(slave_fd, 0)
25
+ os.dup2(slave_fd, 1)
26
+ os.dup2(slave_fd, 2)
27
+ os.close(master_fd)
28
+ os.close(slave_fd)
29
+ os.chdir(str(zone_path))
30
+ env = os.environ.copy()
31
+ env["TERM"] = "xterm-256color"
32
+ env["HOME"] = str(zone_path)
33
+ env["PS1"] = f"[{zone_name}] \\w $ "
34
+ os.execvpe("/bin/bash", ["/bin/bash", "--norc"], env)
35
+ else:
36
+ os.close(slave_fd)
37
+ # Set non-blocking on master
38
+ flag = fcntl.fcntl(master_fd, fcntl.F_GETFL)
39
+ fcntl.fcntl(master_fd, fcntl.F_SETFL, flag | os.O_NONBLOCK)
40
+ return {"fd": master_fd, "pid": child_pid}
41
+
42
+
43
+ def resize_terminal(zone_name: str, rows: int, cols: int):
44
+ """Resize terminal PTY."""
45
+ if zone_name in active_terminals:
46
+ fd = active_terminals[zone_name]["fd"]
47
+ winsize = struct.pack("HHHH", rows, cols, 0, 0)
48
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
49
+
50
+
51
+ def kill_terminal(zone_name: str):
52
+ """Kill terminal process cho zone."""
53
+ if zone_name in active_terminals:
54
+ info = active_terminals.pop(zone_name)
55
+ try:
56
+ os.kill(info["pid"], 9)
57
+ os.waitpid(info["pid"], os.WNOHANG)
58
+ except (ProcessLookupError, ChildProcessError):
59
+ pass
60
+ try:
61
+ os.close(info["fd"])
62
+ except OSError:
63
+ pass
64
+
65
+
66
+ def _is_alive(zone_name: str) -> bool:
67
+ """Kiểm tra terminal process còn sống không."""
68
+ if zone_name not in active_terminals:
69
+ return False
70
+ try:
71
+ pid = active_terminals[zone_name]["pid"]
72
+ result = os.waitpid(pid, os.WNOHANG)
73
+ return result == (0, 0)
74
+ except ChildProcessError:
75
+ active_terminals.pop(zone_name, None)
76
+ return False
77
+
78
+
79
+ async def terminal_ws(websocket: WebSocket, zone_name: str):
80
+ """WebSocket handler cho terminal."""
81
+ await websocket.accept()
82
+
83
+ # Kiểm tra zone tồn tại
84
+ try:
85
+ get_zone_path(zone_name)
86
+ except ValueError as e:
87
+ await websocket.send_json({"error": str(e)})
88
+ await websocket.close()
89
+ return
90
+
91
+ # Spawn hoặc reuse terminal
92
+ if not _is_alive(zone_name):
93
+ kill_terminal(zone_name) # Cleanup nếu cần
94
+ try:
95
+ info = _spawn_shell(zone_name)
96
+ active_terminals[zone_name] = info
97
+ except Exception as e:
98
+ await websocket.send_json({"error": f"Không tạo được terminal: {e}"})
99
+ await websocket.close()
100
+ return
101
+
102
+ fd = active_terminals[zone_name]["fd"]
103
+
104
+ async def read_output():
105
+ """Đọc output từ PTY và gửi qua WebSocket."""
106
+ loop = asyncio.get_event_loop()
107
+ try:
108
+ while True:
109
+ await asyncio.sleep(0.02)
110
+ if not _is_alive(zone_name):
111
+ break
112
+ try:
113
+ r, _, _ = select.select([fd], [], [], 0)
114
+ if r:
115
+ data = os.read(fd, 4096)
116
+ if data:
117
+ await websocket.send_bytes(data)
118
+ except (OSError, BlockingIOError):
119
+ pass
120
+ except (WebSocketDisconnect, Exception):
121
+ pass
122
+
123
+ reader_task = asyncio.create_task(read_output())
124
+
125
+ try:
126
+ while True:
127
+ msg = await websocket.receive()
128
+ if msg.get("type") == "websocket.disconnect":
129
+ break
130
+
131
+ if "text" in msg:
132
+ import json
133
+ data = json.loads(msg["text"])
134
+ if data.get("type") == "resize":
135
+ resize_terminal(zone_name, data.get("rows", 24), data.get("cols", 80))
136
+ elif data.get("type") == "input":
137
+ os.write(fd, data["data"].encode("utf-8"))
138
+ elif "bytes" in msg:
139
+ os.write(fd, msg["bytes"])
140
+ except WebSocketDisconnect:
141
+ pass
142
+ except Exception:
143
+ pass
144
+ finally:
145
+ reader_task.cancel()
146
+ # Không kill terminal khi disconnect — giữ nó chạy
zones.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import shutil
4
+ import re
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+
8
+ DATA_DIR = Path(os.environ.get("DATA_DIR", "/data/zones"))
9
+ ZONES_META = DATA_DIR.parent / "zones_meta.json"
10
+
11
+ # Đảm bảo thư mục tồn tại
12
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
13
+
14
+ ZONE_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,50}$")
15
+
16
+
17
+ def _load_meta() -> dict:
18
+ if ZONES_META.exists():
19
+ return json.loads(ZONES_META.read_text(encoding="utf-8"))
20
+ return {}
21
+
22
+
23
+ def _save_meta(meta: dict):
24
+ ZONES_META.write_text(json.dumps(meta, indent=2, default=str), encoding="utf-8")
25
+
26
+
27
+ def list_zones() -> list[dict]:
28
+ meta = _load_meta()
29
+ zones = []
30
+ for name, info in meta.items():
31
+ zone_path = DATA_DIR / name
32
+ zones.append({
33
+ "name": name,
34
+ "created": info.get("created", ""),
35
+ "description": info.get("description", ""),
36
+ "exists": zone_path.is_dir(),
37
+ })
38
+ return zones
39
+
40
+
41
+ def create_zone(name: str, description: str = "") -> dict:
42
+ if not ZONE_NAME_PATTERN.match(name):
43
+ raise ValueError("Tên zone chỉ chứa a-z, A-Z, 0-9, _, - (tối đa 50 ký tự)")
44
+
45
+ zone_path = DATA_DIR / name
46
+ if zone_path.exists():
47
+ raise ValueError(f"Zone '{name}' đã tồn tại")
48
+
49
+ zone_path.mkdir(parents=True)
50
+
51
+ # Tạo file README mặc định
52
+ (zone_path / "README.md").write_text(f"# {name}\n\nZone được tạo lúc {datetime.now().isoformat()}\n")
53
+
54
+ meta = _load_meta()
55
+ meta[name] = {
56
+ "created": datetime.now().isoformat(),
57
+ "description": description,
58
+ }
59
+ _save_meta(meta)
60
+
61
+ return {"name": name, "path": str(zone_path)}
62
+
63
+
64
+ def delete_zone(name: str):
65
+ if not ZONE_NAME_PATTERN.match(name):
66
+ raise ValueError("Tên zone không hợp lệ")
67
+
68
+ zone_path = DATA_DIR / name
69
+ if not zone_path.exists():
70
+ raise ValueError(f"Zone '{name}' không tồn tại")
71
+
72
+ shutil.rmtree(zone_path)
73
+
74
+ meta = _load_meta()
75
+ meta.pop(name, None)
76
+ _save_meta(meta)
77
+
78
+
79
+ def get_zone_path(name: str) -> Path:
80
+ if not ZONE_NAME_PATTERN.match(name):
81
+ raise ValueError("Tên zone không hợp lệ")
82
+ zone_path = DATA_DIR / name
83
+ if not zone_path.is_dir():
84
+ raise ValueError(f"Zone '{name}' không tồn tại")
85
+ return zone_path
86
+
87
+
88
+ def _safe_path(zone_path: Path, rel_path: str) -> Path:
89
+ """Kiểm tra path traversal — đảm bảo không thoát ra ngoài zone."""
90
+ target = (zone_path / rel_path).resolve()
91
+ zone_resolved = zone_path.resolve()
92
+ # Dùng os.sep để tránh prefix attack: zone "a" không truy cập được zone "ab"
93
+ if target != zone_resolved and not str(target).startswith(str(zone_resolved) + os.sep):
94
+ raise ValueError("Truy cập ngoài zone không được phép")
95
+ return target
96
+
97
+
98
+ def list_files(zone_name: str, rel_path: str = "") -> list[dict]:
99
+ zone_path = get_zone_path(zone_name)
100
+ target = _safe_path(zone_path, rel_path)
101
+ if not target.is_dir():
102
+ raise ValueError("Không phải thư mục")
103
+
104
+ items = []
105
+ for item in sorted(target.iterdir()):
106
+ stat = item.stat()
107
+ items.append({
108
+ "name": item.name,
109
+ "is_dir": item.is_dir(),
110
+ "size": stat.st_size if item.is_file() else 0,
111
+ "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
112
+ })
113
+ return items
114
+
115
+
116
+ def read_file(zone_name: str, rel_path: str) -> str:
117
+ zone_path = get_zone_path(zone_name)
118
+ target = _safe_path(zone_path, rel_path)
119
+ if not target.is_file():
120
+ raise ValueError("File không tồn tại")
121
+ return target.read_text(encoding="utf-8", errors="replace")
122
+
123
+
124
+ def write_file(zone_name: str, rel_path: str, content: str):
125
+ zone_path = get_zone_path(zone_name)
126
+ target = _safe_path(zone_path, rel_path)
127
+ target.parent.mkdir(parents=True, exist_ok=True)
128
+ target.write_text(content, encoding="utf-8")
129
+
130
+
131
+ def delete_file(zone_name: str, rel_path: str):
132
+ zone_path = get_zone_path(zone_name)
133
+ target = _safe_path(zone_path, rel_path)
134
+ if target == zone_path.resolve():
135
+ raise ValueError("Không thể xoá thư mục gốc zone")
136
+ if target.is_dir():
137
+ shutil.rmtree(target)
138
+ elif target.is_file():
139
+ target.unlink()
140
+ else:
141
+ raise ValueError("File/thư mục không tồn tại")
142
+
143
+
144
+ def create_folder(zone_name: str, rel_path: str):
145
+ zone_path = get_zone_path(zone_name)
146
+ target = _safe_path(zone_path, rel_path)
147
+ target.mkdir(parents=True, exist_ok=True)
148
+
149
+
150
+ def rename_item(zone_name: str, old_path: str, new_name: str):
151
+ zone_path = get_zone_path(zone_name)
152
+ source = _safe_path(zone_path, old_path)
153
+ if not source.exists():
154
+ raise ValueError("File/thư mục nguồn không tồn tại")
155
+ dest = _safe_path(zone_path, str(Path(old_path).parent / new_name))
156
+ source.rename(dest)