google-labs-jules[bot] akborana3 commited on
Commit
6db1d4c
·
1 Parent(s): 94511d2

feat: complete data syncing, import/export, file manager, and chat history

Browse files

Co-authored-by: akborana3 <88649979+akborana3@users.noreply.github.com>

backend/database.py CHANGED
@@ -1,11 +1,15 @@
1
  import sqlite3
2
  import os
3
 
4
- DB_FILE = "devportal.db"
5
- USERS_DIR = "user_spaces"
6
- PUBLISHED_DIR = "published_projects"
 
7
 
8
  def init_db():
 
 
 
9
  conn = sqlite3.connect(DB_FILE)
10
  c = conn.cursor()
11
  # Create users table
@@ -16,6 +20,10 @@ def init_db():
16
  c.execute('''CREATE TABLE IF NOT EXISTS projects
17
  (id TEXT PRIMARY KEY, username TEXT, name TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)''')
18
 
 
 
 
 
19
  conn.commit()
20
  conn.close()
21
 
 
1
  import sqlite3
2
  import os
3
 
4
+ DATA_DIR = "data"
5
+ DB_FILE = os.path.join(DATA_DIR, "devportal.db")
6
+ USERS_DIR = os.path.join(DATA_DIR, "user_spaces")
7
+ PUBLISHED_DIR = os.path.join(DATA_DIR, "published_projects")
8
 
9
  def init_db():
10
+ if not os.path.exists(DATA_DIR):
11
+ os.makedirs(DATA_DIR)
12
+
13
  conn = sqlite3.connect(DB_FILE)
14
  c = conn.cursor()
15
  # Create users table
 
20
  c.execute('''CREATE TABLE IF NOT EXISTS projects
21
  (id TEXT PRIMARY KEY, username TEXT, name TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)''')
22
 
23
+ # Create AI history table
24
+ c.execute('''CREATE TABLE IF NOT EXISTS ai_history
25
+ (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, role TEXT, content TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)''')
26
+
27
  conn.commit()
28
  conn.close()
29
 
backend/main.py CHANGED
@@ -20,6 +20,7 @@ from backend.routes_auth import router as auth_router
20
  from backend.routes_files import router as files_router
21
  from backend.routes_ai import router as ai_router
22
  from backend.routes_terminal import router as terminal_router
 
23
 
24
  app = FastAPI()
25
 
@@ -35,6 +36,7 @@ app.include_router(auth_router)
35
  app.include_router(files_router)
36
  app.include_router(ai_router)
37
  app.include_router(terminal_router)
 
38
 
39
  @app.get("/", response_class=HTMLResponse)
40
  async def get(request: Request):
 
20
  from backend.routes_files import router as files_router
21
  from backend.routes_ai import router as ai_router
22
  from backend.routes_terminal import router as terminal_router
23
+ from backend.routes_sync import router as sync_router
24
 
25
  app = FastAPI()
26
 
 
36
  app.include_router(files_router)
37
  app.include_router(ai_router)
38
  app.include_router(terminal_router)
39
+ app.include_router(sync_router)
40
 
41
  @app.get("/", response_class=HTMLResponse)
42
  async def get(request: Request):
backend/routes_ai.py CHANGED
@@ -6,6 +6,8 @@ import subprocess
6
  import asyncio
7
  from fastapi import APIRouter
8
  from fastapi.responses import StreamingResponse
 
 
9
 
10
  router = APIRouter()
11
 
@@ -124,16 +126,56 @@ async def chat_with_ai(data: dict):
124
  yield f"API Error: {err.decode('utf-8')}"
125
  return
126
 
 
127
  async for line in response.aiter_lines():
128
  if line.startswith("data: ") and line != "data: [DONE]":
129
  try:
130
  data_chunk = json.loads(line[6:])
131
- content = data_chunk.get("choices", [{}])[0].get("delta", {}).get("content", "")
132
- if content:
133
- yield content
 
134
  except json.JSONDecodeError:
135
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  except Exception as e:
137
  yield f"\n\n**Network Error:** {str(e)}"
138
-
139
  return StreamingResponse(stream_generator(), media_type="text/plain")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import asyncio
7
  from fastapi import APIRouter
8
  from fastapi.responses import StreamingResponse
9
+ from backend.database import get_username, DB_FILE
10
+ import sqlite3
11
 
12
  router = APIRouter()
13
 
 
126
  yield f"API Error: {err.decode('utf-8')}"
127
  return
128
 
129
+ full_reply = ""
130
  async for line in response.aiter_lines():
131
  if line.startswith("data: ") and line != "data: [DONE]":
132
  try:
133
  data_chunk = json.loads(line[6:])
134
+ content_chunk = data_chunk.get("choices", [{}])[0].get("delta", {}).get("content", "")
135
+ if content_chunk:
136
+ full_reply += content_chunk
137
+ yield content_chunk
138
  except json.JSONDecodeError:
139
  pass
140
+
141
+ # After successful stream, save to DB
142
+ if full_reply:
143
+ from backend.database import get_username
144
+ username = get_username(token)
145
+ if username:
146
+ conn = sqlite3.connect(DB_FILE)
147
+ c = conn.cursor()
148
+ c.execute("INSERT INTO ai_history (username, role, content) VALUES (?, ?, ?)", (username, 'user', user_msg))
149
+ c.execute("INSERT INTO ai_history (username, role, content) VALUES (?, ?, ?)", (username, 'assistant', full_reply))
150
+ conn.commit()
151
+ conn.close()
152
+
153
  except Exception as e:
154
  yield f"\n\n**Network Error:** {str(e)}"
 
