Browse Source

bug-fixes

Nostr-Signature: d98d2d6a6eb27ba36f19015f7d6969fe3925c40b23187d70ccc9b61141c6b4b7 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 8727e3015e38a78d7a6105c26e5b1469dc4d6d701e58d5d6c522ab529b4daa2d39d4353eb6d091f3c1fd28ad0289eae808494c9e2722bf9065dd2b2e9001664f
main
Silberengel 2 weeks ago
parent
commit
b6f6297c65
  1. 1
      nostr/commit-signatures.jsonl
  2. 65
      src/lib/components/CodeEditor.svelte
  3. 54
      src/lib/components/CommentRenderer.svelte
  4. 29
      src/lib/components/DiscussionRenderer.svelte
  5. 242
      src/lib/services/git/file-manager.ts
  6. 38
      src/lib/services/git/file-manager/commit-operations.ts
  7. 304
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  8. 22
      src/routes/api/repos/[npub]/[repo]/clone-urls/reachability/+server.ts
  9. 2
      src/routes/api/repos/[npub]/[repo]/commits/+server.ts
  10. 2
      src/routes/api/repos/[npub]/[repo]/readme/+server.ts
  11. 2
      src/routes/api/repos/[npub]/[repo]/tree/+server.ts
  12. 56
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  13. 84
      src/routes/repos/[npub]/[repo]/+page.svelte
  14. 116
      src/routes/repos/[npub]/[repo]/components/DiscussionsTab.svelte
  15. 63
      src/routes/repos/[npub]/[repo]/components/DocsTab.svelte
  16. 155
      src/routes/repos/[npub]/[repo]/components/FilesTab.svelte
  17. 24
      src/routes/repos/[npub]/[repo]/components/TabLayout.svelte
  18. 133
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateDocumentationDialog.svelte
  19. 17
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateThreadDialog.svelte
  20. 5
      src/routes/repos/[npub]/[repo]/components/dialogs/Modal.svelte
  21. 12
      src/routes/repos/[npub]/[repo]/services/commit-operations.ts
  22. 25
      src/routes/repos/[npub]/[repo]/services/file-operations.ts
  23. 10
      src/routes/repos/[npub]/[repo]/stores/repo-state.ts
  24. 13
      src/routes/repos/[npub]/[repo]/utils/file-processing.ts

1
nostr/commit-signatures.jsonl

@ -109,3 +109,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142558,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove redundancy"]],"content":"Signed commit: remove redundancy","id":"11ac91151bebd4dd49b91bcdef7b0b7157f0afd8ce710f7231be4860fb073d08","sig":"a7efcafa5ea83a0c37eae4562a84a7581c3d5c5dd1416f8f3e2bd2633d8523ae0eb7cc56dc4292c127ea16fb2dd5bc639483cb096263a850956b47312ed7ff6f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142558,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove redundancy"]],"content":"Signed commit: remove redundancy","id":"11ac91151bebd4dd49b91bcdef7b0b7157f0afd8ce710f7231be4860fb073d08","sig":"a7efcafa5ea83a0c37eae4562a84a7581c3d5c5dd1416f8f3e2bd2633d8523ae0eb7cc56dc4292c127ea16fb2dd5bc639483cb096263a850956b47312ed7ff6f"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772182112,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 12"]],"content":"Signed commit: refactor 12","id":"73671ae6535309f9eae164f7a3ec403b1bc818ef811b9692fd0122d0b72c2774","sig":"0df56b009f5afb77de334225ab30cff55586ac0cf48f5ee435428201a1e72dc357a0fb5e80ef196f5bd76d6d448056d25f0feab0b1bcbe45f9af1a2a0d5453ca"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772182112,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 12"]],"content":"Signed commit: refactor 12","id":"73671ae6535309f9eae164f7a3ec403b1bc818ef811b9692fd0122d0b72c2774","sig":"0df56b009f5afb77de334225ab30cff55586ac0cf48f5ee435428201a1e72dc357a0fb5e80ef196f5bd76d6d448056d25f0feab0b1bcbe45f9af1a2a0d5453ca"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772188835,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 13"]],"content":"Signed commit: refactor 13","id":"f41c8662dcbf1be408c560d11eda0890c40582a8ea8bb3220116e645cc6a2bb5","sig":"2b7b70089cecfa4652fe236fa586a6fe1b05c1c95434a160717cbf5ee2f37382cdd8e8f31d7b3a7576ee5264e9e70c7a8651591caaea0cd311d1be4c561d282f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772188835,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 13"]],"content":"Signed commit: refactor 13","id":"f41c8662dcbf1be408c560d11eda0890c40582a8ea8bb3220116e645cc6a2bb5","sig":"2b7b70089cecfa4652fe236fa586a6fe1b05c1c95434a160717cbf5ee2f37382cdd8e8f31d7b3a7576ee5264e9e70c7a8651591caaea0cd311d1be4c561d282f"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772193104,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"02dcdcda1083cffd91dbf8906716c2ae09f06f77ef8590802afecd85f0b3108a","sig":"13d2b30ed37af03fd47dc09536058babb4dc63d1cfc55b8f38651ffd6342abcddc840b543c085b047721e9102b2d07e3dae78ff31d5990c92c04410ef1efcd5b"}

65
src/lib/components/CodeEditor.svelte

