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