155
  return StreamingResponse(stream_generator(), media_type="text/plain")
156
+
157
+ @router.post("/api/chat/history")
158
+ async def get_chat_history(data: dict):
159
+ username = get_username(data.get("token"))
160
+ if not username: return {"error": "Unauthorized"}
161
+
162
+ conn = sqlite3.connect(DB_FILE)
163
+ c = conn.cursor()
164
+ c.execute("SELECT role, content FROM ai_history WHERE username=? ORDER BY timestamp ASC", (username,))
165
+ history = [{"role": row[0], "content": row[1]} for row in c.fetchall()]
166
+ conn.close()
167
+
168
+ return {"history": history}
169
+
170
+ @router.post("/api/chat/history/clear")
171
+ async def clear_chat_history(data: dict):
172
+ username = get_username(data.get("token"))
173
+ if not username: return {"error": "Unauthorized"}
174
+
175
+ conn = sqlite3.connect(DB_FILE)
176
+ c = conn.cursor()
177
+ c.execute("DELETE FROM ai_history WHERE username=?", (username,))
178
+ conn.commit()
179
+ conn.close()
180
+
181
+ return {"success": True}
backend/routes_files.py CHANGED
@@ -59,6 +59,42 @@ async def rename_file(data: FileReq):
59
  os.rename(old_path, new_path)
60
  return {"success": True}
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  @router.get("/preview/{token}/{file_path:path}")
63
  async def serve_preview_file(token: str, file_path: str):
64
  user_dir = get_user_dir(token)
@@ -67,15 +103,16 @@ async def serve_preview_file(token: str, file_path: str):
67
 
68
  full_path = os.path.abspath(os.path.join(user_dir, file_path))
69
  if not full_path.startswith(user_dir) or not os.path.exists(full_path):
70
- return HTMLResponse("<h1>File Not Found</h1><p>Please create an 'index.html' file first to view the live preview.</p>", status_code=404)
71
 
72
  return FileResponse(full_path)
73
 
74
  class PublishReq(BaseModel):
75
  token: str
76
  project_name: str
77
- project_id: str | None = None # if updating
78
- files: list = [] # list of selected files
 
79
 
80
  @router.post("/api/publish")
81
  async def publish_project(data: PublishReq):
@@ -87,7 +124,6 @@ async def publish_project(data: PublishReq):
87
  if not data.files:
88
  return {"error": "No files selected to publish."}
89
 
90
- # Verify all selected files exist in the user's workspace securely
91
  for f in data.files:
92
  p = os.path.abspath(os.path.join(user_dir, f))
93
  if not p.startswith(user_dir) or not os.path.isfile(p):
@@ -99,41 +135,36 @@ async def publish_project(data: PublishReq):
99
  project_id = data.project_id
100
  pub_path = ""
101
 
102
- # If the user is explicitly updating a project via the UI button
103
  if project_id:
104
- # Verify ownership
105
  c.execute("SELECT id FROM projects WHERE username=? AND id=?", (username, project_id))
106
  if not c.fetchone():
107
  conn.close()
108
  return {"error": "Project not found or you don't have permission."}
109
 
110
  pub_path = os.path.join(PUBLISHED_DIR, project_id)
111
- # We wipe the old directory to ensure files that were un-ticked are actually removed from the published site
112
  if os.path.exists(pub_path):
113
  shutil.rmtree(pub_path)
114
 
115
  c.execute("UPDATE projects SET name=?, created_at=CURRENT_TIMESTAMP WHERE id=?", (data.project_name, project_id))
116
-
117
  else:
118
- # Creating a brand new project record
119
- project_id = str(uuid.uuid4())[:8] # short unique id
120
  pub_path = os.path.join(PUBLISHED_DIR, project_id)
121
  c.execute("INSERT INTO projects (id, username, name) VALUES (?, ?, ?)", (project_id, username, data.project_name))
122
 
123
  conn.commit()
124
  conn.close()
125
 
126
- # Create empty directory
127
  os.makedirs(pub_path, exist_ok=True)
128
 
129
- # Selectively copy ONLY the files requested by the user
130
  for f in data.files:
131
  src = os.path.join(user_dir, f)
132
  dst = os.path.join(pub_path, f)
133
  os.makedirs(os.path.dirname(dst), exist_ok=True)
134
  shutil.copy2(src, dst)
135
 
136
- return {"success": True, "project_id": project_id, "url": f"/p/{project_id}/index.html"}
 
 
137
 
138
  @router.post("/api/projects")
139
  async def list_published_projects(data: dict):
@@ -146,7 +177,23 @@ async def list_published_projects(data: dict):
146
  projects = c.fetchall()
147
  conn.close()
148
 
