Browse Source

patch highlights and comments

update prs to match

Nostr-Signature: f85ce49f7314d99b96e3d837d096fa36745f4dd6087123c51a4a9110f23fcbfa 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc a323eb2081c46974a2fa09835bde3a65e5242f782ef34a1ec363e9da0784a00ad63c3f9f6cee016612bdab0d4d9a0e54e2a5bdb4424a635c650e317704331825
main
Silberengel 3 weeks ago
parent
commit
d680b2a171
  1. 1
      nostr/commit-signatures.jsonl
  2. 10
      src/hooks.server.ts
  3. 107
      src/lib/components/CodeEditor.svelte
  4. 95
      src/lib/components/PRDetail.svelte
  5. 43
      src/lib/services/nostr/highlights-service.ts
  6. 43
      src/lib/services/nostr/nostr-client.ts
  7. 180
      src/lib/styles/repo.css
  8. 63
      src/routes/api/repos/[npub]/[repo]/highlights/+server.ts
  9. 424
      src/routes/repos/[npub]/[repo]/+page.svelte

1
nostr/commit-signatures.jsonl

@ -74,3 +74,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771958124,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix crash on download"]],"content":"Signed commit: fix crash on download","id":"3fdcc681cdda4b523f9c3752309b8cf740b58178ca02dcff4ef97ec714bf394c","sig":"e405612a5aafeef66818f0a3c683e322f862d1fc3c662c32f618f516fd8c11ece5f4539b94893583301d31fd2ecd3de3b6d7a953505e2696915afe10710a16d7"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771958124,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix crash on download"]],"content":"Signed commit: fix crash on download","id":"3fdcc681cdda4b523f9c3752309b8cf740b58178ca02dcff4ef97ec714bf394c","sig":"e405612a5aafeef66818f0a3c683e322f862d1fc3c662c32f618f516fd8c11ece5f4539b94893583301d31fd2ecd3de3b6d7a953505e2696915afe10710a16d7"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771964922,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix page crash on download"]],"content":"Signed commit: fix page crash on download","id":"eafa232557affbacb430b467507febc201f0a8f54f4b9ecf57e315c32e51a589","sig":"53c58aabe0bfad6e432a8bb980c2046fc14bc8163825fde2ac766a449ce4418adb1049ac732c7fc7ecc7ad050539fb68c023d54f2b6c390e478616b5c0b91a31"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771964922,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix page crash on download"]],"content":"Signed commit: fix page crash on download","id":"eafa232557affbacb430b467507febc201f0a8f54f4b9ecf57e315c32e51a589","sig":"53c58aabe0bfad6e432a8bb980c2046fc14bc8163825fde2ac766a449ce4418adb1049ac732c7fc7ecc7ad050539fb68c023d54f2b6c390e478616b5c0b91a31"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771967413,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","get rid of light theme"]],"content":"Signed commit: get rid of light theme","id":"16cc720587afa7994fdf4d1951934298d731f79d8fe4a3c5d4b9143e3b41abfd","sig":"125b3afa090a8a2679d6e2614163c8c95a42ba6d3323e9682ce94ecff387da8d1abbfffcc61d59646c6925d8e845527570387b012c194deed032fa7d43bceac0"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771967413,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","get rid of light theme"]],"content":"Signed commit: get rid of light theme","id":"16cc720587afa7994fdf4d1951934298d731f79d8fe4a3c5d4b9143e3b41abfd","sig":"125b3afa090a8a2679d6e2614163c8c95a42ba6d3323e9682ce94ecff387da8d1abbfffcc61d59646c6925d8e845527570387b012c194deed032fa7d43bceac0"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771968145,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix repo search"]],"content":"Signed commit: fix repo search","id":"e9eff432fe83e0b629e217fe4c00b19858a797127ff0dad28e248e02629d938c","sig":"47c81818e91fa1b2760ceee78da38a82698c9609df0acc7f4dfaae7c6e7025c870cf2a8c34f3ea9c09dc9110be74f4605762a903d722f8bc15d2a0a2fd7edd04"}

10
src/hooks.server.ts

