// Alpine.js App Component document.addEventListener("alpine:init", () => { Alpine.data("app", () => ({ // State darkTheme: localStorage.getItem("darkTheme") !== "false", serverUrl: "https://knot.srv.rbrt.fr", isConnected: false, status: { message: "", type: "", }, state: { currentRepo: null, currentBranch: "main", currentPath: "", resolvedHandle: null, }, users: [], branches: [], view: "empty", // empty, repoList, tree, file loading: false, loadingMessage: "", error: null, breadcrumbHtml: "", fileListHtml: "", readmeHtml: "", currentFile: { name: "", content: "", isBinary: false, isMarkdown: false, }, manualRepoPath: "", isRestoringFromURL: false, // Initialization init() { // Apply saved theme this.applyTheme(); // Make this component globally accessible for onclick handlers window.appInstance = this; // Configure marked for syntax highlighting if (typeof marked !== "undefined" && typeof hljs !== "undefined") { marked.setOptions({ highlight: (code, lang) => { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(code, { language: lang }).value; } catch (err) { console.error("Highlight error:", err); } } return hljs.highlightAuto(code).value; }, }); } // Handle browser back/forward window.addEventListener("popstate", async (event) => { if (event.state) { this.isRestoringFromURL = true; Object.assign(this.state, event.state); await this.restoreViewFromState(); this.isRestoringFromURL = false; } }); // Restore from URL on load this.restoreFromURL(); }, // Theme toggleTheme() { this.darkTheme = !this.darkTheme; localStorage.setItem("darkTheme", this.darkTheme); this.applyTheme(); }, applyTheme() { if (this.darkTheme) { document.body.classList.add("dark-theme"); } else { document.body.classList.remove("dark-theme"); } }, // Connection async connectToServer() { let url = this.serverUrl.trim(); if (!url) { this.showStatus("Please enter a server URL", "error"); return; } if (!url.startsWith("http://") && !url.startsWith("https://")) { url = "https://" + url; this.serverUrl = url; } try { this.showStatus("Connecting to server...", "success"); API.setBaseUrl(url); const data = await API.getOwner(); this.showStatus(`Connected to ${data.owner}`, "success"); this.isConnected = true; this.updateURL(); await this.loadUsersAndRepos(); } catch (error) { this.showStatus(`Connection failed: ${error.message}`, "error"); } }, async loadUsersAndRepos() { try { this.showLoading("Loading repositories..."); const data = await API.listRepos(); // Resolve handles for all users if (data.users && data.users.length > 0) { for (const user of data.users) { const handle = await API.resolveDID(user.did); if (handle) { user.handle = handle; } } } this.users = data.users || []; this.view = "repoList"; this.loading = false; this.showStatus( `Found ${this.users.length} users with repositories`, "success", ); } catch (error) { this.users = []; this.view = "repoList"; this.loading = false; this.showStatus( "Server doesn't support repository listing. Please enter repository path manually.", "error", ); } }, loadManualRepo() { const repoPath = this.manualRepoPath.trim(); if (!repoPath) { this.showStatus("Please enter a repository path", "error"); return; } const parts = repoPath.split("/"); if (parts.length < 2) { this.showStatus( "Invalid repository path format. Expected: did:plc:xxx.../repo-name", "error", ); return; } const repo = { fullPath: repoPath, did: parts[0], name: parts.slice(1).join("/"), }; this.selectRepository(repo); }, // Repository Selection async selectRepository(repo) { try { this.showLoading("Loading repository..."); this.state.currentRepo = { fullPath: repo.fullPath, did: repo.did, name: repo.name, }; this.state.currentPath = ""; this.view = "tree"; // Resolve handle const handle = await API.resolveDID(repo.did); if (handle) { this.state.resolvedHandle = handle; } // Get default branch try { const branchData = await API.getDefaultBranch(repo.fullPath); this.state.currentBranch = branchData.branch || "main"; } catch (error) { this.state.currentBranch = "main"; } await this.loadBranches(); await this.loadTree(); this.updateURL(); } catch (error) { this.showError(`Failed to load repository: ${error.message}`); } }, async loadBranches() { try { const data = await API.getBranches(this.state.currentRepo.fullPath); this.branches = data.branches || []; } catch (error) { console.error("Failed to load branches:", error); this.branches = []; } }, switchBranch(branch) { this.state.currentBranch = branch; this.state.currentPath = ""; this.loadTree(); this.updateURL(); }, // Tree/File Loading async loadTree(path = "") { try { this.showLoading("Loading..."); this.state.currentPath = path; this.view = "tree"; const data = await API.getTree( this.state.currentRepo.fullPath, this.state.currentBranch, path, ); this.renderTree(data, path); this.loading = false; this.updateURL(); } catch (error) { this.showError(`Failed to load directory: ${error.message}`); } }, async loadFile(path) { try { this.showLoading("Loading file..."); this.state.currentPath = path; this.view = "file"; const data = await API.getBlob( this.state.currentRepo.fullPath, this.state.currentBranch, path, ); this.renderFile(data, path); this.loading = false; this.updateURL(); } catch (error) { this.showError(`Failed to load file: ${error.message}`); } }, // Rendering renderTree(data, path) { this.breadcrumbHtml = this.renderBreadcrumb(path); const files = data.files || []; let html = ""; // Parent directory link if (path) { const parentPath = path.split("/").slice(0, -1).join("/"); html += `
..
`; } // Sort: directories first, then files const dirs = files.filter((f) => { if (f.is_file === false) return true; if (f.is_file === true) return false; const mode = f.mode || ""; return ( mode.startsWith("d") || mode.startsWith("040") || f.type === "tree" ); }); const regularFiles = files.filter((f) => { if (f.is_file === true) return true; if (f.is_file === false) return false; const mode = f.mode || ""; return ( !mode.startsWith("d") && !mode.startsWith("040") && f.type !== "tree" ); }); // Render directories dirs.forEach((file) => { const fullPath = path ? `${path}/${file.name}` : file.name; html += `
${this.escapeHtml(file.name)}
`; }); // Render files regularFiles.forEach((file) => { const fullPath = path ? `${path}/${file.name}` : file.name; const size = file.size ? this.formatSize(file.size) : ""; html += `
${this.escapeHtml(file.name)} ${size}
`; }); this.fileListHtml = html; // Handle README from API response if (data.readme && data.readme.contents) { const readmeHtml = typeof marked !== "undefined" ? marked.parse(data.readme.contents) : `
${this.escapeHtml(data.readme.contents)}
`; this.readmeHtml = readmeHtml; } else { this.readmeHtml = ""; } }, renderFile(data, path) { this.breadcrumbHtml = this.renderBreadcrumb(path); const fileName = path.split("/").pop(); const isBinary = data.isBinary || false; const extension = fileName.includes(".") ? fileName.split(".").pop().toLowerCase() : ""; const isMarkdown = extension === "md" || extension === "markdown"; this.currentFile = { name: fileName, isBinary: isBinary, isMarkdown: isMarkdown, content: "", }; if (isBinary) { return; } const content = data.content || ""; if (isMarkdown) { this.currentFile.content = marked.parse(content); } else { // Code with syntax highlighting const lines = content.split("\n"); const lineNumbers = lines.map((_, i) => i + 1).join("\n"); const languageMap = { js: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript", py: "python", rb: "ruby", java: "java", cpp: "cpp", c: "c", cs: "csharp", php: "php", go: "go", rs: "rust", sh: "bash", bash: "bash", zsh: "bash", yml: "yaml", yaml: "yaml", json: "json", xml: "xml", html: "html", css: "css", scss: "scss", sass: "sass", sql: "sql", swift: "swift", kt: "kotlin", r: "r", lua: "lua", vim: "vim", diff: "diff", dockerfile: "dockerfile", }; const language = languageMap[extension] || extension; let highlighted = content; if (language && hljs.getLanguage(language)) { highlighted = hljs.highlight(content, { language }).value; } else { highlighted = hljs.highlightAuto(content).value; } this.currentFile.content = `
${lineNumbers}
${highlighted}
`; } }, renderBreadcrumb(path) { const parts = path ? path.split("/") : []; let html = `root`; let currentPathBuild = ""; parts.forEach((part, index) => { currentPathBuild += (currentPathBuild ? "/" : "") + part; const pathCopy = currentPathBuild; if (index === parts.length - 1) { html += ` / ${this.escapeHtml(part)}`; } else { html += ` / ${this.escapeHtml(part)}`; } }); return html; }, // Actions showUsersList() { this.state.currentRepo = null; this.state.currentBranch = "main"; this.state.currentPath = ""; this.state.resolvedHandle = null; this.branches = []; this.view = "repoList"; this.updateURL(); }, downloadFile() { 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`; window.location.href = url; }, copyToClipboard(text) { navigator.clipboard .writeText(text) .then(() => { this.showStatus("Copied to clipboard!", "success"); }) .catch((err) => { console.error("Failed to copy:", err); this.showStatus("Failed to copy to clipboard", "error"); }); }, // URL Management updateURL(replace = false) { if (this.isRestoringFromURL) { return; } const params = []; if (API.getBaseUrl()) { params.push(`server=${encodeURIComponent(API.getBaseUrl())}`); } if (this.state.currentRepo) { params.push( `repo=${this.state.currentRepo.fullPath.split("/").map(encodeURIComponent).join("/")}`, ); } if (this.state.currentBranch && this.state.currentBranch !== "main") { params.push(`branch=${encodeURIComponent(this.state.currentBranch)}`); } if (this.state.currentPath) { params.push( `path=${this.state.currentPath.split("/").map(encodeURIComponent).join("/")}`, ); } const newURL = params.length > 0 ? `?${params.join("&")}` : window.location.pathname; // Create a simple state object that can be cloned const historyState = { currentRepo: this.state.currentRepo ? { fullPath: this.state.currentRepo.fullPath, did: this.state.currentRepo.did, name: this.state.currentRepo.name, } : null, currentBranch: this.state.currentBranch, currentPath: this.state.currentPath, resolvedHandle: this.state.resolvedHandle, }; if (replace) { window.history.replaceState(historyState, "", newURL); } else { window.history.pushState(historyState, "", newURL); } }, async restoreFromURL() { const params = new URLSearchParams(window.location.search); const server = params.get("server"); const repo = params.get("repo"); const branch = params.get("branch") || "main"; const path = params.get("path") || ""; if (!server) { return; } try { this.isRestoringFromURL = true; this.serverUrl = server; API.setBaseUrl(server); const data = await API.getOwner(); this.showStatus(`Connected to ${data.owner}`, "success"); this.isConnected = true; if (repo) { const parts = repo.split("/"); this.state.currentRepo = { fullPath: repo, did: parts[0], name: parts.slice(1).join("/"), }; this.state.currentBranch = branch; this.state.currentPath = path; const handle = await API.resolveDID(this.state.currentRepo.did); if (handle) { this.state.resolvedHandle = handle; } await this.loadBranches(); if (path) { // Check if path is a file or directory try { await this.loadFile(path); } catch (error) { await this.loadTree(path); } } else { await this.loadTree(); } this.updateURL(true); } else { await this.loadUsersAndRepos(); this.updateURL(true); } this.isRestoringFromURL = false; } catch (error) { this.isRestoringFromURL = false; this.showStatus( `Failed to restore from URL: ${error.message}`, "error", ); } }, async restoreViewFromState() { if (!this.state.currentRepo) { this.view = "empty"; return; } await this.loadBranches(); if (this.state.currentPath) { try { await this.loadFile(this.state.currentPath); } catch (error) { await this.loadTree(this.state.currentPath); } } else { await this.loadTree(); } }, // UI Helpers showStatus(message, type) { this.status = { message, type }; setTimeout(() => { this.status = { message: "", type: "" }; }, 5000); }, showLoading(message) { this.loading = true; this.loadingMessage = message; this.error = null; }, showError(message) { this.loading = false; this.error = ` ${this.breadcrumbHtml ? `` : ""}
${this.escapeHtml(message)}
`; }, formatSize(bytes) { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; }, escapeHtml(text) { const map = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; return String(text).replace(/[&<>"']/g, (m) => map[m]); }, })); });