149
- return {"projects": [{"id": p[0], "name": p[1], "created_at": p[2], "url": f"/p/{p[0]}/index.html"} for p in projects]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  @router.post("/api/project/files")
152
  async def list_project_files(data: dict):
@@ -154,7 +201,6 @@ async def list_project_files(data: dict):
154
  project_id = data.get("project_id")
155
  if not username or not project_id: return {"error": "Unauthorized"}
156
 
157
- # Verify ownership
158
  conn = sqlite3.connect(DB_FILE)
159
  c = conn.cursor()
160
  c.execute("SELECT id FROM projects WHERE username=? AND id=?", (username, project_id))
@@ -182,7 +228,6 @@ async def serve_published_file(project_id: str, file_path: str):
182
  if not full_path.startswith(pub_path) or not os.path.exists(full_path):
183
  return HTMLResponse("<h1>404 Not Found</h1>", status_code=404)
184
 
185
- # If it's an HTML file, inject the badge
186
  if file_path.endswith(".html"):
187
  with open(full_path, "r", encoding="utf-8") as f:
188
  content = f.read()
@@ -192,7 +237,6 @@ async def serve_published_file(project_id: str, file_path: str):
192
  ⚡ Created using <span style="color: #ffc107; font-weight: bold;">DEVPORTAL</span>
193
  </div>
194
  """
195
- # Inject just before </body> if it exists, else append
196
  if "</body>" in content:
197
  content = content.replace("</body>", badge + "\n</body>")
198
  else:
 
59
  os.rename(old_path, new_path)
60
  return {"success": True}
61
 
62
+ @router.post("/api/file/delete")
63
+ async def delete_file(data: FileReq):
64
+ user_dir = get_user_dir(data.token)
65
+ if not user_dir: return {"error": "Unauthorized"}
66
+ filepath = os.path.abspath(os.path.join(user_dir, data.filename))
67
+ if not filepath.startswith(user_dir): return {"error": "Access denied"}
68
+ try:
69
+ os.remove(filepath)
70
+ return {"success": True}
71
+ except Exception as e:
72
+ return {"error": str(e)}
73
+
74
+ @router.post("/api/folder/create")
75
+ async def create_folder(data: FileReq):
76
+ user_dir = get_user_dir(data.token)
77
+ if not user_dir: return {"error": "Unauthorized"}
78
+ folderpath = os.path.abspath(os.path.join(user_dir, data.filename))
79
+ if not folderpath.startswith(user_dir): return {"error": "Access denied"}
80
+ try:
81
+ os.makedirs(folderpath, exist_ok=True)
82
+ return {"success": True}
83
+ except Exception as e:
84
+ return {"error": str(e)}
85
+
86
+ @router.post("/api/folder/delete")
87
+ async def delete_folder(data: FileReq):
88
+ user_dir = get_user_dir(data.token)
89
+ if not user_dir: return {"error": "Unauthorized"}
90
+ folderpath = os.path.abspath(os.path.join(user_dir, data.filename))
91
+ if not folderpath.startswith(user_dir): return {"error": "Access denied"}
92
+ try:
93
+ shutil.rmtree(folderpath)
94
+ return {"success": True}
95
+ except Exception as e:
96
+ return {"error": str(e)}
97
+
98
  @router.get("/preview/{token}/{file_path:path}")
99
  async def serve_preview_file(token: str, file_path: str):
100
  user_dir = get_user_dir(token)
 
103
 
104
  full_path = os.path.abspath(os.path.join(user_dir, file_path))
105
  if not full_path.startswith(user_dir) or not os.path.exists(full_path):
106
+ return HTMLResponse("<h1>File Not Found</h1><p>The selected file does not exist.</p>", status_code=404)
107
 
108
  return FileResponse(full_path)
109
 
110
  class PublishReq(BaseModel):
111
  token: str
112
  project_name: str
113
+ project_id: str | None = None
114
+ files: list = []
115
+ main_html_file: str = "index.html" # Used to determine the entry point URL
116
 
117
  @router.post("/api/publish")
118
  async def publish_project(data: PublishReq):
 
124
  if not data.files:
125
  return {"error": "No files selected to publish."}
126
 
 
127
  for f in data.files:
128
  p = os.path.abspath(os.path.join(user_dir, f))
129
  if not p.startswith(user_dir) or not os.path.isfile(p):
 
135
  project_id = data.project_id
136
  pub_path = ""
137
 
 
138
  if project_id:
 
139
  c.execute("SELECT id FROM projects WHERE username=? AND id=?", (username, project_id))
140
  if not c.fetchone():
141
  conn.close()
142
  return {"error": "Project not found or you don't have permission."}
143
 
144
  pub_path = os.path.join(PUBLISHED_DIR, project_id)
 
145
  if os.path.exists(pub_path):
146
  shutil.rmtree(pub_path)
147
 
148
  c.execute("UPDATE projects SET name=?, created_at=CURRENT_TIMESTAMP WHERE id=?", (data.project_name, project_id))
 
149
  else:
150
+ project_id = str(uuid.uuid4())[:8]
 
151
  pub_path = os.path.join(PUBLISHED_DIR, project_id)
152
  c.execute("INSERT INTO projects (id, username, name) VALUES (?, ?, ?)", (project_id, username, data.project_name))
153
 
154
  conn.commit()
155
  conn.close()
156
 
 
157
  os.makedirs(pub_path, exist_ok=True)
158
 
 
159
  for f in data.files:
160
  src = os.path.join(user_dir, f)
161
  dst = os.path.join(pub_path, f)
162
  os.makedirs(os.path.dirname(dst), exist_ok=True)
163
  shutil.copy2(src, dst)
164
 
165
+ # Return the URL pointing to the user's primary HTML file (e.g. index.html, app.html)
166
+ entry_file = data.main_html_file if data.main_html_file else "index.html"
167
+ return {"success": True, "project_id": project_id, "url": f"/p/{project_id}/{entry_file}"}
168
 
169
  @router.post("/api/projects")
170
  async def list_published_projects(data: dict):
 
177
  projects = c.fetchall()
178
  conn.close()
179
 
180
+ # We dynamically check which .html file is inside to generate the link
181
+ # This is slightly heavier, but accurate
182
+ proj_list = []
183
+ for p in projects:
184
+ pid, name, created_at = p
185
+ pub_path = os.path.join(PUBLISHED_DIR, pid)
186
+ entry = "index.html"
187
+ if os.path.exists(pub_path):
188
+ all_files = os.listdir(pub_path)
189
+ if "index.html" not in all_files:
190
+ html_files = [f for f in all_files if f.endswith(".html")]
191
+ if html_files:
192
+ entry = html_files[0]
193
+
194
+ proj_list.append({"id": pid, "name": name, "created_at": created_at, "url": f"/p/{pid}/{entry}"})
195
+
196
+ return {"projects": proj_list}
197
 
198
  @router.post("/api/project/files")
199
  async def list_project_files(data: dict):
 
201
  project_id = data.get("project_id")
202
  if not username or not project_id: return {"error": "Unauthorized"}
203
 
 
204
  conn = sqlite3.connect(DB_FILE)
205
  c = conn.cursor()
206
  c.execute("SELECT id FROM projects WHERE username=? AND id=?", (username, project_id))
 
228
  if not full_path.startswith(pub_path) or not os.path.exists(full_path):
229
  return HTMLResponse("<h1>404 Not Found</h1>", status_code=404)
230
 
 
231
  if file_path.endswith(".html"):
232
  with open(full_path, "r", encoding="utf-8") as f:
233
  content = f.read()
 
237
  ⚡ Created using <span style="color: #ffc107; font-weight: bold;">DEVPORTAL</span>
238
  </div>
239
  """
 
240
  if "</body>" in content:
241
  content = content.replace("</body>", badge + "\n</body>")
242
  else:
backend/routes_sync.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ from fastapi import APIRouter
4
+ from pydantic import BaseModel
5
+ from backend.database import get_username
6
+
7
+ router = APIRouter()
8
+
9
+ class SyncReq(BaseModel):
10
+ token: str
11
+
12
+ @router.post("/api/sync/hf")
13
+ async def sync_huggingface(data: SyncReq):
14
+ username = get_username(data.token)
15
+ if not username: return {"error": "Unauthorized"}
16
+
17
+ # Assuming HF CLI is installed and user provided token in ENV or we just run the command
18
+ # Using the command format provided by the user
19
+ # `hf sync ./data hf://buckets/Ajitdoval/devportal2-storage`
20
+
21
+ try:
22
+ # Check if huggingface-cli is available
23
+ result = subprocess.run(["huggingface-cli", "upload", "Ajitdoval/devportal2-storage", "./data", ".", "--repo-type", "dataset"], capture_output=True, text=True, timeout=120)
24
+
25
+ if result.returncode == 0:
26
+ return {"success": True, "message": "Successfully synced to HuggingFace Datasets."}
27
+ else:
28
+ return {"error": f"Sync failed: {result.stderr}"}
29
+ except Exception as e:
30
+ return {"error": f"Failed to execute sync command: {str(e)}"}
31
+
32
+ import zipfile
33
+ import shutil
34
+ from fastapi.responses import FileResponse
35
+ from backend.database import get_user_dir
36
+
37
+ class ImportReq(BaseModel):
38
+ token: str
39
+ github_url: str
40
+
41
+ @router.get("/api/export/{token}")
42
+ async def export_workspace(token: str):
43
+ user_dir = get_user_dir(token)
44
+ if not user_dir: return {"error": "Unauthorized"}
45
+
46
+ zip_path = f"/tmp/workspace_{token}.zip"
47
+
48
+ # Zip the contents of the user directory
49
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
50
+ for root, _, files in os.walk(user_dir):
51
+ for file in files:
52
+ file_path = os.path.join(root, file)
53
+ rel_path = os.path.relpath(file_path, user_dir)
54
+ zipf.write(file_path, rel_path)
55
+
56
+ return FileResponse(path=zip_path, filename="workspace.zip", media_type='application/zip')
57
+
58
+ @router.post("/api/import/github")
59
+ async def import_github(data: ImportReq):
60
+ user_dir = get_user_dir(data.token)
61
+ if not user_dir: return {"error": "Unauthorized"}
62
+
63
+ # Very basic validation of github url
64
+ if not data.github_url.startswith("https://github.com/"):
65
+ return {"error": "Invalid GitHub URL"}
66
+
67
+ try:
68
+ # Clone into a temp directory
69
+ tmp_dir = f"/tmp/clone_{data.token}"
70
+ if os.path.exists(tmp_dir):
71
+ shutil.rmtree(tmp_dir)
72
+
73
+ result = subprocess.run(["git", "clone", data.github_url, tmp_dir], capture_output=True, text=True, timeout=60)
74
+
75
+ if result.returncode != 0:
76
+ return {"error": f"Failed to clone: {result.stderr}"}
77
+
78
+ # Copy contents to user_dir, removing .git to keep it clean
79
+ git_dir = os.path.join(tmp_dir, ".git")
80
+ if os.path.exists(git_dir):
81
+ shutil.rmtree(git_dir)
82
+
83
+ for item in os.listdir(tmp_dir):
84
+ s = os.path.join(tmp_dir, item)
85
+ d = os.path.join(user_dir, item)
86
+ if os.path.isdir(s):
87
+ shutil.copytree(s, d, dirs_exist_ok=True)
88
+ else:
89
+ shutil.copy2(s, d)
90
+
91
+ shutil.rmtree(tmp_dir)
92
+ return {"success": True, "message": "Repository imported successfully!"}
93
+
94
+ except Exception as e:
95
+ return {"error": f"Import error: {str(e)}"}
static/css/editor.css CHANGED
@@ -240,3 +240,30 @@
240
  background: rgba(0,0,0,0.05);
241
  color: var(--text-primary);
242
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  background: rgba(0,0,0,0.05);
241
  color: var(--text-primary);
242
  }