@ -79,6 +79,8 @@
autocompletion(), autocompletion(),
highlightSelectionMatches(), highlightSelectionMatches(),
highlightField, highlightField,
// Enable line wrapping to prevent horizontal overflow
EditorView.lineWrapping,
keymap.of([ keymap.of([
...closeBracketsKeymap, ...closeBracketsKeymap,
...defaultKeymap, ...defaultKeymap,
@ -230,15 +232,54 @@
.code-editor { .code-editor {
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow: auto; max-width: 100%;
overflow-x: hidden;
overflow-y: auto;
min-width: 0;
box-sizing: border-box;
} }
:global(.code-editor .cm-editor) { :global(.code-editor .cm-editor) {
height: 100%; height: 100%;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
overflow: hidden;
} }
:global(.code-editor .cm-scroller) { :global(.code-editor .cm-scroller) {
overflow: auto; overflow-x: hidden !important;
overflow-y: auto;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
:global(.code-editor .cm-content) {
max-width: 100% !important;
min-width: 0;
box-sizing: border-box;
word-wrap: break-word;
overflow-wrap: break-word;
overflow-x: hidden !important;
width: 100%;
}
:global(.code-editor .cm-line) {
max-width: 100% !important;
min-width: 0;
box-sizing: border-box;
word-wrap: break-word;
overflow-wrap: break-word;
overflow-x: hidden !important;
}
:global(.code-editor .cm-line > *) {
max-width: 100% !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
} }
:global(.code-editor .cm-highlight-marker) { :global(.code-editor .cm-highlight-marker) {
@ -246,4 +287,24 @@
padding: 2px 0; padding: 2px 0;
border-radius: 2px; border-radius: 2px;
} }
/* Prevent any CodeMirror element from causing horizontal overflow */
:global(.code-editor .cm-gutters),
:global(.code-editor .cm-gutter),
:global(.code-editor .cm-panels),
:global(.code-editor .cm-panel),
:global(.code-editor .cm-focused) {
max-width: 100%;
box-sizing: border-box;
overflow-x: hidden !important;
}
/* Ensure all text content in CodeMirror wraps */
:global(.code-editor .cm-content),
:global(.code-editor .cm-line),
:global(.code-editor .cm-lineContent) {
overflow-x: hidden !important;
word-break: break-word;
overflow-wrap: break-word;
}
</style> </style>

54
src/lib/components/CommentRenderer.svelte

@ -1,3 +1,15 @@
<script module lang="ts">
export interface Comment {
id: string;
content: string;
author: string;
createdAt: number;
kind: number;
pubkey: string;
replies?: Comment[];
}
</script>
<script lang="ts"> <script lang="ts">
import UserBadge from '$lib/components/UserBadge.svelte'; import UserBadge from '$lib/components/UserBadge.svelte';
import EventCopyButton from '$lib/components/EventCopyButton.svelte'; import EventCopyButton from '$lib/components/EventCopyButton.svelte';
@ -10,16 +22,6 @@
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import CommentRendererSelf from './CommentRenderer.svelte'; import CommentRendererSelf from './CommentRenderer.svelte';
export interface Comment {
id: string;
content: string;
author: string;
createdAt: number;
kind: number;
pubkey: string;
replies?: Comment[];
}
interface Props { interface Props {
comment: Comment; comment: Comment;
commentEvent?: NostrEvent; // Full event for getting referenced events commentEvent?: NostrEvent; // Full event for getting referenced events
@ -113,15 +115,16 @@
.comment-item { .comment-item {
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0.75rem; padding: 0.75rem;
border-left: 2px solid var(--border-color, #e0e0e0); border-left: 2px solid var(--border-color);
background: var(--comment-bg, #f9f9f9); background: var(--bg-tertiary, var(--bg-secondary));
color: var(--text-primary);
} }
.nested-comment { .nested-comment {
margin-left: 1.5rem; margin-left: 1.5rem;
margin-top: 0.5rem; margin-top: 0.5rem;
border-left-color: var(--nested-border-color, #ccc); border-left-color: var(--border-color);
background: var(--nested-comment-bg, #f5f5f5); background: var(--bg-secondary, var(--bg-primary));
} }
.comment-meta { .comment-meta {
@ -130,11 +133,12 @@
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-secondary, #666); color: var(--text-secondary);
} }
.comment-content { .comment-content {
margin-top: 0.5rem; margin-top: 0.5rem;
color: var(--text-primary);
} }
.create-reply-button { .create-reply-button {
@ -164,9 +168,10 @@
.referenced-event { .referenced-event {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
padding: 0.5rem; padding: 0.5rem;
background: var(--referenced-bg, #f0f0f0); background: var(--bg-secondary, var(--bg-primary));
color: var(--text-primary);
border-radius: 4px; border-radius: 4px;
border-left: 2px solid var(--referenced-border, #999); border-left: 2px solid var(--border-color);
} }
.referenced-event-header { .referenced-event-header {
@ -178,20 +183,21 @@
} }
.referenced-event-time { .referenced-event-time {
color: var(--text-secondary, #666); color: var(--text-secondary);
} }
.referenced-event-content { .referenced-event-content {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-secondary, #666); color: var(--text-primary);
} }
.nostr-link-event { .nostr-link-event {
margin: 0.5rem 0; margin: 0.5rem 0;
padding: 0.5rem; padding: 0.5rem;
background: var(--link-event-bg, #f0f0f0); background: var(--bg-secondary, var(--bg-primary));
color: var(--text-primary);
border-radius: 4px; border-radius: 4px;
border-left: 2px solid var(--link-event-border, #999); border-left: 2px solid var(--border-color);
} }
.nostr-link-event-header { .nostr-link-event-header {
@ -203,16 +209,16 @@
} }
.nostr-link-event-time { .nostr-link-event-time {
color: var(--text-secondary, #666); color: var(--text-secondary);
} }
.nostr-link-event-content { .nostr-link-event-content {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-secondary, #666); color: var(--text-primary);
} }
.nostr-link-placeholder { .nostr-link-placeholder {
color: var(--link-color, #0066cc); color: var(--accent-color, var(--button-primary));
text-decoration: underline; text-decoration: underline;
} }
</style> </style>

29
src/lib/components/DiscussionRenderer.svelte

@ -114,21 +114,28 @@
<style> <style>
.discussion-item { .discussion-item {
width: 100%;
max-width: 100%;
padding: 1rem; padding: 1rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
border: 1px solid var(--border-color, #e0e0e0); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
background: var(--discussion-bg, #fff); background: var(--bg-secondary, var(--bg-primary));
color: var(--text-primary);
box-sizing: border-box;
} }
.discussion-header { .discussion-header {
margin-bottom: 1rem; margin-bottom: 1rem;
width: 100%;
box-sizing: border-box;
} }
.discussion-title { .discussion-title {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary);
} }
.discussion-meta { .discussion-meta {
@ -136,12 +143,15 @@
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-secondary, #666); color: var(--text-secondary);
width: 100%;
box-sizing: border-box;
} }
.discussion-type { .discussion-type {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background: var(--type-bg, #e0e0e0); background: var(--bg-tertiary, var(--bg-secondary));
color: var(--text-primary);
border-radius: 4px; border-radius: 4px;
font-weight: 500; font-weight: 500;
} }
@ -170,25 +180,32 @@
} }
.discussion-body { .discussion-body {
width: 100%;
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0.75rem; padding: 0.75rem;
background: var(--body-bg, #f9f9f9); background: var(--bg-tertiary, var(--bg-secondary));
color: var(--text-primary);
border-radius: 4px; border-radius: 4px;
box-sizing: border-box;
} }
.discussion-body p { .discussion-body p {
margin: 0; margin: 0;
color: var(--text-primary);
} }
.comments-section { .comments-section {
width: 100%;
margin-top: 1rem; margin-top: 1rem;
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0); border-top: 1px solid var(--border-color);
box-sizing: border-box;
} }
.comments-section h4 { .comments-section h4 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary);
} }
</style> </style>

242
src/lib/services/git/file-manager.ts

@ -1,26 +1,25 @@
/** /**
* File Manager - Refactored to use modular components * File Manager - Core service for git repository operations
* Main class that delegates to focused modules * Handles branches, files, commits, tags, and worktrees
*/ */
import { join, resolve } from 'path'; import { join, resolve } from 'path';
import simpleGit, { type SimpleGit } from 'simple-git'; import simpleGit, { type SimpleGit } from 'simple-git';
import { RepoManager } from './repo-manager.js'; import { RepoManager } from './repo-manager.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { sanitizeError, isValidBranchName } from '$lib/utils/security.js'; import { isValidBranchName } from '$lib/utils/security.js';
import { repoCache, RepoCache } from './repo-cache.js'; import { repoCache, RepoCache } from './repo-cache.js';
// Import modular operations // Import modular operations
import { getOrCreateWorktree, removeWorktree } from './file-manager/worktree-manager.js'; import { getOrCreateWorktree, removeWorktree } from './file-manager/worktree-manager.js';
import { validateFilePath, validateRepoName, validateNpub } from './file-manager/path-validator.js'; import { validateFilePath, validateRepoName, validateNpub } from './file-manager/path-validator.js';
import { listFiles, getFileContent } from './file-manager/file-operations.js'; import { listFiles, getFileContent } from './file-manager/file-operations.js';
import { getBranches, validateBranchName } from './file-manager/branch-operations.js'; import { getBranches as getBranchesModule } from './file-manager/branch-operations.js';
import { writeFile, deleteFile } from './file-manager/write-operations.js'; import { writeFile, deleteFile } from './file-manager/write-operations.js';
import { getCommitHistory, getDiff } from './file-manager/commit-operations.js'; import { getCommitHistory, getDiff } from './file-manager/commit-operations.js';
import { createTag, getTags } from './file-manager/tag-operations.js'; import { createTag, getTags } from './file-manager/tag-operations.js';
// Types are defined below // Type definitions
export interface FileEntry { export interface FileEntry {
name: string; name: string;
path: string; path: string;
@ -59,8 +58,6 @@ export interface Tag {
export class FileManager { export class FileManager {
private repoManager: RepoManager; private repoManager: RepoManager;
private repoRoot: string; private repoRoot: string;
private dirExistenceCache: Map<string, { exists: boolean; timestamp: number }> = new Map();
private readonly DIR_CACHE_TTL = 5 * 60 * 1000;
private fsPromises: typeof import('fs/promises') | null = null; private fsPromises: typeof import('fs/promises') | null = null;
constructor(repoRoot: string = '/repos') { constructor(repoRoot: string = '/repos') {
@ -75,39 +72,6 @@ export class FileManager {
return this.fsPromises; return this.fsPromises;
} }
private async pathExists(path: string): Promise<boolean> {
try {
const fs = await this.getFsPromises();
await fs.access(path);
return true;
} catch {
return false;
}
}
private sanitizePathForError(path: string): string {
const resolvedPath = resolve(path).replace(/\\/g, '/');
const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/');
if (resolvedPath.startsWith(resolvedRoot + '/')) {
return resolvedPath.slice(resolvedRoot.length + 1);
}
return path.split(/[/\\]/).pop() || path;
}
private async ensureDirectoryExists(dirPath: string, description: string): Promise<void> {
const exists = await this.pathExists(dirPath);
if (exists) return;
try {
const { mkdir } = await this.getFsPromises();
await mkdir(dirPath, { recursive: true });
logger.debug({ dirPath: this.sanitizePathForError(dirPath) }, `Created ${description}`);
} catch (err) {
logger.error({ error: err, dirPath: this.sanitizePathForError(dirPath) }, `Failed to create ${description}`);
throw new Error(`Failed to create ${description}: ${err instanceof Error ? err.message : String(err)}`);
}
}
getRepoPath(npub: string, repoName: string): string { getRepoPath(npub: string, repoName: string): string {
const repoPath = join(this.repoRoot, npub, `${repoName}.git`); const repoPath = join(this.repoRoot, npub, `${repoName}.git`);
const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); const resolvedPath = resolve(repoPath).replace(/\\/g, '/');
@ -188,7 +152,6 @@ export class FileManager {
throw new Error('Repository not found'); throw new Error('Repository not found');
} }
// Check repo size
const repoSizeCheck = await this.repoManager.checkRepoSizeLimit(repoPath); const repoSizeCheck = await this.repoManager.checkRepoSizeLimit(repoPath);
if (!repoSizeCheck.withinLimit) { if (!repoSizeCheck.withinLimit) {
throw new Error(repoSizeCheck.error || 'Repository size limit exceeded'); throw new Error(repoSizeCheck.error || 'Repository size limit exceeded');
@ -196,12 +159,10 @@ export class FileManager {
const worktreePath = await this.getWorktree(repoPath, branch, npub, repoName); const worktreePath = await this.getWorktree(repoPath, branch, npub, repoName);
// Save commit signature helper
const saveCommitSignature = async (worktreePath: string, event: any) => { const saveCommitSignature = async (worktreePath: string, event: any) => {
await this.saveCommitSignatureEventToWorktree(worktreePath, event); await this.saveCommitSignatureEventToWorktree(worktreePath, event);
}; };
// Check if repo is private
const isRepoPrivate = async (npub: string, repoName: string) => { const isRepoPrivate = async (npub: string, repoName: string) => {
return this.isRepoPrivate(npub, repoName); return this.isRepoPrivate(npub, repoName);
}; };
@ -301,6 +262,9 @@ export class FileManager {
if (match) return match[1]; if (match) return match[1];
} }
} catch { } catch {
// HEAD doesn't point to a branch, try remote
}
try { try {
const remoteHead = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']); const remoteHead = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']);
if (remoteHead) { if (remoteHead) {
@ -308,10 +272,10 @@ export class FileManager {
if (match) return match[1]; if (match) return match[1];
} }
} catch { } catch {
// Fall through // No remote HEAD
}
} }
// Try to get branches and find main/master
try { try {
const branches = await git.branch(['-r']); const branches = await git.branch(['-r']);
const branchList = branches.all const branchList = branches.all
@ -332,7 +296,7 @@ export class FileManager {
if (!this.repoExists(npub, repoName)) { if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found'); throw new Error('Repository not found');
} }
return getBranches({ return getBranchesModule({
npub, npub,
repoName, repoName,
repoPath, repoPath,
@ -340,29 +304,207 @@ export class FileManager {
}); });
} }
/**
* Create a branch in a repository
* Handles empty repositories by creating orphan branches
*/
async createBranch( async createBranch(
npub: string, npub: string,
repoName: string, repoName: string,
branchName: string, branchName: string,
fromBranch: string = 'main' fromBranch?: string
): Promise<void> { ): Promise<void> {
logger.info({ npub, repoName, branchName, fromBranch }, '[FileManager.createBranch] START - called with parameters');
const repoPath = this.getRepoPath(npub, repoName); const repoPath = this.getRepoPath(npub, repoName);
logger.info({ npub, repoName, repoPath }, '[FileManager.createBranch] Repository path resolved');
if (!this.repoExists(npub, repoName)) { if (!this.repoExists(npub, repoName)) {
logger.error({ npub, repoName, repoPath }, '[FileManager.createBranch] Repository does not exist');
throw new Error('Repository not found'); throw new Error('Repository not found');
} }
logger.info({ npub, repoName }, '[FileManager.createBranch] Repository exists confirmed');
if (!isValidBranchName(branchName)) { if (!isValidBranchName(branchName)) {
logger.error({ npub, repoName, branchName }, '[FileManager.createBranch] Invalid branch name');
throw new Error(`Invalid branch name: ${branchName}`); throw new Error(`Invalid branch name: ${branchName}`);
} }
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Branch name validated');
const git: SimpleGit = simpleGit(repoPath); const git: SimpleGit = simpleGit(repoPath);
logger.info({ npub, repoName, repoPath }, '[FileManager.createBranch] Git instance created');
try {
// Check if repository has any commits - use multiple methods for reliability
let hasCommits = false;
let commitCount = 0;
try {
// Method 1: rev-list count
const commitCountStr = await git.raw(['rev-list', '--count', '--all']);
commitCount = parseInt(commitCountStr.trim(), 10);
hasCommits = !isNaN(commitCount) && commitCount > 0;
logger.info({ npub, repoName, branchName, fromBranch, commitCount, hasCommits }, '[FileManager] createBranch - rev-list result');
// Method 2: Verify by checking if any refs exist
if (hasCommits) {
try {
const refs = await git.raw(['for-each-ref', '--count=1', 'refs/heads/']);
if (!refs || refs.trim().length === 0) {
hasCommits = false;
logger.warn({ npub, repoName }, '[FileManager] No refs found despite commit count, treating as empty');
}
} catch (refError) {
hasCommits = false;
logger.warn({ npub, repoName, error: refError }, '[FileManager] Failed to check refs, treating as empty');
}
}
} catch (revListError) {
// rev-list fails for empty repos - this is expected
hasCommits = false;
logger.info({ npub, repoName, error: revListError }, '[FileManager] rev-list failed (empty repo expected)');
}
// CRITICAL SAFETY: Use local variable and clear it if no commits
let sourceBranch: string | undefined = fromBranch;
// CRITICAL SAFETY: If fromBranch is 'master' or 'main' and we have no commits, clear it
if ((sourceBranch === 'master' || sourceBranch === 'main') && !hasCommits) {
logger.error({ npub, repoName, sourceBranch, hasCommits }, '[FileManager] ERROR: sourceBranch is master/main but no commits! Clearing it.');
sourceBranch = undefined;
}
// CRITICAL: If no commits, ALWAYS clear sourceBranch
if (!hasCommits) {
sourceBranch = undefined;
logger.info({ npub, repoName }, '[FileManager] No commits - forcing sourceBranch to undefined');
}
logger.info({ npub, repoName, branchName, sourceBranch, fromBranch, hasCommits, commitCount }, '[FileManager] createBranch - final values before branch creation');
// CRITICAL: If repo has no commits, ALWAYS create orphan branch (completely ignore sourceBranch)
if (!hasCommits) {
logger.info({ npub, repoName, branchName, sourceBranch, fromBranch, hasCommits }, '[FileManager.createBranch] PATH: Creating orphan branch (no commits)');
// For empty repos, we need to create an empty commit first, then create the branch
// This is the only way git will recognize the branch
try {
logger.info({ npub, repoName, branchName, repoPath }, '[FileManager.createBranch] Step 1: Creating empty tree for initial commit');
// Create empty tree object - empty tree hash is always the same: 4b825dc642cb6eb9a060e54bf8d69288fbee4904
// We'll use mktree to create it if needed
let emptyTreeHash = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
// Ensure it exists in the repo
try {
await git.raw(['cat-file', '-e', emptyTreeHash]);
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Empty tree already exists in repo');
} catch {
// Create it using mktree with empty input
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Creating empty tree object');
const { spawn } = await import('child_process');
const createdHash = await new Promise<string>((resolve, reject) => {
const proc = spawn('git', ['hash-object', '-t', 'tree', '-w', '--stdin'], { cwd: repoPath });
proc.stdin.end();
let output = '';
proc.stdout.on('data', (data) => { output += data.toString(); });
proc.on('close', (code) => {
if (code === 0) resolve(output.trim());
else reject(new Error(`hash-object failed with code ${code}`));
});
proc.on('error', reject);
});
emptyTreeHash = createdHash || emptyTreeHash;
logger.info({ npub, repoName, branchName, emptyTreeHash }, '[FileManager.createBranch] Created empty tree object');
}
logger.info({ npub, repoName, branchName, emptyTreeHash }, '[FileManager.createBranch] Step 1 complete: empty tree ready');
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Step 2: Creating empty commit');
// Create an empty commit pointing to the empty tree
const commitHash = await git.raw(['commit-tree', '-m', `Initial commit on ${branchName}`, emptyTreeHash]);
const commit = commitHash.trim();
logger.info({ npub, repoName, branchName, commit }, '[FileManager.createBranch] Step 2 complete: empty commit created');
logger.info({ npub, repoName, branchName, commit }, '[FileManager.createBranch] Step 3: Creating branch ref pointing to empty commit');
// Create the branch ref pointing to the empty commit
const updateRefResult = await git.raw(['update-ref', `refs/heads/${branchName}`, commit]);
logger.info({ npub, repoName, branchName, commit, updateRefResult }, '[FileManager.createBranch] Step 3 complete: update-ref created branch');
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Step 4: Setting HEAD to point to new branch');
// Set HEAD to point to the new branch
const symRefResult = await git.raw(['symbolic-ref', 'HEAD', `refs/heads/${branchName}`]);
logger.info({ npub, repoName, branchName, symRefResult }, '[FileManager.createBranch] Step 4 complete: symbolic-ref HEAD set');
// Verify the branch was created
try {
const branches = await git.branchLocal();
logger.info({ npub, repoName, branchName, branches: branches.all }, '[FileManager.createBranch] Verification: branch list after creation');
} catch (verifyErr) {
logger.warn({ npub, repoName, branchName, error: verifyErr }, '[FileManager.createBranch] Warning: Could not verify branch in list');
}
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] SUCCESS: Orphan branch created with empty commit');
} catch (orphanError) {
logger.error({
error: orphanError,
errorMessage: orphanError instanceof Error ? orphanError.message : String(orphanError),
errorStack: orphanError instanceof Error ? orphanError.stack : undefined,
npub,
repoName,
branchName,
sourceBranch,
fromBranch,
hasCommits,
repoPath
}, '[FileManager.createBranch] ERROR: Failed to create orphan branch');
throw new Error(`Failed to create orphan branch: ${orphanError instanceof Error ? orphanError.message : String(orphanError)}`);
}
} else if (!sourceBranch) {
// Repository has commits but no source branch - create orphan branch
logger.info({ npub, repoName, branchName, hasCommits }, '[FileManager.createBranch] PATH: Creating orphan branch (has commits but no source branch)');
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Setting HEAD to new branch');
await git.raw(['symbolic-ref', 'HEAD', `refs/heads/${branchName}`]);
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Creating branch');
await git.raw(['branch', branchName]);
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] SUCCESS: Orphan branch created');
} else {
// Repository has commits and source branch provided - verify it exists first
logger.info({ npub, repoName, branchName, sourceBranch, hasCommits }, '[FileManager.createBranch] PATH: Creating branch from source');
try { try {
await git.raw(['branch', branchName, fromBranch]); logger.info({ npub, repoName, sourceBranch }, '[FileManager.createBranch] Verifying source branch exists');
// Verify the source branch exists
const verifyResult = await git.raw(['rev-parse', '--verify', `refs/heads/${sourceBranch}`]);
logger.info({ npub, repoName, sourceBranch, verifyResult }, '[FileManager.createBranch] Source branch verified, creating branch');
await git.raw(['branch', branchName, sourceBranch]);
logger.info({ npub, repoName, branchName, sourceBranch }, '[FileManager.createBranch] SUCCESS: Branch created from source');
} catch (verifyError) {
// Source branch doesn't exist - create orphan branch instead
logger.warn({
npub,
repoName,
branchName,
sourceBranch,
error: verifyError,
errorMessage: verifyError instanceof Error ? verifyError.message : String(verifyError)
}, '[FileManager.createBranch] Source branch does not exist, creating orphan branch instead');
await git.raw(['symbolic-ref', 'HEAD', `refs/heads/${branchName}`]);
await git.raw(['branch', branchName]);
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] SUCCESS: Orphan branch created (fallback)');
}
}
// Clear branch cache
const cacheKey = RepoCache.branchesKey(npub, repoName); const cacheKey = RepoCache.branchesKey(npub, repoName);
repoCache.delete(cacheKey); repoCache.delete(cacheKey);
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Branch cache cleared');
} catch (error) { } catch (error) {
logger.error({ error, repoPath, branchName, fromBranch }, 'Error creating branch'); logger.error({
error,
errorMessage: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
repoPath,
branchName,
fromBranch,
npub,
repoName
}, '[FileManager.createBranch] ERROR: Exception caught in createBranch');
throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`); throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`);
} }
} }

38
src/lib/services/git/file-manager/commit-operations.ts

@ -53,20 +53,50 @@ export async function getCommitHistory(options: CommitHistoryOptions): Promise<C
try { try {
logger.operation('Getting commit history', { npub, repoName, branch, limit, path }); logger.operation('Getting commit history', { npub, repoName, branch, limit, path });
// Check if repository has any commits first
try {
const hasCommits = await git.raw(['rev-list', '--count', '--all']);
const commitCount = parseInt(hasCommits.trim(), 10);
if (commitCount === 0 || isNaN(commitCount)) {
logger.debug({ npub, repoName, branch }, 'Repository has no commits, returning empty array');
return [];
}
} catch (checkErr) {
// If we can't check, try to proceed - git.log will fail if empty anyway
logger.debug({ error: checkErr, npub, repoName }, 'Could not check commit count, proceeding');
}
// Try to get log from the specified branch
// If the branch doesn't exist or repo is empty, fall back to --all
const logOptions: { const logOptions: {
maxCount: number; maxCount: number;
from: string; from?: string;
file?: string; file?: string;
} = { } = {
maxCount: limit, maxCount: limit
from: branch
}; };
if (path) { if (path) {
logOptions.file = path; logOptions.file = path;
} }
const log = await git.log(logOptions); let log;
try {
// First try with the specified branch
logOptions.from = branch;
log = await git.log(logOptions);
} catch (branchErr) {
// If branch doesn't exist or is ambiguous, try --all
const errorMsg = branchErr instanceof Error ? branchErr.message : String(branchErr);
if (errorMsg.includes('ambiguous') || errorMsg.includes('unknown') || errorMsg.includes('does not exist')) {
logger.debug({ npub, repoName, branch, error: errorMsg }, 'Branch does not exist or is ambiguous, trying --all');
delete logOptions.from;
log = await git.log(logOptions);
} else {
// Re-throw if it's a different error
throw branchErr;
}
}
const commits = log.all.map(commit => ({ const commits = log.all.map(commit => ({
hash: commit.hash, hash: commit.hash,

304
src/routes/api/repos/[npub]/[repo]/branches/+server.ts

@ -1,15 +1,14 @@
/** /**
* API endpoint for getting and creating repository branches * API endpoint for repository branches
* Handles GET (list), POST (create), and DELETE operations
*/ */
import { json, error } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { fileManager, repoManager, nostrClient } from '$lib/services/service-registry.js'; import { fileManager, repoManager, nostrClient, maintainerService } from '$lib/services/service-registry.js';
import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js'; import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleValidationError, handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js'; import { handleValidationError, handleApiError, handleNotFoundError, handleAuthError, handleAuthorizationError } from '$lib/utils/error-handler.js';
import { KIND } from '$lib/types/nostr.js';
import { join, dirname, resolve } from 'path'; import { join, dirname, resolve } from 'path';
import { existsSync, accessSync, constants } from 'fs'; import { existsSync, accessSync, constants } from 'fs';
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js';
@ -19,16 +18,22 @@ import { eventCache } from '$lib/services/nostr/event-cache.js';
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js';
import { isGraspUrl } from '$lib/services/git/api-repo-fetcher.js'; import { isGraspUrl } from '$lib/services/git/api-repo-fetcher.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import simpleGit from 'simple-git';
// Resolve GIT_REPO_ROOT to absolute path
const repoRootEnv = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT
: '/repos';
const repoRoot = resolve(repoRootEnv);
/** /**
* Check if a directory exists and is writable * Check if a directory exists and is writable
* Provides helpful error messages for container environments
*/ */
function checkDirectoryWritable(dirPath: string, description: string): void { function checkDirectoryWritable(dirPath: string, description: string): void {
if (!existsSync(dirPath)) { if (!existsSync(dirPath)) {
const isContainer = existsSync('/.dockerenv') || process.env.DOCKER_CONTAINER === 'true'; const isContainer = existsSync('/.dockerenv') || process.env.DOCKER_CONTAINER === 'true';
const errorMsg = isContainer const errorMsg = isContainer
? `${description} does not exist at ${dirPath}. In Docker, ensure the volume is mounted correctly and the directory exists on the host. Check docker-compose.yml volumes section.` ? `${description} does not exist at ${dirPath}. In Docker, ensure the volume is mounted correctly.`
: `${description} does not exist at ${dirPath}`; : `${description} does not exist at ${dirPath}`;
throw new Error(errorMsg); throw new Error(errorMsg);
} }
@ -38,41 +43,37 @@ function checkDirectoryWritable(dirPath: string, description: string): void {
} catch (accessErr) { } catch (accessErr) {
const isContainer = existsSync('/.dockerenv') || process.env.DOCKER_CONTAINER === 'true'; const isContainer = existsSync('/.dockerenv') || process.env.DOCKER_CONTAINER === 'true';
const errorMsg = isContainer const errorMsg = isContainer
? `${description} at ${dirPath} is not writable. In Docker, check that the volume mount has correct permissions. The container runs as user 'gitrepublic' (UID 1001). Ensure the host directory is writable by this user or adjust ownership: chown -R 1001:1001 ./repos` ? `${description} at ${dirPath} is not writable. Check volume mount permissions.`
: `${description} at ${dirPath} is not writable`; : `${description} at ${dirPath} is not writable`;
throw new Error(errorMsg); throw new Error(errorMsg);
} }
} }
// Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths) /**
const repoRootEnv = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT * GET: List branches in a repository
? process.env.GIT_REPO_ROOT */
: '/repos';
const repoRoot = resolve(repoRootEnv);
export const GET: RequestHandler = createRepoGetHandler( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext, event: RequestEvent) => { async (context: RepoRequestContext, event: RequestEvent) => {
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); const repoPath = join(repoRoot, context.npub, `${context.repo}.git`);
const skipApiFallback = event.url.searchParams.get('skipApiFallback') === 'true'; const skipApiFallback = event.url.searchParams.get('skipApiFallback') === 'true';
// If repo doesn't exist, try to fetch it on-demand (unless skipApiFallback is true) // If repo doesn't exist, try API fallback (unless skipApiFallback is true)
if (!existsSync(repoPath)) { if (!existsSync(repoPath)) {
// If skipApiFallback is true, return 404 immediately to indicate repo is not cloned
if (skipApiFallback) { if (skipApiFallback) {
throw handleNotFoundError( throw handleNotFoundError(
'Repository is not cloned locally', 'Repository is not cloned locally',
{ operation: 'getBranches', npub: context.npub, repo: context.repo } { operation: 'getBranches', npub: context.npub, repo: context.repo }
); );
} }
try { try {
// Fetch repository announcement (case-insensitive) with caching // Fetch repository announcement
let allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); let allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache);
let announcement = findRepoAnnouncement(allEvents, context.repo); let announcement = findRepoAnnouncement(allEvents, context.repo);
// If no events found in cache/default relays, try all relays (default + search) // Try all relays if not found
if (!announcement) { if (!announcement) {
const allRelays = [...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]; const allRelays = [...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])];
// Only create new client if we have additional relays to try
if (allRelays.length > DEFAULT_NOSTR_RELAYS.length) { if (allRelays.length > DEFAULT_NOSTR_RELAYS.length) {
const allRelaysClient = new NostrClient(allRelays); const allRelaysClient = new NostrClient(allRelays);
allEvents = await fetchRepoAnnouncementsWithCache(allRelaysClient, context.repoOwnerPubkey, eventCache); allEvents = await fetchRepoAnnouncementsWithCache(allRelaysClient, context.repoOwnerPubkey, eventCache);
@ -80,21 +81,19 @@ export const GET: RequestHandler = createRepoGetHandler(
} }
} }
const events = announcement ? [announcement] : []; if (announcement) {
// Try API-based fetching
if (events.length > 0) {
// Try API-based fetching first (no cloning)
const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js');
const { extractCloneUrls } = await import('$lib/utils/nostr-utils.js'); const { extractCloneUrls } = await import('$lib/utils/nostr-utils.js');
const cloneUrls = extractCloneUrls(events[0]); const cloneUrls = extractCloneUrls(announcement);
logger.debug({ npub: context.npub, repo: context.repo, cloneUrlCount: cloneUrls.length, cloneUrls }, 'Attempting API fallback for branches'); logger.debug({ npub: context.npub, repo: context.repo, cloneUrlCount: cloneUrls.length }, 'Attempting API fallback for branches');
const apiData = await tryApiFetch(events[0], context.npub, context.repo); const apiData = await tryApiFetch(announcement, context.npub, context.repo);
if (apiData && apiData.branches && apiData.branches.length > 0) { if (apiData && apiData.branches && apiData.branches.length > 0) {
logger.debug({ npub: context.npub, repo: context.repo, branchCount: apiData.branches.length }, 'Successfully fetched branches via API fallback'); logger.debug({ npub: context.npub, repo: context.repo, branchCount: apiData.branches.length }, 'Successfully fetched branches via API fallback');
// Sort branches: default branch first, then alphabetically // Sort branches: default branch first
const sortedBranches = [...apiData.branches]; const sortedBranches = [...apiData.branches];
if (apiData.defaultBranch) { if (apiData.defaultBranch) {
sortedBranches.sort((a: any, b: any) => { sortedBranches.sort((a: any, b: any) => {
@ -108,18 +107,8 @@ export const GET: RequestHandler = createRepoGetHandler(
return json(sortedBranches); return json(sortedBranches);
} }
// API fetch failed - repo is not cloned and API fetch didn't work // API fetch failed
// Check if we have clone URLs to provide better error message
const hasCloneUrls = cloneUrls.length > 0; const hasCloneUrls = cloneUrls.length > 0;
logger.warn({
npub: context.npub,
repo: context.repo,
hasCloneUrls,
cloneUrlCount: cloneUrls.length,
cloneUrls: cloneUrls.slice(0, 3) // Log first 3 URLs for debugging
}, 'API fallback failed for branches - repo not cloned and API fetch unsuccessful');
// Provide more detailed error message
const cloneUrlTypes = cloneUrls.map(url => { const cloneUrlTypes = cloneUrls.map(url => {
if (url.includes('github.com')) return 'GitHub'; if (url.includes('github.com')) return 'GitHub';
if (url.includes('gitlab.com') || url.includes('gitlab')) return 'GitLab'; if (url.includes('gitlab.com') || url.includes('gitlab')) return 'GitLab';
@ -130,29 +119,22 @@ export const GET: RequestHandler = createRepoGetHandler(
throw handleNotFoundError( throw handleNotFoundError(
hasCloneUrls hasCloneUrls
? `Repository is not cloned locally and could not be fetched via API from external clone URLs (${cloneUrlTypes.join(', ')}). This may be due to API rate limits, network issues, or the repository being private. Privileged users can clone this repository using the "Clone to Server" button.` ? `Repository is not cloned locally and could not be fetched via API from external clone URLs (${cloneUrlTypes.join(', ')}).`
: 'Repository is not cloned locally and has no external clone URLs for API fallback. Privileged users can clone this repository using the "Clone to Server" button.', : 'Repository is not cloned locally and has no external clone URLs for API fallback.',
{ operation: 'getBranches', npub: context.npub, repo: context.repo } { operation: 'getBranches', npub: context.npub, repo: context.repo }
); );
} else { } else {
// No events found - could be because:
// 1. Repository doesn't exist
// 2. Relays are unreachable
// 3. Repository is on different relays
throw handleNotFoundError( throw handleNotFoundError(
'Repository announcement not found in Nostr. This could mean: (1) the repository does not exist, (2) the configured Nostr relays are unreachable, or (3) the repository is published on different relays. Try configuring additional relays via the NOSTR_RELAYS environment variable.', 'Repository announcement not found in Nostr.',
{ operation: 'getBranches', npub: context.npub, repo: context.repo } { operation: 'getBranches', npub: context.npub, repo: context.repo }
); );
} }
} catch (err) { } catch (err) {
// Check if repo was created by another concurrent request // Check if repo was created by another concurrent request
if (existsSync(repoPath)) { if (existsSync(repoPath)) {
// Repo exists now, clear cache and continue with normal flow
repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo)); repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo));
} else { } else {
// Log the error for debugging
logger.error({ error: err, npub: context.npub, repo: context.repo }, '[Branches] Error fetching repository'); logger.error({ error: err, npub: context.npub, repo: context.repo }, '[Branches] Error fetching repository');
// If fetching fails, return 404 with more context
const errorMessage = err instanceof Error ? err.message : 'Repository not found'; const errorMessage = err instanceof Error ? err.message : 'Repository not found';
throw handleNotFoundError( throw handleNotFoundError(
errorMessage, errorMessage,
@ -162,27 +144,18 @@ export const GET: RequestHandler = createRepoGetHandler(
} }
} }
// Double-check repo exists (should be true if we got here) // Repo exists, get branches
if (!existsSync(repoPath)) {
throw handleNotFoundError(
'Repository not found',
{ operation: 'getBranches', npub: context.npub, repo: context.repo }
);
}
try { try {
const branches = await fileManager.getBranches(context.npub, context.repo); const branches = await fileManager.getBranches(context.npub, context.repo);
// If repo exists but has no branches (empty repo), try API fallback // If empty repo, try API fallback
if (branches.length === 0) { if (branches.length === 0) {
logger.debug({ npub: context.npub, repo: context.repo }, 'Repo exists but is empty, attempting API fallback'); logger.debug({ npub: context.npub, repo: context.repo }, 'Repo exists but is empty, attempting API fallback');
try { try {
// Fetch repository announcement for API fallback
let allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); let allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache);
let announcement = findRepoAnnouncement(allEvents, context.repo); let announcement = findRepoAnnouncement(allEvents, context.repo);
// If no events found in cache/default relays, try all relays (default + search)
if (!announcement) { if (!announcement) {
const allRelays = [...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]; const allRelays = [...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])];
if (allRelays.length > DEFAULT_NOSTR_RELAYS.length) { if (allRelays.length > DEFAULT_NOSTR_RELAYS.length) {
@ -194,16 +167,10 @@ export const GET: RequestHandler = createRepoGetHandler(
if (announcement) { if (announcement) {
const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js');
const { extractCloneUrls } = await import('$lib/utils/nostr-utils.js');
const cloneUrls = extractCloneUrls(announcement);
logger.debug({ npub: context.npub, repo: context.repo, cloneUrlCount: cloneUrls.length }, 'Attempting API fallback for empty repo');
const apiData = await tryApiFetch(announcement, context.npub, context.repo); const apiData = await tryApiFetch(announcement, context.npub, context.repo);
if (apiData && apiData.branches && apiData.branches.length > 0) { if (apiData && apiData.branches && apiData.branches.length > 0) {
logger.info({ npub: context.npub, repo: context.repo, branchCount: apiData.branches.length }, 'Successfully fetched branches via API fallback for empty repo'); logger.info({ npub: context.npub, repo: context.repo, branchCount: apiData.branches.length }, 'Successfully fetched branches via API fallback for empty repo');
// Sort branches: default branch first, then alphabetically
const sortedBranches = [...apiData.branches]; const sortedBranches = [...apiData.branches];
if (apiData.defaultBranch) { if (apiData.defaultBranch) {
sortedBranches.sort((a: any, b: any) => { sortedBranches.sort((a: any, b: any) => {
@ -218,17 +185,11 @@ export const GET: RequestHandler = createRepoGetHandler(
} }
} }
} catch (apiErr) { } catch (apiErr) {
logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed for empty repo, returning empty branches'); logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed for empty repo');
} }
} }
// If branches is still empty after API fallback, return empty array (empty repo is valid) // Sort branches: default branch first
if (branches.length === 0) {
logger.debug({ npub: context.npub, repo: context.repo }, 'Repository is empty (no branches), returning empty array');
return json([]);
}
// Sort branches: default branch first, then alphabetically
let sortedBranches = [...branches]; let sortedBranches = [...branches];
try { try {
const defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo); const defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo);
@ -241,7 +202,6 @@ export const GET: RequestHandler = createRepoGetHandler(
return aName.localeCompare(bName); return aName.localeCompare(bName);
}); });
} else { } else {
// No default branch found, just sort alphabetically
sortedBranches.sort((a: any, b: any) => { sortedBranches.sort((a: any, b: any) => {
const aName = typeof a === 'string' ? a : a.name; const aName = typeof a === 'string' ? a : a.name;
const bName = typeof b === 'string' ? b : b.name; const bName = typeof b === 'string' ? b : b.name;
@ -249,7 +209,6 @@ export const GET: RequestHandler = createRepoGetHandler(
}); });
} }
} catch { } catch {
// If we can't get default branch, just sort alphabetically
sortedBranches.sort((a: any, b: any) => { sortedBranches.sort((a: any, b: any) => {
const aName = typeof a === 'string' ? a : a.name; const aName = typeof a === 'string' ? a : a.name;
const bName = typeof b === 'string' ? b : b.name; const bName = typeof b === 'string' ? b : b.name;
@ -259,16 +218,13 @@ export const GET: RequestHandler = createRepoGetHandler(
return json(sortedBranches); return json(sortedBranches);
} catch (err) { } catch (err) {
// Log the actual error for debugging
logger.error({ error: err, npub: context.npub, repo: context.repo }, '[Branches] Error getting branches'); logger.error({ error: err, npub: context.npub, repo: context.repo }, '[Branches] Error getting branches');
// Check if it's a "not found" error
if (err instanceof Error && err.message.includes('not found')) { if (err instanceof Error && err.message.includes('not found')) {
throw handleNotFoundError( throw handleNotFoundError(
err.message, err.message,
{ operation: 'getBranches', npub: context.npub, repo: context.repo } { operation: 'getBranches', npub: context.npub, repo: context.repo }
); );
} }
// Otherwise, it's a server error
throw handleApiError( throw handleApiError(
err, err,
{ operation: 'getBranches', npub: context.npub, repo: context.repo }, { operation: 'getBranches', npub: context.npub, repo: context.repo },
@ -276,34 +232,84 @@ export const GET: RequestHandler = createRepoGetHandler(
); );
} }
}, },
{ operation: 'getBranches', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos { operation: 'getBranches', requireRepoExists: false, requireRepoAccess: true }
); );
/**
* POST: Create a new branch
*/
export const POST: RequestHandler = createRepoPostHandler( export const POST: RequestHandler = createRepoPostHandler(
async (context: RepoRequestContext, event: RequestEvent) => { async (context: RepoRequestContext, event: RequestEvent) => {
logger.info({
npub: context.npub,
repo: context.repo,
userPubkey: context.userPubkeyHex ? context.userPubkeyHex.substring(0, 16) + '...' : null
}, '[Branches POST] ========== START ==========');
const body = await event.request.json(); const body = await event.request.json();
const { branchName, fromBranch, announcement } = body; logger.info({ body, npub: context.npub, repo: context.repo }, '[Branches POST] Request body parsed');
const { branchName, fromBranch } = body;
logger.info({ branchName, fromBranch, npub: context.npub, repo: context.repo }, '[Branches POST] Extracted parameters');
if (!branchName) { if (!branchName) {
logger.error({ npub: context.npub, repo: context.repo }, '[Branches POST] Missing branchName parameter');
throw handleValidationError('Missing branchName parameter', { operation: 'createBranch', npub: context.npub, repo: context.repo }); throw handleValidationError('Missing branchName parameter', { operation: 'createBranch', npub: context.npub, repo: context.repo });
} }
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); const repoPath = join(repoRoot, context.npub, `${context.repo}.git`);
logger.info({ repoPath, npub: context.npub, repo: context.repo }, '[Branches POST] Repository path resolved');
const repoExists = existsSync(repoPath); const repoExists = existsSync(repoPath);
logger.info({ repoExists, repoPath, npub: context.npub, repo: context.repo }, '[Branches POST] Repository existence checked');
// Authorization checks
if (repoExists && context.userPubkeyHex) {
const isMaintainer = await maintainerService.isMaintainer(
context.userPubkeyHex,
context.repoOwnerPubkey,
context.repo
);
if (!isMaintainer) {
throw handleAuthorizationError(
'Only repository maintainers can create branches.',
{ operation: 'createBranch', npub: context.npub, repo: context.repo }
);
}
} else if (repoExists && !context.userPubkeyHex) {
throw handleAuthError(
'Authentication required to create branches in existing repositories.',
{ operation: 'createBranch', npub: context.npub, repo: context.repo }
);
} else if (!repoExists && context.userPubkeyHex) {
// New repo - verify user is the owner
const { requireNpubHex } = await import('$lib/utils/npub-utils.js');
const ownerPubkey = requireNpubHex(context.npub);
if (context.userPubkeyHex.toLowerCase() !== ownerPubkey.toLowerCase()) {
throw handleAuthorizationError(
'Only the repository owner can create the first branch in a new repository.',
{ operation: 'createBranch', npub: context.npub, repo: context.repo }
);
}
} else if (!repoExists && !context.userPubkeyHex) {
throw handleAuthError(
'Authentication required to create branches.',
{ operation: 'createBranch', npub: context.npub, repo: context.repo }
);
}
// Create repo if it doesn't exist // Create repo if it doesn't exist
if (!repoExists) { if (!repoExists) {
logger.info({ npub: context.npub, repo: context.repo }, 'Creating new empty repository for branch creation'); logger.info({ npub: context.npub, repo: context.repo }, 'Creating new empty repository for branch creation');
const { mkdir } = await import('fs/promises'); const { mkdir } = await import('fs/promises');
// Check if repoRoot exists and is writable (with helpful container error messages) // Check/create repoRoot
if (!existsSync(repoRoot)) { if (!existsSync(repoRoot)) {
try { try {
await mkdir(repoRoot, { recursive: true }); await mkdir(repoRoot, { recursive: true });
logger.debug({ repoRoot }, 'Created repoRoot directory'); logger.debug({ repoRoot }, 'Created repoRoot directory');
} catch (rootErr) { } catch (rootErr) {
logger.error({ error: rootErr, repoRoot }, 'Failed to create repoRoot directory'); logger.error({ error: rootErr, repoRoot }, 'Failed to create repoRoot directory');
// Check if parent directory is writable
const parentRoot = dirname(repoRoot); const parentRoot = dirname(repoRoot);
if (existsSync(parentRoot)) { if (existsSync(parentRoot)) {
try { try {
@ -323,7 +329,6 @@ export const POST: RequestHandler = createRepoPostHandler(
); );
} }
} else { } else {
// Directory exists, check if it's writable
try { try {
checkDirectoryWritable(repoRoot, 'GIT_REPO_ROOT directory'); checkDirectoryWritable(repoRoot, 'GIT_REPO_ROOT directory');
} catch (checkErr) { } catch (checkErr) {
@ -341,7 +346,7 @@ export const POST: RequestHandler = createRepoPostHandler(
await mkdir(repoDir, { recursive: true }); await mkdir(repoDir, { recursive: true });
logger.debug({ repoDir }, 'Created repository directory'); logger.debug({ repoDir }, 'Created repository directory');
} catch (dirErr) { } catch (dirErr) {
logger.error({ error: dirErr, repoDir, npub: context.npub, repo: context.repo }, 'Failed to create repository directory'); logger.error({ error: dirErr, repoDir }, 'Failed to create repository directory');
throw handleApiError( throw handleApiError(
dirErr, dirErr,
{ operation: 'createBranch', npub: context.npub, repo: context.repo }, { operation: 'createBranch', npub: context.npub, repo: context.repo },
@ -351,12 +356,13 @@ export const POST: RequestHandler = createRepoPostHandler(
// Initialize bare repository // Initialize bare repository
try { try {
const simpleGit = (await import('simple-git')).default;
const git = simpleGit(); const git = simpleGit();
await git.init(['--bare', repoPath]); await git.init(['--bare', repoPath]);
logger.info({ npub: context.npub, repo: context.repo }, 'Empty repository created successfully'); logger.info({ npub: context.npub, repo: context.repo }, 'Empty repository created successfully');
// Clear cache
repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo));
} catch (initErr) { } catch (initErr) {
logger.error({ error: initErr, repoPath, npub: context.npub, repo: context.repo }, 'Failed to initialize bare repository'); logger.error({ error: initErr, repoPath }, 'Failed to initialize bare repository');
throw handleApiError( throw handleApiError(
initErr, initErr,
{ operation: 'createBranch', npub: context.npub, repo: context.repo }, { operation: 'createBranch', npub: context.npub, repo: context.repo },
@ -365,37 +371,127 @@ export const POST: RequestHandler = createRepoPostHandler(
} }
} }
// Check if repo has any branches first // Check if repo has commits - use multiple verification methods
let hasBranches = false; logger.info({ npub: context.npub, repo: context.repo, repoPath }, '[Branches POST] Starting commit check');
let hasCommits = false;
let commitCount = 0;
try { try {
const existingBranches = await fileManager.getBranches(context.npub, context.repo); const git = simpleGit(repoPath);
hasBranches = existingBranches.length > 0; logger.info({ npub: context.npub, repo: context.repo }, '[Branches POST] Git instance created for commit check');
} catch (err) { try {
// If getBranches fails, assume no branches exist // Method 1: rev-list count
logger.debug({ error: err, npub: context.npub, repo: context.repo }, 'Failed to get branches, assuming empty repo'); logger.info({ npub: context.npub, repo: context.repo }, '[Branches POST] Method 1: Running rev-list --count --all');
hasBranches = false; const commitCountStr = await git.raw(['rev-list', '--count', '--all']);
commitCount = parseInt(commitCountStr.trim(), 10);
hasCommits = !isNaN(commitCount) && commitCount > 0;
logger.info({ npub: context.npub, repo: context.repo, commitCountStr, commitCount, hasCommits }, '[Branches POST] Method 1 result: rev-list completed');
// Method 2: Double-check by verifying refs exist
if (hasCommits) {
logger.info({ npub: context.npub, repo: context.repo }, '[Branches POST] Method 2: Checking refs (hasCommits=true)');
try {
const refs = await git.raw(['for-each-ref', '--count=1', 'refs/heads/']);
logger.info({ npub: context.npub, repo: context.repo, refs, refsLength: refs?.trim().length }, '[Branches POST] Method 2 result: refs checked');
if (!refs || refs.trim().length === 0) {
hasCommits = false;
logger.warn({ npub: context.npub, repo: context.repo }, '[Branches POST] No refs found despite commit count, treating as empty');
}
} catch (refError) {
hasCommits = false;
logger.warn({ npub: context.npub, repo: context.repo, error: refError }, '[Branches POST] Method 2 failed: ref check error');
}
} else {
logger.info({ npub: context.npub, repo: context.repo }, '[Branches POST] Skipping Method 2 (hasCommits=false)');
} }
// Get default branch if fromBranch not provided and repo has branches logger.info({ npub: context.npub, repo: context.repo, commitCount, hasCommits }, '[Branches POST] Final commit check result');
// If repo has no branches, don't pass fromBranch (will use --orphan) } catch (revListErr) {
let sourceBranch = fromBranch; hasCommits = false;
if (!sourceBranch && hasBranches) { logger.info({
npub: context.npub,
repo: context.repo,
error: revListErr,
errorMessage: revListErr instanceof Error ? revListErr.message : String(revListErr)
}, '[Branches POST] rev-list failed (empty repo expected)');
}
} catch (err) {
logger.warn({
error: err,
errorMessage: err instanceof Error ? err.message : String(err),
npub: context.npub,
repo: context.repo
}, '[Branches POST] Failed to check commits, assuming empty');
hasCommits = false;
}
// Determine source branch - CRITICAL: If no commits, NEVER use a source branch
logger.info({ npub: context.npub, repo: context.repo, hasCommits, fromBranch }, '[Branches POST] Starting source branch determination');
let sourceBranch: string | undefined = undefined; // Start with undefined
if (hasCommits) {
logger.info({ npub: context.npub, repo: context.repo }, '[Branches POST] Repo has commits - checking for source branch');
// Only consider using a source branch if repo has commits
if (fromBranch) {
// User explicitly provided a source branch - use it (will be verified in createBranch)
sourceBranch = fromBranch;
logger.info({ npub: context.npub, repo: context.repo, sourceBranch }, '[Branches POST] Using provided fromBranch');
} else {
// Try to get default branch
logger.info({ npub: context.npub, repo: context.repo }, '[Branches POST] No fromBranch provided - getting default branch');
try { try {
logger.info({ npub: context.npub, repo: context.repo }, '[Branches POST] Getting existing branches');
const existingBranches = await fileManager.getBranches(context.npub, context.repo);
logger.info({ npub: context.npub, repo: context.repo, branchCount: existingBranches.length, branches: existingBranches }, '[Branches POST] Existing branches retrieved');
if (existingBranches.length > 0) {
logger.info({ npub: context.npub, repo: context.repo }, '[Branches POST] Getting default branch');
sourceBranch = await fileManager.getDefaultBranch(context.npub, context.repo); sourceBranch = await fileManager.getDefaultBranch(context.npub, context.repo);
logger.info({ npub: context.npub, repo: context.repo, sourceBranch }, '[Branches POST] Got default branch');
} else {
sourceBranch = undefined;
logger.info({ npub: context.npub, repo: context.repo }, '[Branches POST] No branches found, using undefined');
}
} catch (err) { } catch (err) {
// If getDefaultBranch fails, use 'main' as default (only if branches exist) logger.warn({
logger.debug({ error: err, npub: context.npub, repo: context.repo }, 'No default branch found, using main'); error: err,
sourceBranch = 'main'; errorMessage: err instanceof Error ? err.message : String(err),
npub: context.npub,
repo: context.repo
}, '[Branches POST] Failed to get default branch');
sourceBranch = undefined;
}
} }
} else {
// No commits - sourceBranch stays undefined
logger.info({ npub: context.npub, repo: context.repo, hasCommits }, '[Branches POST] Empty repo - sourceBranch will be undefined');
}
// Final safety check - should never happen but be extra safe
if (sourceBranch && !hasCommits) {
logger.error({ sourceBranch, hasCommits, npub: context.npub, repo: context.repo }, '[Branches POST] ERROR: sourceBranch set but no commits! Clearing it.');
sourceBranch = undefined;
} }
// If repo has no branches, sourceBranch will be undefined/null, which createBranch will handle correctly
logger.info({
npub: context.npub,
repo: context.repo,
branchName,
sourceBranch,
fromBranch,
hasCommits,
commitCount
}, '[Branches POST] ========== FINAL VALUES BEFORE createBranch CALL ==========');
logger.info({ npub: context.npub, repo: context.repo, branchName, sourceBranch }, '[Branches POST] Calling fileManager.createBranch');
await fileManager.createBranch(context.npub, context.repo, branchName, sourceBranch); await fileManager.createBranch(context.npub, context.repo, branchName, sourceBranch);
logger.info({ npub: context.npub, repo: context.repo, branchName }, '[Branches POST] ========== SUCCESS ==========');
return json({ success: true, message: 'Branch created successfully' }); return json({ success: true, message: 'Branch created successfully' });
}, },
{ operation: 'createBranch', requireRepoExists: false } // Allow creating branches in empty repos { operation: 'createBranch', requireRepoExists: false, requireMaintainer: false }
); );
/**
* DELETE: Delete a branch
*/
export const DELETE: RequestHandler = createRepoPostHandler( export const DELETE: RequestHandler = createRepoPostHandler(
async (context: RepoRequestContext, event: RequestEvent) => { async (context: RepoRequestContext, event: RequestEvent) => {
const body = await event.request.json(); const body = await event.request.json();

22
src/routes/api/repos/[npub]/[repo]/clone-urls/reachability/+server.ts

@ -9,12 +9,13 @@ import type { RequestHandler } from './$types';
import { getCloneUrlsReachability, type ReachabilityResult } from '$lib/services/git/clone-url-reachability.js'; import { getCloneUrlsReachability, type ReachabilityResult } from '$lib/services/git/clone-url-reachability.js';
import { extractCloneUrls } from '$lib/utils/nostr-utils.js'; import { extractCloneUrls } from '$lib/utils/nostr-utils.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js'; import { requireNpubHex } from '$lib/utils/npub-utils.js';
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js';
import { eventCache } from '$lib/services/nostr/event-cache.js'; import { eventCache } from '$lib/services/nostr/event-cache.js';
import { nostrClient } from '$lib/services/service-registry.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
/** /**
@ -38,12 +39,23 @@ export const GET: RequestHandler = async ({ params, url }) => {
const repoOwnerPubkey = decoded.data as string; const repoOwnerPubkey = decoded.data as string;
// Fetch repository announcement // Fetch repository announcement (case-insensitive) with caching
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); let allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache);
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache); let announcement = findRepoAnnouncement(allEvents, repo);
const announcement = findRepoAnnouncement(allEvents, repo);
// If no events found in cache/default relays, try all relays (default + search)
if (!announcement) { if (!announcement) {
const allRelays = [...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])];
// Only create new client if we have additional relays to try
if (allRelays.length > DEFAULT_NOSTR_RELAYS.length) {
const allRelaysClient = new NostrClient(allRelays);
allEvents = await fetchRepoAnnouncementsWithCache(allRelaysClient, repoOwnerPubkey, eventCache);
announcement = findRepoAnnouncement(allEvents, repo);
}
}
if (!announcement) {
logger.warn({ npub, repo, repoOwnerPubkey, eventCount: allEvents.length }, 'Repository announcement not found for clone URL reachability check');
return error(404, 'Repository announcement not found'); return error(404, 'Repository announcement not found');
} }

2
src/routes/api/repos/[npub]/[repo]/commits/+server.ts

@ -155,5 +155,5 @@ export const GET: RequestHandler = createRepoGetHandler(
); );
} }
}, },
{ operation: 'getCommits', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos { operation: 'getCommits', requireRepoExists: false, requireRepoAccess: false } // Commits should be publicly accessible for public repos
); );

2
src/routes/api/repos/[npub]/[repo]/readme/+server.ts

@ -127,5 +127,5 @@ export const GET: RequestHandler = createRepoGetHandler(
isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown') isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown')
}); });
}, },
{ operation: 'getReadme', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos { operation: 'getReadme', requireRepoExists: false, requireRepoAccess: false } // README should be publicly accessible
); );

2
src/routes/api/repos/[npub]/[repo]/tree/+server.ts

@ -323,5 +323,5 @@ export const GET: RequestHandler = createRepoGetHandler(
); );
} }
}, },
{ operation: 'listFiles', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos { operation: 'listFiles', requireRepoExists: false, requireRepoAccess: false } // Tree listing should be publicly accessible for public repos
); );

56
src/routes/api/repos/[npub]/[repo]/verify/+server.ts

@ -222,10 +222,32 @@ export const POST: RequestHandler = createRepoPostHandler(
} }
try { try {
// Get default branch // Check if repository has any commits
const defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo); const git = simpleGit(repoPath);
let hasCommits = false;
// Use same default branch logic as repo-manager (master, or from env)
let defaultBranch = process.env.DEFAULT_BRANCH || 'master';
// Get worktree for the default branch try {
const commitCount = await git.raw(['rev-list', '--count', '--all']);
hasCommits = parseInt(commitCount.trim(), 10) > 0;
} catch {
// If we can't check, assume no commits
hasCommits = false;
}
// If repository has commits, get the default branch
if (hasCommits) {
try {
defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo);
} catch {
// Fallback to default if getDefaultBranch fails
defaultBranch = process.env.DEFAULT_BRANCH || 'master';
}
}
// Get worktree for the default branch (worktree manager will create branch if needed)
logger.info({ npub: context.npub, repo: context.repo, branch: defaultBranch, hasCommits }, 'Getting worktree for announcement commit');
const worktreePath = await fileManager.getWorktree(repoPath, defaultBranch, context.npub, context.repo); const worktreePath = await fileManager.getWorktree(repoPath, defaultBranch, context.npub, context.repo);
// Check if announcement already exists // Check if announcement already exists
@ -264,10 +286,38 @@ export const POST: RequestHandler = createRepoPostHandler(
// Commit the announcement // Commit the announcement
const commitMessage = `Verify repository ownership by committing repo announcement event\n\nEvent ID: ${announcement.id}`; const commitMessage = `Verify repository ownership by committing repo announcement event\n\nEvent ID: ${announcement.id}`;
// For empty repositories, ensure the branch is set up in the worktree
if (!hasCommits) {
try {
// Check if branch exists in worktree
const currentBranch = await workGit.revparse(['--abbrev-ref', 'HEAD']).catch(() => null);
if (!currentBranch || currentBranch === 'HEAD') {
// Branch doesn't exist, create orphan branch in worktree
logger.debug({ npub: context.npub, repo: context.repo, branch: defaultBranch }, 'Creating orphan branch in worktree');
await workGit.raw(['checkout', '--orphan', defaultBranch]);
} else if (currentBranch !== defaultBranch) {
// Switch to the correct branch
logger.debug({ npub: context.npub, repo: context.repo, currentBranch, targetBranch: defaultBranch }, 'Switching to target branch in worktree');
await workGit.checkout(defaultBranch);
}
} catch (branchErr) {
logger.warn({ error: branchErr, npub: context.npub, repo: context.repo, branch: defaultBranch }, 'Branch setup in worktree failed, attempting commit anyway');
}
}
logger.info({ npub: context.npub, repo: context.repo, branch: defaultBranch, hasCommits }, 'Committing announcement file');
await workGit.commit(commitMessage, ['nostr/repo-events.jsonl'], { await workGit.commit(commitMessage, ['nostr/repo-events.jsonl'], {
'--author': `${authorName} <${authorEmail}>` '--author': `${authorName} <${authorEmail}>`
}); });
// Verify commit was created
const commitHash = await workGit.revparse(['HEAD']).catch(() => null);
if (!commitHash) {
throw new Error('Commit was created but HEAD is not pointing to a valid commit');
}
logger.info({ npub: context.npub, repo: context.repo, commitHash, branch: defaultBranch }, 'Announcement committed successfully');
// Push to default branch (if there's a remote) // Push to default branch (if there's a remote)
try { try {
await workGit.push('origin', defaultBranch); await workGit.push('origin', defaultBranch);

84
src/routes/repos/[npub]/[repo]/+page.svelte

@ -28,6 +28,7 @@
import CreatePatchDialog from './components/dialogs/CreatePatchDialog.svelte'; import CreatePatchDialog from './components/dialogs/CreatePatchDialog.svelte';
import PatchHighlightDialog from './components/dialogs/PatchHighlightDialog.svelte'; import PatchHighlightDialog from './components/dialogs/PatchHighlightDialog.svelte';
import PatchCommentDialog from './components/dialogs/PatchCommentDialog.svelte'; import PatchCommentDialog from './components/dialogs/PatchCommentDialog.svelte';
import CreateDocumentationDialog from './components/dialogs/CreateDocumentationDialog.svelte';
import CommitDialog from './components/dialogs/CommitDialog.svelte'; import CommitDialog from './components/dialogs/CommitDialog.svelte';
import VerificationDialog from './components/dialogs/VerificationDialog.svelte'; import VerificationDialog from './components/dialogs/VerificationDialog.svelte';
import CloneUrlVerificationDialog from './components/dialogs/CloneUrlVerificationDialog.svelte'; import CloneUrlVerificationDialog from './components/dialogs/CloneUrlVerificationDialog.svelte';
@ -601,6 +602,68 @@
const callbacks = createFileCallbacks(state, getUserEmail, getUserName, loadFiles, loadFile, renderFileAsHtml, applySyntaxHighlighting); const callbacks = createFileCallbacks(state, getUserEmail, getUserName, loadFiles, loadFile, renderFileAsHtml, applySyntaxHighlighting);
await createFileService(state, callbacks); await createFileService(state, callbacks);
} }
async function createDocumentation() {
if (!state.user.pubkey || !repoOwnerPubkeyDerived) {
alert('Please log in to create documentation');
return;
}
try {
state.saving = true;
const { signEventWithNIP07 } = await import('$lib/services/nostr/nip07-signer.js');
const { NostrClient } = await import('$lib/services/nostr/nostr-client.js');
const { KIND } = await import('$lib/types/nostr.js');
const { getUserRelays } = await import('$lib/services/nostr/user-relays.js');
const { DEFAULT_NOSTR_RELAYS, combineRelays } = await import('$lib/config.js');
const selectedKind = state.forms.documentation.kind;
// Build tags based on kind
const tags: string[][] = [
['d', state.forms.documentation.identifier],
['title', state.forms.documentation.title]
];
// For repository-related kinds, add the repository address
if (selectedKind === 30818 || selectedKind === 30817) {
const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkeyDerived}:${state.repo}`;
tags.push(['a', repoAddress]);
}
const event = await signEventWithNIP07({
kind: selectedKind,
content: state.forms.documentation.content,
tags,
created_at: Math.floor(Date.now() / 1000),
pubkey: ''
});
// Get user's relays and combine with default relays
const { outbox } = await getUserRelays(state.user.pubkey, nostrClient);
const relaysToPublish = combineRelays([...DEFAULT_NOSTR_RELAYS, ...outbox]);
// Publish the event
const client = new NostrClient(relaysToPublish);
await client.publishEvent(event, relaysToPublish);
// Clear form and close dialog
state.forms.documentation.kind = 30818;
state.forms.documentation.title = '';
state.forms.documentation.identifier = '';
state.forms.documentation.content = '';
state.openDialog = null;
// Reload documentation
await loadDocumentation();
alert('Documentation event created and published successfully!');
} catch (err) {
console.error('Failed to create documentation:', err);
alert(`Failed to create documentation: ${err instanceof Error ? err.message : String(err)}`);
} finally {
state.saving = false;
}
}
async function deleteFile(filePath: string) { async function deleteFile(filePath: string) {
const callbacks = createFileCallbacks(state, getUserEmail, getUserName, loadFiles, loadFile, renderFileAsHtml, applySyntaxHighlighting); const callbacks = createFileCallbacks(state, getUserEmail, getUserName, loadFiles, loadFile, renderFileAsHtml, applySyntaxHighlighting);
await deleteFileService(filePath, state, callbacks); await deleteFileService(filePath, state, callbacks);
@ -1400,10 +1463,14 @@
}} }}
onTogglePreview={() => { onTogglePreview={() => {
state.preview.file.showPreview = !state.preview.file.showPreview; state.preview.file.showPreview = !state.preview.file.showPreview;
// When switching to raw mode, ensure syntax highlighting is applied
if (!state.preview.file.showPreview && state.files.content && state.files.currentFile) { if (!state.preview.file.showPreview && state.files.content && state.files.currentFile) {
const ext = state.files.currentFile.split('.').pop() || ''; const ext = state.files.currentFile.split('.').pop() || '';
// Only apply if we don't already have highlighted content
if (!state.preview.file.highlightedContent || state.preview.file.highlightedContent.trim() === '') {
applySyntaxHighlighting(state.files.content, ext).catch(err => console.error('Error applying syntax highlighting:', err)); applySyntaxHighlighting(state.files.content, ext).catch(err => console.error('Error applying syntax highlighting:', err));
} }
}
}} }}
onCopyFileContent={copyFileContent} onCopyFileContent={copyFileContent}
onDownloadFile={downloadFile} onDownloadFile={downloadFile}
@ -1432,6 +1499,11 @@
} }
goto(url.pathname + url.search, { replaceState: true, noScroll: true }); goto(url.pathname + url.search, { replaceState: true, noScroll: true });
}} }}
onCreateFile={() => {
if (!state.user.pubkey || !state.maintainers.isMaintainer || needsClone) return;
state.openDialog = 'createFile';
}}
onApplySyntaxHighlighting={applySyntaxHighlighting}
/> />
{/if} {/if}
@ -1792,6 +1864,11 @@
} }
goto(url.pathname + url.search, { replaceState: true, noScroll: true }); goto(url.pathname + url.search, { replaceState: true, noScroll: true });
}} }}
isMaintainer={state.maintainers.isMaintainer}
onCreateDocumentation={() => {
if (!state.user.pubkey || !state.maintainers.isMaintainer) return;
state.openDialog = 'createDocumentation';
}}
/> />
{/if} {/if}
@ -1989,6 +2066,13 @@
onVerify={verifyCloneUrl} onVerify={verifyCloneUrl}
onClose={() => state.openDialog = null} onClose={() => state.openDialog = null}
/> />
<CreateDocumentationDialog
open={state.openDialog === 'createDocumentation' && !!state.user.pubkey && state.maintainers.isMaintainer}
{state}
onCreate={createDocumentation}
onClose={() => state.openDialog = null}
/>
</div> </div>
<style> <style>

116
src/routes/repos/[npub]/[repo]/components/DiscussionsTab.svelte

@ -103,6 +103,40 @@
combinedRelays combinedRelays
); );
// Helper function to normalize comments (ensure kind and pubkey are always present)
const normalizeComment = (comment: {
id: string;
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
replies?: Array<{
id: string;
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
replies?: Array<{
id: string;
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
}>;
}>;
}): Comment => ({
id: comment.id,
content: comment.content,
author: comment.author,
createdAt: comment.createdAt,
kind: comment.kind ?? KIND.COMMENT,
pubkey: comment.pubkey ?? comment.author,
replies: comment.replies?.map(normalizeComment)
});
const fetchedDiscussions = discussionEntries.map(entry => ({ const fetchedDiscussions = discussionEntries.map(entry => ({
type: entry.type, type: entry.type,
id: entry.id, id: entry.id,
@ -112,7 +146,7 @@
createdAt: entry.createdAt, createdAt: entry.createdAt,
kind: entry.kind ?? KIND.THREAD, kind: entry.kind ?? KIND.THREAD,
pubkey: entry.pubkey ?? '', pubkey: entry.pubkey ?? '',
comments: entry.comments comments: entry.comments?.map(normalizeComment)
})); }));
discussions = fetchedDiscussions; discussions = fetchedDiscussions;
@ -498,8 +532,14 @@
<style> <style>
.discussions-sidebar { .discussions-sidebar {
width: 100%;
max-width: 100%;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
} }
.discussions-header { .discussions-header {
@ -508,12 +548,15 @@
justify-content: space-between; justify-content: space-between;
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0); border-bottom: 1px solid var(--border-color);
width: 100%;
box-sizing: border-box;
} }
.discussions-header h2 { .discussions-header h2 {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--text-primary);
} }
.create-discussion-button { .create-discussion-button {
@ -538,6 +581,10 @@
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
width: 100%;
box-sizing: border-box;
flex: 1;
overflow-y: auto;
} }
.discussion-item { .discussion-item {
@ -548,20 +595,21 @@
width: 100%; width: 100%;
text-align: left; text-align: left;
padding: 0.75rem; padding: 0.75rem;
background: var(--item-bg, #fff); background: var(--bg-secondary, var(--bg-primary));
border: 1px solid var(--border-color, #e0e0e0); color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
} }
.discussion-item-button:hover { .discussion-item-button:hover {
background: var(--item-hover-bg, #f5f5f5); background: var(--bg-hover, var(--bg-secondary));
} }
.discussion-item.selected .discussion-item-button { .discussion-item.selected .discussion-item-button {
background: var(--selected-bg, #e3f2fd); background: var(--bg-selected, var(--bg-secondary));
border-color: var(--selected-border, #2196f3); border-color: var(--accent-color, var(--button-primary));
} }
.discussion-header { .discussion-header {
@ -578,12 +626,13 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-secondary, #666); color: var(--text-secondary);
} }
.discussion-type { .discussion-type {
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
background: var(--type-bg, #e0e0e0); background: var(--bg-tertiary, var(--bg-secondary));
color: var(--text-primary);
border-radius: 4px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.75rem;
} }
@ -595,13 +644,14 @@
.empty-state { .empty-state {
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
color: var(--text-secondary, #666); color: var(--text-secondary);
} }
.error { .error {
padding: 1rem; padding: 1rem;
background: var(--error-bg, #ffebee); background: var(--bg-secondary);
color: var(--error-color, #c62828); color: var(--accent-error, #ff5252);
border: 1px solid var(--accent-error, #ff5252);
border-radius: 4px; border-radius: 4px;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -620,7 +670,9 @@
} }
.modal { .modal {
background: var(--modal-bg, #fff); background: var(--modal-bg, var(--bg-primary));
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
padding: 1.5rem; padding: 1.5rem;
max-width: 500px; max-width: 500px;
@ -631,11 +683,13 @@
.modal h3 { .modal h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: var(--text-primary);
} }
.modal label { .modal label {
display: block; display: block;
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--text-primary);
} }
.modal label input, .modal label input,
@ -643,8 +697,24 @@
width: 100%; width: 100%;
padding: 0.5rem; padding: 0.5rem;
margin-top: 0.25rem; margin-top: 0.25rem;
border: 1px solid var(--border-color, #e0e0e0); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: inherit;
}
.modal label textarea {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.9rem;
resize: vertical;
}
.modal label input:focus,
.modal label textarea:focus {
outline: none;
border-color: var(--button-primary);
box-shadow: 0 0 0 2px rgba(var(--button-primary-rgb, 220, 20, 60), 0.2);
} }
.modal-actions { .modal-actions {
@ -659,15 +729,27 @@
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, #e0e0e0); background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.cancel-button:hover {
background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, #2196f3); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:disabled { .save-button:disabled {

63
src/routes/repos/[npub]/[repo]/components/DocsTab.svelte

@ -20,6 +20,8 @@
activeTab?: string; activeTab?: string;
tabs?: Array<{ id: string; label: string; icon?: string }>; tabs?: Array<{ id: string; label: string; icon?: string }>;
onTabChange?: (tab: string) => void; onTabChange?: (tab: string) => void;
isMaintainer?: boolean;
onCreateDocumentation?: () => void;
} }
let { let {
@ -29,7 +31,9 @@
relays = DEFAULT_NOSTR_RELAYS, relays = DEFAULT_NOSTR_RELAYS,
activeTab = '', activeTab = '',
tabs = [], tabs = [],
onTabChange = () => {} onTabChange = () => {},
isMaintainer = false,
onCreateDocumentation = () => {}
}: Props = $props(); }: Props = $props();
let documentationContent = $state<string | null>(null); let documentationContent = $state<string | null>(null);
@ -183,7 +187,18 @@
> >
{#snippet leftPane()} {#snippet leftPane()}
<div class="docs-sidebar"> <div class="docs-sidebar">
<div class="docs-header">
<h3>Documentation</h3> <h3>Documentation</h3>
{#if isMaintainer && onCreateDocumentation}
<button
onclick={onCreateDocumentation}
class="create-doc-button"
title="Create Documentation Event"
>
<img src="/icons/plus.svg" alt="New" class="icon" />
</button>
{/if}
</div>
{#if loadingDocs} {#if loadingDocs}
<div class="loading">Loading...</div> <div class="loading">Loading...</div>
{:else if error} {:else if error}
@ -261,21 +276,63 @@
<style> <style>
.docs-sidebar { .docs-sidebar {
padding: 1rem; width: 100%;
max-width: 100%;
height: 100%;
padding: 0;
color: var(--text-primary); color: var(--text-primary);
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.docs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
width: 100%;
box-sizing: border-box;
} }
.docs-sidebar h3 { .docs-sidebar h3 {
margin: 0 0 1rem 0; margin: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
} }
.create-doc-button {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
transition: opacity 0.2s;
}
.create-doc-button:hover {
opacity: 0.7;
}
.create-doc-button .icon {
width: 20px;
height: 20px;
filter: var(--icon-filter, none);
}
.doc-list { .doc-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
width: 100%;
box-sizing: border-box;
flex: 1;
overflow-y: auto;
} }
.doc-item { .doc-item {

155
src/routes/repos/[npub]/[repo]/components/FilesTab.svelte

@ -48,6 +48,8 @@
activeTab?: string; activeTab?: string;
tabs?: Array<{ id: string; label: string; icon?: string }>; tabs?: Array<{ id: string; label: string; icon?: string }>;
onTabChange?: (tab: string) => void; onTabChange?: (tab: string) => void;
onCreateFile?: () => void;
onApplySyntaxHighlighting?: (content: string, ext: string) => Promise<void>;
} }
let { let {
@ -88,8 +90,40 @@
userPubkey = null, userPubkey = null,
activeTab = '', activeTab = '',
tabs = [], tabs = [],
onTabChange = () => {} onTabChange = () => {},
onCreateFile = () => {},
onApplySyntaxHighlighting = async () => {}
}: Props = $props(); }: Props = $props();
// Apply syntax highlighting when fileContent changes and we're showing raw content
// This ensures highlighting is ALWAYS applied for raw files, regardless of maintainer status
$effect(() => {
// Only apply highlighting when:
// 1. We have file content
// 2. We have a current file
// 3. We're NOT in preview mode (showing raw)
// 4. It's NOT an image
// 5. Content is not empty
if (fileContent && currentFile && !showFilePreview && !isImageFile && fileContent.trim().length > 0) {
const ext = currentFile.split('.').pop() || '';
// Always apply highlighting if we don't have highlighted content or it's empty or doesn't contain hljs
const needsHighlighting = !highlightedFileContent ||
highlightedFileContent.trim() === '' ||
!highlightedFileContent.includes('hljs');
if (needsHighlighting) {
// Use a small delay to avoid race conditions with file loading
const timeoutId = setTimeout(() => {
console.log('[FilesTab] Applying syntax highlighting:', { ext, contentLength: fileContent.length, currentFile });
onApplySyntaxHighlighting(fileContent, ext).catch(err => {
console.error('[FilesTab] Error applying syntax highlighting:', err);
});
}, 50);
return () => clearTimeout(timeoutId);
}
}
});
</script> </script>
<TabLayout <TabLayout
@ -101,6 +135,19 @@
title={currentFile ? `File: ${currentFile.split('/').pop()}` : 'Files'} title={currentFile ? `File: ${currentFile.split('/').pop()}` : 'Files'}
> >
{#snippet leftPane()} {#snippet leftPane()}
{#if isMaintainer && onCreateFile}
<div class="create-file-header">
<button
onclick={onCreateFile}
class="create-file-button"
title="Create New File"
disabled={needsClone}
>
<img src="/icons/plus.svg" alt="New" class="icon" />
<span>New File</span>
</button>
</div>
{/if}
<FileBrowser <FileBrowser
{files} {files}
{currentPath} {currentPath}
@ -139,7 +186,7 @@
{#if hasChanges} {#if hasChanges}
<span class="unsaved-indicator">● Unsaved changes</span> <span class="unsaved-indicator">● Unsaved changes</span>
{/if} {/if}
{#if currentFile && supportsPreview((currentFile.split('.').pop() || '').toLowerCase()) && !isMaintainer} {#if currentFile && supportsPreview((currentFile.split('.').pop() || '').toLowerCase())}
<button <button
onclick={onTogglePreview} onclick={onTogglePreview}
class="preview-toggle-button" class="preview-toggle-button"
@ -185,6 +232,13 @@
{:else} {:else}
<div class="editor-container"> <div class="editor-container">
{#if isMaintainer} {#if isMaintainer}
{#if currentFile && showFilePreview && fileHtml && supportsPreview((currentFile.split('.').pop() || '').toLowerCase())}
<div class="read-only-editor" class:word-wrap={wordWrap}>
<div class="file-preview markdown">
<NostrHtmlRenderer html={fileHtml} />
</div>
</div>
{:else}
<CodeEditor <CodeEditor
content={editedContent || fileContent} content={editedContent || fileContent}
language={fileLanguage} language={fileLanguage}
@ -195,6 +249,7 @@
onContentChange(value); onContentChange(value);
}} }}
/> />
{/if}
{:else} {:else}
<div class="read-only-editor" class:word-wrap={wordWrap}> <div class="read-only-editor" class:word-wrap={wordWrap}>
{#if isImageFile && imageUrl} {#if isImageFile && imageUrl}
@ -205,13 +260,13 @@
<div class="file-preview markdown"> <div class="file-preview markdown">
<NostrHtmlRenderer html={fileHtml} /> <NostrHtmlRenderer html={fileHtml} />
</div> </div>
{:else if highlightedFileContent} {:else if fileContent}
<div class="raw-content"> <div class="raw-content">
{#if highlightedFileContent && highlightedFileContent.trim() !== ''}
{@html highlightedFileContent} {@html highlightedFileContent}
</div>
{:else} {:else}
<div class="raw-content"> <pre><code class="hljs language-plaintext">{fileContent}</code></pre>
<pre><code class="hljs">{fileContent}</code></pre> {/if}
</div> </div>
{/if} {/if}
</div> </div>
@ -244,6 +299,10 @@
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
} }
.file-editor .editor-header { .file-editor .editor-header {
@ -296,11 +355,13 @@
.raw-content { .raw-content {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
overflow-x: auto; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
box-sizing: border-box; box-sizing: border-box;
contain: layout; contain: layout;
min-width: 0; min-width: 0;
word-wrap: break-word;
overflow-wrap: break-word;
} }
.raw-content pre { .raw-content pre {
@ -308,8 +369,10 @@
padding: 1rem; padding: 1rem;
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 4px; border-radius: 4px;
overflow-x: auto; overflow-x: hidden;
overflow-y: visible;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word;
white-space: pre-wrap; white-space: pre-wrap;
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
@ -319,23 +382,26 @@
.raw-content code { .raw-content code {
display: block; display: block;
overflow-x: auto; overflow-x: hidden;
overflow-y: visible;
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
white-space: pre-wrap;
} }
.raw-content :global(code.hljs) { .raw-content :global(code.hljs) {
overflow-x: auto; overflow-x: hidden !important;
overflow-y: visible !important;
display: block; display: block;
max-width: 100% !important; max-width: 100% !important;
min-width: 0; min-width: 0;
word-break: break-word; word-break: break-word !important;
overflow-wrap: break-word; overflow-wrap: break-word !important;
white-space: pre-wrap; white-space: pre-wrap !important;
box-sizing: border-box; box-sizing: border-box;
} }
@ -357,6 +423,7 @@
white-space: pre-wrap !important; white-space: pre-wrap !important;
display: inline; display: inline;
box-sizing: border-box; box-sizing: border-box;
overflow-x: hidden !important;
} }
.raw-content :global(pre code.hljs) { .raw-content :global(pre code.hljs) {
@ -374,6 +441,20 @@
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
box-sizing: border-box;
}
.editor-container :global(.code-editor) {
width: 100% !important;
max-width: 100% !important;
min-width: 0;
box-sizing: border-box;
}
.editor-container :global(.code-editor),
.editor-container :global(.code-editor *) {
max-width: 100% !important;
box-sizing: border-box;
} }
.read-only-editor { .read-only-editor {
@ -400,12 +481,22 @@
.read-only-editor > .raw-content > pre { .read-only-editor > .raw-content > pre {
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
overflow-x: hidden !important;
overflow-y: visible !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
white-space: pre-wrap !important;
} }
.read-only-editor > .raw-content > pre > code { .read-only-editor > .raw-content > pre > code {
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
display: block; display: block;
overflow-x: hidden !important;
overflow-y: visible !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
white-space: pre-wrap !important;
} }
.file-editor .editor-actions { .file-editor .editor-actions {
@ -419,4 +510,42 @@
width: auto; width: auto;
min-width: 0; min-width: 0;
} }
.create-file-header {
padding: 0.75rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 0.5rem;
}
.create-file-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
border: none;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
transition: background 0.2s;
}
.create-file-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.create-file-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.create-file-button .icon {
width: 16px;
height: 16px;
filter: brightness(0) invert(1);
}
</style> </style>

24
src/routes/repos/[npub]/[repo]/components/TabLayout.svelte

@ -25,8 +25,7 @@
activeTab = '', activeTab = '',
tabs = [], tabs = [],
onTabChange = () => {}, onTabChange = () => {},
title = '', title = ''
headerActions
}: Props = $props(); }: Props = $props();
</script> </script>
@ -112,18 +111,29 @@
} }
.left-pane { .left-pane {
flex: 0 0 300px; flex: 0 0 400px;
min-width: 300px; min-width: 400px;
max-width: 300px; max-width: 400px;
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
overflow-y: auto; overflow-y: auto;
padding: 1rem; padding: 1rem;
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
flex-direction: column;
}
.left-pane > * {
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
} }
.right-panel { .right-panel {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 400px;
width: auto; width: auto;
max-width: none; max-width: none;
overflow-y: auto; overflow-y: auto;
@ -132,6 +142,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-primary);
color: var(--text-primary);
} }
.right-panel > * { .right-panel > * {

133
src/routes/repos/[npub]/[repo]/components/dialogs/CreateDocumentationDialog.svelte

@ -0,0 +1,133 @@
<script lang="ts">
import Modal from './Modal.svelte';
import type { RepoState } from '../../stores/repo-state.js';
interface Props {
open: boolean;
state: RepoState;
onCreate: () => void;
onClose: () => void;
}
let { open, state, onCreate, onClose }: Props = $props();
</script>
<Modal {open} title="Create Documentation Event" ariaLabel="Create documentation event" {onClose} maxWidth="800px">
{#snippet children()}
<p class="description">
Create a documentation event. This will be published to Nostr relays.
</p>
<label>
Event Kind:
<select bind:value={state.forms.documentation.kind}>
<option value={30818}>30818 - Repository State (Asciidoc)</option>
<option value={30041}>30041 - Publication (Asciidoc)</option>
<option value={30817}>30817 - Repository Announcement</option>
<option value={30023}>30023 - Article</option>
</select>
<small>Select the type of documentation event to create</small>
</label>
<label>
Title:
<input type="text" bind:value={state.forms.documentation.title} placeholder="Documentation Title" />
</label>
<label>
Identifier (d-tag):
<input type="text" bind:value={state.forms.documentation.identifier} placeholder="e.g., nkbip-01" />
<small>Unique identifier for this documentation event</small>
</label>
<label>
Subject:
<textarea bind:value={state.forms.documentation.content} rows="15" placeholder="Content here..."></textarea>
</label>
<div class="modal-actions">
<button onclick={onClose} class="cancel-button">Cancel</button>
<button
onclick={onCreate}
disabled={!state.forms.documentation.title.trim() || !state.forms.documentation.identifier.trim() || !state.forms.documentation.content.trim() || state.saving}
class="save-button"
>
{state.saving ? 'Creating...' : 'Create'}
</button>
</div>
{/snippet}
</Modal>
<style>
.description {
margin-bottom: 1rem;
color: var(--text-secondary);
font-size: 0.9rem;
}
label {
display: block;
margin-bottom: 1rem;
}
label input,
label textarea,
label select {
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
font-family: inherit;
}
label textarea {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.9rem;
}
label small {
display: block;
margin-top: 0.25rem;
color: var(--text-secondary);
font-size: 0.85rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
.cancel-button,
.save-button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.cancel-button {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

17
src/routes/repos/[npub]/[repo]/components/dialogs/CreateThreadDialog.svelte

@ -37,6 +37,7 @@
label { label {
display: block; display: block;
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--text-primary);
} }
label input, label input,
@ -46,6 +47,22 @@
margin-top: 0.25rem; margin-top: 0.25rem;
border: 1px solid var(--border-color, #e0e0e0); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px; border-radius: 4px;
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: inherit;
}
label textarea {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.9rem;
resize: vertical;
}
label input:focus,
label textarea:focus {
outline: none;
border-color: var(--button-primary);
box-shadow: 0 0 0 2px rgba(var(--button-primary-rgb, 220, 20, 60), 0.2);
} }
.modal-actions { .modal-actions {

5
src/routes/repos/[npub]/[repo]/components/dialogs/Modal.svelte

@ -7,9 +7,10 @@
ariaLabel?: string; ariaLabel?: string;
onClose: () => void; onClose: () => void;
children?: Snippet; children?: Snippet;
maxWidth?: string;
} }
let { open, title, ariaLabel, onClose, children }: Props = $props(); let { open, title, ariaLabel, onClose, children, maxWidth = '500px' }: Props = $props();
</script> </script>
{#if open} {#if open}
@ -27,6 +28,7 @@
<div <div
class="modal" class="modal"
role="dialog" role="dialog"
style="max-width: {maxWidth};"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}
tabindex="-1" tabindex="-1"
@ -56,7 +58,6 @@
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
border-radius: 8px; border-radius: 8px;
padding: 1.5rem; padding: 1.5rem;
max-width: 500px;
width: 90%; width: 90%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;

12
src/routes/repos/[npub]/[repo]/services/commit-operations.ts

@ -20,6 +20,11 @@ export async function loadCommitHistory(
state.loading.commits = true; state.loading.commits = true;
state.error = null; state.error = null;
try { try {
// Use currentBranch, fallback to defaultBranch, then 'master'
const branch = state.git.currentBranch || state.git.defaultBranch || 'master';
const url = `/api/repos/${state.npub}/${state.repo}/commits?branch=${encodeURIComponent(branch)}&limit=50`;
console.log('[loadCommitHistory] Fetching commits:', { url, branch, currentBranch: state.git.currentBranch, defaultBranch: state.git.defaultBranch });
const data = await apiRequest<Array<{ const data = await apiRequest<Array<{
hash?: string; hash?: string;
sha?: string; sha?: string;
@ -27,7 +32,9 @@ export async function loadCommitHistory(
author?: string; author?: string;
date?: string; date?: string;
files?: string[]; files?: string[];
}>>(`/api/repos/${state.npub}/${state.repo}/commits?branch=${state.git.currentBranch}&limit=50`); }>>(url);
console.log('[loadCommitHistory] Received data:', { commitCount: data?.length || 0, data });
// Normalize commits: API-based commits use 'sha', local commits use 'hash' // Normalize commits: API-based commits use 'sha', local commits use 'hash'
state.git.commits = data.map((commit: any) => ({ state.git.commits = data.map((commit: any) => ({
@ -38,6 +45,8 @@ export async function loadCommitHistory(
files: commit.files || [] files: commit.files || []
})).filter((commit: any) => commit.hash); // Filter out commits without hash })).filter((commit: any) => commit.hash); // Filter out commits without hash
console.log('[loadCommitHistory] Normalized commits:', { count: state.git.commits.length });
// Verify commits in background (only for cloned repos) // Verify commits in background (only for cloned repos)
if (state.clone.isCloned === true) { if (state.clone.isCloned === true) {
state.git.commits.forEach(commit => { state.git.commits.forEach(commit => {
@ -47,6 +56,7 @@ export async function loadCommitHistory(
}); });
} }
} catch (err) { } catch (err) {
console.error('[loadCommitHistory] Error loading commits:', err);
state.error = err instanceof Error ? err.message : 'Failed to load commit history'; state.error = err instanceof Error ? err.message : 'Failed to load commit history';
} finally { } finally {
state.loading.commits = false; state.loading.commits = false;

25
src/routes/repos/[npub]/[repo]/services/file-operations.ts

@ -591,25 +591,16 @@ export async function loadFile(
state.preview.file.showPreview = true; state.preview.file.showPreview = true;
state.preview.file.html = ''; state.preview.file.html = '';
// Render markdown/asciidoc/HTML/CSV files as HTML for preview // ALWAYS apply syntax highlighting for ALL files - this ensures raw view always has highlighting
if (state.files.content && (ext === 'md' || ext === 'markdown' || ext === 'adoc' || ext === 'asciidoc' || ext === 'html' || ext === 'htm' || ext === 'csv')) { // We'll apply it regardless of preview mode so it's ready when user switches to raw view
await callbacks.renderFileAsHtml(state.files.content, ext || ''); if (state.files.content && state.files.content.trim().length > 0) {
}
// Apply syntax highlighting
// For files that support HTML preview (markdown, HTML, etc.), only show highlighting in raw mode
// For code files and other non-markup files, always show syntax highlighting
const hasHtmlPreview = supportsPreview(ext);
if (state.files.content) {
if (hasHtmlPreview) {
// Markup files: only show highlighting when not in preview mode (raw mode)
if (!state.preview.file.showPreview) {
await callbacks.applySyntaxHighlighting(state.files.content, ext || '');
}
} else {
// Code files and other non-markup files: always show syntax highlighting
await callbacks.applySyntaxHighlighting(state.files.content, ext || ''); await callbacks.applySyntaxHighlighting(state.files.content, ext || '');
} }
// Render markdown/asciidoc/HTML/CSV files as HTML for preview (if in preview mode)
// This happens after highlighting so both are available
if (state.files.content && (ext === 'md' || ext === 'markdown' || ext === 'adoc' || ext === 'asciidoc' || ext === 'html' || ext === 'htm' || ext === 'csv')) {
await callbacks.renderFileAsHtml(state.files.content, ext || '');
} }
} }
} catch (err: any) { } catch (err: any) {

10
src/routes/repos/[npub]/[repo]/stores/repo-state.ts

@ -38,6 +38,7 @@ export type DialogType =
| 'createPR' | 'createPR'
| 'createPatch' | 'createPatch'
| 'createThread' | 'createThread'
| 'createDocumentation'
| 'reply' | 'reply'
| 'commit' | 'commit'
| 'verification' | 'verification'
@ -121,6 +122,13 @@ export interface PatchCommentFormData {
replyingTo: string | null; replyingTo: string | null;
} }
export interface DocumentationFormData {
kind: number; // 30818, 30041, 30817, or 30023
title: string;
identifier: string;
content: string;
}
// Status update tracking // Status update tracking
export interface StatusUpdates { export interface StatusUpdates {
issue: Record<string, boolean>; issue: Record<string, boolean>;
@ -220,6 +228,7 @@ export interface RepoState {
discussion: DiscussionFormData; discussion: DiscussionFormData;
patchHighlight: PatchHighlightFormData; patchHighlight: PatchHighlightFormData;
patchComment: PatchCommentFormData; patchComment: PatchCommentFormData;
documentation: DocumentationFormData;
commit: { commit: {
message: string; message: string;
}; };
@ -545,6 +554,7 @@ export function createRepoState(): RepoState {
discussion: { threadTitle: '', threadContent: '', replyContent: '' }, discussion: { threadTitle: '', threadContent: '', replyContent: '' },
patchHighlight: { text: '', startLine: 0, endLine: 0, startPos: 0, endPos: 0, comment: '' }, patchHighlight: { text: '', startLine: 0, endLine: 0, startPos: 0, endPos: 0, comment: '' },
patchComment: { content: '', replyingTo: null }, patchComment: { content: '', replyingTo: null },
documentation: { kind: 30818, title: '', identifier: '', content: '' },
commit: { message: '' } commit: { message: '' }
}, },
issues: [], issues: [],

13
src/routes/repos/[npub]/[repo]/utils/file-processing.ts

@ -264,9 +264,11 @@ export async function applySyntaxHighlighting(
setHighlightedContent: (html: string) => void setHighlightedContent: (html: string) => void
): Promise<void> { ): Promise<void> {
try { try {
console.log('[applySyntaxHighlighting] Starting:', { ext, contentLength: content.length });
const hljsModule = await import('highlight.js'); const hljsModule = await import('highlight.js');
const hljs = hljsModule.default || hljsModule; const hljs = hljsModule.default || hljsModule;
const lang = getHighlightLanguage(ext); const lang = getHighlightLanguage(ext);
console.log('[applySyntaxHighlighting] Language detected:', lang);
// Register Markdown language if needed // Register Markdown language if needed
if (lang === 'markdown' && !hljs.getLanguage('markdown')) { if (lang === 'markdown' && !hljs.getLanguage('markdown')) {
@ -314,15 +316,18 @@ export async function applySyntaxHighlighting(
} }
// Apply highlighting // Apply highlighting
let highlighted: string;
if (lang === 'plaintext') { if (lang === 'plaintext') {
setHighlightedContent(`<pre><code class="hljs">${hljs.highlight(content, { language: 'plaintext' }).value}</code></pre>`); highlighted = `<pre><code class="hljs">${hljs.highlight(content, { language: 'plaintext' }).value}</code></pre>`;
} else if (hljs.getLanguage(lang)) { } else if (hljs.getLanguage(lang)) {
setHighlightedContent(`<pre><code class="hljs language-${lang}">${hljs.highlight(content, { language: lang }).value}</code></pre>`); highlighted = `<pre><code class="hljs language-${lang}">${hljs.highlight(content, { language: lang }).value}</code></pre>`;
} else { } else {
setHighlightedContent(`<pre><code class="hljs">${hljs.highlightAuto(content).value}</code></pre>`); highlighted = `<pre><code class="hljs">${hljs.highlightAuto(content).value}</code></pre>`;
} }
console.log('[applySyntaxHighlighting] Highlighting complete, setting content:', { highlightedLength: highlighted.length });
setHighlightedContent(highlighted);
} catch (err) { } catch (err) {
console.error('Error applying syntax highlighting:', err); console.error('[applySyntaxHighlighting] Error applying syntax highlighting:', err);
setHighlightedContent(`<pre><code class="hljs">${escapeHtml(content)}</code></pre>`); setHighlightedContent(`<pre><code class="hljs">${escapeHtml(content)}</code></pre>`);
} }
} }

Loading…
Cancel
Save