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 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 "&": "&",
647 "<": "<",
648 ">": ">",
649 '"': """,
650 "'": "'",
651 };
652 return String(text).replace(/[&<>"']/g, (m) => map[m]);
653 },
654 }));
655});