Knot server viewer.
knotview.srv.rbrt.fr
knot
tangled
1// Alpine.js App Component
2document.addEventListener("alpine:init", () => {
3 Alpine.data("app", () => ({
4 // State
5 serverUrl: "https://knot.srv.rbrt.fr",
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 "&": "&",
628 "<": "<",
629 ">": ">",
630 '"': """,
631 "'": "'",
632 };
633 return String(text).replace(/[&<>"']/g, (m) => map[m]);
634 },
635 }));
636});