Browse Source

refactoring 2

Nostr-Signature: 9375bfe35e0574bc722cad243c22fdf374dcc9016f91f358ff9ddf1d0a03bb50 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 10fbbcbc7cab48dfd2340f0c9eceafe558d893789e4838cbe26493e5c339f7a1f015d1cc4af8bfa51d57e9a9da94bb1bb44841305d5ce7cf92db9938985d0459
main
Silberengel 2 weeks ago
parent
commit
0b64a5e8b1
  1. 1
      nostr/commit-signatures.jsonl
  2. 218
      src/lib/components/CommentRenderer.svelte
  3. 194
      src/lib/components/DiscussionRenderer.svelte
  4. 69
      src/lib/components/PRDetail.svelte
  5. 16
      src/lib/components/PublicationIndexViewer.svelte
  6. 36
      src/lib/services/git/file-manager.ts
  7. 2
      src/lib/services/git/file-manager/branch-operations.ts
  8. 3
      src/lib/services/git/file-manager/write-operations.ts
  9. 281
      src/lib/utils/nostr-links.ts
  10. 2
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  11. 413
      src/routes/repos/[npub]/[repo]/+page.svelte
  12. 23
      src/routes/repos/[npub]/[repo]/components/CommitDialog.svelte
  13. 647
      src/routes/repos/[npub]/[repo]/components/DiscussionsTab.svelte
  14. 17
      src/routes/repos/[npub]/[repo]/components/DocsTab.svelte
  15. 17
      src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte
  16. 26
      src/routes/repos/[npub]/[repo]/components/FileBrowser.svelte
  17. 119
      src/routes/repos/[npub]/[repo]/components/FilesTab.svelte
  18. 46
      src/routes/repos/[npub]/[repo]/components/HistoryTab.svelte
  19. 47
      src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte
  20. 41
      src/routes/repos/[npub]/[repo]/components/PRsTab.svelte
  21. 42
      src/routes/repos/[npub]/[repo]/components/PatchesTab.svelte
  22. 39
      src/routes/repos/[npub]/[repo]/components/StatusTabLayout.svelte
  23. 17
      src/routes/repos/[npub]/[repo]/components/TabLayout.svelte
  24. 34
      src/routes/repos/[npub]/[repo]/hooks/use-repo-data.ts

1
nostr/commit-signatures.jsonl

@ -89,3 +89,4 @@ @@ -89,3 +89,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772009909,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix cli sync and refine commit workflow"]],"content":"Signed commit: fix cli sync and refine commit workflow","id":"ddf0b49bb68139efbdacd6308b95b4a5329a37f479b319d609d712bee83e2d45","sig":"aacc22f02a3129d18cd2bdcfc4e2dda66e9358e552eac507cd4c4808bb47cd582298aed7d28f21b677418e1a91f3f1553c08f02671df8f1f43681cf7b19a744e"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772010107,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix build"]],"content":"Signed commit: fix build","id":"968af17f95f1ba0cf6a4d1f04ce108a6e4eb4ec3a4f72ca6a9d2529dacb92811","sig":"1891b6131effda70ec76577efadd9ea7374ebcbd4d738d0b0650e7dce46c3e7253eccb4b8455690297b63b7c30f61a0c7dcc1af0147b2f5a631bbd91c517c32b"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772011169,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","prevent zombie git processes"]],"content":"Signed commit: prevent zombie git processes","id":"fd370d2613105f16b0cfdd55b33f50c5b724ecef272109036a7cce5477da29bc","sig":"1d3cb4392f722b1b356247bde64691576d41fdb697e8dfe62d5e7ecd5ad8ea35757da2d56db310a2005e4b5528013aa1205256e37fc230f024d3b5a2e26735bf"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772087425,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactoring 1"]],"content":"Signed commit: refactoring 1","id":"533e9f7acbdd4dc16dbe304245469d57d8d37f0c0cce53b60d99719e2acf4502","sig":"0fad2d7c44f086ceb06ce40ea8cea2d4d002ebe8caec7d78e83483348b1404cfb6256d8d3796ebd9ae6f7866a431ec4a1abe84e417d3e238b9b554b4a32481e4"}

218
src/lib/components/CommentRenderer.svelte

