at main 19 kB view raw
1// Alpine.js App Component 2document.addEventListener("alpine:init", () => { 3 Alpine.data("app", () => ({ 4 // State 5 darkTheme: localStorage.getItem("darkTheme") !== "false", 6 serverUrl: "https://knot.srv.rbrt.fr", 7 isConnected: false, 8 status: { 9 message: "", 10 type: "", 11 }, 12 state: { 13 currentRepo: null, 14 currentBranch: "main", 15 currentPath: "", 16 resolvedHandle: null, 17 }, 18 users: [], 19 branches: [], 20 view: "empty", // empty, repoList, tree, file 21 loading: false, 22 loadingMessage: "", 23 error: null, 24 breadcrumbHtml: "", 25 fileListHtml: "", 26 readmeHtml: "", 27 currentFile: { 28 name: "", 29 content: "", 30 isBinary: false, 31 isMarkdown: false, 32 }, 33 manualRepoPath: "", 34 isRestoringFromURL: false, 35 36 // Initialization 37 init() { 38 // Apply saved theme 39 this.applyTheme(); 40 41 // Make this component globally accessible for onclick handlers 42 window.appInstance = this; 43 44 // Configure marked for syntax highlighting 45 if (typeof marked !== "undefined" && typeof hljs !== "undefined") { 46 marked.setOptions({ 47 highlight: (code, lang) => { 48 if (lang && hljs.getLanguage(lang)) { 49 try { 50 return hljs.highlight(code, { language: lang }).value; 51 } catch (err) { 52 console.error("Highlight error:", err); 53 } 54 } 55 return hljs.highlightAuto(code).value; 56 }, 57 }); 58 } 59 60 // Handle browser back/forward 61 window.addEventListener("popstate", async (event) => { 62 if (event.state) { 63 this.isRestoringFromURL = true; 64 Object.assign(this.state, event.state); 65 await this.restoreViewFromState(); 66 this.isRestoringFromURL = false; 67 } 68 }); 69 70 // Restore from URL on load 71 this.restoreFromURL(); 72 }, 73 74 // Theme 75 toggleTheme() { 76 this.darkTheme = !this.darkTheme; 77 localStorage.setItem("darkTheme", this.darkTheme); 78 this.applyTheme(); 79 }, 80 81 applyTheme() { 82 if (this.darkTheme) { 83 document.body.classList.add("dark-theme"); 84 } else { 85 document.body.classList.remove("dark-theme"); 86 } 87 }, 88 89 // Connection 90 async connectToServer() { 91 let url = this.serverUrl.trim(); 92 if (!url) { 93 this.showStatus("Please enter a server URL", "error"); 94 return; 95 } 96 97 if (!url.startsWith("http://") && !url.startsWith("https://")) { 98 url = "https://" + url; 99 this.serverUrl = url; 100 } 101 102 try { 103 this.showStatus("Connecting to server...", "success"); 104 API.setBaseUrl(url); 105 106 const data = await API.getOwner(); 107 this.showStatus(`Connected to ${data.owner}`, "success"); 108 this.isConnected = true; 109 110 this.updateURL(); 111 await this.loadUsersAndRepos(); 112 } catch (error) { 113 this.showStatus(`Connection failed: ${error.message}`, "error"); 114 } 115 }, 116 117 async loadUsersAndRepos() { 118 try { 119 this.showLoading("Loading repositories..."); 120 121 const data = await API.listRepos(); 122 123 // Resolve handles for all users 124 if (data.users && data.users.length > 0) { 125 for (const user of data.users) { 126 const handle = await API.resolveDID(user.did); 127 if (handle) { 128 user.handle = handle; 129 } 130 } 131 } 132 133 this.users = data.users || []; 134 this.view = "repoList"; 135 this.loading = false; 136 this.showStatus( 137 `Found ${this.users.length} users with repositories`, 138 "success", 139 ); 140 } catch (error) { 141 this.users = []; 142 this.view = "repoList"; 143 this.loading = false; 144 this.showStatus( 145 "Server doesn't support repository listing. Please enter repository path manually.", 146 "error", 147 ); 148 } 149 }, 150 151 loadManualRepo() { 152 const repoPath = this.manualRepoPath.trim(); 153 if (!repoPath) { 154 this.showStatus("Please enter a repository path", "error"); 155 return; 156 } 157 158 const parts = repoPath.split("/"); 159 if (parts.length < 2) { 160 this.showStatus( 161 "Invalid repository path format. Expected: did:plc:xxx.../repo-name", 162 "error", 163 ); 164 return; 165 } 166 167 const repo = { 168 fullPath: repoPath, 169 did: parts[0], 170 name: parts.slice(1).join("/"), 171 }; 172 173 this.selectRepository(repo); 174 }, 175 176 // Repository Selection 177 async selectRepository(repo) { 178 try { 179 this.showLoading("Loading repository..."); 180 this.state.currentRepo = { 181 fullPath: repo.fullPath, 182 did: repo.did, 183 name: repo.name, 184 }; 185 this.state.currentPath = ""; 186 this.view = "tree"; 187 188 // Resolve handle 189 const handle = await API.resolveDID(repo.did); 190 if (handle) { 191 this.state.resolvedHandle = handle; 192 } 193 194 // Get default branch 195 try { 196 const branchData = await API.getDefaultBranch(repo.fullPath); 197 this.state.currentBranch = branchData.branch || "main"; 198 } catch (error) { 199 this.state.currentBranch = "main"; 200 } 201 202 await this.loadBranches(); 203 await this.loadTree(); 204 205 this.updateURL(); 206 } catch (error) { 207 this.showError(`Failed to load repository: ${error.message}`); 208 } 209 }, 210 211 async loadBranches() { 212 try { 213 const data = await API.getBranches(this.state.currentRepo.fullPath); 214 this.branches = data.branches || []; 215 } catch (error) { 216 console.error("Failed to load branches:", error); 217 this.branches = []; 218 } 219 }, 220 221 switchBranch(branch) { 222 this.state.currentBranch = branch; 223 this.state.currentPath = ""; 224 this.loadTree(); 225 this.updateURL(); 226 }, 227 228 // Tree/File Loading 229 async loadTree(path = "") { 230 try { 231 this.showLoading("Loading..."); 232 this.state.currentPath = path; 233 this.view = "tree"; 234 235 const data = await API.getTree( 236 this.state.currentRepo.fullPath, 237 this.state.currentBranch, 238 path, 239 ); 240 241 this.renderTree(data, path); 242 this.loading = false; 243 this.updateURL(); 244 } catch (error) { 245 this.showError(`Failed to load directory: ${error.message}`); 246 } 247 }, 248 249 async loadFile(path) { 250 try { 251 this.showLoading("Loading file..."); 252 this.state.currentPath = path; 253 this.view = "file"; 254 255 const data = await API.getBlob( 256 this.state.currentRepo.fullPath, 257 this.state.currentBranch, 258 path, 259 ); 260 261 this.renderFile(data, path); 262 this.loading = false; 263 this.updateURL(); 264 } catch (error) { 265 this.showError(`Failed to load file: ${error.message}`); 266 } 267 }, 268 269 // Rendering 270 renderTree(data, path) { 271 this.breadcrumbHtml = this.renderBreadcrumb(path); 272 273 const files = data.files || []; 274 let html = ""; 275 276 // Parent directory link 277 if (path) { 278 const parentPath = path.split("/").slice(0, -1).join("/"); 279 html += ` 280 <div class="file-item" onclick="window.appInstance.loadTree('${this.escapeHtml(parentPath).replace(/'/g, "\\'")}')"> 281 <svg class="file-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 282 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> 283 </svg> 284 <span class="file-name">..</span> 285 </div> 286 `; 287 } 288 289 // Sort: directories first, then files 290 const dirs = files.filter((f) => { 291 if (f.is_file === false) return true; 292 if (f.is_file === true) return false; 293 const mode = f.mode || ""; 294 return ( 295 mode.startsWith("d") || mode.startsWith("040") || f.type === "tree" 296 ); 297 }); 298 const regularFiles = files.filter((f) => { 299 if (f.is_file === true) return true; 300 if (f.is_file === false) return false; 301 const mode = f.mode || ""; 302 return ( 303 !mode.startsWith("d") && !mode.startsWith("040") && f.type !== "tree" 304 ); 305 }); 306 307 // Render directories 308 dirs.forEach((file) => { 309 const fullPath = path ? `${path}/${file.name}` : file.name; 310 html += ` 311 <div class="file-item" onclick="window.appInstance.loadTree('${this.escapeHtml(fullPath).replace(/'/g, "\\'")}')"> 312 <svg class="file-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 313 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> 314 </svg> 315 <span class="file-name">${this.escapeHtml(file.name)}</span> 316 </div> 317 `; 318 }); 319 320 // Render files 321 regularFiles.forEach((file) => { 322 const fullPath = path ? `${path}/${file.name}` : file.name; 323 const size = file.size ? this.formatSize(file.size) : ""; 324 html += ` 325 <div class="file-item" onclick="window.appInstance.loadFile('${this.escapeHtml(fullPath).replace(/'/g, "\\'")}')"> 326 <svg class="file-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 327 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /> 328 </svg> 329 <span class="file-name">${this.escapeHtml(file.name)}</span> 330 <span class="file-size">${size}</span> 331 </div> 332 `; 333 }); 334 335 this.fileListHtml = html; 336 337 // Handle README from API response 338 if (data.readme && data.readme.contents) { 339 const readmeHtml = 340 typeof marked !== "undefined" 341 ? marked.parse(data.readme.contents) 342 : `<pre style="white-space: pre-wrap;">${this.escapeHtml(data.readme.contents)}</pre>`; 343 this.readmeHtml = readmeHtml; 344 } else { 345 this.readmeHtml = ""; 346 } 347 }, 348 349 renderFile(data, path) { 350 this.breadcrumbHtml = this.renderBreadcrumb(path); 351 const fileName = path.split("/").pop(); 352 const isBinary = data.isBinary || false; 353 const extension = fileName.includes(".") 354 ? fileName.split(".").pop().toLowerCase() 355 : ""; 356 const isMarkdown = extension === "md" || extension === "markdown"; 357 358 this.currentFile = { 359 name: fileName, 360 isBinary: isBinary, 361 isMarkdown: isMarkdown, 362 content: "", 363 }; 364 365 if (isBinary) { 366 return; 367 } 368 369 const content = data.content || ""; 370 371 if (isMarkdown) { 372 this.currentFile.content = marked.parse(content); 373 } else { 374 // Code with syntax highlighting 375 const lines = content.split("\n"); 376 const lineNumbers = lines.map((_, i) => i + 1).join("\n"); 377 378 const languageMap = { 379 js: "javascript", 380 jsx: "javascript", 381 ts: "typescript", 382 tsx: "typescript", 383 py: "python", 384 rb: "ruby", 385 java: "java", 386 cpp: "cpp", 387 c: "c", 388 cs: "csharp", 389 php: "php", 390 go: "go", 391 rs: "rust", 392 sh: "bash", 393 bash: "bash", 394 zsh: "bash", 395 yml: "yaml", 396 yaml: "yaml", 397 json: "json", 398 xml: "xml", 399 html: "html", 400 css: "css", 401 scss: "scss", 402 sass: "sass", 403 sql: "sql", 404 swift: "swift", 405 kt: "kotlin", 406 r: "r", 407 lua: "lua", 408 vim: "vim", 409 diff: "diff", 410 dockerfile: "dockerfile", 411 }; 412 413 const language = languageMap[extension] || extension; 414 let highlighted = content; 415 416 if (language && hljs.getLanguage(language)) { 417 highlighted = hljs.highlight(content, { language }).value; 418 } else { 419 highlighted = hljs.highlightAuto(content).value; 420 } 421 422 this.currentFile.content = ` 423 <div class="line-numbers"> 424 <pre class="numbers">${lineNumbers}</pre> 425 <pre><code class="hljs">${highlighted}</code></pre> 426 </div> 427 `; 428 } 429 }, 430 431 renderBreadcrumb(path) { 432 const parts = path ? path.split("/") : []; 433 let html = `<a onclick="window.appInstance.loadTree('')" style="cursor: pointer;">root</a>`; 434 let currentPathBuild = ""; 435 436 parts.forEach((part, index) => { 437 currentPathBuild += (currentPathBuild ? "/" : "") + part; 438 const pathCopy = currentPathBuild; 439 440 if (index === parts.length - 1) { 441 html += ` <span>/</span> <span class="current">${this.escapeHtml(part)}</span>`; 442 } else { 443 html += ` <span>/</span> <a onclick="window.appInstance.loadTree('${this.escapeHtml(pathCopy).replace(/'/g, "\\'")}')" style="cursor: pointer;">${this.escapeHtml(part)}</a>`; 444 } 445 }); 446 447 return html; 448 }, 449 450 // Actions 451 showUsersList() { 452 this.state.currentRepo = null; 453 this.state.currentBranch = "main"; 454 this.state.currentPath = ""; 455 this.state.resolvedHandle = null; 456 this.branches = []; 457 this.view = "repoList"; 458 this.updateURL(); 459 }, 460 461 downloadFile() { 462 const url = `${API.getBaseUrl()}/xrpc/sh.tangled.repo.blob?repo=${encodeURIComponent(this.state.currentRepo.fullPath)}&branch=${encodeURIComponent(this.state.currentBranch)}&path=${encodeURIComponent(this.state.currentPath)}&download=true`; 463 window.location.href = url; 464 }, 465 466 copyToClipboard(text) { 467 navigator.clipboard 468 .writeText(text) 469 .then(() => { 470 this.showStatus("Copied to clipboard!", "success"); 471 }) 472 .catch((err) => { 473 console.error("Failed to copy:", err); 474 this.showStatus("Failed to copy to clipboard", "error"); 475 }); 476 }, 477 478 // URL Management 479 updateURL(replace = false) { 480 if (this.isRestoringFromURL) { 481 return; 482 } 483 484 const params = []; 485 486 if (API.getBaseUrl()) { 487 params.push(`server=${encodeURIComponent(API.getBaseUrl())}`); 488 } 489 490 if (this.state.currentRepo) { 491 params.push( 492 `repo=${this.state.currentRepo.fullPath.split("/").map(encodeURIComponent).join("/")}`, 493 ); 494 } 495 496 if (this.state.currentBranch && this.state.currentBranch !== "main") { 497 params.push(`branch=${encodeURIComponent(this.state.currentBranch)}`); 498 } 499 500 if (this.state.currentPath) { 501 params.push( 502 `path=${this.state.currentPath.split("/").map(encodeURIComponent).join("/")}`, 503 ); 504 } 505 506 const newURL = 507 params.length > 0 ? `?${params.join("&")}` : window.location.pathname; 508 509 // Create a simple state object that can be cloned 510 const historyState = { 511 currentRepo: this.state.currentRepo 512 ? { 513 fullPath: this.state.currentRepo.fullPath, 514 did: this.state.currentRepo.did, 515 name: this.state.currentRepo.name, 516 } 517 : null, 518 currentBranch: this.state.currentBranch, 519 currentPath: this.state.currentPath, 520 resolvedHandle: this.state.resolvedHandle, 521 }; 522 523 if (replace) { 524 window.history.replaceState(historyState, "", newURL); 525 } else { 526 window.history.pushState(historyState, "", newURL); 527 } 528 }, 529 530 async restoreFromURL() { 531 const params = new URLSearchParams(window.location.search); 532 const server = params.get("server"); 533 const repo = params.get("repo"); 534 const branch = params.get("branch") || "main"; 535 const path = params.get("path") || ""; 536 537 if (!server) { 538 return; 539 } 540 541 try { 542 this.isRestoringFromURL = true; 543 544 this.serverUrl = server; 545 API.setBaseUrl(server); 546 547 const data = await API.getOwner(); 548 this.showStatus(`Connected to ${data.owner}`, "success"); 549 this.isConnected = true; 550 551 if (repo) { 552 const parts = repo.split("/"); 553 this.state.currentRepo = { 554 fullPath: repo, 555 did: parts[0], 556 name: parts.slice(1).join("/"), 557 }; 558 this.state.currentBranch = branch; 559 this.state.currentPath = path; 560 561 const handle = await API.resolveDID(this.state.currentRepo.did); 562 if (handle) { 563 this.state.resolvedHandle = handle; 564 } 565 566 await this.loadBranches(); 567 568 if (path) { 569 // Check if path is a file or directory 570 try { 571 await this.loadFile(path); 572 } catch (error) { 573 await this.loadTree(path); 574 } 575 } else { 576 await this.loadTree(); 577 } 578 579 this.updateURL(true); 580 } else { 581 await this.loadUsersAndRepos(); 582 this.updateURL(true); 583 } 584 585 this.isRestoringFromURL = false; 586 } catch (error) { 587 this.isRestoringFromURL = false; 588 this.showStatus( 589 `Failed to restore from URL: ${error.message}`, 590 "error", 591 ); 592 } 593 }, 594 595 async restoreViewFromState() { 596 if (!this.state.currentRepo) { 597 this.view = "empty"; 598 return; 599 } 600 601 await this.loadBranches(); 602 603 if (this.state.currentPath) { 604 try { 605 await this.loadFile(this.state.currentPath); 606 } catch (error) { 607 await this.loadTree(this.state.currentPath); 608 } 609 } else { 610 await this.loadTree(); 611 } 612 }, 613 614 // UI Helpers 615 showStatus(message, type) { 616 this.status = { message, type }; 617 setTimeout(() => { 618 this.status = { message: "", type: "" }; 619 }, 5000); 620 }, 621 622 showLoading(message) { 623 this.loading = true; 624 this.loadingMessage = message; 625 this.error = null; 626 }, 627 628 showError(message) { 629 this.loading = false; 630 this.error = ` 631 ${this.breadcrumbHtml ? `<div class="breadcrumb">${this.breadcrumbHtml}</div>` : ""} 632 <div style="padding: 20px;">${this.escapeHtml(message)}</div> 633 `; 634 }, 635 636 formatSize(bytes) { 637 if (bytes === 0) return "0 B"; 638 const k = 1024; 639 const sizes = ["B", "KB", "MB", "GB"]; 640 const i = Math.floor(Math.log(bytes) / Math.log(k)); 641 return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; 642 }, 643 644 escapeHtml(text) { 645 const map = { 646 "&": "&amp;", 647 "<": "&lt;", 648 ">": "&gt;", 649 '"': "&quot;", 650 "'": "&#039;", 651 }; 652 return String(text).replace(/[&<>"']/g, (m) => map[m]); 653 }, 654 })); 655});