init knotview

+37
.gitignore
··· 1 + # Editor directories and files 2 + .vscode/ 3 + .idea/ 4 + *.swp 5 + *.swo 6 + *~ 7 + .DS_Store 8 + 9 + # Dependencies 10 + node_modules/ 11 + npm-debug.log* 12 + yarn-debug.log* 13 + yarn-error.log* 14 + pnpm-debug.log* 15 + 16 + # Build outputs 17 + dist/ 18 + build/ 19 + *.local 20 + 21 + # Environment files 22 + .env 23 + .env.local 24 + .env.*.local 25 + 26 + # Logs 27 + logs/ 28 + *.log 29 + 30 + # OS generated files 31 + Thumbs.db 32 + Desktop.ini 33 + 34 + # Temporary files 35 + tmp/ 36 + temp/ 37 + *.tmp
+9
README.md
··· 1 + # KnotView 2 + 3 + A lightweight web-based browser for exploring Git repositories hosted on KnotServer instances. 4 + 5 + Selfhost it, or use the hosted version at [`knotview.srv.rbrt.fr`](https://knotview.srv.rbrt.fr) 6 + 7 + ## License 8 + 9 + [MIT](license)
+119
api.js
··· 1 + const API = (() => { 2 + const ENDPOINTS = { 3 + owner: "sh.tangled.owner", 4 + list: "sh.tangled.repo.list", 5 + tree: "sh.tangled.repo.tree", 6 + blob: "sh.tangled.repo.blob", 7 + branches: "sh.tangled.repo.branches", 8 + tags: "sh.tangled.repo.tags", 9 + defaultBranch: "sh.tangled.repo.getDefaultBranch", 10 + archive: "sh.tangled.repo.archive", 11 + }; 12 + 13 + let baseUrl = ""; 14 + 15 + function setBaseUrl(url) { 16 + baseUrl = url.replace(/\/+$/, ""); 17 + } 18 + 19 + function getBaseUrl() { 20 + return baseUrl; 21 + } 22 + 23 + async function fetchWithRetry(url, options = {}, retries = 3) { 24 + let lastError; 25 + for (let attempt = 1; attempt <= retries; attempt++) { 26 + try { 27 + const response = await fetch(url, options); 28 + if (response.ok) return response; 29 + lastError = new Error(`HTTP ${response.status}`); 30 + if (attempt < retries) { 31 + await new Promise((resolve) => setTimeout(resolve, 1000)); 32 + } 33 + } catch (err) { 34 + lastError = err; 35 + if (attempt < retries) { 36 + await new Promise((resolve) => setTimeout(resolve, 1000)); 37 + } 38 + } 39 + } 40 + throw lastError; 41 + } 42 + 43 + async function getOwner() { 44 + const url = `${baseUrl}/xrpc/${ENDPOINTS.owner}`; 45 + const response = await fetchWithRetry(url); 46 + return response.json(); 47 + } 48 + 49 + async function listRepos() { 50 + const url = `${baseUrl}/xrpc/${ENDPOINTS.list}`; 51 + const response = await fetch(url); 52 + if (!response.ok) throw new Error(`HTTP ${response.status}`); 53 + return response.json(); 54 + } 55 + 56 + async function getDefaultBranch(repo) { 57 + const url = `${baseUrl}/xrpc/${ENDPOINTS.defaultBranch}?repo=${encodeURIComponent(repo)}`; 58 + const response = await fetch(url); 59 + if (!response.ok) throw new Error(`HTTP ${response.status}`); 60 + return response.json(); 61 + } 62 + 63 + async function getBranches(repo) { 64 + const url = `${baseUrl}/xrpc/${ENDPOINTS.branches}?repo=${encodeURIComponent(repo)}`; 65 + const response = await fetch(url); 66 + if (!response.ok) throw new Error(`HTTP ${response.status}`); 67 + return response.json(); 68 + } 69 + 70 + async function getTree(repo, branch, path = "") { 71 + const url = `${baseUrl}/xrpc/${ENDPOINTS.tree}?repo=${encodeURIComponent(repo)}&branch=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}`; 72 + const response = await fetchWithRetry(url); 73 + return response.json(); 74 + } 75 + 76 + async function getBlob(repo, branch, path) { 77 + const url = `${baseUrl}/xrpc/${ENDPOINTS.blob}?repo=${encodeURIComponent(repo)}&branch=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}`; 78 + const response = await fetchWithRetry(url); 79 + return response.json(); 80 + } 81 + 82 + function getArchiveUrl(repo, branch) { 83 + return `${baseUrl}/xrpc/${ENDPOINTS.archive}?repo=${encodeURIComponent(repo)}&branch=${encodeURIComponent(branch)}`; 84 + } 85 + 86 + async function resolveDID(did) { 87 + try { 88 + const url = `https://plc.directory/${encodeURIComponent(did)}`; 89 + const response = await fetch(url); 90 + if (!response.ok) return null; 91 + 92 + const didDocument = await response.json(); 93 + 94 + // Extract handle from alsoKnownAs field 95 + if (didDocument.alsoKnownAs && didDocument.alsoKnownAs.length > 0) { 96 + const handle = didDocument.alsoKnownAs[0].replace(/^at:\/\//, ""); 97 + return handle; 98 + } 99 + 100 + return null; 101 + } catch (error) { 102 + console.error("Failed to resolve DID:", error); 103 + return null; 104 + } 105 + } 106 + 107 + return { 108 + setBaseUrl, 109 + getBaseUrl, 110 + getOwner, 111 + listRepos, 112 + getDefaultBranch, 113 + getBranches, 114 + getTree, 115 + getBlob, 116 + getArchiveUrl, 117 + resolveDID, 118 + }; 119 + })();
+636
app.js
··· 1 + // Alpine.js App Component 2 + document.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 + });
+28
favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> 2 + <defs> 3 + <linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%"> 4 + <stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" /> 5 + <stop offset="100%" style="stop-color:#2563eb;stop-opacity:1" /> 6 + </linearGradient> 7 + </defs> 8 + 9 + <!-- Background circle --> 10 + <circle cx="50" cy="50" r="48" fill="url(#grad)"/> 11 + 12 + <!-- Knot design - interwoven circles --> 13 + <g stroke="#ffffff" stroke-width="6" fill="none" stroke-linecap="round"> 14 + <!-- Left loop --> 15 + <path d="M 25 35 Q 15 50 25 65" opacity="0.9"/> 16 + 17 + <!-- Right loop --> 18 + <path d="M 75 35 Q 85 50 75 65" opacity="0.9"/> 19 + 20 + <!-- Center crossing --> 21 + <path d="M 30 50 Q 40 30 60 30 Q 70 30 70 50" opacity="1"/> 22 + <path d="M 30 50 Q 40 70 60 70 Q 70 70 70 50" opacity="0.7"/> 23 + 24 + <!-- Connection dots --> 25 + <circle cx="30" cy="50" r="4" fill="#ffffff"/> 26 + <circle cx="70" cy="50" r="4" fill="#ffffff"/> 27 + </g> 28 + </svg>
+307
index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>KnotView</title> 7 + <link rel="icon" type="image/svg+xml" href="favicon.svg" /> 8 + <link rel="stylesheet" href="styles.css" /> 9 + <link 10 + rel="stylesheet" 11 + href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" 12 + /> 13 + <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> 14 + <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script> 15 + <script 16 + defer 17 + src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" 18 + ></script> 19 + <script src="api.js"></script> 20 + <script src="app.js"></script> 21 + </head> 22 + <body> 23 + <div class="container" x-data="app" x-init="init()"> 24 + <header> 25 + <h1>KnotView</h1> 26 + <div class="connection-panel"> 27 + <input 28 + type="text" 29 + x-model="serverUrl" 30 + placeholder="https://knot.example.com" 31 + @keyup.enter="connectToServer" 32 + /> 33 + <button 34 + @click="connectToServer" 35 + :disabled="isConnected" 36 + x-text="isConnected ? 'Connected ✓' : 'Connect'" 37 + ></button> 38 + </div> 39 + <div 40 + x-show="status.message" 41 + class="status" 42 + :class="status.type" 43 + x-text="status.message" 44 + ></div> 45 + </header> 46 + 47 + <div class="main-content"> 48 + <!-- Sidebar --> 49 + <aside class="sidebar" x-show="state.currentRepo"> 50 + <div> 51 + <h2> 52 + Repository 53 + <button 54 + @click="showUsersList" 55 + class="secondary" 56 + style="padding: 6px 12px; font-size: 12px" 57 + > 58 + ← Back 59 + </button> 60 + </h2> 61 + <div class="repo-info"> 62 + <template x-if="state.currentRepo"> 63 + <div> 64 + <div class="label">Repository</div> 65 + <div 66 + class="value" 67 + x-text="state.currentRepo?.name" 68 + ></div> 69 + 70 + <div class="label">Owner</div> 71 + <div 72 + class="value" 73 + x-text="state.resolvedHandle || state.currentRepo?.did" 74 + ></div> 75 + 76 + <div class="label">Clone URL</div> 77 + <div class="clone-url"> 78 + <code 79 + x-text="`${API.getBaseUrl()}/repo/${state.currentRepo?.fullPath}`" 80 + ></code> 81 + <button 82 + class="copy-btn" 83 + @click="copyToClipboard(`${API.getBaseUrl()}/repo/${state.currentRepo?.fullPath}`)" 84 + > 85 + Copy 86 + </button> 87 + </div> 88 + 89 + <button 90 + @click="window.location.href = `${API.getBaseUrl()}/xrpc/sh.tangled.repo.archive?repo=${encodeURIComponent(state.currentRepo?.fullPath)}&branch=${encodeURIComponent(state.currentBranch)}`" 91 + style="width: 100%; margin-top: 8px" 92 + > 93 + Download Archive 94 + </button> 95 + </div> 96 + </template> 97 + </div> 98 + </div> 99 + 100 + <div class="branches-section"> 101 + <h2>Branches</h2> 102 + <div class="branch-list"> 103 + <template x-for="branch in branches" :key="branch"> 104 + <div 105 + class="branch-item" 106 + :class="{ active: branch.reference.name === state.currentBranch }" 107 + @click="switchBranch(branch.reference.name)" 108 + > 109 + <span x-text="branch.reference.name"></span> 110 + </div> 111 + </template> 112 + </div> 113 + </div> 114 + </aside> 115 + 116 + <!-- Main Viewer --> 117 + <main class="viewer"> 118 + <div x-show="loading" class="loading"> 119 + <div class="spinner"></div> 120 + <p x-text="loadingMessage"></p> 121 + </div> 122 + 123 + <div 124 + x-show="error && !loading" 125 + class="error-message" 126 + x-html="error" 127 + ></div> 128 + 129 + <!-- Empty State --> 130 + <div 131 + x-show="!loading && !error && !state.currentRepo && view === 'empty'" 132 + class="empty-state" 133 + > 134 + <svg 135 + xmlns="http://www.w3.org/2000/svg" 136 + fill="none" 137 + viewBox="0 0 24 24" 138 + stroke="currentColor" 139 + > 140 + <path 141 + stroke-linecap="round" 142 + stroke-linejoin="round" 143 + stroke-width="2" 144 + d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" 145 + /> 146 + </svg> 147 + <h3>No Repository Selected</h3> 148 + <p> 149 + Connect to a server and select a repository to 150 + browse its contents. 151 + </p> 152 + </div> 153 + 154 + <!-- Users/Repos List --> 155 + <div 156 + x-show="!loading && !error && view === 'repoList'" 157 + style="padding: 20px" 158 + > 159 + <template x-for="user in users" :key="user.did"> 160 + <div class="user-item"> 161 + <div 162 + class="user-header" 163 + x-text="user.handle || user.did" 164 + ></div> 165 + <template 166 + x-for="repo in user.repos" 167 + :key="repo.fullPath" 168 + > 169 + <div 170 + class="repo-item" 171 + @click="selectRepository(repo)" 172 + > 173 + <strong x-text="repo.name"></strong> 174 + <small x-text="repo.fullPath"></small> 175 + </div> 176 + </template> 177 + </div> 178 + </template> 179 + 180 + <!-- Manual Entry Fallback --> 181 + <div 182 + x-show="users.length === 0 && !loading" 183 + class="empty-state" 184 + > 185 + <svg 186 + xmlns="http://www.w3.org/2000/svg" 187 + fill="none" 188 + viewBox="0 0 24 24" 189 + stroke="currentColor" 190 + > 191 + <path 192 + stroke-linecap="round" 193 + stroke-linejoin="round" 194 + stroke-width="2" 195 + d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" 196 + /> 197 + </svg> 198 + <h3>Repository List Not Available</h3> 199 + <p> 200 + This server doesn't support automatic repository 201 + listing. 202 + </p> 203 + <p style="margin-top: 20px"> 204 + Please enter repository path manually: 205 + </p> 206 + <div 207 + style=" 208 + margin-top: 20px; 209 + max-width: 400px; 210 + margin-left: auto; 211 + margin-right: auto; 212 + " 213 + > 214 + <input 215 + type="text" 216 + x-model="manualRepoPath" 217 + placeholder="did:plc:xxx.../repo-name" 218 + style=" 219 + width: 100%; 220 + margin-bottom: 10px; 221 + padding: 10px; 222 + border: 1px solid #cbd5e1; 223 + border-radius: 6px; 224 + " 225 + @keyup.enter="loadManualRepo" 226 + /> 227 + <button 228 + @click="loadManualRepo" 229 + style="width: 100%" 230 + > 231 + Load Repository 232 + </button> 233 + </div> 234 + </div> 235 + </div> 236 + 237 + <!-- File Browser --> 238 + <div x-show="!loading && !error && view === 'tree'"> 239 + <div class="breadcrumb" x-html="breadcrumbHtml"></div> 240 + <div class="file-list" x-html="fileListHtml"></div> 241 + <div 242 + x-show="readmeHtml" 243 + style=" 244 + margin-top: 20px; 245 + border: 1px solid #e2e8f0; 246 + border-radius: 6px; 247 + overflow: hidden; 248 + " 249 + > 250 + <div 251 + style=" 252 + padding: 12px 20px; 253 + background: #f8fafc; 254 + border-bottom: 1px solid #e2e8f0; 255 + font-weight: 600; 256 + " 257 + > 258 + 📖 README.md 259 + </div> 260 + <div 261 + class="markdown-content" 262 + x-html="readmeHtml" 263 + ></div> 264 + </div> 265 + </div> 266 + 267 + <!-- File Viewer --> 268 + <div x-show="!loading && !error && view === 'file'"> 269 + <div class="breadcrumb" x-html="breadcrumbHtml"></div> 270 + <div class="file-header"> 271 + <h3 x-text="currentFile.name"></h3> 272 + <div class="file-actions"> 273 + <button @click="downloadFile">Download</button> 274 + </div> 275 + </div> 276 + <div 277 + x-show="currentFile.isBinary" 278 + style=" 279 + padding: 40px; 280 + text-align: center; 281 + color: #64748b; 282 + " 283 + > 284 + <p>Binary file (cannot be displayed)</p> 285 + <button 286 + @click="downloadFile" 287 + style="margin-top: 16px" 288 + > 289 + Download File 290 + </button> 291 + </div> 292 + <div 293 + x-show="currentFile.isMarkdown && !currentFile.isBinary" 294 + class="markdown-content" 295 + x-html="currentFile.content" 296 + ></div> 297 + <div 298 + x-show="!currentFile.isMarkdown && !currentFile.isBinary" 299 + class="file-content" 300 + x-html="currentFile.content" 301 + ></div> 302 + </div> 303 + </main> 304 + </div> 305 + </div> 306 + </body> 307 + </html>
+21
license
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Julien Robert and contributors. 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+679
styles.css
··· 1 + * { 2 + margin: 0; 3 + padding: 0; 4 + box-sizing: border-box; 5 + } 6 + 7 + body { 8 + font-family: 9 + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, 10 + Cantarell, sans-serif; 11 + background: #f1f5f9; 12 + color: #1e293b; 13 + font-size: 14px; 14 + line-height: 1.5; 15 + } 16 + 17 + .container { 18 + max-width: 1400px; 19 + margin: 0 auto; 20 + padding: 20px; 21 + } 22 + 23 + header { 24 + background: white; 25 + padding: 24px; 26 + border-radius: 8px; 27 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 28 + margin-bottom: 20px; 29 + border: 1px solid #e2e8f0; 30 + } 31 + 32 + h1 { 33 + font-size: 24px; 34 + margin-bottom: 20px; 35 + color: #0f172a; 36 + font-weight: 600; 37 + } 38 + 39 + .connection-panel { 40 + display: flex; 41 + gap: 12px; 42 + margin-bottom: 12px; 43 + flex-wrap: wrap; 44 + } 45 + 46 + .connection-panel input { 47 + flex: 1; 48 + min-width: 300px; 49 + padding: 10px 14px; 50 + border: 1px solid #cbd5e1; 51 + border-radius: 6px; 52 + font-size: 14px; 53 + background: white; 54 + transition: all 0.15s; 55 + } 56 + 57 + .connection-panel input:focus { 58 + outline: none; 59 + border-color: #3b82f6; 60 + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); 61 + } 62 + 63 + button { 64 + padding: 10px 20px; 65 + background: #3b82f6; 66 + color: white; 67 + border: none; 68 + border-radius: 6px; 69 + cursor: pointer; 70 + font-size: 14px; 71 + font-weight: 500; 72 + transition: all 0.15s; 73 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 74 + } 75 + 76 + button:hover { 77 + background: #2563eb; 78 + } 79 + 80 + button:active { 81 + transform: translateY(1px); 82 + } 83 + 84 + button:disabled { 85 + background: #94a3b8; 86 + cursor: not-allowed; 87 + transform: none; 88 + } 89 + 90 + button.secondary { 91 + background: white; 92 + color: #475569; 93 + border: 1px solid #cbd5e1; 94 + } 95 + 96 + button.secondary:hover { 97 + background: #f8fafc; 98 + } 99 + 100 + .status { 101 + padding: 10px 14px; 102 + border-radius: 6px; 103 + font-size: 13px; 104 + display: none; 105 + border: 1px solid transparent; 106 + } 107 + 108 + .status.success { 109 + background: #dcfce7; 110 + color: #166534; 111 + border-color: #bbf7d0; 112 + } 113 + 114 + .status.error { 115 + background: #fee2e2; 116 + color: #991b1b; 117 + border-color: #fecaca; 118 + } 119 + 120 + .main-content { 121 + display: flex; 122 + gap: 20px; 123 + align-items: flex-start; 124 + } 125 + 126 + .sidebar { 127 + width: 300px; 128 + background: white; 129 + border-radius: 8px; 130 + border: 1px solid #e2e8f0; 131 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 132 + flex-shrink: 0; 133 + } 134 + 135 + .sidebar h2 { 136 + font-size: 16px; 137 + font-weight: 600; 138 + padding: 16px 20px; 139 + border-bottom: 1px solid #e2e8f0; 140 + color: #0f172a; 141 + display: flex; 142 + align-items: center; 143 + justify-content: space-between; 144 + } 145 + 146 + .repo-info { 147 + padding: 20px; 148 + background: white; 149 + border-radius: 8px; 150 + border: 1px solid #e2e8f0; 151 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 152 + margin-bottom: 20px; 153 + } 154 + 155 + .repo-info h3 { 156 + font-size: 14px; 157 + font-weight: 600; 158 + margin-bottom: 12px; 159 + color: #64748b; 160 + } 161 + 162 + .repo-info .label { 163 + font-size: 12px; 164 + color: #64748b; 165 + margin-bottom: 4px; 166 + font-weight: 500; 167 + text-transform: uppercase; 168 + letter-spacing: 0.5px; 169 + } 170 + 171 + .repo-info .value { 172 + font-size: 13px; 173 + color: #1e293b; 174 + margin-bottom: 12px; 175 + font-family: monospace; 176 + } 177 + 178 + .clone-url { 179 + display: flex; 180 + align-items: center; 181 + gap: 8px; 182 + background: #f8fafc; 183 + padding: 8px 12px; 184 + border-radius: 6px; 185 + border: 1px solid #e2e8f0; 186 + margin-bottom: 12px; 187 + } 188 + 189 + .clone-url code { 190 + flex: 1; 191 + font-size: 12px; 192 + color: #475569; 193 + overflow: hidden; 194 + text-overflow: ellipsis; 195 + } 196 + 197 + .copy-btn { 198 + padding: 4px 8px; 199 + font-size: 11px; 200 + min-width: 50px; 201 + } 202 + 203 + .copy-btn:hover { 204 + background: #2563eb; 205 + } 206 + 207 + .branches-section { 208 + border-top: 1px solid #e2e8f0; 209 + } 210 + 211 + .branch-list { 212 + max-height: 300px; 213 + overflow-y: auto; 214 + } 215 + 216 + .branch-item { 217 + padding: 10px 20px; 218 + cursor: pointer; 219 + transition: background 0.15s; 220 + border-bottom: 1px solid #f1f5f9; 221 + font-size: 13px; 222 + color: #475569; 223 + display: flex; 224 + align-items: center; 225 + gap: 8px; 226 + } 227 + 228 + .branch-item:hover { 229 + background: #f8fafc; 230 + } 231 + 232 + .branch-item.active { 233 + background: #eff6ff; 234 + color: #1e40af; 235 + font-weight: 500; 236 + } 237 + 238 + .viewer { 239 + flex: 1; 240 + background: white; 241 + border-radius: 8px; 242 + border: 1px solid #e2e8f0; 243 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 244 + overflow: hidden; 245 + } 246 + 247 + .breadcrumb { 248 + padding: 16px 20px; 249 + border-bottom: 1px solid #e2e8f0; 250 + font-size: 13px; 251 + color: #64748b; 252 + background: #f8fafc; 253 + display: flex; 254 + align-items: center; 255 + flex-wrap: wrap; 256 + } 257 + 258 + .breadcrumb a { 259 + color: #3b82f6; 260 + text-decoration: none; 261 + transition: color 0.15s; 262 + cursor: pointer; 263 + } 264 + 265 + .breadcrumb a:hover { 266 + color: #2563eb; 267 + text-decoration: underline; 268 + } 269 + 270 + .breadcrumb span { 271 + margin: 0 8px; 272 + } 273 + 274 + .breadcrumb .current { 275 + color: #1e293b; 276 + font-weight: 500; 277 + } 278 + 279 + .file-list { 280 + padding: 0; 281 + list-style: none; 282 + margin: 0; 283 + } 284 + 285 + .file-item { 286 + padding: 12px 20px; 287 + display: flex; 288 + align-items: center; 289 + gap: 12px; 290 + border-bottom: 1px solid #f1f5f9; 291 + cursor: pointer !important; 292 + } 293 + 294 + .file-item:last-child { 295 + border-bottom: none; 296 + } 297 + 298 + .file-item:hover { 299 + background: #f8fafc; 300 + } 301 + 302 + .file-icon { 303 + width: 20px; 304 + height: 20px; 305 + flex-shrink: 0; 306 + color: #64748b; 307 + cursor: pointer; 308 + } 309 + 310 + .file-name { 311 + flex: 1; 312 + color: #1e293b; 313 + font-size: 14px; 314 + cursor: pointer; 315 + } 316 + 317 + .file-size { 318 + color: #64748b; 319 + font-size: 12px; 320 + cursor: pointer; 321 + } 322 + 323 + .file-content { 324 + padding: 0; 325 + overflow-x: auto; 326 + background: #0d1117; 327 + font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; 328 + font-size: 13px; 329 + } 330 + 331 + .file-content pre { 332 + margin: 0; 333 + white-space: pre; 334 + background: transparent; 335 + padding: 20px; 336 + } 337 + 338 + .file-content code { 339 + background: transparent; 340 + } 341 + 342 + .file-content code.hljs { 343 + padding: 0; 344 + background: transparent; 345 + } 346 + 347 + .line-numbers { 348 + display: flex; 349 + gap: 0; 350 + background: #0d1117; 351 + } 352 + 353 + .line-numbers .numbers { 354 + color: #6e7681; 355 + text-align: right; 356 + user-select: none; 357 + min-width: 50px; 358 + padding: 20px 16px 20px 20px; 359 + border-right: 1px solid #30363d; 360 + background: #0d1117; 361 + line-height: 1.5; 362 + } 363 + 364 + .line-numbers pre:not(.numbers) { 365 + flex: 1; 366 + padding-left: 20px; 367 + } 368 + 369 + .loading { 370 + padding: 40px; 371 + text-align: center; 372 + color: #64748b; 373 + } 374 + 375 + .spinner { 376 + width: 40px; 377 + height: 40px; 378 + margin: 0 auto 16px; 379 + border: 3px solid #e2e8f0; 380 + border-top-color: #3b82f6; 381 + border-radius: 50%; 382 + animation: spin 0.8s linear infinite; 383 + } 384 + 385 + @keyframes spin { 386 + to { 387 + transform: rotate(360deg); 388 + } 389 + } 390 + 391 + .empty-state { 392 + padding: 60px 40px; 393 + text-align: center; 394 + } 395 + 396 + .empty-state svg { 397 + width: 64px; 398 + height: 64px; 399 + margin: 0 auto 20px; 400 + color: #cbd5e1; 401 + display: block; 402 + } 403 + 404 + .empty-state h3 { 405 + font-size: 18px; 406 + color: #475569; 407 + margin-bottom: 8px; 408 + } 409 + 410 + .empty-state p { 411 + color: #64748b; 412 + font-size: 14px; 413 + } 414 + 415 + .error-message { 416 + padding: 40px; 417 + text-align: center; 418 + color: #991b1b; 419 + background: #fee2e2; 420 + margin: 20px; 421 + border-radius: 8px; 422 + border: 1px solid #fecaca; 423 + } 424 + 425 + .file-header { 426 + padding: 16px 20px; 427 + border-bottom: 1px solid #e2e8f0; 428 + background: #f8fafc; 429 + display: flex; 430 + justify-content: space-between; 431 + align-items: center; 432 + } 433 + 434 + .file-header h3 { 435 + font-size: 15px; 436 + color: #1e293b; 437 + font-weight: 600; 438 + } 439 + 440 + .file-actions { 441 + display: flex; 442 + gap: 8px; 443 + } 444 + 445 + .file-actions button { 446 + padding: 6px 12px; 447 + font-size: 12px; 448 + } 449 + 450 + .user-item { 451 + margin-bottom: 24px; 452 + } 453 + 454 + .user-header { 455 + font-size: 16px; 456 + font-weight: 600; 457 + color: #0f172a; 458 + margin-bottom: 12px; 459 + padding: 12px; 460 + background: #f8fafc; 461 + border-radius: 6px; 462 + } 463 + 464 + .repo-item { 465 + padding: 12px; 466 + margin-bottom: 8px; 467 + background: white; 468 + border: 1px solid #e2e8f0; 469 + border-radius: 6px; 470 + cursor: pointer; 471 + transition: all 0.15s; 472 + } 473 + 474 + .repo-item:hover { 475 + border-color: #3b82f6; 476 + box-shadow: 0 2px 4px rgba(59, 130, 246, 0.1); 477 + } 478 + 479 + .repo-item strong { 480 + display: block; 481 + font-size: 14px; 482 + color: #1e293b; 483 + margin-bottom: 4px; 484 + } 485 + 486 + .repo-item small { 487 + font-size: 12px; 488 + color: #64748b; 489 + font-family: monospace; 490 + } 491 + 492 + .markdown-content { 493 + padding: 20px 40px; 494 + background: white; 495 + font-family: 496 + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, 497 + Cantarell, sans-serif; 498 + font-size: 15px; 499 + line-height: 1.6; 500 + color: #1e293b; 501 + } 502 + 503 + .markdown-content h1, 504 + .markdown-content h2, 505 + .markdown-content h3, 506 + .markdown-content h4, 507 + .markdown-content h5, 508 + .markdown-content h6 { 509 + margin-top: 24px; 510 + margin-bottom: 16px; 511 + font-weight: 600; 512 + line-height: 1.25; 513 + color: #0f172a; 514 + } 515 + 516 + .markdown-content h1 { 517 + font-size: 2em; 518 + padding-bottom: 0.3em; 519 + border-bottom: 1px solid #e2e8f0; 520 + } 521 + 522 + .markdown-content h2 { 523 + font-size: 1.5em; 524 + padding-bottom: 0.3em; 525 + border-bottom: 1px solid #e2e8f0; 526 + } 527 + 528 + .markdown-content h3 { 529 + font-size: 1.25em; 530 + } 531 + 532 + .markdown-content h4 { 533 + font-size: 1em; 534 + } 535 + 536 + .markdown-content h5 { 537 + font-size: 0.875em; 538 + } 539 + 540 + .markdown-content h6 { 541 + font-size: 0.85em; 542 + color: #64748b; 543 + } 544 + 545 + .markdown-content p { 546 + margin-top: 0; 547 + margin-bottom: 16px; 548 + } 549 + 550 + .markdown-content ul, 551 + .markdown-content ol { 552 + margin-top: 0; 553 + margin-bottom: 16px; 554 + padding-left: 2em; 555 + } 556 + 557 + .markdown-content li + li { 558 + margin-top: 0.25em; 559 + } 560 + 561 + .markdown-content code { 562 + padding: 0.2em 0.4em; 563 + margin: 0; 564 + font-size: 85%; 565 + background: #f1f5f9; 566 + border-radius: 6px; 567 + font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; 568 + color: #e11d48; 569 + } 570 + 571 + .markdown-content pre { 572 + padding: 16px; 573 + overflow: auto; 574 + font-size: 85%; 575 + line-height: 1.45; 576 + background: #0d1117; 577 + border-radius: 6px; 578 + margin-bottom: 16px; 579 + } 580 + 581 + .markdown-content pre code { 582 + display: block; 583 + padding: 0; 584 + margin: 0; 585 + overflow: visible; 586 + line-height: inherit; 587 + word-wrap: normal; 588 + background: transparent; 589 + border: 0; 590 + color: #c9d1d9; 591 + } 592 + 593 + .markdown-content pre code.hljs { 594 + background: transparent; 595 + } 596 + 597 + .markdown-content blockquote { 598 + padding: 0 1em; 599 + color: #64748b; 600 + border-left: 0.25em solid #cbd5e1; 601 + margin: 0 0 16px 0; 602 + } 603 + 604 + .markdown-content blockquote > :first-child { 605 + margin-top: 0; 606 + } 607 + 608 + .markdown-content blockquote > :last-child { 609 + margin-bottom: 0; 610 + } 611 + 612 + .markdown-content table { 613 + border-spacing: 0; 614 + border-collapse: collapse; 615 + margin-bottom: 16px; 616 + width: 100%; 617 + overflow: auto; 618 + } 619 + 620 + .markdown-content table th, 621 + .markdown-content table td { 622 + padding: 6px 13px; 623 + border: 1px solid #e2e8f0; 624 + } 625 + 626 + .markdown-content table th { 627 + font-weight: 600; 628 + background: #f8fafc; 629 + } 630 + 631 + .markdown-content table tr { 632 + background: white; 633 + border-top: 1px solid #e2e8f0; 634 + } 635 + 636 + .markdown-content table tr:nth-child(2n) { 637 + background: #f8fafc; 638 + } 639 + 640 + .markdown-content img { 641 + max-width: 100%; 642 + box-sizing: border-box; 643 + border-radius: 6px; 644 + } 645 + 646 + .markdown-content a { 647 + color: #3b82f6; 648 + text-decoration: none; 649 + } 650 + 651 + .markdown-content a:hover { 652 + text-decoration: underline; 653 + } 654 + 655 + .markdown-content hr { 656 + height: 0.25em; 657 + padding: 0; 658 + margin: 24px 0; 659 + background-color: #e2e8f0; 660 + border: 0; 661 + } 662 + 663 + @media (max-width: 768px) { 664 + .main-content { 665 + flex-direction: column; 666 + } 667 + 668 + .connection-panel { 669 + flex-direction: column; 670 + } 671 + 672 + .connection-panel input { 673 + min-width: 100%; 674 + } 675 + 676 + .markdown-content { 677 + padding: 20px; 678 + } 679 + }