@ -0,0 +1,218 @@ @@ -0,0 +1,218 @@
<script lang="ts">
import UserBadge from '$lib/components/UserBadge.svelte';
import EventCopyButton from '$lib/components/EventCopyButton.svelte';
import {
processContentWithNostrLinks,
getReferencedEventFromDiscussion,
formatDiscussionTime,
type ProcessedContentPart
} from '$lib/utils/nostr-links.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import CommentRendererSelf from './CommentRenderer.svelte';
export interface Comment {
id: string;
content: string;
author: string;
createdAt: number;
kind: number;
pubkey: string;
replies?: Comment[];
}
interface Props {
comment: Comment;
commentEvent?: NostrEvent; // Full event for getting referenced events
eventCache: Map<string, NostrEvent>;
profileCache: Map<string, string>;
userPubkey?: string | null;
onReply?: (commentId: string) => void;
nested?: boolean;
}
let {
comment,
commentEvent,
eventCache,
profileCache,
userPubkey,
onReply,
nested = false
}: Props = $props();
const referencedEvent = $derived(commentEvent
? getReferencedEventFromDiscussion(commentEvent, eventCache)
: undefined);
const contentParts = $derived(processContentWithNostrLinks(comment.content, eventCache, profileCache));
</script>
<div class="comment-item" class:nested-comment={nested}>
<div class="comment-meta">
<UserBadge pubkey={comment.author} />
<span>{new Date(comment.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={comment.id} kind={comment.kind} pubkey={comment.pubkey} />
{#if userPubkey && onReply}
<button
class="create-reply-button"
onclick={() => onReply(comment.id)}
title="Reply to comment"
>
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
<div class="comment-content">
{#if referencedEvent}
<div class="referenced-event">
<div class="referenced-event-header">
<UserBadge pubkey={referencedEvent.pubkey} disableLink={true} />
<span class="referenced-event-time">{formatDiscussionTime(referencedEvent.created_at)}</span>
</div>
<div class="referenced-event-content">{referencedEvent.content || '(No content)'}</div>
</div>
{/if}
<div>
{#each contentParts as part}
{#if part.type === 'text'}
<span>{part.value}</span>
{:else if part.type === 'event' && part.event}
<div class="nostr-link-event">
<div class="nostr-link-event-header">
<UserBadge pubkey={part.event.pubkey} disableLink={true} />
<span class="nostr-link-event-time">{formatDiscussionTime(part.event.created_at)}</span>
</div>
<div class="nostr-link-event-content">{part.event.content || '(No content)'}</div>
</div>
{:else if part.type === 'profile' && part.pubkey}
<UserBadge pubkey={part.pubkey} />
{:else}
<span class="nostr-link-placeholder">{part.value}</span>
{/if}
{/each}
</div>
</div>
{#if comment.replies && comment.replies.length > 0}
<div class="nested-replies">
{#each comment.replies as reply}
<CommentRendererSelf
comment={reply}
commentEvent={eventCache.get(reply.id)}
{eventCache}
{profileCache}
{userPubkey}
{onReply}
nested={true}
/>
{/each}
</div>
{/if}
</div>
<style>
.comment-item {
margin-bottom: 1rem;
padding: 0.75rem;
border-left: 2px solid var(--border-color, #e0e0e0);
background: var(--comment-bg, #f9f9f9);
}
.nested-comment {
margin-left: 1.5rem;
margin-top: 0.5rem;
border-left-color: var(--nested-border-color, #ccc);
background: var(--nested-comment-bg, #f5f5f5);
}
.comment-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
.comment-content {
margin-top: 0.5rem;
}
.create-reply-button {
margin-left: auto;
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
}
.create-reply-button:hover {
opacity: 0.7;
}
.create-reply-button .icon {
width: 16px;
height: 16px;
}
.nested-replies {
margin-top: 0.75rem;
padding-left: 0.5rem;
}
.referenced-event {
margin-bottom: 0.75rem;
padding: 0.5rem;
background: var(--referenced-bg, #f0f0f0);
border-radius: 4px;
border-left: 2px solid var(--referenced-border, #999);
}
.referenced-event-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
.referenced-event-time {
color: var(--text-secondary, #666);
}
.referenced-event-content {
font-size: 0.9rem;
color: var(--text-secondary, #666);
}
.nostr-link-event {
margin: 0.5rem 0;
padding: 0.5rem;
background: var(--link-event-bg, #f0f0f0);
border-radius: 4px;
border-left: 2px solid var(--link-event-border, #999);
}
.nostr-link-event-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
.nostr-link-event-time {
color: var(--text-secondary, #666);
}
.nostr-link-event-content {
font-size: 0.9rem;
color: var(--text-secondary, #666);
}
.nostr-link-placeholder {
color: var(--link-color, #0066cc);
text-decoration: underline;
}
</style>

194
src/lib/components/DiscussionRenderer.svelte

@ -0,0 +1,194 @@ @@ -0,0 +1,194 @@
<script lang="ts">
import CommentRenderer, { type Comment } from './CommentRenderer.svelte';
import UserBadge from './UserBadge.svelte';
import EventCopyButton from './EventCopyButton.svelte';
import type { NostrEvent } from '$lib/types/nostr.js';
export interface Discussion {
id: string;
title: string;
content?: string;
author: string;
createdAt: number;
kind: number;
pubkey: string;
type: 'thread' | 'comments';
comments?: Comment[];
}
interface Props {
discussion: Discussion;
discussionEvent?: NostrEvent;
eventCache: Map<string, NostrEvent>;
profileCache: Map<string, string>;
userPubkey?: string | null;
onReplyToThread?: (threadId: string) => void;
onReplyToComment?: (commentId: string) => void;
}
let {
discussion,
discussionEvent,
eventCache,
profileCache,
userPubkey,
onReplyToThread,
onReplyToComment
}: Props = $props();
function countAllReplies(comments?: Comment[]): number {
if (!comments) return 0;
let count = comments.length;
for (const comment of comments) {
if (comment.replies) {
count += countAllReplies(comment.replies);
}
}
return count;
}
const hasComments = $derived(discussion.comments && discussion.comments.length > 0);
const totalReplies = $derived(hasComments ? countAllReplies(discussion.comments) : 0);
</script>
<div class="discussion-item">
<div class="discussion-header">
<h3 class="discussion-title">{discussion.title}</h3>
<div class="discussion-meta">
{#if discussion.type === 'thread'}
<span class="discussion-type">Thread</span>
{#if hasComments}
<span class="comment-count">{totalReplies} {totalReplies === 1 ? 'reply' : 'replies'}</span>
{/if}
{:else}
<span class="discussion-type">Comments</span>
{/if}
<span>Created {new Date(discussion.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={discussion.id} kind={discussion.kind} pubkey={discussion.pubkey} />
{#if discussion.type === 'thread' && userPubkey && onReplyToThread}
<button
class="create-reply-button"
onclick={() => onReplyToThread(discussion.id)}
title="Reply to thread"
>
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
</div>
{#if discussion.content}
<div class="discussion-body">
<p>{discussion.content}</p>
</div>
{/if}
{#if discussion.type === 'thread' && hasComments}
<div class="comments-section">
<h4>Replies ({totalReplies})</h4>
{#each discussion.comments! as comment}
<CommentRenderer
comment={comment}
commentEvent={eventCache.get(comment.id)}
{eventCache}
{profileCache}
{userPubkey}
onReply={onReplyToComment}
/>
{/each}
</div>
{:else if discussion.type === 'comments' && hasComments}
<div class="comments-section">
<h4>Comments ({totalReplies})</h4>
{#each discussion.comments! as comment}
<CommentRenderer
comment={comment}
commentEvent={eventCache.get(comment.id)}
{eventCache}
{profileCache}
{userPubkey}
onReply={onReplyToComment}
/>
{/each}
</div>
{/if}
</div>
<style>
.discussion-item {
padding: 1rem;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
background: var(--discussion-bg, #fff);
}
.discussion-header {
margin-bottom: 1rem;
}
.discussion-title {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
}
.discussion-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
.discussion-type {
padding: 0.25rem 0.5rem;
background: var(--type-bg, #e0e0e0);
border-radius: 4px;
font-weight: 500;
}
.comment-count {
font-weight: 500;
}
.create-reply-button {
margin-left: auto;
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
}
.create-reply-button:hover {
opacity: 0.7;
}
.create-reply-button .icon {
width: 16px;
height: 16px;
}
.discussion-body {
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--body-bg, #f9f9f9);
border-radius: 4px;
}
.discussion-body p {
margin: 0;
}
.comments-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.comments-section h4 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 600;
}
</style>

69
src/lib/components/PRDetail.svelte

@ -9,6 +9,10 @@ @@ -9,6 +9,10 @@
import { getPublicKeyWithNIP07 } from '../services/nostr/nip07-signer.js';
import { KIND } from '../types/nostr.js';
import { nip19 } from 'nostr-tools';
import CommentRenderer from './CommentRenderer.svelte';
import type { Comment } from './CommentRenderer.svelte';
import { loadNostrLinks } from '../utils/nostr-links.js';
import type { NostrEvent } from '../types/nostr.js';
interface Props {
pr: {
@ -88,6 +92,10 @@ @@ -88,6 +92,10 @@
const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
// Event caches for Nostr links
let nostrLinkEvents = $state<Map<string, NostrEvent>>(new Map());
let nostrLinkProfiles = $state<Map<string, string>>(new Map());
onMount(async () => {
await checkAuth();
await loadHighlights();
@ -115,6 +123,21 @@ @@ -115,6 +123,21 @@
const data = await response.json();
highlights = data.highlights || [];
comments = data.comments || [];
// Load Nostr links from all comment content
for (const comment of comments) {
await loadNostrLinks(comment.content, nostrClient, nostrLinkEvents, nostrLinkProfiles);
}
for (const highlight of highlights) {
if (highlight.comment) {
await loadNostrLinks(highlight.comment, nostrClient, nostrLinkEvents, nostrLinkProfiles);
}
if (highlight.comments) {
for (const comment of highlight.comments) {
await loadNostrLinks(comment.content, nostrClient, nostrLinkEvents, nostrLinkProfiles);
}
}
}
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load highlights';
@ -123,6 +146,17 @@ @@ -123,6 +146,17 @@
}
}
function convertToCommentFormat(comment: typeof comments[0]): Comment {
return {
id: comment.id,
content: comment.content,
author: comment.pubkey,
createdAt: comment.created_at,
kind: KIND.COMMENT,
pubkey: comment.pubkey
};
}
async function loadPRDiff() {
if (!pr.commitId) return;
@ -469,16 +503,13 @@ @@ -469,16 +503,13 @@
{:else}
<!-- Top-level comments on PR -->
{#each comments as comment}
<div class="comment-item">
<div class="comment-header">
<span class="comment-author">{formatPubkey(comment.pubkey)}</span>
<span class="comment-date">{new Date(comment.created_at * 1000).toLocaleString()}</span>
</div>
<div class="comment-content">{comment.content}</div>
{#if userPubkey}
<button onclick={() => startComment(comment.id)} class="reply-btn">Reply</button>
{/if}
</div>
<CommentRenderer
comment={convertToCommentFormat(comment)}
eventCache={nostrLinkEvents}
profileCache={nostrLinkProfiles}
userPubkey={userPubkey}
onReply={userPubkey ? (id) => startComment(id) : undefined}
/>
{/each}
<!-- Highlights with comments - filter to only show highlights for this PR -->
@ -518,16 +549,14 @@ @@ -518,16 +549,14 @@
{#if highlight.comments && highlight.comments.length > 0}
<div class="highlight-comments">
{#each highlight.comments as comment}
<div class="comment-item nested">
<div class="comment-header">
<span class="comment-author">{formatPubkey(comment.pubkey)}</span>
<span class="comment-date">{new Date(comment.created_at * 1000).toLocaleString()}</span>
</div>
<div class="comment-content">{comment.content}</div>
{#if userPubkey}
<button onclick={() => startComment(comment.id)} class="reply-btn">Reply</button>
{/if}
</div>
<CommentRenderer
comment={convertToCommentFormat(comment)}
eventCache={nostrLinkEvents}
profileCache={nostrLinkProfiles}
userPubkey={userPubkey}
onReply={userPubkey ? (id) => startComment(id) : undefined}
nested={true}
/>
{/each}
</div>
{/if}

16
src/lib/components/PublicationIndexViewer.svelte

@ -10,10 +10,6 @@ @@ -10,10 +10,6 @@
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import logger from '$lib/services/logger.js';
export let indexEvent: NostrEvent | null = null;
export let relays: string[] = DEFAULT_NOSTR_RELAYS;
export let onItemClick: ((item: PublicationItem) => void) | null = null;
interface PublicationItem {
id: string;
title: string;
@ -23,6 +19,18 @@ @@ -23,6 +19,18 @@
[key: string]: any;
}
interface Props {
indexEvent?: NostrEvent | null;
relays?: string[];
onItemClick?: ((item: PublicationItem) => void) | null;
}
let {
indexEvent = null,
relays = DEFAULT_NOSTR_RELAYS,
onItemClick = null
}: Props = $props();
let items = $state<PublicationItem[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);

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

@ -5,19 +5,19 @@ @@ -5,19 +5,19 @@
import { join, resolve } from 'path';
import simpleGit, { type SimpleGit } from 'simple-git';
import { RepoManager } from '../repo-manager.js';
import logger from '../../logger.js';
import { sanitizeError, isValidBranchName } from '../../../utils/security.js';
import { repoCache, RepoCache } from '../repo-cache.js';
import { RepoManager } from './repo-manager.js';
import logger from '$lib/services/logger.js';
import { sanitizeError, isValidBranchName } from '$lib/utils/security.js';
import { repoCache, RepoCache } from './repo-cache.js';
// Import modular operations
import { getOrCreateWorktree, removeWorktree } from './worktree-manager.js';
import { validateFilePath, validateRepoName, validateNpub } from './path-validator.js';
import { listFiles, getFileContent } from './file-operations.js';
import { getBranches, validateBranchName } from './branch-operations.js';
import { writeFile, deleteFile } from './write-operations.js';
import { getCommitHistory, getDiff } from './commit-operations.js';
import { createTag, getTags } from './tag-operations.js';
import { getOrCreateWorktree, removeWorktree } from './file-manager/worktree-manager.js';
import { validateFilePath, validateRepoName, validateNpub } from './file-manager/path-validator.js';
import { listFiles, getFileContent } from './file-manager/file-operations.js';
import { getBranches, validateBranchName } from './file-manager/branch-operations.js';
import { writeFile, deleteFile } from './file-manager/write-operations.js';
import { getCommitHistory, getDiff } from './file-manager/commit-operations.js';
import { createTag, getTags } from './file-manager/tag-operations.js';
// Types are defined below
@ -336,7 +336,7 @@ export class FileManager { @@ -336,7 +336,7 @@ export class FileManager {
npub,
repoName,
repoPath,
getDefaultBranch: (npub, repoName) => this.getDefaultBranch(npub, repoName)
getDefaultBranch: (npub: string, repoName: string) => this.getDefaultBranch(npub, repoName)
});
}
@ -503,11 +503,11 @@ export class FileManager { @@ -503,11 +503,11 @@ export class FileManager {
private async isRepoPrivate(npub: string, repoName: string): Promise<boolean> {
try {
const { requireNpubHex } = await import('../../../utils/npub-utils.js');
const { requireNpubHex } = await import('$lib/utils/npub-utils.js');
const repoOwnerPubkey = requireNpubHex(npub);
const { NostrClient } = await import('../../nostr/nostr-client.js');
const { DEFAULT_NOSTR_RELAYS } = await import('../../../config.js');
const { KIND } = await import('../../../types/nostr.js');
const { NostrClient } = await import('$lib/services/nostr/nostr-client.js');
const { DEFAULT_NOSTR_RELAYS } = await import('$lib/config.js');
const { KIND } = await import('$lib/types/nostr.js');
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const events = await nostrClient.fetchEvents([
@ -521,7 +521,7 @@ export class FileManager { @@ -521,7 +521,7 @@ export class FileManager {
if (events.length === 0) return false;
const { isPrivateRepo: checkIsPrivateRepo } = await import('../../../utils/repo-privacy.js');
const { isPrivateRepo: checkIsPrivateRepo } = await import('$lib/utils/repo-privacy.js');
return checkIsPrivateRepo(events[0]);
} catch (err) {
logger.debug({ error: err, npub, repoName }, 'Failed to check repo privacy, defaulting to public');
@ -569,7 +569,7 @@ export class FileManager { @@ -569,7 +569,7 @@ export class FileManager {
if (!announcementEvent) return null;
const { validateAnnouncementEvent } = await import('../../nostr/repo-verification.js');
const { validateAnnouncementEvent } = await import('$lib/services/nostr/repo-verification.js');
const validation = validateAnnouncementEvent(announcementEvent, repoName);
if (!validation.valid) {

2
src/lib/services/git/file-manager/branch-operations.ts

@ -34,7 +34,7 @@ export async function getBranches(options: BranchListOptions): Promise<string[]> @@ -34,7 +34,7 @@ export async function getBranches(options: BranchListOptions): Promise<string[]>
}
// Check cache first (cache for 2 minutes)
const cacheKey = RepoCache.branchListKey(npub, repoName);
const cacheKey = RepoCache.branchesKey(npub, repoName);
const cached = repoCache.get<string[]>(cacheKey);
if (cached !== null) {
logger.debug({ npub, repoName, cachedCount: cached.length }, 'Returning cached branch list');

3
src/lib/services/git/file-manager/write-operations.ts

@ -270,7 +270,8 @@ export async function deleteFile(options: Omit<WriteFileOptions, 'content'>): Pr @@ -270,7 +270,8 @@ export async function deleteFile(options: Omit<WriteFileOptions, 'content'>): Pr
throw new Error('Path validation failed: resolved path outside work directory');
}
const { accessSync, constants, unlink } = await import('fs');
const { accessSync, constants } = await import('fs');
const { unlink } = await import('fs/promises');
try {
accessSync(fullFilePath, constants.F_OK);
await unlink(fullFilePath);

281
src/lib/utils/nostr-links.ts

@ -0,0 +1,281 @@ @@ -0,0 +1,281 @@
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
export interface ParsedNostrLink {
type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'nprofile';
value: string;
start: number;
end: number;
}
export interface ProcessedContentPart {
type: 'text' | 'event' | 'profile' | 'placeholder';
value: string;
event?: NostrEvent;
pubkey?: string;
}
/**
* Parse nostr: links from content string
*/
export function parseNostrLinks(content: string): ParsedNostrLink[] {
const links: ParsedNostrLink[] = [];
const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|nprofile1)[a-zA-Z0-9]+/g;
let match;
while ((match = nostrLinkRegex.exec(content)) !== null) {
const fullMatch = match[0];
const prefix = match[1];
let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'nprofile';
if (prefix === 'nevent1') type = 'nevent';
else if (prefix === 'naddr1') type = 'naddr';
else if (prefix === 'note1') type = 'note1';
else if (prefix === 'npub1') type = 'npub';
else if (prefix === 'nprofile1') type = 'nprofile';
else continue;
links.push({
type,
value: fullMatch,
start: match.index,
end: match.index + fullMatch.length
});
}
return links;
}
/**
* Load events and profiles from nostr: links in content
*/
export async function loadNostrLinks(
content: string,
nostrClient: NostrClient,
eventCache: Map<string, NostrEvent>,
profileCache: Map<string, string>
): Promise<void> {
const links = parseNostrLinks(content);
if (links.length === 0) return;
const eventIds: string[] = [];
const aTags: string[] = [];
const npubs: string[] = [];
for (const link of links) {
try {
if (link.type === 'nevent' || link.type === 'note1') {
const decoded = nip19.decode(link.value.replace('nostr:', ''));
if (decoded.type === 'nevent') {
eventIds.push(decoded.data.id);
} else if (decoded.type === 'note') {
eventIds.push(decoded.data as string);
}
} else if (link.type === 'naddr') {
const decoded = nip19.decode(link.value.replace('nostr:', ''));
if (decoded.type === 'naddr') {
const aTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`;
aTags.push(aTag);
}
} else if (link.type === 'npub' || link.type === 'nprofile') {
const decoded = nip19.decode(link.value.replace('nostr:', ''));
if (decoded.type === 'npub') {
npubs.push(link.value);
profileCache.set(link.value, decoded.data as string);
} else if (decoded.type === 'nprofile') {
npubs.push(link.value);
profileCache.set(link.value, decoded.data.pubkey as string);
}
}
} catch {
// Invalid nostr link, skip
}
}
// Fetch events
if (eventIds.length > 0) {
try {
const events = await Promise.race([
nostrClient.fetchEvents([{ ids: eventIds, limit: eventIds.length }]),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000))
]);
for (const event of events) {
eventCache.set(event.id, event);
}
} catch {
// Ignore fetch errors
}
}
// Fetch a-tag events
if (aTags.length > 0) {
for (const aTag of aTags) {
const parts = aTag.split(':');
if (parts.length === 3) {
try {
const kind = parseInt(parts[0]);
const pubkey = parts[1];
const dTag = parts[2];
const events = await Promise.race([
nostrClient.fetchEvents([{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }]),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000))
]);
if (events.length > 0) {
eventCache.set(events[0].id, events[0]);
}
} catch {
// Ignore fetch errors
}
}
}
}
}
/**
* Get event from nostr: link
*/
export function getEventFromNostrLink(
link: string,
eventCache: Map<string, NostrEvent>
): NostrEvent | undefined {
try {
if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) {
const decoded = nip19.decode(link.replace('nostr:', ''));
if (decoded.type === 'nevent') {
return eventCache.get(decoded.data.id);
} else if (decoded.type === 'note') {
return eventCache.get(decoded.data as string);
}
} else if (link.startsWith('nostr:naddr1')) {
const decoded = nip19.decode(link.replace('nostr:', ''));
if (decoded.type === 'naddr') {
return Array.from(eventCache.values()).find(e => {
const dTag = e.tags.find(t => t[0] === 'd')?.[1];
return e.kind === decoded.data.kind &&
e.pubkey === decoded.data.pubkey &&
dTag === decoded.data.identifier;
});
}
}
} catch {
// Invalid link
}
return undefined;
}
/**
* Get pubkey from nostr: npub/profile link
*/
export function getPubkeyFromNostrLink(
link: string,
profileCache: Map<string, string>
): string | undefined {
return profileCache.get(link);
}
/**
* Process content with nostr links into parts for rendering
*/
export function processContentWithNostrLinks(
content: string,
eventCache: Map<string, NostrEvent>,
profileCache: Map<string, string>
): ProcessedContentPart[] {
const links = parseNostrLinks(content);
if (links.length === 0) {
return [{ type: 'text', value: content }];
}
const parts: ProcessedContentPart[] = [];
let lastIndex = 0;
for (const link of links) {
// Add text before link
if (link.start > lastIndex) {
const textPart = content.slice(lastIndex, link.start);
if (textPart) {
parts.push({ type: 'text', value: textPart });
}
}
// Add link
const event = getEventFromNostrLink(link.value, eventCache);
const pubkey = getPubkeyFromNostrLink(link.value, profileCache);
if (event) {
parts.push({ type: 'event', value: link.value, event });
} else if (pubkey) {
parts.push({ type: 'profile', value: link.value, pubkey });
} else {
parts.push({ type: 'placeholder', value: link.value });
}
lastIndex = link.end;
}
// Add remaining text
if (lastIndex < content.length) {
const textPart = content.slice(lastIndex);
if (textPart) {
parts.push({ type: 'text', value: textPart });
}
}
return parts;
}
/**
* Get referenced event from discussion event (via 'e', 'a', or 'q' tag)
*/
export function getReferencedEventFromDiscussion(
event: NostrEvent,
eventCache: Map<string, NostrEvent>
): NostrEvent | undefined {
// Check e-tag
const eTag = event.tags.find(t => t[0] === 'e' && t[1])?.[1];
if (eTag) {
const referenced = eventCache.get(eTag);
if (referenced) return referenced;
}
// Check a-tag
const aTag = event.tags.find(t => t[0] === 'a' && t[1])?.[1];
if (aTag) {
const parts = aTag.split(':');
if (parts.length === 3) {
const kind = parseInt(parts[0]);
const pubkey = parts[1];
const dTag = parts[2];
return Array.from(eventCache.values()).find(e =>
e.kind === kind &&
e.pubkey === pubkey &&
e.tags.find(t => t[0] === 'd' && t[1] === dTag)
);
}
}
// Check q-tag
const qTag = event.tags.find(t => t[0] === 'q' && t[1])?.[1];
if (qTag) {
return eventCache.get(qTag);
}
return undefined;
}
/**
* Format timestamp for discussion display
*/
export function formatDiscussionTime(timestamp: number): string {
const now = Date.now() / 1000;
const diff = now - timestamp;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return new Date(timestamp * 1000).toLocaleDateString();
}

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

@ -382,7 +382,7 @@ export const POST: RequestHandler = createRepoPostHandler( @@ -382,7 +382,7 @@ export const POST: RequestHandler = createRepoPostHandler(
}
// If repo has no branches, sourceBranch will be undefined/null, which createBranch will handle correctly
await fileManager.createBranch(context.npub, context.repo, branchName, sourceBranch, announcement);
await fileManager.createBranch(context.npub, context.repo, branchName, sourceBranch);
return json({ success: true, message: 'Branch created successfully' });
},
{ operation: 'createBranch', requireRepoExists: false } // Allow creating branches in empty repos

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

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
import PRsTab from './components/PRsTab.svelte';
import PatchesTab from './components/PatchesTab.svelte';
import DocsTab from './components/DocsTab.svelte';
import DiscussionsTab from './components/DiscussionsTab.svelte';
import { downloadRepository as downloadRepoUtil } from './utils/download.js';
import { buildApiHeaders } from './utils/api-client.js';
import '$lib/styles/repo.css';
@ -6125,67 +6126,14 @@ @@ -6125,67 +6126,14 @@
/>
{/if}
<!-- Discussions View -->
<!-- Discussions Tab -->
{#if activeTab === 'discussions'}
<aside class="discussions-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'discussions'}>
<div class="discussions-header">
<TabsMenu
activeTab={activeTab}
{tabs}
onTabChange={(tab) => activeTab = tab as typeof activeTab}
/>
<h2>Discussions</h2>
{#if userPubkey}
<button
onclick={() => showCreateThreadDialog = true}
class="create-discussion-button"
disabled={creatingThread}
title={creatingThread ? 'Creating...' : 'New Discussion Thread'}
>
<img src="/icons/plus.svg" alt="New Discussion" class="icon" />
</button>
{/if}
<button
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile}
class="mobile-toggle-button"
title="Show content"
>
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" />
</button>
</div>
{#if loadingDiscussions}
<div class="loading">Loading discussions...</div>
{:else if discussions.length > 0}
<ul class="discussion-list">
{#each discussions as discussion}
{@const hasComments = discussion.comments && discussion.comments.length > 0}
{@const totalReplies = hasComments ? countAllReplies(discussion.comments) : 0}
<li class="discussion-item" class:selected={selectedDiscussion === discussion.id}>
<button
onclick={() => selectedDiscussion = discussion.id}
class="discussion-item-button"
>
<div class="discussion-header">
<span class="discussion-title">{discussion.title}</span>
</div>
<div class="discussion-meta">
{#if discussion.type === 'thread'}
<span class="discussion-type">Thread</span>
{#if hasComments}
<span class="comment-count">{totalReplies} {totalReplies === 1 ? 'reply' : 'replies'}</span>
{/if}
{:else}
<span class="discussion-type">Comments</span>
{/if}
<span>{new Date(discussion.createdAt * 1000).toLocaleDateString()}</span>
<EventCopyButton eventId={discussion.id} kind={discussion.kind} pubkey={discussion.pubkey} />
</div>
</button>
</li>
{/each}
</ul>
{/if}
</aside>
<DiscussionsTab
{npub}
{repo}
repoAnnouncement={repoAnnouncement}
{userPubkey}
/>
{/if}
<!-- Docs Tab -->
@ -6271,355 +6219,10 @@ @@ -6271,355 +6219,10 @@
<!-- PRs tab content is now handled by PRsTab component -->
<!-- Patches tab content is now handled by PatchesTab component -->
</div>
</div>
<!-- Highlights Section -->
<div class="patch-highlights-section">
<div class="section-header">
<h4>Highlights & Comments</h4>
{#if userPubkey}
<button onclick={() => startPatchComment()} class="add-comment-btn">Add Comment</button>
{/if}
</div>
{#if loadingPatchHighlights}
<div class="loading">Loading highlights...</div>
{:else}
<!-- Top-level comments on patch -->
{#each patchComments as comment}
<div class="comment-item">
<div class="comment-header">
<span class="comment-author">{formatPubkey(comment.pubkey)}</span>
<span class="comment-date">{new Date(comment.created_at * 1000).toLocaleString()}</span>
</div>
<div class="comment-content">{comment.content}</div>
{#if userPubkey}
<button onclick={() => startPatchComment(comment.id)} class="reply-btn">Reply</button>
{/if}
</div>
{/each}
<!-- Highlights with comments - filter to only show highlights for this patch -->
{#each patchHighlights.filter(h => !h.sourceEventId || h.sourceEventId === patch.id) as highlight}
<div class="highlight-item">
<div class="highlight-header">
<span class="highlight-author">{formatPubkey(highlight.pubkey)}</span>
<span class="highlight-date">{new Date(highlight.created_at * 1000).toLocaleString()}</span>
{#if highlight.file}
<span class="highlight-file">{highlight.file}</span>
{/if}
{#if highlight.lineStart && highlight.sourceEventId === patch.id}
<button
class="highlight-lines-button"
onclick={() => {
if (patchEditor && highlight.lineStart && highlight.lineEnd) {
patchEditor.scrollToLines(highlight.lineStart, highlight.lineEnd);
}
}}
title="Click to highlight these lines in the patch"
>
Lines {highlight.lineStart}-{highlight.lineEnd}
</button>
{/if}
</div>
<div class="highlighted-code">
<pre><code>{highlight.highlightedContent || highlight.content}</code></pre>
</div>
{#if highlight.comment}
<div class="highlight-comment">
<img src="/icons/message-circle.svg" alt="Comment" class="comment-icon" />
{highlight.comment}
</div>
{/if}
<!-- Comments on this highlight -->
{#if highlight.comments && highlight.comments.length > 0}
<div class="highlight-comments">
{#each highlight.comments as comment}
<div class="comment-item nested">
<div class="comment-header">
<span class="comment-author">{formatPubkey(comment.pubkey)}</span>
<span class="comment-date">{new Date(comment.created_at * 1000).toLocaleString()}</span>
</div>
<div class="comment-content">{comment.content}</div>
{#if userPubkey}
<button onclick={() => startPatchComment(comment.id)} class="reply-btn">Reply</button>
{/if}
</div>
{/each}
</div>
{/if}
{#if userPubkey}
<button onclick={() => startPatchComment(highlight.id)} class="add-comment-btn">Add Comment</button>
{/if}
</div>
{/each}
{#if patchHighlights.filter(h => !h.sourceEventId || h.sourceEventId === patch.id).length === 0 && patchComments.length === 0}
<div class="empty">No highlights or comments yet. Select text in the patch to create a highlight.</div>
{/if}
{/if}
</div>
</div>
{/each}
{:else}
<div class="empty-state">
<p>Select a patch from the sidebar to view it</p>
</div>
{/if}
</div>
{/if}
{#if activeTab === 'discussions'}
<div class="discussions-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'discussions'}>
<div class="content-header-mobile">
<button
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile}
class="mobile-toggle-button"
title="Show list"
>
<img src="/icons/arrow-right.svg" alt="Show list" class="icon-inline mobile-toggle-left" />
</button>
</div>
{#if discussions.length === 0}
<div class="empty-state">
<p>No discussions found. {#if userPubkey}Create a new discussion thread to get started!{:else}Log in to create a discussion thread.{/if}</p>
</div>
{:else if selectedDiscussion}
{#each discussions.filter(d => d.id === selectedDiscussion) as discussion}
{@const isExpanded = discussion.type === 'thread' && expandedThreads.has(discussion.id)}
{@const hasComments = discussion.comments && discussion.comments.length > 0}
<div class="discussion-detail">
<div class="discussion-header-detail">
<h3>{discussion.title}</h3>
<div class="discussion-meta-detail">
{#if discussion.type === 'thread'}
<span class="discussion-type">Thread</span>
{#if hasComments}
{@const totalReplies = countAllReplies(discussion.comments)}
<span class="comment-count">{totalReplies} {totalReplies === 1 ? 'reply' : 'replies'}</span>
{/if}
{:else}
<span class="discussion-type">Comments</span>
{/if}
<span>Created {new Date(discussion.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={discussion.id} kind={discussion.kind} pubkey={discussion.pubkey} />
{#if discussion.type === 'thread' && userPubkey}
<button
class="create-reply-button"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = null;
showReplyDialog = true;
}}
title="Reply to thread"
>
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
</div>
{#if discussion.content}
<div class="discussion-body">
<p>{discussion.content}</p>
</div>
{/if}
{#if discussion.type === 'thread' && hasComments}
{@const totalReplies = countAllReplies(discussion.comments)}
<div class="comments-section">
<h4>Replies ({totalReplies})</h4>
{#each discussion.comments! as comment}
<div class="comment-item">
<div class="comment-meta">
<UserBadge pubkey={comment.author} />
<span>{new Date(comment.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={comment.id} kind={comment.kind} pubkey={comment.pubkey} />
{#if userPubkey}
<button
class="create-reply-button"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: comment.id, kind: comment.kind, pubkey: comment.pubkey, author: comment.author };
showReplyDialog = true;
}}
title="Reply to comment"
>
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
{#if true}
{@const commentEvent = getDiscussionEvent(comment.id)}
{@const referencedEvent = commentEvent ? getReferencedEventFromDiscussion(commentEvent) : undefined}
{@const parts = processContentWithNostrLinks(comment.content)}
<div class="comment-content">
{#if referencedEvent}
<div class="referenced-event">
<div class="referenced-event-header">
<UserBadge pubkey={referencedEvent.pubkey} disableLink={true} />
<span class="referenced-event-time">{formatDiscussionTime(referencedEvent.created_at)}</span>
</div>
<div class="referenced-event-content">{referencedEvent.content || '(No content)'}</div>
</div>
{/if}
<div>
{#each parts as part}
{#if part.type === 'text'}
<span>{part.value}</span>
{:else if part.type === 'event' && part.event}
<div class="nostr-link-event">
<div class="nostr-link-event-header">
<UserBadge pubkey={part.event.pubkey} disableLink={true} />
<span class="nostr-link-event-time">{formatDiscussionTime(part.event.created_at)}</span>
</div>
<div class="nostr-link-event-content">{part.event.content || '(No content)'}</div>
</div>
{:else if part.type === 'profile' && part.pubkey}
<UserBadge pubkey={part.pubkey} />
{:else}
<span class="nostr-link-placeholder">{part.value}</span>
{/if}
{/each}
</div>
</div>
{/if}
{#if comment.replies && comment.replies.length > 0}
<div class="nested-replies">
{#each comment.replies as reply}
<div class="comment-item nested-comment">
<div class="comment-meta">
<UserBadge pubkey={reply.author} />
<span>{new Date(reply.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={reply.id} kind={reply.kind} pubkey={reply.pubkey} />
{#if userPubkey}
<button
class="create-reply-button"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: reply.id, kind: reply.kind, pubkey: reply.pubkey, author: reply.author };
showReplyDialog = true;
}}
title="Reply to comment"
>
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
<div class="comment-content">
<p>{reply.content}</p>
</div>
{#if reply.replies && reply.replies.length > 0}
<div class="nested-replies">
{#each reply.replies as nestedReply}
<div class="comment-item nested-comment">
<div class="comment-meta">
<UserBadge pubkey={nestedReply.author} />
<span>{new Date(nestedReply.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={nestedReply.id} kind={nestedReply.kind} pubkey={nestedReply.pubkey} />
{#if userPubkey}
<button
class="create-reply-button"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: nestedReply.id, kind: nestedReply.kind, pubkey: nestedReply.pubkey, author: nestedReply.author };
showReplyDialog = true;
}}
title="Reply to comment"
>
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
<div class="comment-content">
<p>{nestedReply.content}</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{:else if discussion.type === 'comments' && hasComments}
{@const totalReplies = countAllReplies(discussion.comments)}
<div class="comments-section">
<h4>Comments ({totalReplies})</h4>
{#each discussion.comments! as comment}
<div class="comment-item">
<div class="comment-meta">
<UserBadge pubkey={comment.author} />
<span>{new Date(comment.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={comment.id} kind={comment.kind} pubkey={comment.pubkey} />
{#if userPubkey}
<button
class="create-reply-button"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: comment.id, kind: comment.kind, pubkey: comment.pubkey, author: comment.author };
showReplyDialog = true;
}}
title="Reply to comment"
>
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
{#if true}
{@const commentEvent = getDiscussionEvent(comment.id)}
{@const referencedEvent = commentEvent ? getReferencedEventFromDiscussion(commentEvent) : undefined}
{@const parts = processContentWithNostrLinks(comment.content)}
<div class="comment-content">
{#if referencedEvent}
<div class="referenced-event">
<div class="referenced-event-header">
<UserBadge pubkey={referencedEvent.pubkey} disableLink={true} />
<span class="referenced-event-time">{formatDiscussionTime(referencedEvent.created_at)}</span>
</div>
<div class="referenced-event-content">{referencedEvent.content || '(No content)'}</div>
</div>
{/if}
<div>
{#each parts as part}
{#if part.type === 'text'}
<span>{part.value}</span>
{:else if part.type === 'event' && part.event}
<div class="nostr-link-event">
<div class="nostr-link-event-header">
<UserBadge pubkey={part.event.pubkey} disableLink={true} />
<span class="nostr-link-event-time">{formatDiscussionTime(part.event.created_at)}</span>
</div>
<div class="nostr-link-event-content">{part.event.content || '(No content)'}</div>
</div>
{:else if part.type === 'profile' && part.pubkey}
<UserBadge pubkey={part.pubkey} />
{:else}
<span class="nostr-link-placeholder">{part.value}</span>
{/if}
{/each}
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
{:else}
<div class="empty-state">
<p>Select a discussion from the sidebar to view it</p>
</div>
{/if}
</div>
{/if}
<!-- Docs tab content is now handled by DocsTab component -->
</div>
</div>
{/if}
</main>

23
src/routes/repos/[npub]/[repo]/components/CommitDialog.svelte

@ -1,10 +1,21 @@ @@ -1,10 +1,21 @@
<script lang="ts">
export let show: boolean = false;
export let commitMessage: string = '';
export let saving: boolean = false;
export let onCommit: () => void = () => {};
export let onCancel: () => void = () => {};
export let onMessageChange: (message: string) => void = () => {};
interface Props {
show?: boolean;
commitMessage?: string;
saving?: boolean;
onCommit?: () => void;
onCancel?: () => void;
onMessageChange?: (message: string) => void;
}
let {
show = false,
commitMessage = '',
saving = false,
onCommit = () => {},
onCancel = () => {},
onMessageChange = () => {}
}: Props = $props();
</script>
{#if show}

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

@ -0,0 +1,647 @@ @@ -0,0 +1,647 @@
<script lang="ts">
import { onMount } from 'svelte';
import TabLayout from './TabLayout.svelte';
import DiscussionRenderer, { type Discussion } from '$lib/components/DiscussionRenderer.svelte';
import CommentRenderer from '$lib/components/CommentRenderer.svelte';
import type { Comment } from '$lib/components/CommentRenderer.svelte';
import EventCopyButton from '$lib/components/EventCopyButton.svelte';
import { DiscussionsService } from '$lib/services/nostr/discussions-service.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { loadNostrLinks } from '$lib/utils/nostr-links.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import { KIND } from '$lib/types/nostr.js';
import { signEventWithNIP07, getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
interface Props {
npub: string;
repo: string;
repoAnnouncement?: NostrEvent;
userPubkey?: string | null;
}
let { npub, repo, repoAnnouncement, userPubkey }: Props = $props();
let discussions = $state<Discussion[]>([]);
let loadingDiscussions = $state(false);
let selectedDiscussion = $state<string | null>(null);
let error = $state<string | null>(null);
// Event caches for Nostr links
let discussionEvents = $state<Map<string, NostrEvent>>(new Map());
let nostrLinkEvents = $state<Map<string, NostrEvent>>(new Map());
let nostrLinkProfiles = $state<Map<string, string>>(new Map());
// Dialog states
let showCreateThreadDialog = $state(false);
let showReplyDialog = $state(false);
let creatingThread = $state(false);
let creatingReply = $state(false);
let replyingToThread = $state<{ id: string; kind: number; pubkey: string; author: string } | null>(null);
let replyingToComment = $state<{ id: string; kind: number; pubkey: string; author: string } | null>(null);
let newThreadTitle = $state('');
let newThreadContent = $state('');
let replyContent = $state('');
const discussionsService = new DiscussionsService(DEFAULT_NOSTR_RELAYS);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
onMount(async () => {
await loadDiscussions();
});
async function loadDiscussions() {
if (!repoAnnouncement) return;
loadingDiscussions = true;
error = null;
try {
const userRelays = userPubkey ? await getUserRelays(userPubkey, nostrClient) : null;
const combinedRelays = combineRelays(
DEFAULT_NOSTR_RELAYS,
DEFAULT_NOSTR_SEARCH_RELAYS,
userRelays?.outbox || []
);
const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
const discussionsServiceWithRelays = new DiscussionsService(combinedRelays);
const discussionEntries = await discussionsServiceWithRelays.getDiscussions(
repoOwnerPubkey,
repo,
repoAnnouncement.id,
repoAnnouncement.pubkey,
combinedRelays,
combinedRelays
);
const fetchedDiscussions = discussionEntries.map(entry => ({
type: entry.type,
id: entry.id,
title: entry.title,
content: entry.content,
author: entry.author,
createdAt: entry.createdAt,
kind: entry.kind,
pubkey: entry.pubkey,
comments: entry.comments
}));
discussions = fetchedDiscussions;
// Load events for discussions and comments
await loadDiscussionEvents(fetchedDiscussions);
// Load Nostr links from all discussion content
for (const discussion of fetchedDiscussions) {
if (discussion.content) {
await loadNostrLinks(discussion.content, nostrClient, nostrLinkEvents, nostrLinkProfiles);
}
if (discussion.comments) {
for (const comment of discussion.comments) {
await loadNostrLinks(comment.content, nostrClient, nostrLinkEvents, nostrLinkProfiles);
if (comment.replies) {
for (const reply of comment.replies) {
await loadNostrLinks(reply.content, nostrClient, nostrLinkEvents, nostrLinkProfiles);
}
}
}
}
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load discussions';
console.error('Error loading discussions:', err);
} finally {
loadingDiscussions = false;
}
}
async function loadDiscussionEvents(discussionsList: Discussion[]) {
const eventIds = new Set<string>();
// Collect all event IDs
for (const discussion of discussionsList) {
if (discussion.id) {
eventIds.add(discussion.id);
}
if (discussion.comments) {
for (const comment of discussion.comments) {
if (comment.id) {
eventIds.add(comment.id);
}
if (comment.replies) {
for (const reply of comment.replies) {
if (reply.id) {
eventIds.add(reply.id);
}
}
}
}
}
}
if (eventIds.size === 0) return;
try {
const events = await Promise.race([
nostrClient.fetchEvents([{ ids: Array.from(eventIds), limit: eventIds.size }]),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000))
]);
for (const event of events) {
discussionEvents.set(event.id, event);
}
} catch {
// Ignore fetch errors
}
}
function countAllReplies(comments?: Comment[]): number {
if (!comments) return 0;
let count = comments.length;
for (const comment of comments) {
if (comment.replies) {
count += countAllReplies(comment.replies);
}
}
return count;
}
async function createDiscussionThread() {
if (!userPubkey || !repoAnnouncement || !newThreadTitle.trim()) return;
creatingThread = true;
error = null;
try {
const userPubkeyHex = await getPublicKeyWithNIP07();
if (!userPubkeyHex) throw new Error('Failed to get user pubkey');
const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`;
const threadEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.THREAD,
pubkey: userPubkeyHex,
created_at: Math.floor(Date.now() / 1000),
tags: [
['a', repoAddress],
['title', newThreadTitle.trim()],
['t', 'repo']
],
content: newThreadContent.trim() || ''
};
const signedEvent = await signEventWithNIP07(threadEventTemplate);
const userRelays = await getUserRelays(userPubkeyHex, nostrClient);
const combinedRelays = combineRelays(
DEFAULT_NOSTR_RELAYS,
DEFAULT_NOSTR_SEARCH_RELAYS,
userRelays?.outbox || []
);
const publishClient = new NostrClient(combinedRelays);
await publishClient.publishEvent(signedEvent, combinedRelays);
showCreateThreadDialog = false;
newThreadTitle = '';
newThreadContent = '';
await loadDiscussions();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create discussion thread';
console.error('Error creating thread:', err);
} finally {
creatingThread = false;
}
}
async function createThreadReply() {
if (!userPubkey || !replyingToThread || !replyContent.trim()) return;
creatingReply = true;
error = null;
try {
const userPubkeyHex = await getPublicKeyWithNIP07();
if (!userPubkeyHex) throw new Error('Failed to get user pubkey');
const commentEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.COMMENT,
pubkey: userPubkeyHex,
created_at: Math.floor(Date.now() / 1000),
tags: [
['E', replyingToThread.id], // Root event (the thread)
['K', KIND.THREAD.toString()], // Root kind
['P', replyingToThread.pubkey], // Root pubkey
...(replyingToComment ? [
['e', replyingToComment.id], // Parent event (the comment being replied to)
['k', replyingToComment.kind.toString()], // Parent kind
['p', replyingToComment.pubkey] // Parent pubkey
] : [])
],
content: replyContent.trim()
};
const signedEvent = await signEventWithNIP07(commentEventTemplate);
const userRelays = await getUserRelays(userPubkeyHex, nostrClient);
const combinedRelays = combineRelays(
DEFAULT_NOSTR_RELAYS,
DEFAULT_NOSTR_SEARCH_RELAYS,
userRelays?.outbox || []
);
const publishClient = new NostrClient(combinedRelays);
await publishClient.publishEvent(signedEvent, combinedRelays);
showReplyDialog = false;
replyContent = '';
replyingToThread = null;
replyingToComment = null;
await loadDiscussions();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create reply';
console.error('Error creating reply:', err);
} finally {
creatingReply = false;
}
}
function handleReplyToThread(threadId: string) {
const discussion = discussions.find(d => d.id === threadId);
if (discussion) {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = null;
showReplyDialog = true;
}
}
function handleReplyToComment(commentId: string) {
// Find the discussion and comment
for (const discussion of discussions) {
if (discussion.comments) {
const findComment = (comments: Comment[]): Comment | undefined => {
for (const comment of comments) {
if (comment.id === commentId) return comment;
if (comment.replies) {
const found = findComment(comment.replies);
if (found) return found;
}
}
return undefined;
};
const comment = findComment(discussion.comments);
if (comment) {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: comment.id, kind: comment.kind, pubkey: comment.pubkey, author: comment.author };
showReplyDialog = true;
return;
}
}
}
}
</script>
<TabLayout>
{#snippet leftPane()}
<div class="discussions-sidebar">
<div class="discussions-header">
<h2>Discussions</h2>
{#if userPubkey}
<button
onclick={() => showCreateThreadDialog = true}
class="create-discussion-button"
disabled={creatingThread}
title={creatingThread ? 'Creating...' : 'New Discussion Thread'}
>
<img src="/icons/plus.svg" alt="New Discussion" class="icon" />
</button>
{/if}
</div>
{#if loadingDiscussions}
<div class="loading">Loading discussions...</div>
{:else if discussions.length > 0}
<ul class="discussion-list">
{#each discussions as discussion}
{@const hasComments = discussion.comments && discussion.comments.length > 0}
{@const totalReplies = hasComments ? countAllReplies(discussion.comments) : 0}
<li class="discussion-item" class:selected={selectedDiscussion === discussion.id}>
<button
onclick={() => selectedDiscussion = discussion.id}
class="discussion-item-button"
>
<div class="discussion-header">
<span class="discussion-title">{discussion.title}</span>
</div>
<div class="discussion-meta">
{#if discussion.type === 'thread'}
<span class="discussion-type">Thread</span>
{#if hasComments}
<span class="comment-count">{totalReplies} {totalReplies === 1 ? 'reply' : 'replies'}</span>
{/if}
{:else}
<span class="discussion-type">Comments</span>
{/if}
<span>{new Date(discussion.createdAt * 1000).toLocaleDateString()}</span>
<EventCopyButton eventId={discussion.id} kind={discussion.kind} pubkey={discussion.pubkey} />
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/snippet}
{#snippet rightPanel()}
{#if error}
<div class="error">{error}</div>
{/if}
{#if selectedDiscussion}
{@const discussion = discussions.find(d => d.id === selectedDiscussion)}
{#if discussion}
<DiscussionRenderer
{discussion}
discussionEvent={discussionEvents.get(discussion.id)}
eventCache={discussionEvents}
profileCache={nostrLinkProfiles}
{userPubkey}
onReplyToThread={handleReplyToThread}
onReplyToComment={handleReplyToComment}
/>
{/if}
{:else}
<div class="empty-state">
<p>Select a discussion from the sidebar to view it</p>
</div>
{/if}
{/snippet}
</TabLayout>
<!-- Create Thread Dialog -->
{#if showCreateThreadDialog && userPubkey}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Create discussion thread"
onclick={() => showCreateThreadDialog = false}
onkeydown={(e) => e.key === 'Escape' && (showCreateThreadDialog = false)}
tabindex="-1"
>
<div
class="modal"
role="document"
onclick={(e) => e.stopPropagation()}
>
<h3>Create Discussion Thread</h3>
<label>
Title:
<input type="text" bind:value={newThreadTitle} placeholder="Thread title..." />
</label>
<label>
Content:
<textarea bind:value={newThreadContent} rows="6" placeholder="Thread content..."></textarea>
</label>
<div class="modal-actions">
<button onclick={() => showCreateThreadDialog = false} class="cancel-button">Cancel</button>
<button
onclick={createDiscussionThread}
disabled={!newThreadTitle.trim() || creatingThread}
class="save-button"
>
{creatingThread ? 'Creating...' : 'Create'}
</button>
</div>
</div>
</div>
{/if}
<!-- Reply Dialog -->
{#if showReplyDialog && userPubkey && replyingToThread}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Reply to discussion"
onclick={() => showReplyDialog = false}
onkeydown={(e) => e.key === 'Escape' && (showReplyDialog = false)}
tabindex="-1"
>
<div
class="modal"
role="document"
onclick={(e) => e.stopPropagation()}
>
<h3>{replyingToComment ? 'Reply to Comment' : 'Reply to Thread'}</h3>
<label>
Reply:
<textarea bind:value={replyContent} rows="6" placeholder="Your reply..."></textarea>
</label>
<div class="modal-actions">
<button onclick={() => showReplyDialog = false} class="cancel-button">Cancel</button>
<button
onclick={createThreadReply}
disabled={!replyContent.trim() || creatingReply}
class="save-button"
>
{creatingReply ? 'Posting...' : 'Post Reply'}
</button>
</div>
</div>
</div>
{/if}
<style>
.discussions-sidebar {
height: 100%;
overflow-y: auto;
}
.discussions-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.discussions-header h2 {
margin: 0;
font-size: 1.25rem;
}
.create-discussion-button {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
}
.create-discussion-button:hover {
opacity: 0.7;
}
.create-discussion-button .icon {
width: 20px;
height: 20px;
}
.discussion-list {
list-style: none;
padding: 0;
margin: 0;
}
.discussion-item {
margin-bottom: 0.5rem;
}
.discussion-item-button {
width: 100%;
text-align: left;
padding: 0.75rem;
background: var(--item-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.discussion-item-button:hover {
background: var(--item-hover-bg, #f5f5f5);
}
.discussion-item.selected .discussion-item-button {
background: var(--selected-bg, #e3f2fd);
border-color: var(--selected-border, #2196f3);
}
.discussion-header {
margin-bottom: 0.5rem;
}
.discussion-title {
font-weight: 600;
font-size: 1rem;
}
.discussion-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
.discussion-type {
padding: 0.125rem 0.5rem;
background: var(--type-bg, #e0e0e0);
border-radius: 4px;
font-size: 0.75rem;
}
.comment-count {
font-weight: 500;
}
.empty-state {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #666);
}
.error {
padding: 1rem;
background: var(--error-bg, #ffebee);
color: var(--error-color, #c62828);
border-radius: 4px;
margin-bottom: 1rem;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--modal-bg, #fff);
border-radius: 8px;
padding: 1.5rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal h3 {
margin: 0 0 1rem 0;
}
.modal label {
display: block;
margin-bottom: 1rem;
}
.modal label input,
.modal label textarea {
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.cancel-button,
.save-button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
}
.save-button {
background: var(--primary-color, #2196f3);
color: white;
}
.save-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

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

@ -12,10 +12,19 @@ @@ -12,10 +12,19 @@
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import logger from '$lib/services/logger.js';
export let npub: string = '';
export let repo: string = '';
export let currentBranch: string | null = null;
export let relays: string[] = DEFAULT_NOSTR_RELAYS;
interface Props {
npub?: string;
repo?: string;
currentBranch?: string | null;
relays?: string[];
}
let {
npub = '',
repo = '',
currentBranch = null,
relays = DEFAULT_NOSTR_RELAYS
}: Props = $props();
let documentationContent = $state<string | null>(null);
let documentationKind = $state<'markdown' | 'asciidoc' | 'text' | '30040' | null>(null);

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

@ -10,10 +10,19 @@ @@ -10,10 +10,19 @@
import { KIND } from '$lib/types/nostr.js';
import logger from '$lib/services/logger.js';
export let content: string = '';
export let contentType: 'markdown' | 'asciidoc' | 'text' | '30040' = 'text';
export let indexEvent: NostrEvent | null = null;
export let relays: string[] = [];
interface Props {
content?: string;
contentType?: 'markdown' | 'asciidoc' | 'text' | '30040';
indexEvent?: NostrEvent | null;
relays?: string[];
}
let {
content = '',
contentType = 'text',
indexEvent = null,
relays = []
}: Props = $props();
let renderedContent = $state('');
let loading = $state(false);

26
src/routes/repos/[npub]/[repo]/components/FileBrowser.svelte

@ -1,13 +1,25 @@ @@ -1,13 +1,25 @@
<script lang="ts">
import type { FileEntry } from '$lib/services/git/file-manager.js';
export let files: FileEntry[] = [];
export let currentPath: string = '';
export let loading: boolean = false;
export let onFileClick: (file: FileEntry) => void = () => {};
export let onDirectoryClick: (path: string) => void = () => {};
export let onNavigateBack: () => void = () => {};
export let pathStack: string[] = [];
interface Props {
files?: FileEntry[];
currentPath?: string;
loading?: boolean;
onFileClick?: (file: FileEntry) => void;
onDirectoryClick?: (path: string) => void;
onNavigateBack?: () => void;
pathStack?: string[];
}
let {
files = [],
currentPath = '',
loading = false,
onFileClick = () => {},
onDirectoryClick = () => {},
onNavigateBack = () => {},
pathStack = []
}: Props = $props();
</script>
<div class="file-browser">

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

@ -8,44 +8,87 @@ @@ -8,44 +8,87 @@
import FileBrowser from './FileBrowser.svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
export let files: Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }> = [];
export let currentPath: string = '';
export let currentFile: string | null = null;
export let fileContent: string = '';
export let fileLanguage: 'markdown' | 'asciidoc' | 'text' = 'text';
export let editedContent: string = '';
export let hasChanges: boolean = false;
export let loading: boolean = false;
export let error: string | null = null;
export let pathStack: string[] = [];
export let onFileClick: (file: { name: string; path: string; type: 'file' | 'directory' }) => void = () => {};
export let onDirectoryClick: (path: string) => void = () => {};
export let onNavigateBack: () => void = () => {};
export let onContentChange: (content: string) => void = () => {};
export let isMaintainer: boolean = false;
export let readmeContent: string | null = null;
export let readmePath: string | null = null;
export let readmeHtml: string | null = null;
export let showFilePreview: boolean = false;
export let fileHtml: string | null = null;
export let highlightedFileContent: string | null = null;
export let isImageFile: boolean = false;
export let imageUrl: string | null = null;
export let wordWrap: boolean = false;
export let supportsPreview: (ext: string) => boolean = () => false;
export let onSave: () => void = () => {};
export let onTogglePreview: () => void = () => {};
export let onCopyFileContent: (e: Event) => void = () => {};
export let onDownloadFile: () => void = () => {};
export let copyingFile: boolean = false;
export let saving: boolean = false;
export let needsClone: boolean = false;
export let cloneTooltip: string = '';
export let branches: Array<string | { name: string }> = [];
export let currentBranch: string | null = null;
export let defaultBranch: string | null = null;
export let onBranchChange: (branch: string) => void = () => {};
export let userPubkey: string | null = null;
interface Props {
files?: Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }>;
currentPath?: string;
currentFile?: string | null;
fileContent?: string;
fileLanguage?: 'markdown' | 'asciidoc' | 'text';
editedContent?: string;
hasChanges?: boolean;
loading?: boolean;
error?: string | null;
pathStack?: string[];
onFileClick?: (file: { name: string; path: string; type: 'file' | 'directory' }) => void;
onDirectoryClick?: (path: string) => void;
onNavigateBack?: () => void;
onContentChange?: (content: string) => void;
isMaintainer?: boolean;
readmeContent?: string | null;
readmePath?: string | null;
readmeHtml?: string | null;
showFilePreview?: boolean;
fileHtml?: string | null;
highlightedFileContent?: string | null;
isImageFile?: boolean;
imageUrl?: string | null;
wordWrap?: boolean;
supportsPreview?: (ext: string) => boolean;
onSave?: () => void;
onTogglePreview?: () => void;
onCopyFileContent?: (e: Event) => void;
onDownloadFile?: () => void;
copyingFile?: boolean;
saving?: boolean;
needsClone?: boolean;
cloneTooltip?: string;
branches?: Array<string | { name: string }>;
currentBranch?: string | null;
defaultBranch?: string | null;
onBranchChange?: (branch: string) => void;
userPubkey?: string | null;
}
let {
files = [],
currentPath = '',
currentFile = null,
fileContent = '',
fileLanguage = 'text',
editedContent = '',
hasChanges = false,
loading = false,
error = null,
pathStack = [],
onFileClick = () => {},
onDirectoryClick = () => {},
onNavigateBack = () => {},
onContentChange = () => {},
isMaintainer = false,
readmeContent = null,
readmePath = null,
readmeHtml = null,
showFilePreview = false,
fileHtml = null,
highlightedFileContent = null,
isImageFile = false,
imageUrl = null,
wordWrap = false,
supportsPreview = () => false,
onSave = () => {},
onTogglePreview = () => {},
onCopyFileContent = () => {},
onDownloadFile = () => {},
copyingFile = false,
saving = false,
needsClone = false,
cloneTooltip = '',
branches = [],
currentBranch = null,
defaultBranch = null,
onBranchChange = () => {},
userPubkey = null
}: Props = $props();
</script>
<TabLayout {loading} {error}>

46
src/routes/repos/[npub]/[repo]/components/HistoryTab.svelte

@ -5,22 +5,36 @@ @@ -5,22 +5,36 @@
import TabLayout from './TabLayout.svelte';
export let commits: Array<{
hash: string;
message: string;
author: string;
date: string;
files: string[];
verification?: any;
}> = [];
export let selectedCommit: string | null = null;
export let loading: boolean = false;
export let error: string | null = null;
export let onSelect: (hash: string) => void = () => {};
export let onVerify: (hash: string) => void = () => {};
export let verifyingCommits: Set<string> = new Set();
export let showDiff: boolean = false;
export let diffData: Array<{ file: string; additions: number; deletions: number; diff: string }> = [];
interface Props {
commits?: Array<{
hash: string;
message: string;
author: string;
date: string;
files: string[];
verification?: any;
}>;
selectedCommit?: string | null;
loading?: boolean;
error?: string | null;
onSelect?: (hash: string) => void;
onVerify?: (hash: string) => void;
verifyingCommits?: Set<string>;
showDiff?: boolean;
diffData?: Array<{ file: string; additions: number; deletions: number; diff: string }>;
}
let {
commits = [],
selectedCommit = null,
loading = false,
error = null,
onSelect = () => {},
onVerify = () => {},
verifyingCommits = new Set(),
showDiff = false,
diffData = []
}: Props = $props();
</script>
<TabLayout {loading} {error}>

47
src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte

@ -5,23 +5,36 @@ @@ -5,23 +5,36 @@
import StatusTabLayout from './StatusTabLayout.svelte';
export let issues: Array<{
id: string;
subject: string;
content: string;
status: string;
author: string;
created_at: number;
kind: number;
tags?: string[][];
}> = [];
export let selectedIssue: string | null = null;
export let loading: boolean = false;
export let error: string | null = null;
export let onSelect: (id: string) => void = () => {};
export let onStatusUpdate: (id: string, status: string) => void = () => {};
export let issueReplies: Array<any> = [];
export let loadingReplies: boolean = false;
interface Props {
issues: Array<{
id: string;
subject: string;
content: string;
status: string;
author: string;
created_at: number;
kind: number;
tags?: string[][];
}>;
selectedIssue?: string | null;
loading?: boolean;
error?: string | null;
onSelect?: (id: string) => void;
onStatusUpdate?: (id: string, status: string) => void;
issueReplies?: Array<any>;
loadingReplies?: boolean;
}
let {
issues = [],
selectedIssue = null,
loading = false,
error = null,
onSelect = () => {},
onStatusUpdate = () => {},
issueReplies = [],
loadingReplies = false
}: Props = $props();
const items = $derived(issues.map(issue => ({
id: issue.id,

41
src/routes/repos/[npub]/[repo]/components/PRsTab.svelte

@ -5,21 +5,32 @@ @@ -5,21 +5,32 @@
import StatusTabLayout from './StatusTabLayout.svelte';
export let prs: Array<{
id: string;
subject: string;
content: string;
status: string;
author: string;
created_at: number;
commitId?: string;
kind: number;
}> = [];
export let selectedPR: string | null = null;
export let loading: boolean = false;
export let error: string | null = null;
export let onSelect: (id: string) => void = () => {};
export let onStatusUpdate: (id: string, status: string) => void = () => {};
interface Props {
prs: Array<{
id: string;
subject: string;
content: string;
status: string;
author: string;
created_at: number;
commitId?: string;
kind: number;
}>;
selectedPR?: string | null;
loading?: boolean;
error?: string | null;
onSelect?: (id: string) => void;
onStatusUpdate?: (id: string, status: string) => void;
}
let {
prs = [],
selectedPR = null,
loading = false,
error = null,
onSelect = () => {},
onStatusUpdate = () => {}
}: Props = $props();
const items = $derived(prs.map(pr => ({
id: pr.id,

42
src/routes/repos/[npub]/[repo]/components/PatchesTab.svelte

@ -5,21 +5,33 @@ @@ -5,21 +5,33 @@
import StatusTabLayout from './StatusTabLayout.svelte';
export let patches: Array<{
id: string;
subject: string;
content: string;
status: string;
author: string;
created_at: number;
[key: string]: any;
}> = [];
export let selectedPatch: string | null = null;
export let loading: boolean = false;
export let error: string | null = null;
export let onSelect: (id: string) => void = () => {};
export let onApply: (id: string) => void = () => {};
export let applying: Record<string, boolean> = {};
interface Props {
patches: Array<{
id: string;
subject: string;
content: string;
status: string;
author: string;
created_at: number;
[key: string]: any;
}>;
selectedPatch?: string | null;
loading?: boolean;
error?: string | null;
onSelect?: (id: string) => void;
onApply?: (id: string) => void;
applying?: Record<string, boolean>;
}
let {
patches = [],
selectedPatch = null,
loading = false,
error = null,
onSelect = () => {},
onApply = () => {},
applying = {}
}: Props = $props();
const items = $derived(patches.map(patch => ({
id: patch.id,

39
src/routes/repos/[npub]/[repo]/components/StatusTabLayout.svelte

@ -6,20 +6,31 @@ @@ -6,20 +6,31 @@
import TabLayout from './TabLayout.svelte';
export let items: Array<{
id: string;
title: string;
status: string;
[key: string]: any;
}> = [];
export let selectedId: string | null = null;
export let loading: boolean = false;
export let error: string | null = null;
export let onSelect: (id: string) => void = () => {};
export let statusGroups: Array<{ label: string; value: string }> = [
{ label: 'Open', value: 'open' },
{ label: 'Closed', value: 'closed' }
];
interface Props {
items?: Array<{
id: string;
title: string;
status: string;
[key: string]: any;
}>;
selectedId?: string | null;
loading?: boolean;
error?: string | null;
onSelect?: (id: string) => void;
statusGroups?: Array<{ label: string; value: string }>;
}
let {
items = [],
selectedId = null,
loading = false,
error = null,
onSelect = () => {},
statusGroups = [
{ label: 'Open', value: 'open' },
{ label: 'Closed', value: 'closed' }
]
}: Props = $props();
let selectedItem = $derived(items.find(item => item.id === selectedId) || null);

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

@ -4,10 +4,19 @@ @@ -4,10 +4,19 @@
* Provides left-pane/right-panel structure for all tabs
*/
export let leftPane: any = null;
export let rightPanel: any = null;
export let loading: boolean = false;
export let error: string | null = null;
interface Props {
leftPane?: any;
rightPanel?: any;
loading?: boolean;
error?: string | null;
}
let {
leftPane = null,
rightPanel = null,
loading = false,
error = null
}: Props = $props();
</script>
<div class="tab-layout">

34
src/routes/repos/[npub]/[repo]/hooks/use-repo-data.ts

@ -28,11 +28,15 @@ export function useRepoData( @@ -28,11 +28,15 @@ export function useRepoData(
if (typeof window === 'undefined' || !state.isMounted) return;
try {
const data = $page.data as RepoPageData;
if (data && state.isMounted) {
setPageData(data || {});
logger.debug({ hasAnnouncement: !!data.announcement }, 'Page data loaded');
}
page.subscribe(p => {
if (p && state.isMounted) {
const data = p.data as RepoPageData;
if (data) {
setPageData(data || {});
logger.debug({ hasAnnouncement: !!data.announcement }, 'Page data loaded');
}
}
});
} catch (err) {
if (state.isMounted) {
logger.warn({ error: err }, 'Failed to update pageData');
@ -51,15 +55,19 @@ export function useRepoParams( @@ -51,15 +55,19 @@ export function useRepoParams(
if (typeof window === 'undefined' || !state.isMounted) return;
try {
const params = $page.params as { npub?: string; repo?: string };
if (params && state.isMounted) {
if (params.npub && params.npub !== state.userPubkey) {
setNpub(params.npub);
}
if (params.repo) {
setRepo(params.repo);
page.subscribe(p => {
if (p && state.isMounted) {
const params = p.params as { npub?: string; repo?: string };
if (params) {
if (params.npub && params.npub !== state.userPubkey) {
setNpub(params.npub);
}
if (params.repo) {
setRepo(params.repo);
}
}
}
}
});
} catch {
// If $page.params fails, try to parse from URL path
if (!state.isMounted) return;

Loading…
Cancel
Save