243
+
244
+ /* File Item Actions */
245
+ .file-item {
246
+ position: relative;
247
+ padding-right: 30px; /* Room for delete button */
248
+ }
249
+
250
+ .file-item-delete {
251
+ display: none;
252
+ position: absolute;
253
+ right: 8px;
254
+ background: transparent;
255
+ border: none;
256
+ color: var(--error-color);
257
+ cursor: pointer;
258
+ font-size: 11px;
259
+ padding: 2px 4px;
260
+ border-radius: 4px;
261
+ }
262
+
263
+ .file-item:hover .file-item-delete {
264
+ display: inline-block;
265
+ }
266
+
267
+ .file-item-delete:hover {
268
+ background: rgba(255, 51, 102, 0.2);
269
+ }
static/js/app.js CHANGED
@@ -12,6 +12,7 @@ function switchView(viewId, navItem) {
12
  // Close mobile sidebar
13
  // Load projects if switching to projects view
14
  if (viewId === 'projects-view') loadPublishedProjects();
 
15
 
16
  if(window.innerWidth <= 768) {
17
  document.getElementById('sidebar').classList.remove('open');
@@ -255,3 +256,60 @@ function handleChatInput(e) {
255
  sendChatMessage();
256
  }
257
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  // Close mobile sidebar
13
  // Load projects if switching to projects view
14
  if (viewId === 'projects-view') loadPublishedProjects();
15
+ if (viewId === 'ai-chat-view') loadAIChatHistory();
16
 
17
  if(window.innerWidth <= 768) {
18
  document.getElementById('sidebar').classList.remove('open');
 
256
  sendChatMessage();
257
  }
258
  }
259
+
260
+ async function loadAIChatHistory() {
261
+ if (!currentToken) return;
262
+
263
+ try {
264
+ const res = await fetch('/api/chat/history', {
265
+ method: 'POST',
266
+ headers: {'Content-Type': 'application/json'},
267
+ body: JSON.stringify({token: currentToken})
268
+ });
269
+
270
+ const data = await res.json();
271
+ if (data.history) {
272
+ aiChatHistory = data.history;
273
+ const historyDiv = document.getElementById('ai-chat-history');
274
+
275
+ // Clear default messages
276
+ historyDiv.innerHTML = '';
277
+
278
+ if (data.history.length === 0) {
279
+ appendChatMsg('system', 'Hello! I am your AI coding assistant. How can I help you build today?');
280
+ } else {
281
+ data.history.forEach(msg => {
282
+ if (msg.role === 'system') return; // Hide system prompt from UI
283
+
284
+ let rawHtml = msg.role === 'assistant' ? marked.parse(msg.content) : msg.content;
285
+ let safeHtml = msg.role === 'assistant' ? DOMPurify.sanitize(rawHtml) : msg.content;
286
+ appendChatMsg(msg.role, safeHtml);
287
+ });
288
+ }
289
+ }
290
+ } catch(e) {
291
+ console.error("Failed to load chat history.", e);
292
+ }
293
+ }
294
+
295
+ async function clearAIChatHistory() {
296
+ if (!confirm("Are you sure you want to clear your chat history?")) return;
297
+
298
+ try {
299
+ const res = await fetch('/api/chat/history/clear', {
300
+ method: 'POST',
301
+ headers: {'Content-Type': 'application/json'},
302
+ body: JSON.stringify({token: currentToken})
303
+ });
304
+
305
+ const data = await res.json();
306
+ if (data.success) {
307
+ aiChatHistory = [];
308
+ const historyDiv = document.getElementById('ai-chat-history');
309
+ historyDiv.innerHTML = '';
310
+ appendChatMsg('system', 'Chat history cleared. How can I help you build today?');
311
+ }
312
+ } catch(e) {
313
+ showToast("Failed to clear chat history", "error");
314
+ }
315
+ }
static/js/editor.js CHANGED
@@ -50,7 +50,7 @@ async function loadFiles() {
50
  data.files.forEach(f => {
51
  const div = document.createElement('div');
52
  div.className = 'file-item';
53
- div.innerHTML = `${getIconForFile(f)} ${f}`;
54
  div.onclick = () => openFile(f);
55
  list.appendChild(div);
56
  });
@@ -262,3 +262,80 @@ function toggleFullScreenPreview() {
262
  icon.classList.add('fa-compress');
263
  }
264
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  data.files.forEach(f => {
51
  const div = document.createElement('div');
52
  div.className = 'file-item';
53
+ div.innerHTML = `${getIconForFile(f)} <span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${f}</span> <button class="file-item-delete" title="Delete" onclick="deleteFile(event, '${f}')"><i class="fa-solid fa-trash"></i></button>`;
54
  div.onclick = () => openFile(f);
55
  list.appendChild(div);
56
  });
 
262
  icon.classList.add('fa-compress');
263
  }
264
  }
