+37
.gitignore
+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
+9
README.md
+119
api.js
+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
+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
+
"&": "&",
628
+
"<": "<",
629
+
">": ">",
630
+
'"': """,
631
+
"'": "'",
632
+
};
633
+
return String(text).replace(/[&<>"']/g, (m) => map[m]);
634
+
},
635
+
}));
636
+
});
+28
favicon.svg
+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
+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
+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
+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
+
}