// 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 += `
${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}