265
+
266
+ async function deleteFile(e, filename) {
267
+ e.stopPropagation(); // Don't trigger the row click
268
+ if (!confirm(`Are you sure you want to delete ${filename}?`)) return;
269
+
270
+ try {
271
+ const res = await fetch('/api/file/delete', {
272
+ method: 'POST',
273
+ headers: {'Content-Type': 'application/json'},
274
+ body: JSON.stringify({token: currentToken, filename: filename})
275
+ });
276
+ const data = await res.json();
277
+
278
+ if (data.success) {
279
+ showToast(`Deleted ${filename}`, "success");
280
+ if (document.getElementById('current-filename').value === filename) {
281
+ document.getElementById('current-filename').value = '';
282
+ editor.setValue('', -1);
283
+ checkRunVisibility();
284
+ }
285
+ loadFiles();
286
+ } else {
287
+ showToast(`Failed to delete: ${data.error}`, "error");
288
+ }
289
+ } catch(err) {
290
+ showToast("Network error while deleting.", "error");
291
+ }
292
+ }
293
+
294
+ async function createFolder() {
295
+ const folderName = prompt("Enter new folder name (e.g. 'src' or 'src/components'):");
296
+ if (!folderName) return;
297
+
298
+ try {
299
+ const res = await fetch('/api/folder/create', {
300
+ method: 'POST',
301
+ headers: {'Content-Type': 'application/json'},
302
+ body: JSON.stringify({token: currentToken, filename: folderName})
303
+ });
304
+ const data = await res.json();
305
+
306
+ if (data.success) {
307
+ showToast(`Created folder ${folderName}`, "success");
308
+ loadFiles();
309
+ } else {
310
+ showToast(`Failed to create folder: ${data.error}`, "error");
311
+ }
312
+ } catch(err) {
313
+ showToast("Network error while creating folder.", "error");
314
+ }
315
+ }
316
+
317
+
318
+ async function deleteFolder() {
319
+ const folderName = prompt("Enter folder path to delete (e.g. 'src/components'):");
320
+ if (!folderName) return;
321
+
322
+ if (!confirm(`Are you absolutely sure you want to delete the folder '${folderName}' AND all of its contents?`)) return;
323
+
324
+ try {
325
+ const res = await fetch('/api/folder/delete', {
326
+ method: 'POST',
327
+ headers: {'Content-Type': 'application/json'},
328
+ body: JSON.stringify({token: currentToken, filename: folderName})
329
+ });
330
+ const data = await res.json();
331
+
332
+ if (data.success) {
333
+ showToast(`Deleted folder ${folderName}`, "success");
334
+ loadFiles();
335
+ } else {
336
+ showToast(`Failed to delete folder: ${data.error}`, "error");
337
+ }
338
+ } catch(err) {
339
+ showToast("Network error while deleting folder.", "error");
340
+ }
341
+ }
static/js/projects.js CHANGED
@@ -101,6 +101,8 @@ async function publishCurrentWorkspace(existingName = null, existingId = null) {
101
  }