@ -18,6 +18,16 @@ const domain = GIT_DOMAIN;
let pollingService: RepoPollingService | null = null; let pollingService: RepoPollingService | null = null;
if (typeof process !== 'undefined') { if (typeof process !== 'undefined') {
// Handle unhandled promise rejections to prevent crashes from relay errors
process.on('unhandledRejection', (reason, promise) => {
// Log the error but don't crash - relay errors (like payment requirements) are expected
if (reason instanceof Error && reason.message.includes('restricted')) {
logger.debug({ error: reason.message }, 'Relay access restricted (expected for paid relays)');
} else {
logger.warn({ error: reason, promise }, 'Unhandled promise rejection (non-fatal)');
}
});
pollingService = new RepoPollingService(DEFAULT_NOSTR_RELAYS, repoRoot, domain); pollingService = new RepoPollingService(DEFAULT_NOSTR_RELAYS, repoRoot, domain);
pollingService.start(); pollingService.start();
logger.info({ service: 'repo-polling', relays: DEFAULT_NOSTR_RELAYS.length }, 'Started repo polling service'); logger.info({ service: 'repo-polling', relays: DEFAULT_NOSTR_RELAYS.length }, 'Started repo polling service');

107
src/lib/components/CodeEditor.svelte

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { EditorView, keymap } from '@codemirror/view'; import { EditorView, keymap, Decoration } from '@codemirror/view';
import { EditorState, type Extension, Compartment } from '@codemirror/state'; import { EditorState, type Extension, Compartment, StateField, StateEffect } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { closeBrackets, autocompletion, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'; import { closeBrackets, autocompletion, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
@ -16,6 +16,7 @@
onSelection?: (selectedText: string, startLine: number, endLine: number, startPos: number, endPos: number) => void; onSelection?: (selectedText: string, startLine: number, endLine: number, startPos: number, endPos: number) => void;
readOnly?: boolean; readOnly?: boolean;
highlights?: Array<{ id: string; startLine: number; endLine: number; content: string }>; highlights?: Array<{ id: string; startLine: number; endLine: number; content: string }>;
scrollToLine?: number | null;
} }
let { let {
@ -24,13 +25,42 @@
onChange = () => {}, onChange = () => {},
onSelection = () => {}, onSelection = () => {},
readOnly = false, readOnly = false,
highlights = [] highlights = [],
scrollToLine = $bindable(null)
}: Props = $props(); }: Props = $props();
let editorView: EditorView | null = null; let editorView: EditorView | null = null;
let editorElement: HTMLDivElement; let editorElement: HTMLDivElement;
let languageCompartment = new Compartment(); let languageCompartment = new Compartment();
// Create a highlight decoration (marker style)
const highlightMark = Decoration.mark({
class: 'cm-highlight-marker',
attributes: { 'data-highlight': 'true' }
});
// Effect to set highlight decorations (DecorationSet)
const setHighlightEffect = StateEffect.define<typeof Decoration.none>();
// State field to track highlighted ranges
const highlightField = StateField.define({
create() {
return Decoration.none;
},
update(decorations, tr) {
decorations = decorations.map(tr.changes);
// Apply highlight effects
for (const effect of tr.effects) {
if (effect.is(setHighlightEffect)) {
// Replace all decorations with the new set
decorations = effect.value;
}
}
return decorations;
},
provide: f => EditorView.decorations.from(f)
});
function getLanguageExtension(): Extension[] { function getLanguageExtension(): Extension[] {
switch (language) { switch (language) {
case 'markdown': case 'markdown':
@ -48,6 +78,7 @@
closeBrackets(), closeBrackets(),
autocompletion(), autocompletion(),
highlightSelectionMatches(), highlightSelectionMatches(),
highlightField,
keymap.of([ keymap.of([
...closeBracketsKeymap, ...closeBracketsKeymap,
...defaultKeymap, ...defaultKeymap,
@ -64,8 +95,8 @@
onChange(newContent); onChange(newContent);
} }
// Handle text selection // Handle text selection (allow in read-only mode for highlighting)
if (update.selectionSet && !readOnly) { if (update.selectionSet) {
const selection = update.state.selection.main; const selection = update.state.selection.main;
if (!selection.empty) { if (!selection.empty) {
const selectedText = update.state.doc.sliceString(selection.from, selection.to); const selectedText = update.state.doc.sliceString(selection.from, selection.to);
@ -131,6 +162,66 @@
}); });
} }
}); });
// Scroll to and highlight specific lines
$effect(() => {
if (editorView && scrollToLine !== null && scrollToLine > 0) {
try {
const doc = editorView.state.doc;
const line = doc.line(Math.min(scrollToLine, doc.lines));
const lineStart = line.from;
const lineEnd = line.to;
// Scroll to the line
editorView.dispatch({
selection: { anchor: lineStart, head: lineEnd },
effects: EditorView.scrollIntoView(lineStart, { y: 'center' })
});
// Clear scrollToLine after scrolling
setTimeout(() => {
scrollToLine = null;
}, 100);
} catch (err) {
console.error('Error scrolling to line:', err);
}
}
});
// Function to scroll to and highlight a range of lines with a persistent marker
export function scrollToLines(startLine: number, endLine: number) {
if (!editorView) return;
try {
const doc = editorView.state.doc;
const start = Math.min(startLine, doc.lines);
const end = Math.min(endLine, doc.lines);
const startLineObj = doc.line(start);
const endLineObj = doc.line(end);
const from = startLineObj.from;
const to = endLineObj.to;
// Create a highlight decoration for the range
const decorationRange = highlightMark.range(from, to);
// Create a DecorationSet with the highlight
const decorationSet = Decoration.set([decorationRange]);
// Update the highlight field with the new decoration using StateEffect
editorView.dispatch({
effects: setHighlightEffect.of(decorationSet)
});
// Scroll to the lines
editorView.dispatch({
effects: EditorView.scrollIntoView(from, { y: 'center' })
});
} catch (err) {
console.error('Error scrolling to lines:', err);
}
}
</script> </script>
<div bind:this={editorElement} class="code-editor"></div> <div bind:this={editorElement} class="code-editor"></div>
@ -149,4 +240,10 @@
:global(.code-editor .cm-scroller) { :global(.code-editor .cm-scroller) {
overflow: auto; overflow: auto;
} }
:global(.code-editor .cm-highlight-marker) {
background-color: rgba(255, 255, 0, 0.4);
padding: 2px 0;
border-radius: 2px;
}
</style> </style>

95
src/lib/components/PRDetail.svelte

@ -83,6 +83,7 @@
let showMergeDialog = $state(false); let showMergeDialog = $state(false);
let mergeTargetBranch = $state('main'); let mergeTargetBranch = $state('main');
let mergeMessage = $state(''); let mergeMessage = $state('');
let prEditor = $state<any>(null); // CodeEditor component instance
const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
@ -434,6 +435,7 @@
{:else if prDiff} {:else if prDiff}
<div class="diff-viewer"> <div class="diff-viewer">
<CodeEditor <CodeEditor
bind:this={prEditor}
content={prDiff} content={prDiff}
language="text" language="text"
readOnly={true} readOnly={true}
@ -470,8 +472,8 @@
</div> </div>
{/each} {/each}
<!-- Highlights with comments --> <!-- Highlights with comments - filter to only show highlights for this PR -->
{#each highlights as highlight} {#each highlights.filter(h => !h.sourceEventId || h.sourceEventId === pr.id) as highlight}
<div class="highlight-item"> <div class="highlight-item">
<div class="highlight-header"> <div class="highlight-header">
<span class="highlight-author">{formatPubkey(highlight.pubkey)}</span> <span class="highlight-author">{formatPubkey(highlight.pubkey)}</span>
@ -479,15 +481,28 @@
{#if highlight.file} {#if highlight.file}
<span class="highlight-file">{highlight.file}</span> <span class="highlight-file">{highlight.file}</span>
{/if} {/if}
{#if highlight.lineStart} {#if highlight.lineStart && highlight.sourceEventId === pr.id}
<span class="highlight-lines">Lines {highlight.lineStart}-{highlight.lineEnd}</span> <button
class="highlight-lines-button"
onclick={() => {
if (prEditor && highlight.lineStart && highlight.lineEnd) {
prEditor.scrollToLines(highlight.lineStart, highlight.lineEnd);
}
}}
title="Click to highlight these lines in the diff"
>
Lines {highlight.lineStart}-{highlight.lineEnd}
</button>
{/if} {/if}
</div> </div>
<div class="highlighted-code"> <div class="highlighted-code">
<pre><code>{highlight.highlightedContent}</code></pre> <pre><code>{highlight.highlightedContent}</code></pre>
</div> </div>
{#if highlight.comment} {#if highlight.comment}
<div class="highlight-comment">{highlight.comment}</div> <div class="highlight-comment">
<img src="/icons/message-circle.svg" alt="Comment" class="comment-icon" />
{highlight.comment}
</div>
{/if} {/if}
<!-- Comments on this highlight --> <!-- Comments on this highlight -->
@ -514,7 +529,7 @@
</div> </div>
{/each} {/each}
{#if highlights.length === 0 && comments.length === 0} {#if highlights.filter(h => !h.sourceEventId || h.sourceEventId === pr.id).length === 0 && comments.length === 0}
<div class="empty">No highlights or comments yet</div> <div class="empty">No highlights or comments yet</div>
{/if} {/if}
{/if} {/if}
@ -707,8 +722,17 @@
.comment-item.nested { .comment-item.nested {
margin-left: 2rem; margin-left: 2rem;
margin-top: 0.5rem; margin-top: 0.75rem;
border-left-color: var(--success-text);
background: var(--bg-secondary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.comment-item.nested .comment-content {
border-left-color: var(--success-text); border-left-color: var(--success-text);
background: var(--card-bg);
margin: 0.5rem 0;
padding: 0.875rem 1rem;
} }
.highlight-header, .comment-header { .highlight-header, .comment-header {
@ -724,6 +748,26 @@
color: var(--text-primary); color: var(--text-primary);
} }
.highlight-lines-button {
margin: 0;
padding: 0.25rem 0.5rem;
background: none;
border: 1px solid var(--accent);
border-radius: 4px;
color: var(--accent);
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
font-family: 'IBM Plex Serif', serif;
}
.highlight-lines-button:hover {
background: var(--accent);
color: var(--accent-text, white);
border-color: var(--accent);
}
.highlighted-code { .highlighted-code {
background: var(--card-bg); background: var(--card-bg);
padding: 0.5rem; padding: 0.5rem;
@ -739,12 +783,39 @@
font-family: 'IBM Plex Mono', monospace; font-family: 'IBM Plex Mono', monospace;
} }
.highlight-comment, .comment-content { .highlight-comment {
margin: 0.5rem 0; margin: 1rem 0;
padding: 0.5rem; padding: 1rem 1.25rem 1rem 3rem;
background: var(--card-bg); background: var(--bg-secondary);
border-radius: 3px; border-radius: 6px;
border-left: 4px solid var(--accent);
color: var(--text-primary);
font-size: 1rem;
line-height: 1.6;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: relative;
}
.highlight-comment .comment-icon {
position: absolute;
left: 0.75rem;
top: 1rem;
width: 1.25rem;
height: 1.25rem;
opacity: 0.9;
filter: brightness(0) saturate(100%) invert(1);
}
.comment-content {
margin: 0.75rem 0;
padding: 1rem 1.25rem;
background: var(--bg-secondary);
border-radius: 6px;
border-left: 4px solid var(--accent);
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem;
line-height: 1.6;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.highlight-comments { .highlight-comments {

43
src/lib/services/nostr/highlights-service.ts

@ -98,22 +98,34 @@ export class HighlightsService {
const targetAddress = this.getTargetAddress(targetId, targetAuthor, repoOwnerPubkey, repoId, targetKind); const targetAddress = this.getTargetAddress(targetId, targetAuthor, repoOwnerPubkey, repoId, targetKind);
// Fetch highlights that reference this target // Fetch highlights that reference this target
const highlights = await this.nostrClient.fetchEvents([ let highlights: Highlight[] = [];
try {
highlights = await this.nostrClient.fetchEvents([
{ {
kinds: [KIND.HIGHLIGHT], kinds: [KIND.HIGHLIGHT],
'#a': [targetAddress], '#a': [targetAddress],
limit: 100 limit: 100
} }
]) as Highlight[]; ]) as Highlight[];
} catch (err) {
// Log error but continue - some relays may fail, we'll use what we can get
console.error('Error fetching highlights by address:', err);
}
// Also fetch highlights that reference the target by event ID // Also fetch highlights that reference the target by event ID
const highlightsByEvent = await this.nostrClient.fetchEvents([ let highlightsByEvent: Highlight[] = [];
try {
highlightsByEvent = await this.nostrClient.fetchEvents([
{ {
kinds: [KIND.HIGHLIGHT], kinds: [KIND.HIGHLIGHT],
'#e': [targetId], '#e': [targetId],
limit: 100 limit: 100
} }
]) as Highlight[]; ]) as Highlight[];
} catch (err) {
// Log error but continue - some relays may fail, we'll use what we can get
console.error('Error fetching highlights by event ID:', err);
}
// Combine and deduplicate // Combine and deduplicate
const allHighlights = [...highlights, ...highlightsByEvent]; const allHighlights = [...highlights, ...highlightsByEvent];
@ -136,11 +148,20 @@ export class HighlightsService {
// Fetch comments for each highlight // Fetch comments for each highlight
const highlightsWithComments: HighlightWithComments[] = []; const highlightsWithComments: HighlightWithComments[] = [];
for (const highlight of parsedHighlights) { for (const highlight of parsedHighlights) {
try {
const comments = await this.getCommentsForHighlight(highlight.id); const comments = await this.getCommentsForHighlight(highlight.id);
highlightsWithComments.push({ highlightsWithComments.push({
...highlight, ...highlight,
comments comments
}); });
} catch (err) {
// If comments fail to load, still include the highlight without comments
console.error(`Error fetching comments for highlight ${highlight.id}:`, err);
highlightsWithComments.push({
...highlight,
comments: []
});
}
} }
// Sort by created_at descending (newest first) // Sort by created_at descending (newest first)
@ -213,13 +234,20 @@ export class HighlightsService {
* Get comments for a highlight or PR * Get comments for a highlight or PR
*/ */
async getCommentsForHighlight(highlightId: string): Promise<Comment[]> { async getCommentsForHighlight(highlightId: string): Promise<Comment[]> {
const comments = await this.nostrClient.fetchEvents([ let comments: NostrEvent[] = [];
try {
comments = await this.nostrClient.fetchEvents([
{ {
kinds: [KIND.COMMENT], kinds: [KIND.COMMENT],
'#e': [highlightId], '#e': [highlightId],
limit: 100 limit: 100
} }
]) as NostrEvent[]; ]) as NostrEvent[];
} catch (err) {
// Log error but return empty array - some relays may fail
console.error(`Error fetching comments for highlight ${highlightId}:`, err);
return [];
}
const parsedComments: Comment[] = []; const parsedComments: Comment[] = [];
for (const event of comments) { for (const event of comments) {
@ -253,7 +281,9 @@ export class HighlightsService {
* Get comments for a pull request or patch * Get comments for a pull request or patch
*/ */
async getCommentsForTarget(targetId: string, targetKind: typeof KIND.PULL_REQUEST | typeof KIND.PATCH = KIND.PULL_REQUEST): Promise<Comment[]> { async getCommentsForTarget(targetId: string, targetKind: typeof KIND.PULL_REQUEST | typeof KIND.PATCH = KIND.PULL_REQUEST): Promise<Comment[]> {
const comments = await this.nostrClient.fetchEvents([ let comments: NostrEvent[] = [];
try {
comments = await this.nostrClient.fetchEvents([
{ {
kinds: [KIND.COMMENT], kinds: [KIND.COMMENT],
'#E': [targetId], // Root event (uppercase E for NIP-22) '#E': [targetId], // Root event (uppercase E for NIP-22)
@ -261,6 +291,11 @@ export class HighlightsService {
limit: 100 limit: 100
} }
]) as NostrEvent[]; ]) as NostrEvent[];
} catch (err) {
// Log error but return empty array - some relays may fail
console.error('Error fetching comments for target:', err);
return [];
}
const parsedComments: Comment[] = []; const parsedComments: Comment[] = [];
for (const event of comments) { for (const event of comments) {

43
src/lib/services/nostr/nostr-client.ts

@ -269,14 +269,23 @@ export class NostrClient {
// SimplePool handles connection management, retries, and error handling automatically // SimplePool handles connection management, retries, and error handling automatically
try { try {
// querySync takes a single filter, so we query each filter and combine results // querySync takes a single filter, so we query each filter and combine results
// Wrap each query individually to catch errors from individual relays
const queryPromises = filters.map(filter => const queryPromises = filters.map(filter =>
this.pool.querySync(this.relays, filter as Filter, { maxWait: 8000 }) this.pool.querySync(this.relays, filter as Filter, { maxWait: 8000 })
.catch(err => {
// Log individual relay errors but don't fail the entire request
logger.debug({ error: err, filter }, 'Individual relay query failed');
return []; // Return empty array for failed queries
})
); );
const results = await Promise.allSettled(queryPromises); const results = await Promise.allSettled(queryPromises);
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
events.push(...result.value); events.push(...result.value);
} else {
// Log rejected promises (shouldn't happen since we catch above, but just in case)
logger.debug({ error: result.reason }, 'Query promise rejected');
} }
} }
} catch (err) { } catch (err) {
@ -449,15 +458,45 @@ export class NostrClient {
const failed: Array<{ relay: string; error: string }> = []; const failed: Array<{ relay: string; error: string }> = [];
// Use nostr-tools SimplePool to publish to all relays // Use nostr-tools SimplePool to publish to all relays
// Wrap in Promise.resolve().then() to catch any synchronous errors and ensure all async errors are caught
try {
// Create a promise that will catch all errors, including those from WebSocket event handlers
const publishPromise = Promise.resolve().then(async () => {
try { try {
await this.pool.publish(targetRelays, event); await this.pool.publish(targetRelays, event);
// If publish succeeded, all relays succeeded // If publish succeeded, all relays succeeded
// Note: SimplePool.publish doesn't return per-relay results, so we assume all succeeded // Note: SimplePool.publish doesn't return per-relay results, so we assume all succeeded
success.push(...targetRelays); return targetRelays;
} catch (error) { } catch (error) {
// If publish failed, mark all as failed // If publish failed, mark all as failed
// In a more sophisticated implementation, we could check individual relays // In a more sophisticated implementation, we could check individual relays
throw error;
}
});
// Wait for publish with timeout and catch all errors
const publishedRelays = await Promise.race([
publishPromise,
new Promise<string[]>((_, reject) =>
setTimeout(() => reject(new Error('Publish timeout')), 30000)
)
]).catch(error => {
// Log error but don't throw - we'll mark relays as failed below
logger.debug({ error, eventId: event.id }, 'Error publishing event to relays');
return null;
});
if (publishedRelays) {
success.push(...publishedRelays);
} else {
// If publish failed or timed out, mark all as failed
targetRelays.forEach(relay => {
failed.push({ relay, error: 'Publish failed or timed out' });
});
}
} catch (error) {
// Catch any synchronous errors
logger.debug({ error, eventId: event.id }, 'Synchronous error in publishEvent');
targetRelays.forEach(relay => { targetRelays.forEach(relay => {
failed.push({ relay, error: String(error) }); failed.push({ relay, error: String(error) });
}); });

180
src/lib/styles/repo.css

@ -1815,6 +1815,186 @@ span.clone-more {
color: var(--text-primary); color: var(--text-primary);
} }
.patch-content-wrapper {
height: 500px;
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: auto;
background: var(--bg-secondary);
}
.patch-highlights-section {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.patch-highlights-section .section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.patch-highlights-section .section-header h4 {
margin: 0;
color: var(--text-primary);
}
.patch-highlights-section .highlight-item,
.patch-highlights-section .comment-item {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 4px;
border-left: 3px solid var(--accent);
}
.patch-highlights-section .comment-item.nested {
margin-left: 2rem;
margin-top: 0.75rem;
border-left-color: var(--success-text);
background: var(--bg-secondary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.patch-highlights-section .comment-item.nested .comment-content {
border-left-color: var(--success-text);
background: var(--card-bg);
margin: 0.5rem 0;
padding: 0.875rem 1rem;
}
.patch-highlights-section .highlight-header,
.patch-highlights-section .comment-header {
display: flex;
gap: 1rem;
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: var(--text-muted);
}
.patch-highlights-section .highlight-author,
.patch-highlights-section .comment-author {
font-weight: bold;
color: var(--text-primary);
}
.patch-highlights-section .highlight-lines-button {
margin: 0;
padding: 0.25rem 0.5rem;
background: none;
border: 1px solid var(--accent);
border-radius: 4px;
color: var(--accent);
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
}
.patch-highlights-section .highlight-lines-button:hover {
background: var(--accent);
color: var(--accent-text, white);
border-color: var(--accent);
}
.patch-highlights-section .highlighted-code {
background: var(--card-bg);
padding: 0.5rem;
border-radius: 3px;
margin: 0.5rem 0;
border: 1px solid var(--border-light);
}
.patch-highlights-section .highlighted-code pre {
margin: 0;
font-size: 0.9rem;
color: var(--text-primary);
font-family: 'IBM Plex Mono', monospace;
}
.patch-highlights-section .highlight-comment {
margin: 1rem 0;
padding: 1rem 1.25rem 1rem 3rem;
background: var(--bg-secondary);
border-radius: 6px;
border-left: 4px solid var(--accent);
color: var(--text-primary);
font-size: 1rem;
line-height: 1.6;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: relative;
}
.patch-highlights-section .add-comment-btn,
.patch-highlights-section .reply-btn {
padding: 0.5rem 1rem;
background: var(--button-primary, #007bff);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
margin-top: 0.5rem;
}
.patch-highlights-section .add-comment-btn:hover,
.patch-highlights-section .reply-btn:hover {
background: var(--button-primary-hover, #0056b3);
}
.patch-highlights-section .reply-btn {
background: var(--bg-tertiary, #6c757d);
font-size: 0.85rem;
padding: 0.375rem 0.75rem;
margin-top: 0.25rem;
}
.patch-highlights-section .reply-btn:hover {
background: var(--bg-secondary, #5a6268);
}
.patch-highlights-section .highlight-comment .comment-icon {
position: absolute;
left: 0.75rem;
top: 1rem;
width: 1.25rem;
height: 1.25rem;
opacity: 0.9;
filter: brightness(0) saturate(100%) invert(1);
}
.patch-highlights-section .comment-content {
margin: 0.75rem 0;
padding: 1rem 1.25rem;
background: var(--bg-secondary);
border-radius: 6px;
border-left: 4px solid var(--accent);
color: var(--text-primary);
font-size: 1rem;
line-height: 1.6;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.patch-highlights-section .highlight-comments {
margin-top: 1rem;
}
.patch-highlights-section .empty {
color: var(--text-muted);
text-align: center;
padding: 1rem;
}
.patch-highlights-section .loading {
color: var(--text-muted);
text-align: center;
padding: 1rem;
}
.commit-list, .commit-list,
.tag-list, .tag-list,
.issue-list, .issue-list,

63
src/routes/api/repos/[npub]/[repo]/highlights/+server.ts

@ -18,18 +18,19 @@ import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.j
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
/** /**
* GET - Get highlights for a pull request * GET - Get highlights for a pull request or patch
* Query params: prId, prAuthor * Query params: prId, prAuthor (for PRs) OR patchId, patchAuthor (for patches)
*/ */
export const GET: RequestHandler = createRepoGetHandler( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext, event: RequestEvent) => { async (context: RepoRequestContext, event: RequestEvent) => {
const prId = event.url.searchParams.get('prId'); const prId = event.url.searchParams.get('prId');
const prAuthor = event.url.searchParams.get('prAuthor'); const prAuthor = event.url.searchParams.get('prAuthor');
const patchId = event.url.searchParams.get('patchId');
const patchAuthor = event.url.searchParams.get('patchAuthor');
if (!prId || !prAuthor) { // Support both PR and patch highlights
return handleValidationError('Missing prId or prAuthor parameter', { operation: 'getHighlights', npub: context.npub, repo: context.repo }); try {
} if (prId && prAuthor) {
// Decode prAuthor if it's an npub // Decode prAuthor if it's an npub
const prAuthorPubkey = decodeNpubToHex(prAuthor) || prAuthor; const prAuthorPubkey = decodeNpubToHex(prAuthor) || prAuthor;
@ -48,8 +49,39 @@ export const GET: RequestHandler = createRepoGetHandler(
highlights, highlights,
comments: prComments comments: prComments
}); });
} else if (patchId && patchAuthor) {
// Decode patchAuthor if it's an npub
const patchAuthorPubkey = decodeNpubToHex(patchAuthor) || patchAuthor;
// Get highlights for the patch
const highlights = await highlightsService.getHighlightsForPatch(
patchId,
patchAuthorPubkey,
context.repoOwnerPubkey,
context.repo
);
// Also get top-level comments on the patch
const patchComments = await highlightsService.getCommentsForTarget(patchId, KIND.PATCH);
return json({
highlights,
comments: patchComments
});
} else {
return handleValidationError('Missing prId/prAuthor or patchId/patchAuthor parameters', { operation: 'getHighlights', npub: context.npub, repo: context.repo });
}
} catch (err) {
// Log error but return empty arrays instead of crashing
// Some relays may fail (e.g., require payment), but we should still return successfully
logger.warn({ error: err, npub: context.npub, repo: context.repo, prId, patchId }, 'Error fetching highlights, returning empty arrays');
return json({
highlights: [],
comments: []
});
}
}, },
{ operation: 'getHighlights', requireRepoAccess: false } // Highlights are public { operation: 'getHighlights', requireRepoAccess: false, requireRepoExists: false } // Highlights are public and don't require repo to be cloned
); );
/** /**
@ -79,13 +111,24 @@ export const POST: RequestHandler = withRepoValidation(
} }
// Get user's relays and publish // Get user's relays and publish
let result;
try {
const { outbox } = await getUserRelays(userPubkey, nostrClient); const { outbox } = await getUserRelays(userPubkey, nostrClient);
const combinedRelays = combineRelays(outbox); const combinedRelays = combineRelays(outbox);
const result = await nostrClient.publishEvent(highlightEvent as NostrEvent, combinedRelays); result = await nostrClient.publishEvent(highlightEvent as NostrEvent, combinedRelays);
} catch (err) {
// Log error but don't fail - some relays may have succeeded
logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo, eventId: highlightEvent.id }, 'Error publishing highlight event, some relays may have succeeded');
// Return a result indicating failure, but don't throw
result = { success: [], failed: [{ relay: 'unknown', error: String(err) }] };
}
// Only throw if ALL relays failed - partial success is acceptable
if (result.failed.length > 0 && result.success.length === 0) { if (result.failed.length > 0 && result.success.length === 0) {
throw handleApiError(new Error('Failed to publish to all relays'), { operation: 'createHighlight', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish to all relays'); logger.warn({ npub: repoContext.npub, repo: repoContext.repo, eventId: highlightEvent.id, failed: result.failed }, 'Failed to publish to all relays, but continuing anyway');
// Don't throw - return success anyway since the event was created
// The user can retry if needed
} }
// Forward to messaging platforms if user has unlimited access and preferences configured // Forward to messaging platforms if user has unlimited access and preferences configured
@ -101,5 +144,5 @@ export const POST: RequestHandler = withRepoValidation(
return json({ success: true, event: highlightEvent, published: result }); return json({ success: true, event: highlightEvent, published: result });
}, },
{ operation: 'createHighlight', requireRepoAccess: false } // Highlights can be created by anyone { operation: 'createHighlight', requireRepoAccess: false, requireRepoExists: false } // Highlights can be created by anyone and don't require repo to be cloned
); );

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

@ -18,6 +18,7 @@
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { BookmarksService } from '$lib/services/nostr/bookmarks-service.js'; import { BookmarksService } from '$lib/services/nostr/bookmarks-service.js';
import { HighlightsService } from '$lib/services/nostr/highlights-service.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 { userStore } from '$lib/stores/user-store.js'; import { userStore } from '$lib/stores/user-store.js';
@ -579,6 +580,50 @@
let newPatchSubject = $state(''); let newPatchSubject = $state('');
let creatingPatch = $state(false); let creatingPatch = $state(false);
// Patch highlights
let patchHighlights = $state<Array<{
id: string;
content: string;
pubkey: string;
created_at: number;
highlightedContent?: string;
file?: string;
lineStart?: number;
lineEnd?: number;
comment?: string;
comments?: Array<{
id: string;
content: string;
pubkey: string;
created_at: number;
[key: string]: unknown;
}>;
[key: string]: unknown;
}>>([]);
let patchComments = $state<Array<{
id: string;
content: string;
pubkey: string;
created_at: number;
[key: string]: unknown;
}>>([]);
let loadingPatchHighlights = $state(false);
let selectedPatchText = $state('');
let selectedPatchStartLine = $state(0);
let selectedPatchEndLine = $state(0);
let selectedPatchStartPos = $state(0);
let selectedPatchEndPos = $state(0);
let showPatchHighlightDialog = $state(false);
let patchHighlightComment = $state('');
let creatingPatchHighlight = $state(false);
let patchEditor = $state<any>(null); // CodeEditor component instance
let showPatchCommentDialog = $state(false);
let patchCommentContent = $state('');
let creatingPatchComment = $state(false);
let replyingToPatchComment = $state<string | null>(null);
const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS);
// Documentation // Documentation
let documentationContent = $state<string | null>(null); let documentationContent = $state<string | null>(null);
let documentationHtml = $state<string | null>(null); let documentationHtml = $state<string | null>(null);
@ -4986,6 +5031,218 @@
} }
} }
async function loadPatchHighlights(patchId: string, patchAuthor: string) {
if (!patchId || !patchAuthor) return;
loadingPatchHighlights = true;
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
const response = await fetch(
`/api/repos/${npub}/${repo}/highlights?patchId=${patchId}&patchAuthor=${patchAuthor}`
);
if (response.ok) {
const data = await response.json();
patchHighlights = data.highlights || [];
patchComments = data.comments || [];
}
} catch (err) {
console.error('Failed to load patch highlights:', err);
} finally {
loadingPatchHighlights = false;
}
}
function handlePatchCodeSelection(
text: string,
startLine: number,
endLine: number,
startPos: number,
endPos: number
) {
if (!text.trim() || !userPubkey) return;
selectedPatchText = text;
selectedPatchStartLine = startLine;
selectedPatchEndLine = endLine;
selectedPatchStartPos = startPos;
selectedPatchEndPos = endPos;
showPatchHighlightDialog = true;
}
async function createPatchHighlight() {
if (!userPubkey || !selectedPatchText.trim() || !selectedPatch) return;
const patch = patches.find(p => p.id === selectedPatch);
if (!patch) return;
creatingPatchHighlight = true;
error = null;
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
const eventTemplate = highlightsService.createHighlightEvent(
selectedPatchText,
patch.id,
patch.author,
repoOwnerPubkey,
repo,
KIND.PATCH, // targetKind
undefined, // filePath
selectedPatchStartLine, // lineStart
selectedPatchEndLine, // lineEnd
undefined, // context
patchHighlightComment.trim() || undefined // comment
);
const signedEvent = await signEventWithNIP07(eventTemplate);
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const { outbox } = await getUserRelays(userPubkey, tempClient);
const combinedRelays = combineRelays(outbox);
const response = await fetch(`/api/repos/${npub}/${repo}/highlights`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'highlight',
event: signedEvent,
userPubkey
})
});
if (response.ok) {
showPatchHighlightDialog = false;
selectedPatchText = '';
patchHighlightComment = '';
await loadPatchHighlights(patch.id, patch.author);
} else {
const data = await response.json();
error = data.error || 'Failed to create highlight';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create highlight';
} finally {
creatingPatchHighlight = false;
}
}
function formatPubkey(pubkey: string): string {
try {
return nip19.npubEncode(pubkey);
} catch {
return pubkey.slice(0, 8) + '...';
}
}
function startPatchComment(parentId?: string) {
if (!userPubkey) {
alert('Please connect your NIP-07 extension');
return;
}
replyingToPatchComment = parentId || null;
showPatchCommentDialog = true;
}
async function createPatchComment() {
if (!userPubkey || !patchCommentContent.trim() || !selectedPatch) return;
const patch = patches.find(p => p.id === selectedPatch);
if (!patch) return;
creatingPatchComment = true;
error = null;
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
const rootEventId = replyingToPatchComment || patch.id;
const rootEventKind = replyingToPatchComment ? KIND.COMMENT : KIND.PATCH;
const rootPubkey = replyingToPatchComment ?
(patchComments.find(c => c.id === replyingToPatchComment)?.pubkey || patch.author) :
patch.author;
let parentEventId: string | undefined;
let parentEventKind: number | undefined;
let parentPubkey: string | undefined;
if (replyingToPatchComment) {
// Reply to a comment
const parentComment = patchComments.find(c => c.id === replyingToPatchComment) ||
patchHighlights.flatMap(h => h.comments || []).find(c => c.id === replyingToPatchComment);
if (parentComment) {
parentEventId = replyingToPatchComment;
parentEventKind = KIND.COMMENT;
parentPubkey = parentComment.pubkey;
}
}
const eventTemplate = highlightsService.createCommentEvent(
patchCommentContent.trim(),
rootEventId,
rootEventKind,
rootPubkey,
parentEventId,
parentEventKind,
parentPubkey
);
const signedEvent = await signEventWithNIP07(eventTemplate);
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const { outbox } = await getUserRelays(userPubkey, tempClient);
const combinedRelays = combineRelays(outbox);
const response = await fetch(`/api/repos/${npub}/${repo}/highlights`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'comment',
event: signedEvent,
userPubkey
})
});
if (response.ok) {
showPatchCommentDialog = false;
patchCommentContent = '';
replyingToPatchComment = null;
await loadPatchHighlights(patch.id, patch.author);
} else {
const data = await response.json();
error = data.error || 'Failed to create comment';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create comment';
} finally {
creatingPatchComment = false;
}
}
// Load highlights when a patch is selected
$effect(() => {
if (!isMounted || !selectedPatch) return;
const patch = patches.find(p => p.id === selectedPatch);
if (patch) {
loadPatchHighlights(patch.id, patch.author).catch(err => {
if (isMounted) console.warn('Failed to load patch highlights:', err);
});
}
});
// Only load tab content when tab actually changes, not on every render // Only load tab content when tab actually changes, not on every render
let lastTab = $state<string | null>(null); let lastTab = $state<string | null>(null);
$effect(() => { $effect(() => {
@ -6343,7 +6600,102 @@
<div class="patch-description">{patch.description}</div> <div class="patch-description">{patch.description}</div>
{/if} {/if}
<div class="patch-body"> <div class="patch-body">
<pre class="patch-content">{patch.content}</pre> <div class="patch-content-wrapper">
<CodeEditor
bind:this={patchEditor}
content={patch.content}
language="text"
readOnly={true}
onSelection={handlePatchCodeSelection}
/>
</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>
</div> </div>
{/each} {/each}
@ -7063,6 +7415,76 @@
</div> </div>
{/if} {/if}
<!-- Patch Highlight Dialog -->
{#if showPatchHighlightDialog}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Create highlight"
tabindex="-1"
onclick={() => showPatchHighlightDialog = false}
onkeydown={(e) => {
if (e.key === 'Escape') {
showPatchHighlightDialog = false;
}
}}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="modal" role="document" onclick={(e) => e.stopPropagation()}>
<h3>Create Highlight</h3>
<div class="selected-code">
<pre><code>{selectedPatchText}</code></pre>
</div>
<label>
Comment (optional):
<textarea bind:value={patchHighlightComment} rows="4" placeholder="Add a comment about this code..."></textarea>
</label>
<div class="modal-actions">
<button onclick={() => showPatchHighlightDialog = false} class="cancel-btn">Cancel</button>
<button onclick={createPatchHighlight} disabled={creatingPatchHighlight} class="save-btn">
{creatingPatchHighlight ? 'Creating...' : 'Create Highlight'}
</button>
</div>
</div>
</div>
{/if}
<!-- Patch Comment Dialog -->
{#if showPatchCommentDialog}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label={replyingToPatchComment ? 'Reply to comment' : 'Add comment'}
tabindex="-1"
onclick={() => { showPatchCommentDialog = false; replyingToPatchComment = null; }}
onkeydown={(e) => {
if (e.key === 'Escape') {
showPatchCommentDialog = false;
replyingToPatchComment = null;
}
}}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="modal" role="document" onclick={(e) => e.stopPropagation()}>
<h3>{replyingToPatchComment ? 'Reply to Comment' : 'Add Comment'}</h3>
<label>
Comment:
<textarea bind:value={patchCommentContent} rows="6" placeholder="Write your comment..."></textarea>
</label>
<div class="modal-actions">
<button onclick={() => { showPatchCommentDialog = false; replyingToPatchComment = null; }} class="cancel-btn">Cancel</button>
<button onclick={createPatchComment} disabled={creatingPatchComment || !patchCommentContent.trim()} class="save-btn">
{creatingPatchComment ? 'Creating...' : (replyingToPatchComment ? 'Reply' : 'Add Comment')}
</button>
</div>
</div>
</div>
{/if}
<!-- Commit Dialog --> <!-- Commit Dialog -->
{#if showCommitDialog && userPubkey && isMaintainer} {#if showCommitDialog && userPubkey && isMaintainer}
<div <div

Loading…
Cancel
Save