102
 
103
  fileListDiv.innerHTML = '';
 
 
104
  if (wsData.files && wsData.files.length > 0) {
105
  wsData.files.forEach(f => {
106
  const label = document.createElement('label');
@@ -111,6 +113,11 @@ async function publishCurrentWorkspace(existingName = null, existingId = null) {
111
  checkbox.value = f;
112
  checkbox.className = 'publish-file-checkbox';
113
 
 
 
 
 
 
114
  // If it's a new project, check all by default.
115
  // If it's an update, check only the ones that were published before.
116
  if (!existingId || previouslyPublished.includes(f)) {
@@ -125,13 +132,19 @@ async function publishCurrentWorkspace(existingName = null, existingId = null) {
125
  fileListDiv.innerHTML = '<span style="color: var(--error-color);">Workspace is empty. Add files in Code Editor.</span>';
126
  }
127
 
 
 
 
 
 
 
 
 
 
 
128
  } catch (e) {
129
  fileListDiv.innerHTML = '<span style="color: var(--error-color);">Failed to load files.</span>';
130
  }
131
-
132
- // Set up live preview
133
- const iframe = document.getElementById('publish-preview-frame');
134
- iframe.src = `/preview/${currentToken}/index.html`; // Will return 404 naturally if not there
135
  }
136
 
137
  function closePublishModal() {
@@ -154,9 +167,15 @@ async function confirmPublish() {
154
  return;
155
  }
156
 
157
- if (!selectedFiles.includes('index.html')) {
158
- const proceed = confirm("Warning: You did not select an 'index.html' file. The site will not work correctly when visited. Publish anyway?");
 
 
 
159
  if(!proceed) return;
 
 
 
160
  }
161
 
162
  const btn = document.getElementById('publish-confirm-btn');
@@ -170,8 +189,9 @@ async function confirmPublish() {
170
  body: JSON.stringify({
171
  token: currentToken,
172
  project_name: nameInput,
173
- project_id: publishingProjectID, // If this is an update, tell the backend
174
- files: selectedFiles // The exact files to copy
 
175
  })
176
  });
177
  const data = await res.json();
 
101
  }
102
 
103
  fileListDiv.innerHTML = '';
104
+ let primaryHtmlFile = null;
105
+
106
  if (wsData.files && wsData.files.length > 0) {
107
  wsData.files.forEach(f => {
108
  const label = document.createElement('label');
 
113
  checkbox.value = f;
114
  checkbox.className = 'publish-file-checkbox';
115
 
116
+ // Track primary HTML file for preview
117
+ if (f.endsWith('.html') && (!primaryHtmlFile || f === 'index.html')) {
118
+ primaryHtmlFile = f;
119
+ }
120
+
121
  // If it's a new project, check all by default.
122
  // If it's an update, check only the ones that were published before.
123
  if (!existingId || previouslyPublished.includes(f)) {
 
132
  fileListDiv.innerHTML = '<span style="color: var(--error-color);">Workspace is empty. Add files in Code Editor.</span>';
133
  }
134
 
135
+ // Set up live preview to use the dynamically found HTML file
136
+ const iframe = document.getElementById('publish-preview-frame');
137
+ if (primaryHtmlFile) {
138
+ iframe.src = `/preview/${currentToken}/${primaryHtmlFile}`;
139
+ document.querySelector('#publish-modal .fa-html5').nextSibling.textContent = ` Live Preview (${primaryHtmlFile})`;
140
+ } else {
141
+ iframe.src = 'about:blank';
142
+ document.querySelector('#publish-modal .fa-html5').nextSibling.textContent = ` Live Preview (No HTML Found)`;
143
+ }
144
+
145
  } catch (e) {
146
  fileListDiv.innerHTML = '<span style="color: var(--error-color);">Failed to load files.</span>';
147
  }
 
 
 
 
148
  }
149
 
150
  function closePublishModal() {
 
167
  return;
168
  }
169
 
170
+ let mainHtmlFile = null;
171
+ const htmlFiles = selectedFiles.filter(f => f.endsWith('.html'));
172
+
173
+ if (htmlFiles.length === 0) {
174
+ const proceed = confirm("Warning: You did not select any HTML files. The published link may not display anything visually. Publish anyway?");
175
  if(!proceed) return;
176
+ } else {
177
+ // Prefer index.html if selected, otherwise just pick the first html file
178
+ mainHtmlFile = htmlFiles.includes('index.html') ? 'index.html' : htmlFiles[0];
179
  }
180
 
181
  const btn = document.getElementById('publish-confirm-btn');
 
189
  body: JSON.stringify({
190
  token: currentToken,
191
  project_name: nameInput,
192
+ project_id: publishingProjectID,
193
+ files: selectedFiles,
194
+ main_html_file: mainHtmlFile
195
  })
196
  });
197
  const data = await res.json();
static/js/settings.js CHANGED
@@ -158,3 +158,71 @@ function updateAIModelBadge() {
158
  badge.innerText = el.options[el.selectedIndex].text;
159
  }
160
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  badge.innerText = el.options[el.selectedIndex].text;
159
  }
160
  }
161
+
162
+ async function syncHuggingFace() {
163
+ const btn = document.getElementById('hf-sync-btn');
164
+ const status = document.getElementById('sync-status');
165
+
166
+ btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Syncing... Please wait.';
167
+ btn.disabled = true;
168
+ status.innerText = "Executing hf sync. This may take a minute...";
169
+
170
+ try {
171
+ const res = await fetch('/api/sync/hf', {
172
+ method: 'POST',
173
+ headers: {'Content-Type': 'application/json'},
174
+ body: JSON.stringify({token: currentToken})
175
+ });
176
+ const data = await res.json();
177
+
178
+ if(data.success) {
179
+ showToast("Successfully synced to Hugging Face!", "success");
180
+ status.innerText = data.message;
181
+ status.style.color = "var(--success-color)";
182
+ } else {
183
+ showToast("Failed to sync.", "error");
184
+ status.innerText = data.error;
185
+ status.style.color = "var(--error-color)";
186
+ }
187
+ } catch(e) {
188
+ showToast("Network error during sync.", "error");
189
+ status.innerText = "Network error occurred.";
190
+ status.style.color = "var(--error-color)";
191
+ } finally {
192
+ btn.innerHTML = '<i class="fa-solid fa-rotate"></i> Sync Data to Hugging Face Bucket';
193
+ btn.disabled = false;
194
+ }
195
+ }
196
+
197
+ function downloadWorkspace() {
198
+ if (!currentToken) return;
199
+ window.location.href = `/api/export/${currentToken}`;
200
+ }
201
+
202
+ async function importGithubRepo() {
203
+ if (!currentToken) return;
204
+
205
+ const url = prompt("Enter a public GitHub repository URL to clone into your workspace (e.g., https://github.com/user/repo):");
206
+ if (!url) return;
207
+
208
+ showToast("Cloning repository... this may take a moment.", "info");
209
+
210
+ try {
211
+ const res = await fetch('/api/import/github', {
212
+ method: 'POST',
213
+ headers: {'Content-Type': 'application/json'},
214
+ body: JSON.stringify({token: currentToken, github_url: url})
215
+ });
216
+
217
+ const data = await res.json();
218
+ if (data.success) {
219
+ showToast("Repository imported successfully!", "success");
220
+ // If they are on the editor view, reload files
221
+ if (typeof loadFiles !== 'undefined') loadFiles();
222
+ } else {
223
+ showToast("Failed to import: " + (data.error || "Unknown error"), "error");
224
+ }
225
+ } catch(e) {
226
+ showToast("Network error during import.", "error");
227
+ }
228
+ }
templates/views/ai_chat.html CHANGED
@@ -3,7 +3,8 @@
3
  <!-- Header -->
4
  <div style="display: flex; justify-content: space-between; align-items: center; border-bottom: var(--glass-border); padding-bottom: 15px;">
5
  <h2 style="margin: 0; color: var(--text-primary);"><i class="fa-solid fa-sparkles" style="color: var(--accent-main);"></i> AI Assistant</h2>
6
- <div style="font-size: 12px; color: var(--text-secondary); background: rgba(0,0,0,0.3); padding: 5px 10px; border-radius: 4px; border: var(--glass-border);">
 
7
  Model: <span id="current-ai-model" style="color: var(--accent-main);">default</span>
8
  </div>
9
  </div>
 
3
  <!-- Header -->
4
  <div style="display: flex; justify-content: space-between; align-items: center; border-bottom: var(--glass-border); padding-bottom: 15px;">
5
  <h2 style="margin: 0; color: var(--text-primary);"><i class="fa-solid fa-sparkles" style="color: var(--accent-main);"></i> AI Assistant</h2>
6
+ <button class="editor-btn error" style="margin-right:10px; font-size:12px; padding:4px 8px;" onclick="clearAIChatHistory()"><i class="fa-solid fa-trash"></i> New Chat</button>
7
+ <div style="font-size: 12px; color: var(--text-secondary); background: rgba(0,0,0,0.3); padding: 5px 10px; border-radius: 4px; border: var(--glass-border); display:inline-block;">
8
  Model: <span id="current-ai-model" style="color: var(--accent-main);">default</span>
9
  </div>
10
  </div>
templates/views/editor.html CHANGED
@@ -4,7 +4,7 @@
4
  <div class="file-explorer">
5
  <div class="explorer-header">
6
  <span><i class="fa-solid fa-folder-tree"></i> EXPLORER</span>
7
- <button class="editor-btn" onclick="newFile()" style="padding: 2px 6px;" title="New File"><i class="fa-solid fa-plus"></i></button>
8
  </div>
9
  <div id="file-list" class="file-list">
10
  <div style="color: var(--text-muted); font-size: 12px; text-align: center; margin-top: 20px;">
 
4
  <div class="file-explorer">
5
  <div class="explorer-header">
6
  <span><i class="fa-solid fa-folder-tree"></i> EXPLORER</span>
7
+ <div style="display:flex; gap:3px;"><button class="editor-btn" onclick="newFile()" style="padding: 2px 6px;" title="New File"><i class="fa-solid fa-file-circle-plus"></i></button><button class="editor-btn" onclick="createFolder()" style="padding: 2px 6px;" title="New Folder"><i class="fa-solid fa-folder-plus"></i></button><button class="editor-btn error" onclick="deleteFolder()" style="padding: 2px 6px; margin-left:10px;" title="Delete Folder"><i class="fa-solid fa-folder-minus"></i></button></div>
8
  </div>
9
  <div id="file-list" class="file-list">
10
  <div style="color: var(--text-muted); font-size: 12px; text-align: center; margin-top: 20px;">
templates/views/settings.html CHANGED
@@ -7,6 +7,7 @@
7
  <div class="settings-tab" onclick="switchSettingsPanel('panel-editor', this)"><i class="fa-solid fa-keyboard"></i> Editor Settings</div>
8
  <div class="settings-tab" onclick="switchSettingsPanel('panel-terminal', this)"><i class="fa-solid fa-terminal"></i> Terminal Settings</div>
9
  <div class="settings-tab" onclick="switchSettingsPanel('panel-ai', this)"><i class="fa-solid fa-brain"></i> AI Settings</div>
 
10
  </div>
11
 
12
  <div class="settings-content-wrapper">
@@ -137,6 +138,27 @@
137
  </div>
138
  </div>
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  <div class="settings-actions">
141
  <button class="editor-btn primary" id="save-settings-btn" onclick="saveSettings()">Save Settings</button>
142
  <button class="editor-btn" onclick="loadSettings()">Reset Changes</button>
 
7
  <div class="settings-tab" onclick="switchSettingsPanel('panel-editor', this)"><i class="fa-solid fa-keyboard"></i> Editor Settings</div>
8
  <div class="settings-tab" onclick="switchSettingsPanel('panel-terminal', this)"><i class="fa-solid fa-terminal"></i> Terminal Settings</div>
9
  <div class="settings-tab" onclick="switchSettingsPanel('panel-ai', this)"><i class="fa-solid fa-brain"></i> AI Settings</div>
10
+ <div class="settings-tab" onclick="switchSettingsPanel('panel-sync', this)"><i class="fa-solid fa-cloud-arrow-up"></i> Data & Sync</div>
11
  </div>
12
 
13
  <div class="settings-content-wrapper">
 
138
  </div>
139
  </div>
140
 
141
+ <div id="panel-sync" class="settings-section">
142
+ <h2 class="settings-title">Data Backup & Sync</h2>
143
+ <div style="background: rgba(255, 193, 7, 0.1); border: 1px solid var(--accent-main); padding: 15px; border-radius: 8px; margin-bottom: 20px;">
144
+ <p style="margin: 0 0 10px 0; color: var(--accent-main); font-weight: bold;"><i class="fa-solid fa-triangle-exclamation"></i> Hugging Face Storage</p>
145
+ <p style="margin: 0; font-size: 13px; color: var(--text-secondary);">Your code is deployed on a Hugging Face Space Docker container. Data may be lost on restart. Sync your data to your permanent HF bucket regularly to prevent data loss.</p>
146
+ </div>
147
+
148
+ <button class="editor-btn success" id="hf-sync-btn" onclick="syncHuggingFace()" style="padding: 10px 20px; font-size: 14px;">
149
+ <i class="fa-solid fa-rotate"></i> Sync Data to Hugging Face Bucket
150
+ </button>
151
+ <div id="sync-status" style="margin-top: 10px; font-size: 12px; color: var(--text-muted);"></div>
152
+
153
+ <div style="margin-top: 30px; border-top: var(--glass-border); padding-top: 20px;">
154
+ <h3 class="settings-title">Import/Export</h3>
155
+ <div style="display: flex; gap: 10px; margin-top: 10px;">
156
+ <button class="editor-btn" onclick="downloadWorkspace()"><i class="fa-solid fa-download"></i> Download Workspace (.zip)</button>
157
+ <button class="editor-btn" onclick="importGithubRepo()"><i class="fa-brands fa-github"></i> Import GitHub Repo</button>
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
  <div class="settings-actions">
163
  <button class="editor-btn primary" id="save-settings-btn" onclick="saveSettings()">Save Settings</button>
164
  <button class="editor-btn" onclick="loadSettings()">Reset Changes</button>