Browse Source
Nostr-Signature: 533e9f7acbdd4dc16dbe304245469d57d8d37f0c0cce53b60d99719e2acf4502 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 0fad2d7c44f086ceb06ce40ea8cea2d4d002ebe8caec7d78e83483348b1404cfb6256d8d3796ebd9ae6f7866a431ec4a1abe84e417d3e238b9b554b4a32481e4main
32 changed files with 8004 additions and 2966 deletions
@ -0,0 +1,287 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
/** |
||||||
|
* Generic publication index viewer for Nostr kind 30040 |
||||||
|
* Renders publication indexes similar to NKBIP-01 and aitherboard |
||||||
|
*/ |
||||||
|
|
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
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; |
||||||
|
description?: string; |
||||||
|
url?: string; |
||||||
|
tags?: string[][]; |
||||||
|
[key: string]: any; |
||||||
|
} |
||||||
|
|
||||||
|
let items = $state<PublicationItem[]>([]); |
||||||
|
let loading = $state(false); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (indexEvent) { |
||||||
|
loadIndex(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadIndex() { |
||||||
|
if (!indexEvent) return; |
||||||
|
|
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Loading publication index', { eventId: indexEvent.id }); |
||||||
|
|
||||||
|
// Parse index event - kind 30040 typically has items in tags or content |
||||||
|
// Format: items can be in 'item' tags or JSON in content |
||||||
|
const itemTags = indexEvent.tags.filter(t => t[0] === 'item' || t[0] === 'p'); |
||||||
|
|
||||||
|
if (indexEvent.content) { |
||||||
|
try { |
||||||
|
// Try parsing as JSON first |
||||||
|
const parsed = JSON.parse(indexEvent.content); |
||||||
|
if (Array.isArray(parsed)) { |
||||||
|
items = parsed; |
||||||
|
} else if (parsed.items && Array.isArray(parsed.items)) { |
||||||
|
items = parsed.items; |
||||||
|
} else { |
||||||
|
// Fallback to tag-based parsing |
||||||
|
items = parseItemsFromTags(itemTags); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Not JSON, try parsing from tags |
||||||
|
items = parseItemsFromTags(itemTags); |
||||||
|
} |
||||||
|
} else { |
||||||
|
items = parseItemsFromTags(itemTags); |
||||||
|
} |
||||||
|
|
||||||
|
// If we have item IDs, fetch full events |
||||||
|
if (items.length > 0 && items[0].id) { |
||||||
|
await fetchItemDetails(); |
||||||
|
} |
||||||
|
|
||||||
|
logger.operation('Publication index loaded', { itemCount: items.length }); |
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Failed to load publication index'; |
||||||
|
logger.error({ error: err, eventId: indexEvent?.id }, 'Error loading publication index'); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function parseItemsFromTags(tags: string[][]): PublicationItem[] { |
||||||
|
const items: PublicationItem[] = []; |
||||||
|
|
||||||
|
for (const tag of tags) { |
||||||
|
if (tag.length < 2) continue; |
||||||
|
|
||||||
|
const [type, ...rest] = tag; |
||||||
|
|
||||||
|
if (type === 'item' || type === 'p') { |
||||||
|
// Format: ['item', 'event-id', 'relay-url', ...] or ['p', 'pubkey', 'relay', ...] |
||||||
|
const item: PublicationItem = { |
||||||
|
id: rest[0] || '', |
||||||
|
title: rest[1] || rest[0] || 'Untitled', |
||||||
|
url: rest[2] || undefined |
||||||
|
}; |
||||||
|
|
||||||
|
// Look for title/description in subsequent tags |
||||||
|
const titleTag = indexEvent?.tags.find(t => t[0] === 'title' && t[1] === item.id); |
||||||
|
if (titleTag && titleTag[2]) { |
||||||
|
item.title = titleTag[2]; |
||||||
|
} |
||||||
|
|
||||||
|
const descTag = indexEvent?.tags.find(t => t[0] === 'description' && t[1] === item.id); |
||||||
|
if (descTag && descTag[2]) { |
||||||
|
item.description = descTag[2]; |
||||||
|
} |
||||||
|
|
||||||
|
items.push(item); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return items; |
||||||
|
} |
||||||
|
|
||||||
|
async function fetchItemDetails() { |
||||||
|
if (items.length === 0) return; |
||||||
|
|
||||||
|
try { |
||||||
|
const client = new NostrClient(relays); |
||||||
|
const itemIds = items.map(item => item.id).filter(Boolean); |
||||||
|
|
||||||
|
if (itemIds.length === 0) return; |
||||||
|
|
||||||
|
// Fetch events for item IDs |
||||||
|
const events = await client.fetchEvents([ |
||||||
|
{ |
||||||
|
ids: itemIds, |
||||||
|
limit: itemIds.length |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
// Merge event data into items |
||||||
|
const eventMap = new Map(events.map(e => [e.id, e])); |
||||||
|
items = items.map(item => { |
||||||
|
const event = eventMap.get(item.id); |
||||||
|
if (event) { |
||||||
|
return { |
||||||
|
...item, |
||||||
|
title: item.title || extractTitle(event), |
||||||
|
description: item.description || event.content?.substring(0, 200), |
||||||
|
event |
||||||
|
}; |
||||||
|
} |
||||||
|
return item; |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
logger.warn({ error: err }, 'Failed to fetch item details'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function extractTitle(event: NostrEvent): string { |
||||||
|
// Try to get title from tags |
||||||
|
const titleTag = event.tags.find(t => t[0] === 'title'); |
||||||
|
if (titleTag && titleTag[1]) { |
||||||
|
return titleTag[1]; |
||||||
|
} |
||||||
|
|
||||||
|
// Try to get title from subject tag |
||||||
|
const subjectTag = event.tags.find(t => t[0] === 'subject'); |
||||||
|
if (subjectTag && subjectTag[1]) { |
||||||
|
return subjectTag[1]; |
||||||
|
} |
||||||
|
|
||||||
|
// Fallback to first line of content |
||||||
|
if (event.content) { |
||||||
|
const firstLine = event.content.split('\n')[0].trim(); |
||||||
|
if (firstLine.length > 0 && firstLine.length < 100) { |
||||||
|
return firstLine; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return 'Untitled'; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="publication-index"> |
||||||
|
{#if loading} |
||||||
|
<div class="loading">Loading publication index...</div> |
||||||
|
{:else if error} |
||||||
|
<div class="error">{error}</div> |
||||||
|
{:else if items.length === 0} |
||||||
|
<div class="empty">No items found in publication index</div> |
||||||
|
{:else} |
||||||
|
<div class="items-list"> |
||||||
|
{#each items as item} |
||||||
|
<div |
||||||
|
class="item" |
||||||
|
onclick={() => onItemClick?.(item)} |
||||||
|
role="button" |
||||||
|
tabindex="0" |
||||||
|
> |
||||||
|
<h3 class="item-title">{item.title}</h3> |
||||||
|
{#if item.description} |
||||||
|
<p class="item-description">{item.description}</p> |
||||||
|
{/if} |
||||||
|
{#if item.url} |
||||||
|
<a href={item.url} class="item-url" onclick={(e) => e.stopPropagation()}> |
||||||
|
{item.url} |
||||||
|
</a> |
||||||
|
{/if} |
||||||
|
<div class="item-meta"> |
||||||
|
{#if item.id} |
||||||
|
<span class="item-id">ID: {item.id.substring(0, 16)}...</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.publication-index { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.loading, .error, .empty { |
||||||
|
padding: 2rem; |
||||||
|
text-align: center; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.error { |
||||||
|
color: var(--accent-error); |
||||||
|
} |
||||||
|
|
||||||
|
.items-list { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.item { |
||||||
|
padding: 1.5rem; |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
border-radius: 8px; |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
background: var(--bg-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.item:hover { |
||||||
|
border-color: var(--accent-color); |
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||||
|
transform: translateY(-2px); |
||||||
|
} |
||||||
|
|
||||||
|
.item-title { |
||||||
|
margin: 0 0 0.5rem 0; |
||||||
|
font-size: 1.1rem; |
||||||
|
font-weight: 600; |
||||||
|
color: var(--text-primary); |
||||||
|
} |
||||||
|
|
||||||
|
.item-description { |
||||||
|
margin: 0.5rem 0; |
||||||
|
color: var(--text-secondary); |
||||||
|
line-height: 1.5; |
||||||
|
} |
||||||
|
|
||||||
|
.item-url { |
||||||
|
display: inline-block; |
||||||
|
margin-top: 0.5rem; |
||||||
|
color: var(--accent-color); |
||||||
|
text-decoration: none; |
||||||
|
font-size: 0.9rem; |
||||||
|
word-break: break-all; |
||||||
|
} |
||||||
|
|
||||||
|
.item-url:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.item-meta { |
||||||
|
margin-top: 0.75rem; |
||||||
|
padding-top: 0.75rem; |
||||||
|
border-top: 1px solid var(--border-color); |
||||||
|
font-size: 0.85rem; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.item-id { |
||||||
|
font-family: monospace; |
||||||
|
} |
||||||
|
</style> |
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,126 @@ |
|||||||
|
/** |
||||||
|
* Branch operations module |
||||||
|
* Handles branch creation, deletion, and listing |
||||||
|
*/ |
||||||
|
|
||||||
|
import simpleGit, { type SimpleGit } from 'simple-git'; |
||||||
|
import logger from '../../logger.js'; |
||||||
|
import { sanitizeError } from '../../../utils/security.js'; |
||||||
|
import { isValidBranchName } from '../../../utils/security.js'; |
||||||
|
import { validateRepoName, validateNpub } from './path-validator.js'; |
||||||
|
import { repoCache, RepoCache } from '../repo-cache.js'; |
||||||
|
|
||||||
|
export interface BranchListOptions { |
||||||
|
npub: string; |
||||||
|
repoName: string; |
||||||
|
repoPath: string; |
||||||
|
getDefaultBranch: (npub: string, repoName: string) => Promise<string>; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get list of branches in a repository |
||||||
|
*/ |
||||||
|
export async function getBranches(options: BranchListOptions): Promise<string[]> { |
||||||
|
const { npub, repoName, repoPath, getDefaultBranch } = options; |
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const npubValidation = validateNpub(npub); |
||||||
|
if (!npubValidation.valid) { |
||||||
|
throw new Error(`Invalid npub: ${npubValidation.error}`); |
||||||
|
} |
||||||
|
const repoValidation = validateRepoName(repoName); |
||||||
|
if (!repoValidation.valid) { |
||||||
|
throw new Error(`Invalid repository name: ${repoValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Check cache first (cache for 2 minutes)
|
||||||
|
const cacheKey = RepoCache.branchListKey(npub, repoName); |
||||||
|
const cached = repoCache.get<string[]>(cacheKey); |
||||||
|
if (cached !== null) { |
||||||
|
logger.debug({ npub, repoName, cachedCount: cached.length }, 'Returning cached branch list'); |
||||||
|
return cached; |
||||||
|
} |
||||||
|
|
||||||
|
const git: SimpleGit = simpleGit(repoPath); |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Listing branches', { npub, repoName }); |
||||||
|
|
||||||
|
const allBranches = new Set<string>(); |
||||||
|
|
||||||
|
// Get local branches
|
||||||
|
try { |
||||||
|
const localBranches = await git.branchLocal(); |
||||||
|
localBranches.all |
||||||
|
.filter(b => !b.startsWith('remotes/') && !b.includes('HEAD')) |
||||||
|
.forEach(b => allBranches.add(b)); |
||||||
|
} catch { |
||||||
|
// Ignore if local branches fail
|
||||||
|
} |
||||||
|
|
||||||
|
// Get remote branches
|
||||||
|
try { |
||||||
|
const remoteBranches = await git.branch(['-r']); |
||||||
|
remoteBranches.all |
||||||
|
.map(b => b.replace(/^origin\//, '')) |
||||||
|
.filter(b => !b.includes('HEAD')) |
||||||
|
.forEach(b => allBranches.add(b)); |
||||||
|
} catch { |
||||||
|
// Ignore if remote branches fail
|
||||||
|
} |
||||||
|
|
||||||
|
// If no branches found, try listing refs directly (for bare repos)
|
||||||
|
if (allBranches.size === 0) { |
||||||
|
try { |
||||||
|
const refs = await git.raw(['for-each-ref', '--format=%(refname:short)', 'refs/heads/']); |
||||||
|
if (refs) { |
||||||
|
refs.trim().split('\n').forEach(b => { |
||||||
|
if (b && !b.includes('HEAD')) { |
||||||
|
allBranches.add(b); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// If that fails too, continue with empty set
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Sort branches: default branch first, then alphabetically
|
||||||
|
let branchList = Array.from(allBranches); |
||||||
|
try { |
||||||
|
const defaultBranch = await getDefaultBranch(npub, repoName); |
||||||
|
if (defaultBranch) { |
||||||
|
branchList.sort((a, b) => { |
||||||
|
if (a === defaultBranch) return -1; |
||||||
|
if (b === defaultBranch) return 1; |
||||||
|
return a.localeCompare(b); |
||||||
|
}); |
||||||
|
} else { |
||||||
|
branchList.sort(); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
branchList.sort(); |
||||||
|
} |
||||||
|
|
||||||
|
// Cache the result (cache for 2 minutes)
|
||||||
|
repoCache.set(cacheKey, branchList, 2 * 60 * 1000); |
||||||
|
|
||||||
|
logger.operation('Branches listed', { npub, repoName, count: branchList.length }); |
||||||
|
return branchList; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, repoPath }, 'Error getting branches'); |
||||||
|
const defaultBranches = ['main', 'master']; |
||||||
|
repoCache.set(cacheKey, defaultBranches, 30 * 1000); |
||||||
|
return defaultBranches; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate branch name |
||||||
|
*/ |
||||||
|
export function validateBranchName(branch: string): { valid: boolean; error?: string } { |
||||||
|
if (!isValidBranchName(branch)) { |
||||||
|
return { valid: false, error: `Invalid branch name: ${branch}` }; |
||||||
|
} |
||||||
|
return { valid: true }; |
||||||
|
} |
||||||
@ -0,0 +1,171 @@ |
|||||||
|
/** |
||||||
|
* Commit operations module |
||||||
|
* Handles commit history and diff operations |
||||||
|
*/ |
||||||
|
|
||||||
|
import simpleGit, { type SimpleGit } from 'simple-git'; |
||||||
|
import logger from '../../logger.js'; |
||||||
|
import { sanitizeError } from '../../../utils/security.js'; |
||||||
|
import { validateRepoName, validateNpub } from './path-validator.js'; |
||||||
|
import type { Commit, Diff } from '../file-manager.js'; |
||||||
|
|
||||||
|
export interface CommitHistoryOptions { |
||||||
|
npub: string; |
||||||
|
repoName: string; |
||||||
|
branch?: string; |
||||||
|
limit?: number; |
||||||
|
path?: string; |
||||||
|
repoPath: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface DiffOptions { |
||||||
|
npub: string; |
||||||
|
repoName: string; |
||||||
|
fromRef: string; |
||||||
|
toRef?: string; |
||||||
|
filePath?: string; |
||||||
|
repoPath: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get commit history |
||||||
|
*/ |
||||||
|
export async function getCommitHistory(options: CommitHistoryOptions): Promise<Commit[]> { |
||||||
|
const { npub, repoName, branch = 'main', limit = 50, path, repoPath } = options; |
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const npubValidation = validateNpub(npub); |
||||||
|
if (!npubValidation.valid) { |
||||||
|
throw new Error(`Invalid npub: ${npubValidation.error}`); |
||||||
|
} |
||||||
|
const repoValidation = validateRepoName(repoName); |
||||||
|
if (!repoValidation.valid) { |
||||||
|
throw new Error(`Invalid repository name: ${repoValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
const git: SimpleGit = simpleGit(repoPath); |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Getting commit history', { npub, repoName, branch, limit, path }); |
||||||
|
|
||||||
|
const logOptions: { |
||||||
|
maxCount: number; |
||||||
|
from: string; |
||||||
|
file?: string; |
||||||
|
} = { |
||||||
|
maxCount: limit, |
||||||
|
from: branch |
||||||
|
}; |
||||||
|
|
||||||
|
if (path) { |
||||||
|
logOptions.file = path; |
||||||
|
} |
||||||
|
|
||||||
|
const log = await git.log(logOptions); |
||||||
|
|
||||||
|
const commits = log.all.map(commit => ({ |
||||||
|
hash: commit.hash, |
||||||
|
message: commit.message, |
||||||
|
author: `${commit.author_name} <${commit.author_email}>`, |
||||||
|
date: commit.date, |
||||||
|
files: commit.diff?.files?.map((f: { file: string }) => f.file) || [] |
||||||
|
})); |
||||||
|
|
||||||
|
logger.operation('Commit history retrieved', { npub, repoName, count: commits.length }); |
||||||
|
return commits; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, repoPath, branch, limit }, 'Error getting commit history'); |
||||||
|
throw new Error(`Failed to get commit history: ${error instanceof Error ? error.message : String(error)}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get diff between two commits or for a file |
||||||
|
*/ |
||||||
|
export async function getDiff(options: DiffOptions): Promise<Diff[]> { |
||||||
|
const { npub, repoName, fromRef, toRef = 'HEAD', filePath, repoPath } = options; |
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const npubValidation = validateNpub(npub); |
||||||
|
if (!npubValidation.valid) { |
||||||
|
throw new Error(`Invalid npub: ${npubValidation.error}`); |
||||||
|
} |
||||||
|
const repoValidation = validateRepoName(repoName); |
||||||
|
if (!repoValidation.valid) { |
||||||
|
throw new Error(`Invalid repository name: ${repoValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
const git: SimpleGit = simpleGit(repoPath); |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Getting diff', { npub, repoName, fromRef, toRef, filePath }); |
||||||
|
|
||||||
|
const diffOptions: string[] = [fromRef, toRef]; |
||||||
|
if (filePath) { |
||||||
|
diffOptions.push('--', filePath); |
||||||
|
} |
||||||
|
|
||||||
|
const [diff, stats] = await Promise.all([ |
||||||
|
git.diff(diffOptions), |
||||||
|
git.diffSummary(diffOptions) |
||||||
|
]); |
||||||
|
|
||||||
|
// Parse diff output
|
||||||
|
const files: Diff[] = []; |
||||||
|
const diffLines = diff.split('\n'); |
||||||
|
let currentFile = ''; |
||||||
|
let currentDiff = ''; |
||||||
|
let inFileHeader = false; |
||||||
|
|
||||||
|
for (const line of diffLines) { |
||||||
|
if (line.startsWith('diff --git')) { |
||||||
|
if (currentFile) { |
||||||
|
files.push({ |
||||||
|
file: currentFile, |
||||||
|
additions: 0, |
||||||
|
deletions: 0, |
||||||
|
diff: currentDiff |
||||||
|
}); |
||||||
|
} |
||||||
|
const match = line.match(/diff --git a\/(.+?) b\/(.+?)$/); |
||||||
|
if (match) { |
||||||
|
currentFile = match[2]; |
||||||
|
currentDiff = line + '\n'; |
||||||
|
inFileHeader = true; |
||||||
|
} |
||||||
|
} else { |
||||||
|
currentDiff += line + '\n'; |
||||||
|
if (line.startsWith('@@')) { |
||||||
|
inFileHeader = false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (currentFile) { |
||||||
|
files.push({ |
||||||
|
file: currentFile, |
||||||
|
additions: 0, |
||||||
|
deletions: 0, |
||||||
|
diff: currentDiff |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Add stats from diffSummary
|
||||||
|
if (stats.files && files.length > 0) { |
||||||
|
for (const statFile of stats.files) { |
||||||
|
const file = files.find(f => f.file === statFile.file); |
||||||
|
if (file && 'insertions' in statFile && 'deletions' in statFile) { |
||||||
|
file.additions = statFile.insertions; |
||||||
|
file.deletions = statFile.deletions; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
logger.operation('Diff retrieved', { npub, repoName, fileCount: files.length }); |
||||||
|
return files; |
||||||
|
} catch (error) { |
||||||
|
const sanitizedError = sanitizeError(error); |
||||||
|
logger.error({ error: sanitizedError, repoPath, fromRef, toRef }, 'Error getting diff'); |
||||||
|
throw new Error(`Failed to get diff: ${sanitizedError}`); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,586 @@ |
|||||||
|
/** |
||||||
|
* File Manager - Refactored to use modular components |
||||||
|
* Main class that delegates to focused modules
|
||||||
|
*/ |
||||||
|
|
||||||
|
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 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'; |
||||||
|
|
||||||
|
// Types are defined below
|
||||||
|
|
||||||
|
export interface FileEntry { |
||||||
|
name: string; |
||||||
|
path: string; |
||||||
|
type: 'file' | 'directory'; |
||||||
|
size?: number; |
||||||
|
} |
||||||
|
|
||||||
|
export interface FileContent { |
||||||
|
content: string; |
||||||
|
encoding: string; |
||||||
|
size: number; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Commit { |
||||||
|
hash: string; |
||||||
|
message: string; |
||||||
|
author: string; |
||||||
|
date: string; |
||||||
|
files: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Diff { |
||||||
|
file: string; |
||||||
|
additions: number; |
||||||
|
deletions: number; |
||||||
|
diff: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Tag { |
||||||
|
name: string; |
||||||
|
hash: string; |
||||||
|
message?: string; |
||||||
|
date?: number; |
||||||
|
} |
||||||
|
|
||||||
|
export class FileManager { |
||||||
|
private repoManager: RepoManager; |
||||||
|
private repoRoot: string; |
||||||
|
private dirExistenceCache: Map<string, { exists: boolean; timestamp: number }> = new Map(); |
||||||
|
private readonly DIR_CACHE_TTL = 5 * 60 * 1000; |
||||||
|
private fsPromises: typeof import('fs/promises') | null = null; |
||||||
|
|
||||||
|
constructor(repoRoot: string = '/repos') { |
||||||
|
this.repoRoot = repoRoot; |
||||||
|
this.repoManager = new RepoManager(repoRoot); |
||||||
|
} |
||||||
|
|
||||||
|
private async getFsPromises(): Promise<typeof import('fs/promises')> { |
||||||
|
if (!this.fsPromises) { |
||||||
|
this.fsPromises = await import('fs/promises'); |
||||||
|
} |
||||||
|
return this.fsPromises; |
||||||
|
} |
||||||
|
|
||||||
|
private async pathExists(path: string): Promise<boolean> { |
||||||
|
try { |
||||||
|
const fs = await this.getFsPromises(); |
||||||
|
await fs.access(path); |
||||||
|
return true; |
||||||
|
} catch { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private sanitizePathForError(path: string): string { |
||||||
|
const resolvedPath = resolve(path).replace(/\\/g, '/'); |
||||||
|
const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); |
||||||
|
if (resolvedPath.startsWith(resolvedRoot + '/')) { |
||||||
|
return resolvedPath.slice(resolvedRoot.length + 1); |
||||||
|
} |
||||||
|
return path.split(/[/\\]/).pop() || path; |
||||||
|
} |
||||||
|
|
||||||
|
private async ensureDirectoryExists(dirPath: string, description: string): Promise<void> { |
||||||
|
const exists = await this.pathExists(dirPath); |
||||||
|
if (exists) return; |
||||||
|
|
||||||
|
try { |
||||||
|
const { mkdir } = await this.getFsPromises(); |
||||||
|
await mkdir(dirPath, { recursive: true }); |
||||||
|
logger.debug({ dirPath: this.sanitizePathForError(dirPath) }, `Created ${description}`); |
||||||
|
} catch (err) { |
||||||
|
logger.error({ error: err, dirPath: this.sanitizePathForError(dirPath) }, `Failed to create ${description}`); |
||||||
|
throw new Error(`Failed to create ${description}: ${err instanceof Error ? err.message : String(err)}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
getRepoPath(npub: string, repoName: string): string { |
||||||
|
const repoPath = join(this.repoRoot, npub, `${repoName}.git`); |
||||||
|
const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); |
||||||
|
const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); |
||||||
|
if (!resolvedPath.startsWith(resolvedRoot + '/')) { |
||||||
|
throw new Error('Path traversal detected: repository path outside allowed root'); |
||||||
|
} |
||||||
|
return repoPath; |
||||||
|
} |
||||||
|
|
||||||
|
repoExists(npub: string, repoName: string): boolean { |
||||||
|
const npubValidation = validateNpub(npub); |
||||||
|
if (!npubValidation.valid) return false; |
||||||
|
const repoValidation = validateRepoName(repoName); |
||||||
|
if (!repoValidation.valid) return false; |
||||||
|
|
||||||
|
const cacheKey = RepoCache.repoExistsKey(npub, repoName); |
||||||
|
const cached = repoCache.get<boolean>(cacheKey); |
||||||
|
if (cached !== null) return cached; |
||||||
|
|
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
const exists = this.repoManager.repoExists(repoPath); |
||||||
|
repoCache.set(cacheKey, exists, 60 * 1000); |
||||||
|
return exists; |
||||||
|
} |
||||||
|
|
||||||
|
async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise<string> { |
||||||
|
if (!isValidBranchName(branch)) { |
||||||
|
throw new Error(`Invalid branch name: ${branch}`); |
||||||
|
} |
||||||
|
return getOrCreateWorktree({ |
||||||
|
repoPath, |
||||||
|
branch, |
||||||
|
npub, |
||||||
|
repoName, |
||||||
|
repoRoot: this.repoRoot |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
async removeWorktree(repoPath: string, worktreePath: string): Promise<void> { |
||||||
|
return removeWorktree(repoPath, worktreePath); |
||||||
|
} |
||||||
|
|
||||||
|
async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise<FileEntry[]> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
return listFiles({ npub, repoName, ref, path, repoPath }); |
||||||
|
} |
||||||
|
|
||||||
|
async getFileContent(npub: string, repoName: string, filePath: string, ref: string = 'HEAD'): Promise<FileContent> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
return getFileContent({ npub, repoName, filePath, ref, repoPath }); |
||||||
|
} |
||||||
|
|
||||||
|
async writeFile( |
||||||
|
npub: string, |
||||||
|
repoName: string, |
||||||
|
filePath: string, |
||||||
|
content: string, |
||||||
|
commitMessage: string, |
||||||
|
authorName: string, |
||||||
|
authorEmail: string, |
||||||
|
branch: string = 'main', |
||||||
|
signingOptions?: { |
||||||
|
commitSignatureEvent?: any; |
||||||
|
useNIP07?: boolean; |
||||||
|
nip98Event?: any; |
||||||
|
nsecKey?: string; |
||||||
|
} |
||||||
|
): Promise<void> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
|
||||||
|
// Check repo size
|
||||||
|
const repoSizeCheck = await this.repoManager.checkRepoSizeLimit(repoPath); |
||||||
|
if (!repoSizeCheck.withinLimit) { |
||||||
|
throw new Error(repoSizeCheck.error || 'Repository size limit exceeded'); |
||||||
|
} |
||||||
|
|
||||||
|
const worktreePath = await this.getWorktree(repoPath, branch, npub, repoName); |
||||||
|
|
||||||
|
// Save commit signature helper
|
||||||
|
const saveCommitSignature = async (worktreePath: string, event: any) => { |
||||||
|
await this.saveCommitSignatureEventToWorktree(worktreePath, event); |
||||||
|
}; |
||||||
|
|
||||||
|
// Check if repo is private
|
||||||
|
const isRepoPrivate = async (npub: string, repoName: string) => { |
||||||
|
return this.isRepoPrivate(npub, repoName); |
||||||
|
}; |
||||||
|
|
||||||
|
await writeFile({ |
||||||
|
npub, |
||||||
|
repoName, |
||||||
|
filePath, |
||||||
|
content, |
||||||
|
commitMessage, |
||||||
|
authorName, |
||||||
|
authorEmail, |
||||||
|
branch, |
||||||
|
repoPath, |
||||||
|
worktreePath, |
||||||
|
signingOptions, |
||||||
|
saveCommitSignature, |
||||||
|
isRepoPrivate |
||||||
|
}); |
||||||
|
|
||||||
|
await this.removeWorktree(repoPath, worktreePath); |
||||||
|
} |
||||||
|
|
||||||
|
async deleteFile( |
||||||
|
npub: string, |
||||||
|
repoName: string, |
||||||
|
filePath: string, |
||||||
|
commitMessage: string, |
||||||
|
authorName: string, |
||||||
|
authorEmail: string, |
||||||
|
branch: string = 'main', |
||||||
|
signingOptions?: { |
||||||
|
commitSignatureEvent?: any; |
||||||
|
useNIP07?: boolean; |
||||||
|
nip98Event?: any; |
||||||
|
nsecKey?: string; |
||||||
|
} |
||||||
|
): Promise<void> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
|
||||||
|
const worktreePath = await this.getWorktree(repoPath, branch, npub, repoName); |
||||||
|
|
||||||
|
const saveCommitSignature = async (worktreePath: string, event: any) => { |
||||||
|
await this.saveCommitSignatureEventToWorktree(worktreePath, event); |
||||||
|
}; |
||||||
|
|
||||||
|
await deleteFile({ |
||||||
|
npub, |
||||||
|
repoName, |
||||||
|
filePath, |
||||||
|
commitMessage, |
||||||
|
authorName, |
||||||
|
authorEmail, |
||||||
|
branch, |
||||||
|
repoPath, |
||||||
|
worktreePath, |
||||||
|
signingOptions, |
||||||
|
saveCommitSignature |
||||||
|
}); |
||||||
|
|
||||||
|
await this.removeWorktree(repoPath, worktreePath); |
||||||
|
} |
||||||
|
|
||||||
|
async createFile( |
||||||
|
npub: string, |
||||||
|
repoName: string, |
||||||
|
filePath: string, |
||||||
|
content: string, |
||||||
|
commitMessage: string, |
||||||
|
authorName: string, |
||||||
|
authorEmail: string, |
||||||
|
branch: string = 'main', |
||||||
|
signingOptions?: { |
||||||
|
useNIP07?: boolean; |
||||||
|
nip98Event?: any; |
||||||
|
nsecKey?: string; |
||||||
|
} |
||||||
|
): Promise<void> { |
||||||
|
return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch, signingOptions); |
||||||
|
} |
||||||
|
|
||||||
|
async getDefaultBranch(npub: string, repoName: string): Promise<string> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
|
||||||
|
const git: SimpleGit = simpleGit(repoPath); |
||||||
|
|
||||||
|
try { |
||||||
|
const defaultRef = await git.raw(['symbolic-ref', 'HEAD']); |
||||||
|
if (defaultRef) { |
||||||
|
const match = defaultRef.trim().match(/^refs\/heads\/(.+)$/); |
||||||
|
if (match) return match[1]; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
try { |
||||||
|
const remoteHead = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']); |
||||||
|
if (remoteHead) { |
||||||
|
const match = remoteHead.trim().match(/^refs\/remotes\/origin\/(.+)$/); |
||||||
|
if (match) return match[1]; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Fall through
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const branches = await git.branch(['-r']); |
||||||
|
const branchList = branches.all |
||||||
|
.map(b => b.replace(/^origin\//, '')) |
||||||
|
.filter(b => !b.includes('HEAD')); |
||||||
|
|
||||||
|
if (branchList.length === 0) return 'main'; |
||||||
|
if (branchList.includes('main')) return 'main'; |
||||||
|
if (branchList.includes('master')) return 'master'; |
||||||
|
return branchList[0]; |
||||||
|
} catch { |
||||||
|
return 'main'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async getBranches(npub: string, repoName: string): Promise<string[]> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
return getBranches({ |
||||||
|
npub, |
||||||
|
repoName, |
||||||
|
repoPath, |
||||||
|
getDefaultBranch: (npub, repoName) => this.getDefaultBranch(npub, repoName) |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
async createBranch( |
||||||
|
npub: string, |
||||||
|
repoName: string, |
||||||
|
branchName: string, |
||||||
|
fromBranch: string = 'main' |
||||||
|
): Promise<void> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
|
||||||
|
if (!isValidBranchName(branchName)) { |
||||||
|
throw new Error(`Invalid branch name: ${branchName}`); |
||||||
|
} |
||||||
|
|
||||||
|
const git: SimpleGit = simpleGit(repoPath); |
||||||
|
|
||||||
|
try { |
||||||
|
await git.raw(['branch', branchName, fromBranch]); |
||||||
|
const cacheKey = RepoCache.branchesKey(npub, repoName); |
||||||
|
repoCache.delete(cacheKey); |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, repoPath, branchName, fromBranch }, 'Error creating branch'); |
||||||
|
throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async deleteBranch(npub: string, repoName: string, branchName: string): Promise<void> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
|
||||||
|
if (!isValidBranchName(branchName)) { |
||||||
|
throw new Error(`Invalid branch name: ${branchName}`); |
||||||
|
} |
||||||
|
|
||||||
|
const git: SimpleGit = simpleGit(repoPath); |
||||||
|
|
||||||
|
try { |
||||||
|
await git.raw(['branch', '-D', branchName]).catch(async () => { |
||||||
|
await git.raw(['update-ref', '-d', `refs/heads/${branchName}`]); |
||||||
|
}); |
||||||
|
|
||||||
|
const cacheKey = RepoCache.branchesKey(npub, repoName); |
||||||
|
repoCache.delete(cacheKey); |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, repoPath, branchName }, 'Error deleting branch'); |
||||||
|
throw new Error(`Failed to delete branch: ${error instanceof Error ? error.message : String(error)}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async getCommitHistory( |
||||||
|
npub: string, |
||||||
|
repoName: string, |
||||||
|
branch: string = 'main', |
||||||
|
limit: number = 50, |
||||||
|
path?: string |
||||||
|
): Promise<Commit[]> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
return getCommitHistory({ npub, repoName, branch, limit, path, repoPath }); |
||||||
|
} |
||||||
|
|
||||||
|
async getDiff( |
||||||
|
npub: string, |
||||||
|
repoName: string, |
||||||
|
fromRef: string, |
||||||
|
toRef: string = 'HEAD', |
||||||
|
filePath?: string |
||||||
|
): Promise<Diff[]> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
return getDiff({ npub, repoName, fromRef, toRef, filePath, repoPath }); |
||||||
|
} |
||||||
|
|
||||||
|
async createTag( |
||||||
|
npub: string, |
||||||
|
repoName: string, |
||||||
|
tagName: string, |
||||||
|
ref: string = 'HEAD', |
||||||
|
message?: string, |
||||||
|
authorName?: string, |
||||||
|
authorEmail?: string |
||||||
|
): Promise<void> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
return createTag({ npub, repoName, tagName, ref, message, authorName, authorEmail, repoPath }); |
||||||
|
} |
||||||
|
|
||||||
|
async getTags(npub: string, repoName: string): Promise<Tag[]> { |
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
if (!this.repoExists(npub, repoName)) { |
||||||
|
throw new Error('Repository not found'); |
||||||
|
} |
||||||
|
return getTags({ npub, repoName, repoPath }); |
||||||
|
} |
||||||
|
|
||||||
|
private async saveCommitSignatureEventToWorktree(worktreePath: string, event: any): Promise<void> { |
||||||
|
try { |
||||||
|
const { mkdir, writeFile } = await this.getFsPromises(); |
||||||
|
const nostrDir = join(worktreePath, 'nostr'); |
||||||
|
await mkdir(nostrDir, { recursive: true }); |
||||||
|
const jsonlFile = join(nostrDir, 'commit-signatures.jsonl'); |
||||||
|
const eventLine = JSON.stringify(event) + '\n'; |
||||||
|
await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); |
||||||
|
} catch (err) { |
||||||
|
logger.debug({ error: err, worktreePath }, 'Failed to save commit signature event'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async saveRepoEventToWorktree( |
||||||
|
worktreePath: string, |
||||||
|
event: any, |
||||||
|
eventType: 'announcement' | 'transfer', |
||||||
|
skipIfExists: boolean = true |
||||||
|
): Promise<boolean> { |
||||||
|
try { |
||||||
|
const { mkdir, writeFile, readFile } = await this.getFsPromises(); |
||||||
|
const nostrDir = join(worktreePath, 'nostr'); |
||||||
|
await mkdir(nostrDir, { recursive: true }); |
||||||
|
const jsonlFile = join(nostrDir, 'repo-events.jsonl'); |
||||||
|
|
||||||
|
if (skipIfExists) { |
||||||
|
try { |
||||||
|
const existingContent = await readFile(jsonlFile, 'utf-8'); |
||||||
|
const lines = existingContent.trim().split('\n').filter(l => l.trim()); |
||||||
|
for (const line of lines) { |
||||||
|
try { |
||||||
|
const parsed = JSON.parse(line); |
||||||
|
if (parsed.event && parsed.event.id === event.id) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Skip invalid lines
|
||||||
|
} |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// File doesn't exist yet
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const eventLine = JSON.stringify({ |
||||||
|
type: eventType, |
||||||
|
timestamp: event.created_at, |
||||||
|
event |
||||||
|
}) + '\n'; |
||||||
|
await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); |
||||||
|
return true; |
||||||
|
} catch (err) { |
||||||
|
logger.debug({ error: err, worktreePath, eventType }, 'Failed to save repo event'); |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async isRepoPrivate(npub: string, repoName: string): Promise<boolean> { |
||||||
|
try { |
||||||
|
const { requireNpubHex } = await import('../../../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 = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
const events = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
authors: [repoOwnerPubkey], |
||||||
|
'#d': [repoName], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (events.length === 0) return false; |
||||||
|
|
||||||
|
const { isPrivateRepo: checkIsPrivateRepo } = await import('../../../utils/repo-privacy.js'); |
||||||
|
return checkIsPrivateRepo(events[0]); |
||||||
|
} catch (err) { |
||||||
|
logger.debug({ error: err, npub, repoName }, 'Failed to check repo privacy, defaulting to public'); |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise<string | null> { |
||||||
|
try { |
||||||
|
if (!this.repoExists(npub, repoName)) return null; |
||||||
|
|
||||||
|
const repoPath = this.getRepoPath(npub, repoName); |
||||||
|
const git: SimpleGit = simpleGit(repoPath); |
||||||
|
|
||||||
|
const logOutput = await git.raw(['log', '--all', '--format=%H', '--reverse', '--', 'nostr/repo-events.jsonl']); |
||||||
|
const commitHashes = logOutput.trim().split('\n').filter(Boolean); |
||||||
|
|
||||||
|
if (commitHashes.length === 0) return null; |
||||||
|
|
||||||
|
const mostRecentCommit = commitHashes[commitHashes.length - 1]; |
||||||
|
const repoEventsFile = await this.getFileContent(npub, repoName, 'nostr/repo-events.jsonl', mostRecentCommit); |
||||||
|
|
||||||
|
let announcementEvent: any = null; |
||||||
|
let latestTimestamp = 0; |
||||||
|
|
||||||
|
try { |
||||||
|
const lines = repoEventsFile.content.trim().split('\n').filter(Boolean); |
||||||
|
for (const line of lines) { |
||||||
|
try { |
||||||
|
const entry = JSON.parse(line); |
||||||
|
if (entry.type === 'announcement' && entry.event && entry.timestamp) { |
||||||
|
if (entry.timestamp > latestTimestamp) { |
||||||
|
latestTimestamp = entry.timestamp; |
||||||
|
announcementEvent = entry.event; |
||||||
|
} |
||||||
|
} |
||||||
|
} catch { |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (parseError) { |
||||||
|
logger.warn({ error: parseError, npub, repoName }, 'Failed to parse repo-events.jsonl'); |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
if (!announcementEvent) return null; |
||||||
|
|
||||||
|
const { validateAnnouncementEvent } = await import('../../nostr/repo-verification.js'); |
||||||
|
const validation = validateAnnouncementEvent(announcementEvent, repoName); |
||||||
|
|
||||||
|
if (!validation.valid) { |
||||||
|
logger.warn({ error: validation.error, npub, repoName }, 'Announcement validation failed'); |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return announcementEvent.pubkey; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, npub, repoName }, 'Error getting current owner from repo'); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,295 @@ |
|||||||
|
/** |
||||||
|
* File operations module |
||||||
|
* Handles reading, writing, and listing files in git repositories |
||||||
|
*/ |
||||||
|
|
||||||
|
import simpleGit, { type SimpleGit } from 'simple-git'; |
||||||
|
import { join } from 'path'; |
||||||
|
import logger from '../../logger.js'; |
||||||
|
import { sanitizeError } from '../../../utils/security.js'; |
||||||
|
import { validateFilePath, validateRepoName, validateNpub } from './path-validator.js'; |
||||||
|
import type { FileEntry, FileContent } from '../file-manager.js'; |
||||||
|
import { repoCache, RepoCache } from '../repo-cache.js'; |
||||||
|
|
||||||
|
export interface FileListOptions { |
||||||
|
npub: string; |
||||||
|
repoName: string; |
||||||
|
ref?: string; |
||||||
|
path?: string; |
||||||
|
repoPath: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface FileReadOptions { |
||||||
|
npub: string; |
||||||
|
repoName: string; |
||||||
|
filePath: string; |
||||||
|
ref?: string; |
||||||
|
repoPath: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* List files and directories in a repository at a given path |
||||||
|
*/ |
||||||
|
export async function listFiles(options: FileListOptions): Promise<FileEntry[]> { |
||||||
|
const { npub, repoName, ref = 'HEAD', path = '', repoPath } = options; |
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const npubValidation = validateNpub(npub); |
||||||
|
if (!npubValidation.valid) { |
||||||
|
throw new Error(`Invalid npub: ${npubValidation.error}`); |
||||||
|
} |
||||||
|
const repoValidation = validateRepoName(repoName); |
||||||
|
if (!repoValidation.valid) { |
||||||
|
throw new Error(`Invalid repository name: ${repoValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
const pathValidation = validateFilePath(path); |
||||||
|
if (!pathValidation.valid) { |
||||||
|
throw new Error(`Invalid file path: ${pathValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Check cache first (cache for 2 minutes)
|
||||||
|
const cacheKey = RepoCache.fileListKey(npub, repoName, ref, path); |
||||||
|
const cached = repoCache.get<FileEntry[]>(cacheKey); |
||||||
|
if (cached !== null) { |
||||||
|
logger.debug({ npub, repoName, path, ref, cachedCount: cached.length }, 'Returning cached file list'); |
||||||
|
return cached; |
||||||
|
} |
||||||
|
|
||||||
|
const git: SimpleGit = simpleGit(repoPath); |
||||||
|
|
||||||
|
try { |
||||||
|
const gitPath = path ? (path.endsWith('/') ? path : `${path}/`) : '.'; |
||||||
|
logger.operation('Listing files', { npub, repoName, path, ref, gitPath }); |
||||||
|
|
||||||
|
let tree: string; |
||||||
|
try { |
||||||
|
tree = await git.raw(['ls-tree', '-l', ref, gitPath]); |
||||||
|
} catch (lsTreeError) { |
||||||
|
const errorMsg = lsTreeError instanceof Error ? lsTreeError.message : String(lsTreeError); |
||||||
|
const errorStr = String(lsTreeError).toLowerCase(); |
||||||
|
const errorMsgLower = errorMsg.toLowerCase(); |
||||||
|
|
||||||
|
const isEmptyBranchError =
|
||||||
|
errorMsgLower.includes('not a valid object') || |
||||||
|
errorMsgLower.includes('not found') || |
||||||
|
errorMsgLower.includes('bad revision') || |
||||||
|
errorMsgLower.includes('ambiguous argument') || |
||||||
|
errorStr.includes('not a valid object') || |
||||||
|
errorStr.includes('not found') || |
||||||
|
errorStr.includes('bad revision') || |
||||||
|
errorStr.includes('ambiguous argument') || |
||||||
|
(errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads'))); |
||||||
|
|
||||||
|
if (isEmptyBranchError) { |
||||||
|
logger.debug({ npub, repoName, path, ref }, 'Branch has no commits, returning empty list'); |
||||||
|
const emptyResult: FileEntry[] = []; |
||||||
|
repoCache.set(cacheKey, emptyResult, 30 * 1000); |
||||||
|
return emptyResult; |
||||||
|
} |
||||||
|
|
||||||
|
logger.error({ error: lsTreeError, npub, repoName, path, ref }, 'Unexpected error from git ls-tree'); |
||||||
|
throw lsTreeError; |
||||||
|
} |
||||||
|
|
||||||
|
if (!tree || !tree.trim()) { |
||||||
|
const emptyResult: FileEntry[] = []; |
||||||
|
repoCache.set(cacheKey, emptyResult, 30 * 1000); |
||||||
|
return emptyResult; |
||||||
|
} |
||||||
|
|
||||||
|
const entries: FileEntry[] = []; |
||||||
|
const lines = tree.trim().split('\n').filter(line => line.length > 0); |
||||||
|
const normalizedPath = path ? (path.endsWith('/') ? path : `${path}/`) : ''; |
||||||
|
|
||||||
|
for (const line of lines) { |
||||||
|
const tabIndex = line.lastIndexOf('\t'); |
||||||
|
if (tabIndex === -1) { |
||||||
|
// Space-separated format
|
||||||
|
const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/); |
||||||
|
if (match) { |
||||||
|
const [, , type, , size, gitPath] = match; |
||||||
|
const { fullPath, displayName } = parseGitPath(gitPath, normalizedPath, path); |
||||||
|
|
||||||
|
entries.push({ |
||||||
|
name: displayName, |
||||||
|
path: fullPath, |
||||||
|
type: type === 'tree' ? 'directory' : 'file', |
||||||
|
size: size !== '-' ? parseInt(size, 10) : undefined |
||||||
|
}); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Tab-separated format (standard)
|
||||||
|
const beforeTab = line.substring(0, tabIndex); |
||||||
|
const gitPath = line.substring(tabIndex + 1); |
||||||
|
const match = beforeTab.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)$/); |
||||||
|
if (match) { |
||||||
|
const [, , type, , size] = match; |
||||||
|
const { fullPath, displayName } = parseGitPath(gitPath, normalizedPath, path); |
||||||
|
|
||||||
|
entries.push({ |
||||||
|
name: displayName, |
||||||
|
path: fullPath, |
||||||
|
type: type === 'tree' ? 'directory' : 'file', |
||||||
|
size: size !== '-' ? parseInt(size, 10) : undefined |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const sortedEntries = entries.sort((a, b) => { |
||||||
|
if (a.type !== b.type) { |
||||||
|
return a.type === 'directory' ? -1 : 1; |
||||||
|
} |
||||||
|
return a.name.localeCompare(b.name); |
||||||
|
}); |
||||||
|
|
||||||
|
// Cache the result (cache for 2 minutes)
|
||||||
|
repoCache.set(cacheKey, sortedEntries, 2 * 60 * 1000); |
||||||
|
|
||||||
|
logger.operation('Files listed', { npub, repoName, path, count: sortedEntries.length }); |
||||||
|
return sortedEntries; |
||||||
|
} catch (error) { |
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error); |
||||||
|
const errorStr = String(error).toLowerCase(); |
||||||
|
const errorMsgLower = errorMsg.toLowerCase(); |
||||||
|
|
||||||
|
const isEmptyBranchError =
|
||||||
|
errorMsgLower.includes('not a valid object') || |
||||||
|
errorMsgLower.includes('not found') || |
||||||
|
errorMsgLower.includes('bad revision') || |
||||||
|
errorMsgLower.includes('ambiguous argument') || |
||||||
|
errorStr.includes('not a valid object') || |
||||||
|
errorStr.includes('not found') || |
||||||
|
errorStr.includes('bad revision') || |
||||||
|
errorStr.includes('ambiguous argument') || |
||||||
|
(errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads'))); |
||||||
|
|
||||||
|
if (isEmptyBranchError) { |
||||||
|
logger.debug({ npub, repoName, path, ref }, 'Branch has no commits, returning empty list'); |
||||||
|
const emptyResult: FileEntry[] = []; |
||||||
|
repoCache.set(cacheKey, emptyResult, 30 * 1000); |
||||||
|
return emptyResult; |
||||||
|
} |
||||||
|
|
||||||
|
logger.error({ error, repoPath, ref }, 'Error listing files'); |
||||||
|
throw new Error(`Failed to list files: ${errorMsg}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse git path and extract full path and display name |
||||||
|
*/ |
||||||
|
function parseGitPath( |
||||||
|
gitPath: string, |
||||||
|
normalizedPath: string, |
||||||
|
originalPath: string |
||||||
|
): { fullPath: string; displayName: string } { |
||||||
|
let fullPath: string; |
||||||
|
let displayName: string; |
||||||
|
|
||||||
|
if (normalizedPath) { |
||||||
|
if (gitPath.startsWith(normalizedPath)) { |
||||||
|
fullPath = gitPath; |
||||||
|
const relativePath = gitPath.slice(normalizedPath.length); |
||||||
|
const cleanRelative = relativePath.replace(/^\/+|\/+$/g, ''); |
||||||
|
displayName = cleanRelative.split('/')[0] || cleanRelative; |
||||||
|
} else { |
||||||
|
fullPath = join(originalPath, gitPath); |
||||||
|
displayName = gitPath.split('/').pop() || gitPath; |
||||||
|
} |
||||||
|
} else { |
||||||
|
fullPath = gitPath; |
||||||
|
displayName = gitPath.split('/')[0]; |
||||||
|
} |
||||||
|
|
||||||
|
return { fullPath, displayName }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get file content from a repository |
||||||
|
*/ |
||||||
|
export async function getFileContent(options: FileReadOptions): Promise<FileContent> { |
||||||
|
const { npub, repoName, filePath, ref = 'HEAD', repoPath } = options; |
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const npubValidation = validateNpub(npub); |
||||||
|
if (!npubValidation.valid) { |
||||||
|
throw new Error(`Invalid npub: ${npubValidation.error}`); |
||||||
|
} |
||||||
|
const repoValidation = validateRepoName(repoName); |
||||||
|
if (!repoValidation.valid) { |
||||||
|
throw new Error(`Invalid repository name: ${repoValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
const pathValidation = validateFilePath(filePath); |
||||||
|
if (!pathValidation.valid) { |
||||||
|
throw new Error(`Invalid file path: ${pathValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
const git: SimpleGit = simpleGit(repoPath); |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Reading file', { npub, repoName, filePath, ref }); |
||||||
|
|
||||||
|
let content: string; |
||||||
|
try { |
||||||
|
content = await git.raw(['show', `${ref}:${filePath}`]); |
||||||
|
} catch (gitError: any) { |
||||||
|
const stderr = gitError?.stderr || gitError?.message || String(gitError); |
||||||
|
const stderrLower = stderr.toLowerCase(); |
||||||
|
|
||||||
|
if (stderrLower.includes('not found') ||
|
||||||
|
stderrLower.includes('no such file') ||
|
||||||
|
stderrLower.includes('does not exist') || |
||||||
|
stderrLower.includes('fatal:') || |
||||||
|
stderr.includes('pathspec') || |
||||||
|
stderr.includes('ambiguous argument') || |
||||||
|
stderr.includes('unknown revision') || |
||||||
|
stderr.includes('bad revision')) { |
||||||
|
throw new Error(`File not found: ${filePath} at ref ${ref}`); |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error(`Git command failed: ${stderr}`); |
||||||
|
} |
||||||
|
|
||||||
|
if (content === undefined || content === null) { |
||||||
|
throw new Error(`File not found: ${filePath} at ref ${ref}`); |
||||||
|
} |
||||||
|
|
||||||
|
const encoding = 'utf-8'; |
||||||
|
const size = Buffer.byteLength(content, encoding); |
||||||
|
|
||||||
|
logger.operation('File read', { npub, repoName, filePath, size }); |
||||||
|
return { |
||||||
|
content, |
||||||
|
encoding, |
||||||
|
size |
||||||
|
}; |
||||||
|
} catch (error) { |
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error); |
||||||
|
const errorLower = errorMessage.toLowerCase(); |
||||||
|
const errorString = String(error); |
||||||
|
const errorStringLower = errorString.toLowerCase(); |
||||||
|
|
||||||
|
if (errorLower.includes('not found') ||
|
||||||
|
errorStringLower.includes('not found') || |
||||||
|
errorLower.includes('no such file') ||
|
||||||
|
errorStringLower.includes('no such file') || |
||||||
|
errorLower.includes('does not exist') || |
||||||
|
errorStringLower.includes('does not exist') || |
||||||
|
errorLower.includes('fatal:') || |
||||||
|
errorStringLower.includes('fatal:') || |
||||||
|
errorMessage.includes('pathspec') || |
||||||
|
errorString.includes('pathspec') || |
||||||
|
errorMessage.includes('ambiguous argument') || |
||||||
|
errorString.includes('ambiguous argument') || |
||||||
|
errorString.includes('unknown revision') || |
||||||
|
errorString.includes('bad revision')) { |
||||||
|
throw new Error(`File not found: ${filePath} at ref ${ref}`); |
||||||
|
} |
||||||
|
|
||||||
|
logger.error({ error, repoPath, filePath, ref }, 'Error reading file'); |
||||||
|
throw new Error(`Failed to read file: ${errorMessage}`); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
/** |
||||||
|
* File Manager - Modular exports |
||||||
|
* Re-exports all file manager functionality from focused modules
|
||||||
|
*/ |
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export type { |
||||||
|
FileEntry, |
||||||
|
FileContent, |
||||||
|
Commit, |
||||||
|
Diff, |
||||||
|
Tag |
||||||
|
} from '../file-manager.js'; |
||||||
|
|
||||||
|
// Re-export modules
|
||||||
|
export * from './worktree-manager.js'; |
||||||
|
export * from './path-validator.js'; |
||||||
|
export * from './file-operations.js'; |
||||||
|
export * from './branch-operations.js'; |
||||||
|
export * from './write-operations.js'; |
||||||
|
export * from './commit-operations.js'; |
||||||
|
export * from './tag-operations.js'; |
||||||
@ -0,0 +1,128 @@ |
|||||||
|
/** |
||||||
|
* Path validation utilities |
||||||
|
* Security-focused path validation to prevent path traversal attacks |
||||||
|
*/ |
||||||
|
|
||||||
|
import { normalize, resolve } from 'path'; |
||||||
|
import logger from '../../logger.js'; |
||||||
|
|
||||||
|
export interface ValidationResult { |
||||||
|
valid: boolean; |
||||||
|
error?: string; |
||||||
|
normalized?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate and sanitize file path to prevent path traversal attacks |
||||||
|
*/ |
||||||
|
export function validateFilePath(filePath: string): ValidationResult { |
||||||
|
// Allow empty string for root directory
|
||||||
|
if (filePath === '') { |
||||||
|
return { valid: true, normalized: '' }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!filePath || typeof filePath !== 'string') { |
||||||
|
return { valid: false, error: 'File path must be a non-empty string' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Normalize the path (resolves .. and .)
|
||||||
|
const normalized = normalize(filePath); |
||||||
|
|
||||||
|
// Check for path traversal attempts
|
||||||
|
if (normalized.includes('..')) { |
||||||
|
logger.security('Path traversal attempt detected', { filePath, normalized }); |
||||||
|
return { valid: false, error: 'Path traversal detected (..)' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Check for absolute paths
|
||||||
|
if (normalized.startsWith('/')) { |
||||||
|
return { valid: false, error: 'Absolute paths are not allowed' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Check for null bytes
|
||||||
|
if (normalized.includes('\0')) { |
||||||
|
logger.security('Null byte detected in path', { filePath }); |
||||||
|
return { valid: false, error: 'Null bytes are not allowed in paths' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Check for control characters
|
||||||
|
if (/[\x00-\x1f\x7f]/.test(normalized)) { |
||||||
|
logger.security('Control character detected in path', { filePath }); |
||||||
|
return { valid: false, error: 'Control characters are not allowed in paths' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Limit path length (reasonable limit)
|
||||||
|
if (normalized.length > 4096) { |
||||||
|
return { valid: false, error: 'Path is too long (max 4096 characters)' }; |
||||||
|
} |
||||||
|
|
||||||
|
return { valid: true, normalized }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate repository name to prevent injection attacks |
||||||
|
*/ |
||||||
|
export function validateRepoName(repoName: string): ValidationResult { |
||||||
|
if (!repoName || typeof repoName !== 'string') { |
||||||
|
return { valid: false, error: 'Repository name must be a non-empty string' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Check length
|
||||||
|
if (repoName.length > 100) { |
||||||
|
return { valid: false, error: 'Repository name is too long (max 100 characters)' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Check for invalid characters (alphanumeric, hyphens, underscores, dots)
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) { |
||||||
|
logger.security('Invalid characters in repo name', { repoName }); |
||||||
|
return { valid: false, error: 'Repository name contains invalid characters' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Check for path traversal
|
||||||
|
if (repoName.includes('..') || repoName.includes('/') || repoName.includes('\\')) { |
||||||
|
logger.security('Path traversal attempt in repo name', { repoName }); |
||||||
|
return { valid: false, error: 'Repository name contains invalid path characters' }; |
||||||
|
} |
||||||
|
|
||||||
|
return { valid: true, normalized: repoName }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate npub format |
||||||
|
*/ |
||||||
|
export function validateNpub(npub: string): ValidationResult { |
||||||
|
if (!npub || typeof npub !== 'string') { |
||||||
|
return { valid: false, error: 'npub must be a non-empty string' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Basic npub format check (starts with npub, base58 encoded)
|
||||||
|
if (!npub.startsWith('npub1') || npub.length < 10 || npub.length > 100) { |
||||||
|
return { valid: false, error: 'Invalid npub format' }; |
||||||
|
} |
||||||
|
|
||||||
|
return { valid: true, normalized: npub }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate repository path is within allowed root |
||||||
|
*/ |
||||||
|
export function validateRepoPath( |
||||||
|
repoPath: string, |
||||||
|
repoRoot: string |
||||||
|
): ValidationResult { |
||||||
|
try { |
||||||
|
const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); |
||||||
|
const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); |
||||||
|
|
||||||
|
// Must be a subdirectory of repoRoot, not equal to it
|
||||||
|
if (!resolvedPath.startsWith(resolvedRoot + '/')) { |
||||||
|
logger.security('Repository path outside allowed root', { repoPath, repoRoot }); |
||||||
|
return { valid: false, error: 'Path traversal detected: repository path outside allowed root' }; |
||||||
|
} |
||||||
|
|
||||||
|
return { valid: true, normalized: resolvedPath }; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, repoPath, repoRoot }, 'Failed to validate repo path'); |
||||||
|
return { valid: false, error: 'Failed to validate repository path' }; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,187 @@ |
|||||||
|
/** |
||||||
|
* Tag operations module |
||||||
|
* Handles tag creation and listing |
||||||
|
*/ |
||||||
|
|
||||||
|
import simpleGit, { type SimpleGit } from 'simple-git'; |
||||||
|
import logger from '../../logger.js'; |
||||||
|
import { validateRepoName, validateNpub } from './path-validator.js'; |
||||||
|
import type { Tag } from '../file-manager.js'; |
||||||
|
|
||||||
|
export interface CreateTagOptions { |
||||||
|
npub: string; |
||||||
|
repoName: string; |
||||||
|
tagName: string; |
||||||
|
ref?: string; |
||||||
|
message?: string; |
||||||
|
authorName?: string; |
||||||
|
authorEmail?: string; |
||||||
|
repoPath: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface GetTagsOptions { |
||||||
|
npub: string; |
||||||
|
repoName: string; |
||||||
|
repoPath: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a tag |
||||||
|
*/ |
||||||
|
export async function createTag(options: CreateTagOptions): Promise<void> { |
||||||
|
const { npub, repoName, tagName, ref = 'HEAD', message, repoPath } = options; |
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const npubValidation = validateNpub(npub); |
||||||
|
if (!npubValidation.valid) { |
||||||
|
throw new Error(`Invalid npub: ${npubValidation.error}`); |
||||||
|
} |
||||||
|
const repoValidation = validateRepoName(repoName); |
||||||
|
if (!repoValidation.valid) { |
||||||
|
throw new Error(`Invalid repository name: ${repoValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
const git: SimpleGit = simpleGit(repoPath); |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Creating tag', { npub, repoName, tagName, ref }); |
||||||
|
|
||||||
|
// Check if repository has any commits
|
||||||
|
let hasCommits = false; |
||||||
|
let actualRef = ref; |
||||||
|
|
||||||
|
try { |
||||||
|
const headCommit = await git.raw(['rev-parse', 'HEAD']).catch(() => null); |
||||||
|
hasCommits = !!(headCommit && headCommit.trim().length > 0); |
||||||
|
} catch { |
||||||
|
// Check if any branch has commits
|
||||||
|
try { |
||||||
|
const branches = await git.branch(['-a']); |
||||||
|
for (const branch of branches.all) { |
||||||
|
const branchName = branch.replace(/^remotes\/origin\//, '').replace(/^remotes\//, ''); |
||||||
|
if (branchName.includes('HEAD')) continue; |
||||||
|
try { |
||||||
|
const commitHash = await git.raw(['rev-parse', `refs/heads/${branchName}`]).catch(() => null); |
||||||
|
if (commitHash && commitHash.trim().length > 0) { |
||||||
|
hasCommits = true; |
||||||
|
if (ref === 'HEAD') { |
||||||
|
actualRef = branchName; |
||||||
|
} |
||||||
|
break; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Continue checking other branches
|
||||||
|
} |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Could not check branches
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!hasCommits) { |
||||||
|
throw new Error('Cannot create tag: repository has no commits. Please create at least one commit first.'); |
||||||
|
} |
||||||
|
|
||||||
|
// Validate that the ref exists
|
||||||
|
try { |
||||||
|
await git.raw(['rev-parse', '--verify', actualRef]); |
||||||
|
} catch (refErr) { |
||||||
|
throw new Error(`Invalid reference '${actualRef}': ${refErr instanceof Error ? refErr.message : String(refErr)}`); |
||||||
|
} |
||||||
|
|
||||||
|
if (message) { |
||||||
|
// Create annotated tag
|
||||||
|
if (actualRef !== 'HEAD') { |
||||||
|
await git.raw(['tag', '-a', tagName, '-m', message, actualRef]); |
||||||
|
} else { |
||||||
|
await git.raw(['tag', '-a', tagName, '-m', message]); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Create lightweight tag
|
||||||
|
if (actualRef !== 'HEAD') { |
||||||
|
await git.raw(['tag', tagName, actualRef]); |
||||||
|
} else { |
||||||
|
await git.addTag(tagName); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
logger.operation('Tag created', { npub, repoName, tagName }); |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, repoPath, tagName, ref }, 'Error creating tag'); |
||||||
|
throw new Error(`Failed to create tag: ${error instanceof Error ? error.message : String(error)}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get list of tags |
||||||
|
*/ |
||||||
|
export async function getTags(options: GetTagsOptions): Promise<Tag[]> { |
||||||
|
const { npub, repoName, repoPath } = options; |
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const npubValidation = validateNpub(npub); |
||||||
|
if (!npubValidation.valid) { |
||||||
|
throw new Error(`Invalid npub: ${npubValidation.error}`); |
||||||
|
} |
||||||
|
const repoValidation = validateRepoName(repoName); |
||||||
|
if (!repoValidation.valid) { |
||||||
|
throw new Error(`Invalid repository name: ${repoValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
const git: SimpleGit = simpleGit(repoPath); |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Getting tags', { npub, repoName }); |
||||||
|
|
||||||
|
const tags = await git.tags(); |
||||||
|
const tagList: Tag[] = []; |
||||||
|
|
||||||
|
for (const tagName of tags.all) { |
||||||
|
try { |
||||||
|
// Get the commit hash the tag points to
|
||||||
|
const hash = await git.raw(['rev-parse', tagName]); |
||||||
|
const commitHash = hash.trim(); |
||||||
|
|
||||||
|
// Get the commit date (Unix timestamp)
|
||||||
|
let commitDate: number | undefined; |
||||||
|
try { |
||||||
|
const dateStr = await git.raw(['log', '-1', '--format=%at', commitHash]); |
||||||
|
commitDate = parseInt(dateStr.trim(), 10); |
||||||
|
if (isNaN(commitDate)) { |
||||||
|
commitDate = undefined; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
commitDate = undefined; |
||||||
|
} |
||||||
|
|
||||||
|
// Try to get tag message (for annotated tags)
|
||||||
|
try { |
||||||
|
const tagInfo = await git.raw(['cat-file', '-p', tagName]); |
||||||
|
const messageMatch = tagInfo.match(/^(.+)$/m); |
||||||
|
|
||||||
|
tagList.push({ |
||||||
|
name: tagName, |
||||||
|
hash: commitHash, |
||||||
|
message: messageMatch ? messageMatch[1] : undefined, |
||||||
|
date: commitDate |
||||||
|
}); |
||||||
|
} catch { |
||||||
|
// Lightweight tag - no message
|
||||||
|
tagList.push({ |
||||||
|
name: tagName, |
||||||
|
hash: commitHash, |
||||||
|
date: commitDate |
||||||
|
}); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
logger.warn({ error: err, tagName }, 'Error processing tag, skipping'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
logger.operation('Tags retrieved', { npub, repoName, count: tagList.length }); |
||||||
|
return tagList; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, repoPath }, 'Error getting tags'); |
||||||
|
return []; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,207 @@ |
|||||||
|
/** |
||||||
|
* Worktree management module |
||||||
|
* Handles git worktree operations with proper cleanup |
||||||
|
*/ |
||||||
|
|
||||||
|
import { join, resolve, dirname } from 'path'; |
||||||
|
import { spawn } from 'child_process'; |
||||||
|
import simpleGit, { type SimpleGit } from 'simple-git'; |
||||||
|
import logger from '../../logger.js'; |
||||||
|
import { sanitizeError } from '../../../utils/security.js'; |
||||||
|
import { execGitProcess } from '../../../utils/git-process.js'; |
||||||
|
|
||||||
|
export interface WorktreeOptions { |
||||||
|
repoPath: string; |
||||||
|
branch: string; |
||||||
|
npub: string; |
||||||
|
repoName: string; |
||||||
|
repoRoot: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get or create a worktree for a branch |
||||||
|
*/ |
||||||
|
export async function getOrCreateWorktree( |
||||||
|
options: WorktreeOptions |
||||||
|
): Promise<string> { |
||||||
|
const { repoPath, branch, npub, repoName, repoRoot } = options; |
||||||
|
|
||||||
|
const worktreeRoot = join(repoRoot, npub, `${repoName}.worktrees`); |
||||||
|
const worktreePath = resolve(join(worktreeRoot, branch)); |
||||||
|
const resolvedWorktreeRoot = resolve(worktreeRoot); |
||||||
|
|
||||||
|
// Security: Ensure resolved path is still within worktreeRoot
|
||||||
|
const resolvedPath = worktreePath.replace(/\\/g, '/'); |
||||||
|
const resolvedRoot = resolvedWorktreeRoot.replace(/\\/g, '/'); |
||||||
|
if (!resolvedPath.startsWith(resolvedRoot + '/')) { |
||||||
|
throw new Error('Path traversal detected: worktree path outside allowed root'); |
||||||
|
} |
||||||
|
|
||||||
|
const { mkdir, rm } = await import('fs/promises'); |
||||||
|
|
||||||
|
// Ensure worktree root exists
|
||||||
|
await mkdir(resolvedWorktreeRoot, { recursive: true }); |
||||||
|
|
||||||
|
const git = simpleGit(repoPath); |
||||||
|
|
||||||
|
// Check if worktree already exists
|
||||||
|
try { |
||||||
|
const worktrees = await git.raw(['worktree', 'list', '--porcelain']); |
||||||
|
const worktreeLines = worktrees.split('\n'); |
||||||
|
let currentWorktreePath = ''; |
||||||
|
|
||||||
|
for (const line of worktreeLines) { |
||||||
|
if (line.startsWith('worktree ')) { |
||||||
|
currentWorktreePath = line.substring(9).trim(); |
||||||
|
} else if (line.startsWith('branch ') && currentWorktreePath) { |
||||||
|
const branchRef = line.substring(7).trim(); |
||||||
|
if (branchRef === `refs/heads/${branch}` || branchRef.endsWith(`/${branch}`)) { |
||||||
|
logger.debug({ branch, worktreePath: currentWorktreePath }, 'Worktree already exists'); |
||||||
|
return currentWorktreePath; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
logger.debug({ error: err }, 'Failed to list worktrees, will create new one'); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if directory exists but is not a valid worktree
|
||||||
|
try { |
||||||
|
const { accessSync, constants } = await import('fs'); |
||||||
|
accessSync(worktreePath, constants.F_OK); |
||||||
|
// Directory exists, check if it's a valid git repo
|
||||||
|
const worktreeGit = simpleGit(worktreePath); |
||||||
|
await worktreeGit.status(); |
||||||
|
logger.debug({ branch, worktreePath }, 'Existing directory is valid worktree'); |
||||||
|
return worktreePath; |
||||||
|
} catch { |
||||||
|
// Directory doesn't exist or is invalid, will create new worktree
|
||||||
|
} |
||||||
|
|
||||||
|
// Remove existing directory if it exists but is invalid
|
||||||
|
try { |
||||||
|
await rm(worktreePath, { recursive: true, force: true }); |
||||||
|
} catch { |
||||||
|
// Ignore errors - directory might not exist
|
||||||
|
} |
||||||
|
|
||||||
|
// Create worktree
|
||||||
|
try { |
||||||
|
await execGitProcess(['worktree', 'add', worktreePath, branch], { |
||||||
|
cwd: repoPath, |
||||||
|
timeoutMs: 5 * 60 * 1000 // 5 minutes
|
||||||
|
}); |
||||||
|
} catch (error: any) { |
||||||
|
const stderr = error.message || ''; |
||||||
|
|
||||||
|
// If branch doesn't exist, create it first
|
||||||
|
if (stderr.includes('fatal: invalid reference') ||
|
||||||
|
stderr.includes('fatal: not a valid object name') ||
|
||||||
|
stderr.includes('Ungültige Referenz')) { |
||||||
|
|
||||||
|
// Find source branch
|
||||||
|
const branches = await git.branch(['-a']); |
||||||
|
let sourceBranch = 'HEAD'; |
||||||
|
|
||||||
|
if (branches.all.includes('HEAD') || branches.all.includes('origin/HEAD')) { |
||||||
|
sourceBranch = 'HEAD'; |
||||||
|
} else if (branches.all.includes('main') || branches.all.includes('origin/main')) { |
||||||
|
sourceBranch = 'main'; |
||||||
|
} else if (branches.all.includes('master') || branches.all.includes('origin/master')) { |
||||||
|
sourceBranch = 'master'; |
||||||
|
} else { |
||||||
|
const firstBranch = branches.all.find(b => !b.includes('HEAD')); |
||||||
|
if (firstBranch) { |
||||||
|
sourceBranch = firstBranch.replace(/^origin\//, ''); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Create branch
|
||||||
|
try { |
||||||
|
await execGitProcess(['branch', branch, sourceBranch], { |
||||||
|
cwd: repoPath, |
||||||
|
timeoutMs: 2 * 60 * 1000 |
||||||
|
}); |
||||||
|
} catch (branchError: any) { |
||||||
|
// Try creating orphan branch
|
||||||
|
if (branchError.message?.includes('fatal: invalid reference')) { |
||||||
|
await execGitProcess(['branch', branch], { |
||||||
|
cwd: repoPath, |
||||||
|
timeoutMs: 2 * 60 * 1000 |
||||||
|
}); |
||||||
|
} else { |
||||||
|
throw branchError; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Retry worktree creation
|
||||||
|
try { |
||||||
|
await execGitProcess(['worktree', 'add', worktreePath, branch], { |
||||||
|
cwd: repoPath, |
||||||
|
timeoutMs: 5 * 60 * 1000 |
||||||
|
}); |
||||||
|
} catch (retryError: any) { |
||||||
|
// Try with --orphan as last resort
|
||||||
|
if (retryError.message?.includes('fatal: invalid reference')) { |
||||||
|
await execGitProcess(['worktree', 'add', '--orphan', branch, worktreePath], { |
||||||
|
cwd: repoPath, |
||||||
|
timeoutMs: 5 * 60 * 1000 |
||||||
|
}); |
||||||
|
} else { |
||||||
|
throw retryError; |
||||||
|
} |
||||||
|
} |
||||||
|
} else { |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Verify worktree was created
|
||||||
|
const { accessSync, constants } = await import('fs'); |
||||||
|
try { |
||||||
|
accessSync(worktreePath, constants.F_OK); |
||||||
|
} catch { |
||||||
|
throw new Error(`Worktree directory was not created: ${worktreePath}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify it's a valid git repository
|
||||||
|
const worktreeGit = simpleGit(worktreePath); |
||||||
|
try { |
||||||
|
await worktreeGit.status(); |
||||||
|
} catch { |
||||||
|
throw new Error(`Created worktree directory is not a valid git repository: ${worktreePath}`); |
||||||
|
} |
||||||
|
|
||||||
|
logger.operation('Worktree created', { branch, worktreePath }); |
||||||
|
return worktreePath; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Remove a worktree |
||||||
|
*/ |
||||||
|
export async function removeWorktree( |
||||||
|
repoPath: string, |
||||||
|
worktreePath: string |
||||||
|
): Promise<void> { |
||||||
|
try { |
||||||
|
await execGitProcess(['worktree', 'remove', worktreePath], { |
||||||
|
cwd: repoPath, |
||||||
|
timeoutMs: 2 * 60 * 1000 |
||||||
|
}); |
||||||
|
} catch (error: any) { |
||||||
|
// Try force remove
|
||||||
|
try { |
||||||
|
await execGitProcess(['worktree', 'remove', '--force', worktreePath], { |
||||||
|
cwd: repoPath, |
||||||
|
timeoutMs: 2 * 60 * 1000 |
||||||
|
}); |
||||||
|
} catch { |
||||||
|
// Last resort: delete directory
|
||||||
|
const { rm } = await import('fs/promises'); |
||||||
|
await rm(worktreePath, { recursive: true, force: true }); |
||||||
|
logger.warn({ worktreePath }, 'Force removed worktree directory'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
logger.operation('Worktree removed', { worktreePath }); |
||||||
|
} |
||||||
@ -0,0 +1,311 @@ |
|||||||
|
/** |
||||||
|
* Write operations module |
||||||
|
* Handles file writing, deletion, and commit operations |
||||||
|
*/ |
||||||
|
|
||||||
|
import { join, dirname, resolve } from 'path'; |
||||||
|
import simpleGit, { type SimpleGit } from 'simple-git'; |
||||||
|
import logger from '../../logger.js'; |
||||||
|
import { sanitizeError } from '../../../utils/security.js'; |
||||||
|
import { isValidBranchName } from '../../../utils/security.js'; |
||||||
|
import { validateFilePath, validateRepoName, validateNpub } from './path-validator.js'; |
||||||
|
import { getOrCreateWorktree, removeWorktree } from './worktree-manager.js'; |
||||||
|
import { createGitCommitSignature } from '../commit-signer.js'; |
||||||
|
import type { NostrEvent } from '../../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface WriteFileOptions { |
||||||
|
npub: string; |
||||||
|
repoName: string; |
||||||
|
filePath: string; |
||||||
|
content: string; |
||||||
|
commitMessage: string; |
||||||
|
authorName: string; |
||||||
|
authorEmail: string; |
||||||
|
branch?: string; |
||||||
|
repoPath: string; |
||||||
|
worktreePath: string; |
||||||
|
signingOptions?: { |
||||||
|
commitSignatureEvent?: NostrEvent; |
||||||
|
useNIP07?: boolean; |
||||||
|
nip98Event?: NostrEvent; |
||||||
|
nsecKey?: string; |
||||||
|
}; |
||||||
|
saveCommitSignature?: (worktreePath: string, event: NostrEvent) => Promise<void>; |
||||||
|
isRepoPrivate?: (npub: string, repoName: string) => Promise<boolean>; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Write file and commit changes |
||||||
|
*/ |
||||||
|
export async function writeFile(options: WriteFileOptions): Promise<void> { |
||||||
|
const { |
||||||
|
npub, |
||||||
|
repoName, |
||||||
|
filePath, |
||||||
|
content, |
||||||
|
commitMessage, |
||||||
|
authorName, |
||||||
|
authorEmail, |
||||||
|
branch = 'main', |
||||||
|
repoPath, |
||||||
|
worktreePath, |
||||||
|
signingOptions, |
||||||
|
saveCommitSignature, |
||||||
|
isRepoPrivate |
||||||
|
} = options; |
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const npubValidation = validateNpub(npub); |
||||||
|
if (!npubValidation.valid) { |
||||||
|
throw new Error(`Invalid npub: ${npubValidation.error}`); |
||||||
|
} |
||||||
|
const repoValidation = validateRepoName(repoName); |
||||||
|
if (!repoValidation.valid) { |
||||||
|
throw new Error(`Invalid repository name: ${repoValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
const pathValidation = validateFilePath(filePath); |
||||||
|
if (!pathValidation.valid) { |
||||||
|
throw new Error(`Invalid file path: ${pathValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
if (!isValidBranchName(branch)) { |
||||||
|
throw new Error(`Invalid branch name: ${branch}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Validate content size (500 MB max)
|
||||||
|
const maxFileSize = 500 * 1024 * 1024; |
||||||
|
if (Buffer.byteLength(content, 'utf-8') > maxFileSize) { |
||||||
|
throw new Error(`File is too large (max ${maxFileSize / 1024 / 1024} MB)`); |
||||||
|
} |
||||||
|
|
||||||
|
// Validate commit message
|
||||||
|
if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { |
||||||
|
throw new Error('Commit message is required'); |
||||||
|
} |
||||||
|
if (commitMessage.length > 1000) { |
||||||
|
throw new Error('Commit message is too long (max 1000 characters)'); |
||||||
|
} |
||||||
|
|
||||||
|
// Validate author info
|
||||||
|
if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { |
||||||
|
throw new Error('Author name is required'); |
||||||
|
} |
||||||
|
if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { |
||||||
|
throw new Error('Valid author email is required'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Writing file', { npub, repoName, filePath, branch }); |
||||||
|
|
||||||
|
const workGit: SimpleGit = simpleGit(worktreePath); |
||||||
|
|
||||||
|
// Write the file
|
||||||
|
const validatedPath = pathValidation.normalized || filePath; |
||||||
|
const fullFilePath = join(worktreePath, validatedPath); |
||||||
|
const fileDir = dirname(fullFilePath); |
||||||
|
|
||||||
|
// Security: ensure resolved path is within workDir
|
||||||
|
const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); |
||||||
|
const resolvedWorkDir = resolve(worktreePath).replace(/\\/g, '/'); |
||||||
|
if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { |
||||||
|
throw new Error('Path validation failed: resolved path outside work directory'); |
||||||
|
} |
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
const { mkdir } = await import('fs/promises'); |
||||||
|
await mkdir(fileDir, { recursive: true }); |
||||||
|
|
||||||
|
const { writeFile: writeFileFs } = await import('fs/promises'); |
||||||
|
await writeFileFs(fullFilePath, content, 'utf-8'); |
||||||
|
|
||||||
|
// Stage the file
|
||||||
|
await workGit.add(validatedPath); |
||||||
|
|
||||||
|
// Sign commit if signing options are provided
|
||||||
|
let finalCommitMessage = commitMessage; |
||||||
|
let signatureEvent: NostrEvent | null = null; |
||||||
|
|
||||||
|
if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { |
||||||
|
try { |
||||||
|
const result = await createGitCommitSignature( |
||||||
|
commitMessage, |
||||||
|
authorName, |
||||||
|
authorEmail, |
||||||
|
signingOptions |
||||||
|
); |
||||||
|
finalCommitMessage = result.signedMessage; |
||||||
|
signatureEvent = signingOptions.commitSignatureEvent || result.signatureEvent; |
||||||
|
} catch (err) { |
||||||
|
const sanitizedErr = sanitizeError(err); |
||||||
|
logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Commit
|
||||||
|
const commitResult = await workGit.commit(finalCommitMessage, [filePath], { |
||||||
|
'--author': `${authorName} <${authorEmail}>` |
||||||
|
}) as string | { commit: string }; |
||||||
|
|
||||||
|
// Get commit hash
|
||||||
|
let commitHash: string; |
||||||
|
if (typeof commitResult === 'string') { |
||||||
|
commitHash = commitResult.trim(); |
||||||
|
} else if (commitResult && typeof commitResult === 'object' && 'commit' in commitResult) { |
||||||
|
commitHash = String(commitResult.commit); |
||||||
|
} else { |
||||||
|
commitHash = await workGit.revparse(['HEAD']); |
||||||
|
} |
||||||
|
|
||||||
|
// Save commit signature event if signing was used
|
||||||
|
if (signatureEvent && saveCommitSignature) { |
||||||
|
try { |
||||||
|
await saveCommitSignature(worktreePath, signatureEvent); |
||||||
|
|
||||||
|
// Publish to relays if repo is public
|
||||||
|
if (isRepoPrivate && !(await isRepoPrivate(npub, repoName))) { |
||||||
|
try { |
||||||
|
const { NostrClient } = await import('../../nostr/nostr-client.js'); |
||||||
|
const { DEFAULT_NOSTR_RELAYS } = await import('../../../config.js'); |
||||||
|
const { getUserRelays } = await import('../../nostr/user-relays.js'); |
||||||
|
const { combineRelays } = await import('../../../config.js'); |
||||||
|
const { nip19 } = await import('nostr-tools'); |
||||||
|
const { requireNpubHex } = await import('../../../utils/npub-utils.js'); |
||||||
|
|
||||||
|
const userPubkeyHex = requireNpubHex(npub); |
||||||
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
const { inbox, outbox } = await getUserRelays(userPubkeyHex, nostrClient); |
||||||
|
|
||||||
|
const userRelays = outbox.length > 0
|
||||||
|
? combineRelays(outbox, DEFAULT_NOSTR_RELAYS) |
||||||
|
: inbox.length > 0 |
||||||
|
? combineRelays(inbox, DEFAULT_NOSTR_RELAYS) |
||||||
|
: DEFAULT_NOSTR_RELAYS; |
||||||
|
|
||||||
|
const publishResult = await nostrClient.publishEvent(signatureEvent, userRelays); |
||||||
|
if (publishResult.success.length > 0) { |
||||||
|
logger.debug({
|
||||||
|
eventId: signatureEvent.id,
|
||||||
|
commitHash, |
||||||
|
relays: publishResult.success
|
||||||
|
}, 'Published commit signature event to relays'); |
||||||
|
} |
||||||
|
} catch (publishErr) { |
||||||
|
logger.debug({ error: publishErr }, 'Failed to publish commit signature event to relays'); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
logger.debug({ error: err }, 'Failed to save commit signature event'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
logger.operation('File written', { npub, repoName, filePath, commitHash }); |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, repoPath, filePath, npub }, 'Error writing file'); |
||||||
|
throw new Error(`Failed to write file: ${error instanceof Error ? error.message : String(error)}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Delete a file |
||||||
|
*/ |
||||||
|
export async function deleteFile(options: Omit<WriteFileOptions, 'content'>): Promise<void> { |
||||||
|
const { |
||||||
|
npub, |
||||||
|
repoName, |
||||||
|
filePath, |
||||||
|
commitMessage, |
||||||
|
authorName, |
||||||
|
authorEmail, |
||||||
|
branch = 'main', |
||||||
|
repoPath, |
||||||
|
worktreePath, |
||||||
|
signingOptions, |
||||||
|
saveCommitSignature |
||||||
|
} = options; |
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const npubValidation = validateNpub(npub); |
||||||
|
if (!npubValidation.valid) { |
||||||
|
throw new Error(`Invalid npub: ${npubValidation.error}`); |
||||||
|
} |
||||||
|
const repoValidation = validateRepoName(repoName); |
||||||
|
if (!repoValidation.valid) { |
||||||
|
throw new Error(`Invalid repository name: ${repoValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
const pathValidation = validateFilePath(filePath); |
||||||
|
if (!pathValidation.valid) { |
||||||
|
throw new Error(`Invalid file path: ${pathValidation.error}`); |
||||||
|
} |
||||||
|
|
||||||
|
if (!isValidBranchName(branch)) { |
||||||
|
throw new Error(`Invalid branch name: ${branch}`); |
||||||
|
} |
||||||
|
|
||||||
|
if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { |
||||||
|
throw new Error('Commit message is required'); |
||||||
|
} |
||||||
|
|
||||||
|
if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { |
||||||
|
throw new Error('Author name is required'); |
||||||
|
} |
||||||
|
if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { |
||||||
|
throw new Error('Valid author email is required'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Deleting file', { npub, repoName, filePath, branch }); |
||||||
|
|
||||||
|
const workGit: SimpleGit = simpleGit(worktreePath); |
||||||
|
|
||||||
|
// Remove the file
|
||||||
|
const validatedPath = pathValidation.normalized || filePath; |
||||||
|
const fullFilePath = join(worktreePath, validatedPath); |
||||||
|
|
||||||
|
// Security: ensure resolved path is within workDir
|
||||||
|
const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); |
||||||
|
const resolvedWorkDir = resolve(worktreePath).replace(/\\/g, '/'); |
||||||
|
if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { |
||||||
|
throw new Error('Path validation failed: resolved path outside work directory'); |
||||||
|
} |
||||||
|
|
||||||
|
const { accessSync, constants, unlink } = await import('fs'); |
||||||
|
try { |
||||||
|
accessSync(fullFilePath, constants.F_OK); |
||||||
|
await unlink(fullFilePath); |
||||||
|
} catch { |
||||||
|
// File doesn't exist, that's fine - git rm will handle it
|
||||||
|
} |
||||||
|
|
||||||
|
// Stage the deletion
|
||||||
|
await workGit.rm([validatedPath]); |
||||||
|
|
||||||
|
// Sign commit if signing options are provided
|
||||||
|
let finalCommitMessage = commitMessage; |
||||||
|
if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { |
||||||
|
try { |
||||||
|
const { signedMessage } = await createGitCommitSignature( |
||||||
|
commitMessage, |
||||||
|
authorName, |
||||||
|
authorEmail, |
||||||
|
signingOptions |
||||||
|
); |
||||||
|
finalCommitMessage = signedMessage; |
||||||
|
} catch (err) { |
||||||
|
const sanitizedErr = sanitizeError(err); |
||||||
|
logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Commit
|
||||||
|
await workGit.commit(finalCommitMessage, [filePath], { |
||||||
|
'--author': `${authorName} <${authorEmail}>` |
||||||
|
}); |
||||||
|
|
||||||
|
logger.operation('File deleted', { npub, repoName, filePath }); |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, repoPath, filePath, npub }, 'Error deleting file'); |
||||||
|
throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,177 @@ |
|||||||
|
/** |
||||||
|
* Utility functions for safely spawning git processes |
||||||
|
* Prevents zombie processes by ensuring proper cleanup |
||||||
|
*/ |
||||||
|
|
||||||
|
import { spawn, type ChildProcess } from 'child_process'; |
||||||
|
import logger from '../services/logger.js'; |
||||||
|
|
||||||
|
export interface GitProcessOptions { |
||||||
|
cwd?: string; |
||||||
|
env?: Record<string, string>; |
||||||
|
timeoutMs?: number; |
||||||
|
stdio?: ('ignore' | 'pipe')[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface GitProcessResult { |
||||||
|
stdout: string; |
||||||
|
stderr: string; |
||||||
|
code: number | null; |
||||||
|
signal: NodeJS.Signals | null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Safely spawn a git process with proper cleanup to prevent zombies |
||||||
|
*
|
||||||
|
* @param args - Git command arguments |
||||||
|
* @param options - Process options |
||||||
|
* @returns Promise that resolves with process output |
||||||
|
*/ |
||||||
|
export function spawnGitProcess( |
||||||
|
args: string[], |
||||||
|
options: GitProcessOptions = {} |
||||||
|
): Promise<GitProcessResult> { |
||||||
|
const { |
||||||
|
cwd, |
||||||
|
env = {}, |
||||||
|
timeoutMs = 30 * 60 * 1000, // 30 minutes default
|
||||||
|
stdio = ['ignore', 'pipe', 'pipe'] |
||||||
|
} = options; |
||||||
|
|
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
const gitProcess = spawn('git', args, { |
||||||
|
cwd, |
||||||
|
env: Object.keys(env).length > 0 ? env : undefined, |
||||||
|
stdio, |
||||||
|
detached: false // Keep in same process group to prevent zombies
|
||||||
|
}); |
||||||
|
|
||||||
|
let stdout = ''; |
||||||
|
let stderr = ''; |
||||||
|
let resolved = false; |
||||||
|
|
||||||
|
// Set timeout to prevent hanging processes
|
||||||
|
const timeoutId = timeoutMs > 0 ? setTimeout(() => { |
||||||
|
if (!resolved && !gitProcess.killed) { |
||||||
|
resolved = true; |
||||||
|
logger.warn({ args, timeoutMs }, 'Git process timeout, killing process'); |
||||||
|
|
||||||
|
// Kill the process tree to prevent zombies
|
||||||
|
try { |
||||||
|
gitProcess.kill('SIGTERM'); |
||||||
|
// Force kill after grace period
|
||||||
|
const forceKillTimeout = setTimeout(() => { |
||||||
|
if (gitProcess.pid && !gitProcess.killed) { |
||||||
|
try { |
||||||
|
gitProcess.kill('SIGKILL'); |
||||||
|
} catch (err) { |
||||||
|
logger.warn({ err, pid: gitProcess.pid }, 'Failed to force kill git process'); |
||||||
|
} |
||||||
|
} |
||||||
|
}, 5000); |
||||||
|
|
||||||
|
// Clear force kill timeout if process terminates
|
||||||
|
gitProcess.once('close', () => { |
||||||
|
clearTimeout(forceKillTimeout); |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
logger.warn({ err }, 'Error killing timed out git process'); |
||||||
|
} |
||||||
|
|
||||||
|
reject(new Error(`Git command timeout after ${timeoutMs}ms: ${args.join(' ')}`)); |
||||||
|
} |
||||||
|
}, timeoutMs) : null; |
||||||
|
|
||||||
|
// Collect stdout
|
||||||
|
if (gitProcess.stdout) { |
||||||
|
gitProcess.stdout.on('data', (chunk: Buffer) => { |
||||||
|
stdout += chunk.toString(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Collect stderr
|
||||||
|
if (gitProcess.stderr) { |
||||||
|
gitProcess.stderr.on('data', (chunk: Buffer) => { |
||||||
|
stderr += chunk.toString(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Handle process close (main cleanup point)
|
||||||
|
gitProcess.on('close', (code, signal) => { |
||||||
|
if (timeoutId) clearTimeout(timeoutId); |
||||||
|
|
||||||
|
if (resolved) return; |
||||||
|
resolved = true; |
||||||
|
|
||||||
|
// Ensure process is fully cleaned up
|
||||||
|
if (gitProcess.pid) { |
||||||
|
try { |
||||||
|
// Check if process still exists (this helps ensure cleanup)
|
||||||
|
process.kill(gitProcess.pid, 0); |
||||||
|
} catch { |
||||||
|
// Process already dead, that's fine
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
resolve({ |
||||||
|
stdout, |
||||||
|
stderr, |
||||||
|
code, |
||||||
|
signal |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// Handle process errors
|
||||||
|
gitProcess.on('error', (err) => { |
||||||
|
if (timeoutId) clearTimeout(timeoutId); |
||||||
|
|
||||||
|
if (resolved) return; |
||||||
|
resolved = true; |
||||||
|
|
||||||
|
logger.error({ err, args }, 'Git process error'); |
||||||
|
reject(err); |
||||||
|
}); |
||||||
|
|
||||||
|
// Handle process exit (backup cleanup)
|
||||||
|
gitProcess.on('exit', (code, signal) => { |
||||||
|
// This is primarily handled by 'close' event
|
||||||
|
// But we ensure we catch all cases
|
||||||
|
if (!resolved && code !== null && code !== 0) { |
||||||
|
if (timeoutId) clearTimeout(timeoutId); |
||||||
|
resolved = true; |
||||||
|
|
||||||
|
const errorMsg = signal
|
||||||
|
? `Git command terminated by signal ${signal}: ${stderr || stdout}` |
||||||
|
: `Git command failed with code ${code}: ${stderr || stdout}`; |
||||||
|
|
||||||
|
reject(new Error(errorMsg)); |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Safely spawn a git process and throw on non-zero exit code |
||||||
|
*
|
||||||
|
* @param args - Git command arguments |
||||||
|
* @param options - Process options |
||||||
|
* @returns Promise that resolves with stdout/stderr only on success |
||||||
|
*/ |
||||||
|
export async function execGitProcess( |
||||||
|
args: string[], |
||||||
|
options: GitProcessOptions = {} |
||||||
|
): Promise<{ stdout: string; stderr: string }> { |
||||||
|
const result = await spawnGitProcess(args, options); |
||||||
|
|
||||||
|
if (result.code !== 0) { |
||||||
|
const errorMsg = result.signal
|
||||||
|
? `Git command terminated by signal ${result.signal}: ${result.stderr || result.stdout}` |
||||||
|
: `Git command failed with code ${result.code}: ${result.stderr || result.stdout}`; |
||||||
|
throw new Error(errorMsg); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
stdout: result.stdout, |
||||||
|
stderr: result.stderr |
||||||
|
}; |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,119 @@ |
|||||||
|
<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 = () => {}; |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if show} |
||||||
|
<div class="modal-overlay" onclick={onCancel}> |
||||||
|
<div class="modal-content" onclick={(e) => e.stopPropagation()}> |
||||||
|
<h2>Commit Changes</h2> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="commit-message">Commit Message</label> |
||||||
|
<textarea |
||||||
|
id="commit-message" |
||||||
|
bind:value={commitMessage} |
||||||
|
oninput={(e) => onMessageChange(e.target.value)} |
||||||
|
placeholder="Enter commit message..." |
||||||
|
rows="5" |
||||||
|
disabled={saving} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="modal-footer"> |
||||||
|
<button |
||||||
|
class="primary-button" |
||||||
|
onclick={onCommit} |
||||||
|
disabled={saving || !commitMessage.trim()} |
||||||
|
> |
||||||
|
{saving ? 'Committing...' : 'Commit'} |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="cancel-button" |
||||||
|
onclick={onCancel} |
||||||
|
disabled={saving} |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.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-content { |
||||||
|
background: var(--bg-primary); |
||||||
|
border-radius: 8px; |
||||||
|
padding: 2rem; |
||||||
|
max-width: 600px; |
||||||
|
width: 90%; |
||||||
|
max-height: 80vh; |
||||||
|
overflow-y: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.form-group { |
||||||
|
margin: 1.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.form-group label { |
||||||
|
display: block; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.form-group textarea { |
||||||
|
width: 100%; |
||||||
|
padding: 0.75rem; |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
border-radius: 4px; |
||||||
|
font-family: inherit; |
||||||
|
resize: vertical; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer { |
||||||
|
display: flex; |
||||||
|
gap: 1rem; |
||||||
|
justify-content: flex-end; |
||||||
|
margin-top: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.primary-button, .cancel-button { |
||||||
|
padding: 0.75rem 1.5rem; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.primary-button { |
||||||
|
background: var(--accent-color); |
||||||
|
color: white; |
||||||
|
} |
||||||
|
|
||||||
|
.primary-button:disabled { |
||||||
|
opacity: 0.5; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
|
||||||
|
.cancel-button { |
||||||
|
background: var(--bg-secondary); |
||||||
|
color: var(--text-primary); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,240 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
/** |
||||||
|
* Documentation tab component |
||||||
|
* Handles markdown, asciidoc, and kind 30040 publication indexes |
||||||
|
*/ |
||||||
|
|
||||||
|
import TabLayout from './TabLayout.svelte'; |
||||||
|
import DocsViewer from './DocsViewer.svelte'; |
||||||
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||||
|
import { KIND } from '$lib/types/nostr.js'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
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; |
||||||
|
|
||||||
|
let documentationContent = $state<string | null>(null); |
||||||
|
let documentationKind = $state<'markdown' | 'asciidoc' | 'text' | '30040' | null>(null); |
||||||
|
let indexEvent = $state<NostrEvent | null>(null); |
||||||
|
let loading = $state(false); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
let docFiles: Array<{ name: string; path: string }> = $state([]); |
||||||
|
let selectedDoc: string | null = $state(null); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (npub && repo && currentBranch) { |
||||||
|
loadDocumentation(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadDocumentation() { |
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
documentationContent = null; |
||||||
|
documentationKind = null; |
||||||
|
indexEvent = null; |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Loading documentation', { npub, repo, branch: currentBranch }); |
||||||
|
|
||||||
|
// Try to find documentation files |
||||||
|
const response = await fetch(`/api/repos/${npub}/${repo}/tree?ref=${currentBranch || 'HEAD'}&path=docs`); |
||||||
|
|
||||||
|
if (response.ok) { |
||||||
|
const data = await response.json(); |
||||||
|
docFiles = data.files || []; |
||||||
|
|
||||||
|
// Look for README or index files first |
||||||
|
const readmeFile = docFiles.find(f => |
||||||
|
f.name.toLowerCase() === 'readme.md' || |
||||||
|
f.name.toLowerCase() === 'readme.adoc' || |
||||||
|
f.name.toLowerCase() === 'index.md' |
||||||
|
); |
||||||
|
|
||||||
|
if (readmeFile) { |
||||||
|
await loadDocFile(readmeFile.path); |
||||||
|
} else if (docFiles.length > 0) { |
||||||
|
// Load first file |
||||||
|
await loadDocFile(docFiles[0].path); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Try to load README from root |
||||||
|
try { |
||||||
|
const readmeResponse = await fetch(`/api/repos/${npub}/${repo}/readme?ref=${currentBranch || 'HEAD'}`); |
||||||
|
if (readmeResponse.ok) { |
||||||
|
const readmeData = await readmeResponse.json(); |
||||||
|
documentationContent = readmeData.content || ''; |
||||||
|
documentationKind = readmeData.type || 'markdown'; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// No README found |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Check for kind 30040 publication index |
||||||
|
await checkForPublicationIndex(); |
||||||
|
|
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Failed to load documentation'; |
||||||
|
logger.error({ error: err, npub, repo }, 'Error loading documentation'); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function loadDocFile(path: string) { |
||||||
|
try { |
||||||
|
const response = await fetch(`/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(path)}&ref=${currentBranch || 'HEAD'}`); |
||||||
|
if (response.ok) { |
||||||
|
const content = await response.text(); |
||||||
|
documentationContent = content; |
||||||
|
|
||||||
|
// Determine type from extension |
||||||
|
const ext = path.split('.').pop()?.toLowerCase(); |
||||||
|
if (ext === 'md' || ext === 'markdown') { |
||||||
|
documentationKind = 'markdown'; |
||||||
|
} else if (ext === 'adoc' || ext === 'asciidoc') { |
||||||
|
documentationKind = 'asciidoc'; |
||||||
|
} else { |
||||||
|
documentationKind = 'text'; |
||||||
|
} |
||||||
|
|
||||||
|
selectedDoc = path; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
logger.warn({ error: err, path }, 'Failed to load doc file'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function checkForPublicationIndex() { |
||||||
|
try { |
||||||
|
// Look for kind 30040 events in the repo announcement |
||||||
|
const { requireNpubHex } = await import('$lib/utils/npub-utils.js'); |
||||||
|
const repoOwnerPubkey = requireNpubHex(npub); |
||||||
|
|
||||||
|
const client = new NostrClient(relays); |
||||||
|
const events = await client.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [30040], |
||||||
|
authors: [repoOwnerPubkey], |
||||||
|
'#d': [repo], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (events.length > 0) { |
||||||
|
indexEvent = events[0]; |
||||||
|
documentationKind = '30040'; |
||||||
|
logger.debug({ eventId: indexEvent.id }, 'Found kind 30040 publication index'); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
logger.debug({ error: err }, 'No kind 30040 index found or error checking'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleItemClick(item: any) { |
||||||
|
if (item.url) { |
||||||
|
window.open(item.url, '_blank'); |
||||||
|
} else if (item.path) { |
||||||
|
loadDocFile(item.path); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<TabLayout {loading} {error}> |
||||||
|
{#snippet leftPane()} |
||||||
|
<div class="docs-sidebar"> |
||||||
|
<h3>Documentation</h3> |
||||||
|
{#if docFiles.length > 0} |
||||||
|
<ul class="doc-list"> |
||||||
|
{#each docFiles as file} |
||||||
|
<li> |
||||||
|
<button |
||||||
|
class="doc-item {selectedDoc === file.path ? 'selected' : ''}" |
||||||
|
onclick={() => loadDocFile(file.path)} |
||||||
|
> |
||||||
|
{file.name} |
||||||
|
</button> |
||||||
|
</li> |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/snippet} |
||||||
|
|
||||||
|
{#snippet rightPanel()} |
||||||
|
{#if documentationKind === '30040' && indexEvent} |
||||||
|
<DocsViewer |
||||||
|
contentType="30040" |
||||||
|
{indexEvent} |
||||||
|
{relays} |
||||||
|
onItemClick={handleItemClick} |
||||||
|
/> |
||||||
|
{:else if documentationContent} |
||||||
|
<DocsViewer |
||||||
|
content={documentationContent} |
||||||
|
contentType={documentationKind || 'text'} |
||||||
|
/> |
||||||
|
{:else} |
||||||
|
<div class="empty-docs"> |
||||||
|
<p>No documentation found</p> |
||||||
|
<p class="hint">Add a README.md, README.adoc, or docs/ folder to your repository</p> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/snippet} |
||||||
|
</TabLayout> |
||||||
|
|
||||||
|
<style> |
||||||
|
.docs-sidebar { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.docs-sidebar h3 { |
||||||
|
margin: 0 0 1rem 0; |
||||||
|
font-size: 1rem; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
.doc-list { |
||||||
|
list-style: none; |
||||||
|
padding: 0; |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.doc-item { |
||||||
|
width: 100%; |
||||||
|
padding: 0.75rem; |
||||||
|
text-align: left; |
||||||
|
background: transparent; |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
border-radius: 4px; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.doc-item:hover { |
||||||
|
background: var(--bg-hover); |
||||||
|
border-color: var(--accent-color); |
||||||
|
} |
||||||
|
|
||||||
|
.doc-item.selected { |
||||||
|
background: var(--bg-selected); |
||||||
|
border-color: var(--accent-color); |
||||||
|
} |
||||||
|
|
||||||
|
.empty-docs { |
||||||
|
padding: 3rem; |
||||||
|
text-align: center; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.hint { |
||||||
|
font-size: 0.9rem; |
||||||
|
margin-top: 0.5rem; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,242 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
/** |
||||||
|
* Generic documentation viewer |
||||||
|
* Handles markdown, asciidoc, and kind 30040 publication indexes |
||||||
|
*/ |
||||||
|
|
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import PublicationIndexViewer from '$lib/components/PublicationIndexViewer.svelte'; |
||||||
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||||
|
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[] = []; |
||||||
|
|
||||||
|
let renderedContent = $state(''); |
||||||
|
let loading = $state(false); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (contentType === '30040' && indexEvent) { |
||||||
|
// Publication index - handled by PublicationIndexViewer |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (content) { |
||||||
|
renderContent(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function renderContent() { |
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Rendering content', { contentType, length: content.length }); |
||||||
|
|
||||||
|
if (contentType === 'markdown') { |
||||||
|
const MarkdownIt = (await import('markdown-it')).default; |
||||||
|
const hljsModule = await import('highlight.js'); |
||||||
|
const hljs = hljsModule.default || hljsModule; |
||||||
|
|
||||||
|
const md = new MarkdownIt({ |
||||||
|
highlight: function (str: string, lang: string): string { |
||||||
|
if (lang && hljs.getLanguage(lang)) { |
||||||
|
try { |
||||||
|
return '<pre class="hljs"><code>' + |
||||||
|
hljs.highlight(str, { language: lang }).value + |
||||||
|
'</code></pre>'; |
||||||
|
} catch (err) { |
||||||
|
// Fallback to escaped HTML |
||||||
|
} |
||||||
|
} |
||||||
|
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>'; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
renderedContent = md.render(content); |
||||||
|
|
||||||
|
// Add IDs to headings for anchor links |
||||||
|
renderedContent = renderedContent.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, (match, level, text) => { |
||||||
|
const textContent = text.replace(/<[^>]*>/g, '').trim(); |
||||||
|
const slug = textContent |
||||||
|
.toLowerCase() |
||||||
|
.replace(/[^\w\s-]/g, '') |
||||||
|
.replace(/\s+/g, '-') |
||||||
|
.replace(/-+/g, '-') |
||||||
|
.replace(/^-|-$/g, ''); |
||||||
|
|
||||||
|
return `<h${level} id="${slug}">${text}</h${level}>`; |
||||||
|
}); |
||||||
|
} else if (contentType === 'asciidoc') { |
||||||
|
const asciidoctor = (await import('asciidoctor')).default(); |
||||||
|
renderedContent = asciidoctor.convert(content, { |
||||||
|
safe: 'safe', |
||||||
|
attributes: { |
||||||
|
'source-highlighter': 'highlight.js' |
||||||
|
} |
||||||
|
}); |
||||||
|
} else { |
||||||
|
// Plain text - escape HTML |
||||||
|
renderedContent = content |
||||||
|
.replace(/&/g, '&') |
||||||
|
.replace(/</g, '<') |
||||||
|
.replace(/>/g, '>') |
||||||
|
.replace(/\n/g, '<br>'); |
||||||
|
} |
||||||
|
|
||||||
|
logger.operation('Content rendered', { contentType }); |
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Failed to render content'; |
||||||
|
logger.error({ error: err, contentType }, 'Error rendering content'); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleItemClick(item: any) { |
||||||
|
logger.debug({ item }, 'Publication index item clicked'); |
||||||
|
// Could navigate to item URL or emit event |
||||||
|
if (item.url) { |
||||||
|
window.open(item.url, '_blank'); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="docs-viewer"> |
||||||
|
{#if loading} |
||||||
|
<div class="loading">Rendering content...</div> |
||||||
|
{:else if error} |
||||||
|
<div class="error">{error}</div> |
||||||
|
{:else if contentType === '30040' && indexEvent} |
||||||
|
<PublicationIndexViewer |
||||||
|
{indexEvent} |
||||||
|
{relays} |
||||||
|
onItemClick={handleItemClick} |
||||||
|
/> |
||||||
|
{:else if renderedContent} |
||||||
|
<div class="rendered-content" class:markdown={contentType === 'markdown'} class:asciidoc={contentType === 'asciidoc'}> |
||||||
|
{@html renderedContent} |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="empty">No content to display</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.docs-viewer { |
||||||
|
padding: 1rem; |
||||||
|
max-width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
.loading, .error, .empty { |
||||||
|
padding: 2rem; |
||||||
|
text-align: center; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.error { |
||||||
|
color: var(--accent-error); |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content { |
||||||
|
line-height: 1.6; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(h1), |
||||||
|
.rendered-content :global(h2), |
||||||
|
.rendered-content :global(h3), |
||||||
|
.rendered-content :global(h4), |
||||||
|
.rendered-content :global(h5), |
||||||
|
.rendered-content :global(h6) { |
||||||
|
margin-top: 2rem; |
||||||
|
margin-bottom: 1rem; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(h1) { |
||||||
|
font-size: 2rem; |
||||||
|
border-bottom: 2px solid var(--border-color); |
||||||
|
padding-bottom: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(h2) { |
||||||
|
font-size: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(h3) { |
||||||
|
font-size: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(p) { |
||||||
|
margin: 1rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(code) { |
||||||
|
background: var(--bg-secondary); |
||||||
|
padding: 0.2rem 0.4rem; |
||||||
|
border-radius: 3px; |
||||||
|
font-family: monospace; |
||||||
|
font-size: 0.9em; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(pre) { |
||||||
|
background: var(--bg-secondary); |
||||||
|
padding: 1rem; |
||||||
|
border-radius: 4px; |
||||||
|
overflow-x: auto; |
||||||
|
margin: 1rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(pre code) { |
||||||
|
background: none; |
||||||
|
padding: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(blockquote) { |
||||||
|
border-left: 4px solid var(--accent-color); |
||||||
|
padding-left: 1rem; |
||||||
|
margin: 1rem 0; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(ul), |
||||||
|
.rendered-content :global(ol) { |
||||||
|
margin: 1rem 0; |
||||||
|
padding-left: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(li) { |
||||||
|
margin: 0.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(a) { |
||||||
|
color: var(--accent-color); |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(a:hover) { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(table) { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
margin: 1rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(th), |
||||||
|
.rendered-content :global(td) { |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
padding: 0.5rem; |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-content :global(th) { |
||||||
|
background: var(--bg-secondary); |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,109 @@ |
|||||||
|
<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[] = []; |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="file-browser"> |
||||||
|
{#if loading} |
||||||
|
<div class="loading">Loading files...</div> |
||||||
|
{:else if files.length === 0} |
||||||
|
<div class="empty">No files found</div> |
||||||
|
{:else} |
||||||
|
<div class="file-list"> |
||||||
|
{#if currentPath} |
||||||
|
<button class="nav-back" onclick={onNavigateBack}> |
||||||
|
← Back |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#each files as file} |
||||||
|
<div |
||||||
|
class="file-item {file.type}" |
||||||
|
onclick={() => file.type === 'directory' ? onDirectoryClick(file.path) : onFileClick(file)} |
||||||
|
> |
||||||
|
<span class="icon"> |
||||||
|
{#if file.type === 'directory'} |
||||||
|
📁 |
||||||
|
{:else} |
||||||
|
📄 |
||||||
|
{/if} |
||||||
|
</span> |
||||||
|
<span class="name">{file.name}</span> |
||||||
|
{#if file.size !== undefined} |
||||||
|
<span class="size">{(file.size / 1024).toFixed(1)} KB</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.file-browser { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.loading, .empty { |
||||||
|
padding: 2rem; |
||||||
|
text-align: center; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.file-list { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.nav-back { |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
margin-bottom: 1rem; |
||||||
|
background: var(--bg-secondary); |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.nav-back:hover { |
||||||
|
background: var(--bg-hover); |
||||||
|
} |
||||||
|
|
||||||
|
.file-item { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.75rem; |
||||||
|
padding: 0.75rem; |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
transition: background 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.file-item:hover { |
||||||
|
background: var(--bg-hover); |
||||||
|
} |
||||||
|
|
||||||
|
.file-item.directory { |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.icon { |
||||||
|
font-size: 1.2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.name { |
||||||
|
flex: 1; |
||||||
|
} |
||||||
|
|
||||||
|
.size { |
||||||
|
color: var(--text-secondary); |
||||||
|
font-size: 0.9rem; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,235 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
/** |
||||||
|
* Files tab component |
||||||
|
* Handles file browser, editor, and README display |
||||||
|
*/ |
||||||
|
|
||||||
|
import TabLayout from './TabLayout.svelte'; |
||||||
|
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; |
||||||
|
</script> |
||||||
|
|
||||||
|
<TabLayout {loading} {error}> |
||||||
|
{#snippet leftPane()} |
||||||
|
<FileBrowser |
||||||
|
{files} |
||||||
|
{currentPath} |
||||||
|
{onFileClick} |
||||||
|
{onDirectoryClick} |
||||||
|
{onNavigateBack} |
||||||
|
{pathStack} |
||||||
|
/> |
||||||
|
{/snippet} |
||||||
|
|
||||||
|
{#snippet rightPanel()} |
||||||
|
{#if readmeContent && !currentFile} |
||||||
|
<div class="readme-section"> |
||||||
|
<div class="readme-header"> |
||||||
|
<h3>README</h3> |
||||||
|
<div class="readme-actions"> |
||||||
|
{#if readmePath && supportsPreview((readmePath.split('.').pop() || '').toLowerCase())} |
||||||
|
<button |
||||||
|
onclick={onTogglePreview} |
||||||
|
class="preview-toggle-button" |
||||||
|
title={showFilePreview ? 'Show raw' : 'Show preview'} |
||||||
|
> |
||||||
|
{showFilePreview ? 'Raw' : 'Preview'} |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
{#if readmePath} |
||||||
|
<a href={`/api/repos/${readmePath}`} target="_blank" class="raw-link">View Raw</a> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{#if showFilePreview && readmeHtml && readmeHtml.trim()} |
||||||
|
<div class="readme-content markdown"> |
||||||
|
{@html readmeHtml} |
||||||
|
</div> |
||||||
|
{:else if readmeContent} |
||||||
|
<div class="readme-content"> |
||||||
|
<pre><code class="hljs language-text">{readmeContent}</code></pre> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{:else if currentFile} |
||||||
|
<div class="file-editor"> |
||||||
|
<div class="editor-header"> |
||||||
|
<span class="file-path">{currentFile}</span> |
||||||
|
<div class="editor-actions"> |
||||||
|
{#if branches.length > 0 && isMaintainer} |
||||||
|
<select |
||||||
|
value={currentBranch || ''} |
||||||
|
class="branch-selector" |
||||||
|
disabled={saving || needsClone} |
||||||
|
title="Select branch" |
||||||
|
onchange={(e) => { |
||||||
|
const target = e.target as HTMLSelectElement; |
||||||
|
if (target.value) onBranchChange(target.value); |
||||||
|
}} |
||||||
|
> |
||||||
|
{#each branches as branch} |
||||||
|
{@const branchName = typeof branch === 'string' ? branch : branch.name} |
||||||
|
<option value={branchName}>{branchName}{#if branchName === defaultBranch} (default){/if}</option> |
||||||
|
{/each} |
||||||
|
</select> |
||||||
|
{:else if currentBranch && isMaintainer} |
||||||
|
<span class="branch-display" title="Current branch">{currentBranch}</span> |
||||||
|
{/if} |
||||||
|
{#if hasChanges} |
||||||
|
<span class="unsaved-indicator">● Unsaved changes</span> |
||||||
|
{/if} |
||||||
|
{#if currentFile && supportsPreview((currentFile.split('.').pop() || '').toLowerCase()) && !isMaintainer} |
||||||
|
<button |
||||||
|
onclick={onTogglePreview} |
||||||
|
class="preview-toggle-button" |
||||||
|
title={showFilePreview ? 'Show raw' : 'Show preview'} |
||||||
|
> |
||||||
|
{showFilePreview ? 'Raw' : 'Preview'} |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
{#if currentFile && fileContent} |
||||||
|
<button |
||||||
|
onclick={onCopyFileContent} |
||||||
|
disabled={copyingFile} |
||||||
|
class="file-action-button" |
||||||
|
title="Copy raw content to clipboard" |
||||||
|
> |
||||||
|
<img src="/icons/copy.svg" alt="Copy" class="icon-inline" /> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
onclick={onDownloadFile} |
||||||
|
class="file-action-button" |
||||||
|
title="Download file" |
||||||
|
> |
||||||
|
<img src="/icons/download.svg" alt="Download" class="icon-inline" /> |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
{#if isMaintainer} |
||||||
|
<button |
||||||
|
onclick={onSave} |
||||||
|
disabled={!hasChanges || saving || needsClone} |
||||||
|
class="save-button" |
||||||
|
title={needsClone ? cloneTooltip : (hasChanges ? 'Save changes' : 'No changes to save')} |
||||||
|
> |
||||||
|
{saving ? 'Saving...' : 'Save'} |
||||||
|
</button> |
||||||
|
{:else if userPubkey} |
||||||
|
<span class="non-maintainer-notice">Only maintainers can edit files. Submit a PR instead.</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if loading} |
||||||
|
<div class="loading">Loading file...</div> |
||||||
|
{:else} |
||||||
|
<div class="editor-container"> |
||||||
|
{#if isMaintainer} |
||||||
|
<CodeEditor |
||||||
|
content={editedContent || fileContent} |
||||||
|
language={fileLanguage} |
||||||
|
readOnly={needsClone} |
||||||
|
onChange={(value) => { |
||||||
|
editedContent = value; |
||||||
|
hasChanges = value !== fileContent; |
||||||
|
onContentChange(value); |
||||||
|
}} |
||||||
|
/> |
||||||
|
{:else} |
||||||
|
<div class="read-only-editor" class:word-wrap={wordWrap}> |
||||||
|
{#if isImageFile && imageUrl} |
||||||
|
<div class="file-preview image-preview"> |
||||||
|
<img src={imageUrl} alt={currentFile?.split('/').pop() || 'Image'} class="file-image" /> |
||||||
|
</div> |
||||||
|
{:else if currentFile && showFilePreview && fileHtml && supportsPreview((currentFile.split('.').pop() || '').toLowerCase())} |
||||||
|
<div class="file-preview markdown"> |
||||||
|
{@html fileHtml} |
||||||
|
</div> |
||||||
|
{:else if highlightedFileContent} |
||||||
|
{@html highlightedFileContent} |
||||||
|
{:else} |
||||||
|
<pre><code class="hljs">{fileContent}</code></pre> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if hasChanges && isMaintainer} |
||||||
|
<div class="editor-footer"> |
||||||
|
<span class="unsaved-indicator">Unsaved changes</span> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="empty-state"> |
||||||
|
<p>Select a file to view or edit</p> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/snippet} |
||||||
|
</TabLayout> |
||||||
|
|
||||||
|
<style> |
||||||
|
.file-editor { |
||||||
|
height: 100%; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
} |
||||||
|
|
||||||
|
.editor-footer { |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
border-top: 1px solid var(--border-color); |
||||||
|
background: var(--bg-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.unsaved-indicator { |
||||||
|
color: var(--accent-warning); |
||||||
|
font-size: 0.9rem; |
||||||
|
} |
||||||
|
|
||||||
|
.empty-state { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
height: 100%; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,272 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
/** |
||||||
|
* Commit history tab component |
||||||
|
*/ |
||||||
|
|
||||||
|
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 }> = []; |
||||||
|
</script> |
||||||
|
|
||||||
|
<TabLayout {loading} {error}> |
||||||
|
{#snippet leftPane()} |
||||||
|
<div class="commits-list"> |
||||||
|
<h3>Commits</h3> |
||||||
|
{#if commits.length === 0} |
||||||
|
<div class="empty">No commits found</div> |
||||||
|
{:else} |
||||||
|
<ul class="commit-list"> |
||||||
|
{#each commits as commit} |
||||||
|
<li> |
||||||
|
<button |
||||||
|
class="commit-item {selectedCommit === commit.hash ? 'selected' : ''}" |
||||||
|
onclick={() => onSelect(commit.hash)} |
||||||
|
> |
||||||
|
<div class="commit-hash">{commit.hash.slice(0, 7)}</div> |
||||||
|
<div class="commit-message">{commit.message || 'No message'}</div> |
||||||
|
<div class="commit-meta"> |
||||||
|
<span>{commit.author}</span> |
||||||
|
<span>{new Date(commit.date).toLocaleString()}</span> |
||||||
|
</div> |
||||||
|
{#if commit.verification} |
||||||
|
<div class="commit-verification"> |
||||||
|
{#if commit.verification.valid} |
||||||
|
<span class="verified">✓ Verified</span> |
||||||
|
{:else} |
||||||
|
<span class="unverified">✗ {commit.verification.error || 'Invalid'}</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</button> |
||||||
|
</li> |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/snippet} |
||||||
|
|
||||||
|
{#snippet rightPanel()} |
||||||
|
{#if selectedCommit} |
||||||
|
{@const commit = commits.find(c => c.hash === selectedCommit)} |
||||||
|
{#if commit} |
||||||
|
<div class="commit-detail"> |
||||||
|
<div class="commit-detail-header"> |
||||||
|
<h2>Commit {commit.hash.slice(0, 7)}</h2> |
||||||
|
<button |
||||||
|
onclick={() => onVerify(commit.hash)} |
||||||
|
disabled={verifyingCommits.has(commit.hash)} |
||||||
|
> |
||||||
|
{verifyingCommits.has(commit.hash) ? 'Verifying...' : 'Verify Signature'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="commit-info"> |
||||||
|
<div class="info-row"> |
||||||
|
<strong>Author:</strong> {commit.author} |
||||||
|
</div> |
||||||
|
<div class="info-row"> |
||||||
|
<strong>Date:</strong> {new Date(commit.date).toLocaleString()} |
||||||
|
</div> |
||||||
|
<div class="info-row"> |
||||||
|
<strong>Message:</strong> |
||||||
|
<div class="commit-message-text">{commit.message || 'No message'}</div> |
||||||
|
</div> |
||||||
|
{#if commit.files && commit.files.length > 0} |
||||||
|
<div class="info-row"> |
||||||
|
<strong>Files ({commit.files.length}):</strong> |
||||||
|
<ul class="files-list"> |
||||||
|
{#each commit.files as file} |
||||||
|
<li>{file}</li> |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if showDiff && diffData.length > 0} |
||||||
|
<div class="diff-section"> |
||||||
|
<h3>Changes</h3> |
||||||
|
{#each diffData as diff} |
||||||
|
<div class="diff-file"> |
||||||
|
<div class="diff-header"> |
||||||
|
<strong>{diff.file}</strong> |
||||||
|
<span class="diff-stats"> |
||||||
|
+{diff.additions} -{diff.deletions} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<pre class="diff-content"><code>{diff.diff}</code></pre> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{:else} |
||||||
|
<div class="empty-state"> |
||||||
|
<p>Select a commit to view details</p> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/snippet} |
||||||
|
</TabLayout> |
||||||
|
|
||||||
|
<style> |
||||||
|
.commits-list { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.commits-list h3 { |
||||||
|
margin: 0 0 1rem 0; |
||||||
|
font-size: 1rem; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
.empty { |
||||||
|
padding: 2rem; |
||||||
|
text-align: center; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.commit-list { |
||||||
|
list-style: none; |
||||||
|
padding: 0; |
||||||
|
margin: 0; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.commit-item { |
||||||
|
width: 100%; |
||||||
|
padding: 0.75rem; |
||||||
|
text-align: left; |
||||||
|
background: transparent; |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.commit-item:hover { |
||||||
|
background: var(--bg-hover); |
||||||
|
border-color: var(--accent-color); |
||||||
|
} |
||||||
|
|
||||||
|
.commit-item.selected { |
||||||
|
background: var(--bg-selected); |
||||||
|
border-color: var(--accent-color); |
||||||
|
} |
||||||
|
|
||||||
|
.commit-hash { |
||||||
|
font-family: monospace; |
||||||
|
font-weight: 600; |
||||||
|
margin-bottom: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.commit-message { |
||||||
|
margin: 0.25rem 0; |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.commit-meta { |
||||||
|
display: flex; |
||||||
|
gap: 1rem; |
||||||
|
font-size: 0.85rem; |
||||||
|
color: var(--text-secondary); |
||||||
|
margin-top: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.commit-verification { |
||||||
|
margin-top: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.verified { |
||||||
|
color: var(--accent-success); |
||||||
|
} |
||||||
|
|
||||||
|
.unverified { |
||||||
|
color: var(--accent-error); |
||||||
|
} |
||||||
|
|
||||||
|
.commit-detail { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.commit-detail-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding-bottom: 1rem; |
||||||
|
border-bottom: 1px solid var(--border-color); |
||||||
|
} |
||||||
|
|
||||||
|
.commit-info { |
||||||
|
margin: 1rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.info-row { |
||||||
|
margin: 1rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.commit-message-text { |
||||||
|
margin-top: 0.5rem; |
||||||
|
padding: 0.75rem; |
||||||
|
background: var(--bg-secondary); |
||||||
|
border-radius: 4px; |
||||||
|
white-space: pre-wrap; |
||||||
|
} |
||||||
|
|
||||||
|
.files-list { |
||||||
|
margin-top: 0.5rem; |
||||||
|
padding-left: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.diff-section { |
||||||
|
margin-top: 2rem; |
||||||
|
padding-top: 2rem; |
||||||
|
border-top: 1px solid var(--border-color); |
||||||
|
} |
||||||
|
|
||||||
|
.diff-file { |
||||||
|
margin: 1rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.diff-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
padding: 0.5rem; |
||||||
|
background: var(--bg-secondary); |
||||||
|
border-radius: 4px 4px 0 0; |
||||||
|
} |
||||||
|
|
||||||
|
.diff-stats { |
||||||
|
font-family: monospace; |
||||||
|
font-size: 0.9rem; |
||||||
|
} |
||||||
|
|
||||||
|
.diff-content { |
||||||
|
margin: 0; |
||||||
|
padding: 1rem; |
||||||
|
background: var(--bg-secondary); |
||||||
|
border-radius: 0 0 4px 4px; |
||||||
|
overflow-x: auto; |
||||||
|
font-family: monospace; |
||||||
|
font-size: 0.85rem; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,158 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
/** |
||||||
|
* Issues tab component using hierarchical layout |
||||||
|
*/ |
||||||
|
|
||||||
|
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; |
||||||
|
|
||||||
|
const items = $derived(issues.map(issue => ({ |
||||||
|
id: issue.id, |
||||||
|
title: issue.subject, |
||||||
|
status: issue.status, |
||||||
|
...issue |
||||||
|
}))); |
||||||
|
|
||||||
|
const selectedId = $derived(selectedIssue); |
||||||
|
</script> |
||||||
|
|
||||||
|
<StatusTabLayout |
||||||
|
{items} |
||||||
|
{selectedId} |
||||||
|
{loading} |
||||||
|
{error} |
||||||
|
{onSelect} |
||||||
|
statusGroups={[ |
||||||
|
{ label: 'Open', value: 'open' }, |
||||||
|
{ label: 'Closed', value: 'closed' }, |
||||||
|
{ label: 'Resolved', value: 'resolved' } |
||||||
|
]} |
||||||
|
> |
||||||
|
{#snippet itemRenderer({ item })} |
||||||
|
<div class="issue-item-content"> |
||||||
|
<div class="issue-subject">{item.subject}</div> |
||||||
|
<div class="issue-meta"> |
||||||
|
<span class="issue-id">#{item.id.slice(0, 7)}</span> |
||||||
|
<span class="issue-date">{new Date(item.created_at * 1000).toLocaleDateString()}</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/snippet} |
||||||
|
|
||||||
|
{#snippet detailRenderer({ item })} |
||||||
|
<div class="issue-detail"> |
||||||
|
<div class="issue-detail-header"> |
||||||
|
<h2>{item.subject}</h2> |
||||||
|
<div class="issue-actions"> |
||||||
|
<select |
||||||
|
value={item.status} |
||||||
|
onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)} |
||||||
|
> |
||||||
|
<option value="open">Open</option> |
||||||
|
<option value="closed">Closed</option> |
||||||
|
<option value="resolved">Resolved</option> |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="issue-content"> |
||||||
|
{@html item.content || 'No content'} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if loadingReplies} |
||||||
|
<div class="loading">Loading replies...</div> |
||||||
|
{:else if issueReplies.length > 0} |
||||||
|
<div class="issue-replies"> |
||||||
|
<h3>Replies</h3> |
||||||
|
{#each issueReplies as reply} |
||||||
|
<div class="reply"> |
||||||
|
<div class="reply-author">{reply.author}</div> |
||||||
|
<div class="reply-content">{reply.content}</div> |
||||||
|
<div class="reply-date">{new Date(reply.created_at * 1000).toLocaleString()}</div> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/snippet} |
||||||
|
</StatusTabLayout> |
||||||
|
|
||||||
|
<style> |
||||||
|
.issue-item-content { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.issue-subject { |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.issue-meta { |
||||||
|
display: flex; |
||||||
|
gap: 0.5rem; |
||||||
|
font-size: 0.85rem; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.issue-detail { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.issue-detail-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding-bottom: 1rem; |
||||||
|
border-bottom: 1px solid var(--border-color); |
||||||
|
} |
||||||
|
|
||||||
|
.issue-content { |
||||||
|
margin: 1rem 0; |
||||||
|
line-height: 1.6; |
||||||
|
} |
||||||
|
|
||||||
|
.issue-replies { |
||||||
|
margin-top: 2rem; |
||||||
|
padding-top: 2rem; |
||||||
|
border-top: 1px solid var(--border-color); |
||||||
|
} |
||||||
|
|
||||||
|
.reply { |
||||||
|
padding: 1rem; |
||||||
|
margin: 1rem 0; |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
.reply-author { |
||||||
|
font-weight: 500; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.reply-content { |
||||||
|
margin: 0.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.reply-date { |
||||||
|
font-size: 0.85rem; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,131 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
/** |
||||||
|
* Pull Requests tab component using hierarchical layout |
||||||
|
*/ |
||||||
|
|
||||||
|
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 = () => {}; |
||||||
|
|
||||||
|
const items = $derived(prs.map(pr => ({ |
||||||
|
id: pr.id, |
||||||
|
title: pr.subject, |
||||||
|
status: pr.status, |
||||||
|
...pr |
||||||
|
}))); |
||||||
|
|
||||||
|
const selectedId = $derived(selectedPR); |
||||||
|
</script> |
||||||
|
|
||||||
|
<StatusTabLayout |
||||||
|
{items} |
||||||
|
{selectedId} |
||||||
|
{loading} |
||||||
|
{error} |
||||||
|
{onSelect} |
||||||
|
statusGroups={[ |
||||||
|
{ label: 'Open', value: 'open' }, |
||||||
|
{ label: 'Closed', value: 'closed' }, |
||||||
|
{ label: 'Merged', value: 'merged' } |
||||||
|
]} |
||||||
|
> |
||||||
|
{#snippet itemRenderer({ item })} |
||||||
|
<div class="pr-item-content"> |
||||||
|
<div class="pr-subject">{item.subject}</div> |
||||||
|
<div class="pr-meta"> |
||||||
|
<span class="pr-id">#{item.id.slice(0, 7)}</span> |
||||||
|
{#if item.commitId} |
||||||
|
<span class="pr-commit">Commit: {item.commitId.slice(0, 7)}</span> |
||||||
|
{/if} |
||||||
|
<span class="pr-date">{new Date(item.created_at * 1000).toLocaleDateString()}</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/snippet} |
||||||
|
|
||||||
|
{#snippet detailRenderer({ item })} |
||||||
|
<div class="pr-detail"> |
||||||
|
<div class="pr-detail-header"> |
||||||
|
<h2>{item.subject}</h2> |
||||||
|
<div class="pr-actions"> |
||||||
|
<select |
||||||
|
value={item.status} |
||||||
|
onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)} |
||||||
|
> |
||||||
|
<option value="open">Open</option> |
||||||
|
<option value="closed">Closed</option> |
||||||
|
<option value="merged">Merged</option> |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="pr-content"> |
||||||
|
{@html item.content || 'No content'} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if item.commitId} |
||||||
|
<div class="pr-commit-info"> |
||||||
|
<strong>Commit:</strong> {item.commitId} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/snippet} |
||||||
|
</StatusTabLayout> |
||||||
|
|
||||||
|
<style> |
||||||
|
.pr-item-content { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-subject { |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-meta { |
||||||
|
display: flex; |
||||||
|
gap: 0.5rem; |
||||||
|
font-size: 0.85rem; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.pr-detail { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-detail-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding-bottom: 1rem; |
||||||
|
border-bottom: 1px solid var(--border-color); |
||||||
|
} |
||||||
|
|
||||||
|
.pr-content { |
||||||
|
margin: 1rem 0; |
||||||
|
line-height: 1.6; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-commit-info { |
||||||
|
margin-top: 1rem; |
||||||
|
padding: 1rem; |
||||||
|
background: var(--bg-secondary); |
||||||
|
border-radius: 4px; |
||||||
|
font-family: monospace; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,137 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
/** |
||||||
|
* Patches tab component using hierarchical layout |
||||||
|
*/ |
||||||
|
|
||||||
|
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> = {}; |
||||||
|
|
||||||
|
const items = $derived(patches.map(patch => ({ |
||||||
|
id: patch.id, |
||||||
|
title: patch.subject, |
||||||
|
status: patch.status || 'open', |
||||||
|
...patch |
||||||
|
}))); |
||||||
|
|
||||||
|
const selectedId = $derived(selectedPatch); |
||||||
|
</script> |
||||||
|
|
||||||
|
<StatusTabLayout |
||||||
|
{items} |
||||||
|
{selectedId} |
||||||
|
{loading} |
||||||
|
{error} |
||||||
|
{onSelect} |
||||||
|
statusGroups={[ |
||||||
|
{ label: 'Open', value: 'open' }, |
||||||
|
{ label: 'Applied', value: 'applied' }, |
||||||
|
{ label: 'Rejected', value: 'rejected' } |
||||||
|
]} |
||||||
|
> |
||||||
|
{#snippet itemRenderer({ item })} |
||||||
|
<div class="patch-item-content"> |
||||||
|
<div class="patch-subject">{item.subject}</div> |
||||||
|
<div class="patch-meta"> |
||||||
|
<span class="patch-id">#{item.id.slice(0, 7)}</span> |
||||||
|
<span class="patch-date">{new Date(item.created_at * 1000).toLocaleDateString()}</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/snippet} |
||||||
|
|
||||||
|
{#snippet detailRenderer({ item })} |
||||||
|
<div class="patch-detail"> |
||||||
|
<div class="patch-detail-header"> |
||||||
|
<h2>{item.subject}</h2> |
||||||
|
<div class="patch-actions"> |
||||||
|
{#if item.status === 'open'} |
||||||
|
<button |
||||||
|
onclick={() => onApply(item.id)} |
||||||
|
disabled={applying[item.id]} |
||||||
|
class="apply-button" |
||||||
|
> |
||||||
|
{applying[item.id] ? 'Applying...' : 'Apply Patch'} |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="patch-content"> |
||||||
|
<pre><code>{item.content}</code></pre> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/snippet} |
||||||
|
</StatusTabLayout> |
||||||
|
|
||||||
|
<style> |
||||||
|
.patch-item-content { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.patch-subject { |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.patch-meta { |
||||||
|
display: flex; |
||||||
|
gap: 0.5rem; |
||||||
|
font-size: 0.85rem; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.patch-detail { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.patch-detail-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding-bottom: 1rem; |
||||||
|
border-bottom: 1px solid var(--border-color); |
||||||
|
} |
||||||
|
|
||||||
|
.patch-content { |
||||||
|
margin: 1rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.patch-content pre { |
||||||
|
background: var(--bg-secondary); |
||||||
|
padding: 1rem; |
||||||
|
border-radius: 4px; |
||||||
|
overflow-x: auto; |
||||||
|
font-family: monospace; |
||||||
|
font-size: 0.9rem; |
||||||
|
} |
||||||
|
|
||||||
|
.apply-button { |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
background: var(--accent-color); |
||||||
|
color: white; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.apply-button:disabled { |
||||||
|
opacity: 0.5; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,148 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
/** |
||||||
|
* Status-based tab layout for issues, patches, and PRs |
||||||
|
* Groups items by status (open, closed, etc.) |
||||||
|
*/ |
||||||
|
|
||||||
|
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' } |
||||||
|
]; |
||||||
|
|
||||||
|
let selectedItem = $derived(items.find(item => item.id === selectedId) || null); |
||||||
|
|
||||||
|
function groupByStatus() { |
||||||
|
const grouped: Record<string, typeof items> = {}; |
||||||
|
statusGroups.forEach(group => { |
||||||
|
grouped[group.value] = []; |
||||||
|
}); |
||||||
|
|
||||||
|
items.forEach(item => { |
||||||
|
const status = item.status || 'open'; |
||||||
|
if (!grouped[status]) { |
||||||
|
grouped[status] = []; |
||||||
|
} |
||||||
|
grouped[status].push(item); |
||||||
|
}); |
||||||
|
|
||||||
|
return grouped; |
||||||
|
} |
||||||
|
|
||||||
|
const grouped = $derived(groupByStatus()); |
||||||
|
</script> |
||||||
|
|
||||||
|
<TabLayout {loading} {error}> |
||||||
|
{#snippet leftPane()} |
||||||
|
<div class="status-groups"> |
||||||
|
{#each statusGroups as { label, value }} |
||||||
|
{#if grouped[value] && grouped[value].length > 0} |
||||||
|
<div class="status-group"> |
||||||
|
<h3 class="status-header">{label} ({grouped[value].length})</h3> |
||||||
|
<div class="items-list"> |
||||||
|
{#each grouped[value] as item} |
||||||
|
<div |
||||||
|
class="item {selectedId === item.id ? 'selected' : ''}" |
||||||
|
onclick={() => onSelect(item.id)} |
||||||
|
> |
||||||
|
<slot name="itemRenderer" {item}> |
||||||
|
<div class="item-title">{item.title}</div> |
||||||
|
<div class="item-meta">#{item.id.slice(0, 7)}</div> |
||||||
|
</slot> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/snippet} |
||||||
|
|
||||||
|
{#snippet rightPanel()} |
||||||
|
{#if selectedItem} |
||||||
|
<slot name="detailRenderer" item={selectedItem}> |
||||||
|
<div class="detail-view"> |
||||||
|
<h2>{selectedItem.title}</h2> |
||||||
|
<pre>{JSON.stringify(selectedItem, null, 2)}</pre> |
||||||
|
</div> |
||||||
|
</slot> |
||||||
|
{/if} |
||||||
|
{/snippet} |
||||||
|
</TabLayout> |
||||||
|
|
||||||
|
<style> |
||||||
|
.status-groups { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.status-group { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.status-header { |
||||||
|
font-size: 0.9rem; |
||||||
|
font-weight: 600; |
||||||
|
color: var(--text-secondary); |
||||||
|
text-transform: uppercase; |
||||||
|
letter-spacing: 0.5px; |
||||||
|
margin: 0; |
||||||
|
padding: 0.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.items-list { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.item { |
||||||
|
padding: 0.75rem; |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.item:hover { |
||||||
|
background: var(--bg-hover); |
||||||
|
border-color: var(--accent-color); |
||||||
|
} |
||||||
|
|
||||||
|
.item.selected { |
||||||
|
background: var(--bg-selected); |
||||||
|
border-color: var(--accent-color); |
||||||
|
} |
||||||
|
|
||||||
|
.item-title { |
||||||
|
font-weight: 500; |
||||||
|
margin-bottom: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.item-meta { |
||||||
|
font-size: 0.85rem; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.detail-view { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.detail-view h2 { |
||||||
|
margin-top: 0; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,73 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
/** |
||||||
|
* Hierarchical tab layout component |
||||||
|
* 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; |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="tab-layout"> |
||||||
|
<div class="left-pane"> |
||||||
|
{#if loading} |
||||||
|
<div class="loading">Loading...</div> |
||||||
|
{:else if error} |
||||||
|
<div class="error">{error}</div> |
||||||
|
{:else} |
||||||
|
{#if leftPane} |
||||||
|
{@render leftPane()} |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="right-panel"> |
||||||
|
{#if rightPanel} |
||||||
|
{@render rightPanel()} |
||||||
|
{:else} |
||||||
|
<div class="empty-state"> |
||||||
|
<p>Select an item to view details</p> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.tab-layout { |
||||||
|
display: flex; |
||||||
|
height: 100%; |
||||||
|
gap: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.left-pane { |
||||||
|
flex: 0 0 300px; |
||||||
|
border-right: 1px solid var(--border-color); |
||||||
|
overflow-y: auto; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.right-panel { |
||||||
|
flex: 1; |
||||||
|
overflow-y: auto; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.loading, .error { |
||||||
|
padding: 2rem; |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
.error { |
||||||
|
color: var(--accent-error); |
||||||
|
} |
||||||
|
|
||||||
|
.empty-state { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
height: 100%; |
||||||
|
color: var(--text-secondary); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,204 @@ |
|||||||
|
/** |
||||||
|
* Repository API hooks |
||||||
|
* Centralized API calls for repository operations |
||||||
|
*/ |
||||||
|
|
||||||
|
import { buildApiHeaders } from '../utils/api-client.js'; |
||||||
|
import logger from '$lib/services/logger.js'; |
||||||
|
|
||||||
|
export interface LoadFilesOptions { |
||||||
|
npub: string; |
||||||
|
repo: string; |
||||||
|
branch: string; |
||||||
|
path?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface LoadFileOptions { |
||||||
|
npub: string; |
||||||
|
repo: string; |
||||||
|
branch: string; |
||||||
|
filePath: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Load files from repository |
||||||
|
*/ |
||||||
|
export async function loadFiles(options: LoadFilesOptions): Promise<Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }>> { |
||||||
|
const { npub, repo, branch, path = '' } = options; |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Loading files', { npub, repo, branch, path }); |
||||||
|
|
||||||
|
const url = `/api/repos/${npub}/${repo}/tree?ref=${branch}${path ? `&path=${encodeURIComponent(path)}` : ''}`; |
||||||
|
const response = await fetch(url, { |
||||||
|
headers: buildApiHeaders() |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error(`Failed to load files: ${response.statusText}`); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
logger.operation('Files loaded', { npub, repo, count: data.files?.length || 0 }); |
||||||
|
return data.files || []; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, npub, repo, branch, path }, 'Error loading files'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Load file content |
||||||
|
*/ |
||||||
|
export async function loadFile(options: LoadFileOptions): Promise<{ content: string; type: string }> { |
||||||
|
const { npub, repo, branch, filePath } = options; |
||||||
|
|
||||||
|
try { |
||||||
|
logger.operation('Loading file', { npub, repo, branch, filePath }); |
||||||
|
|
||||||
|
const url = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(filePath)}&ref=${branch}`; |
||||||
|
const response = await fetch(url, { |
||||||
|
headers: buildApiHeaders() |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error(`Failed to load file: ${response.statusText}`); |
||||||
|
} |
||||||
|
|
||||||
|
const content = await response.text(); |
||||||
|
const ext = filePath.split('.').pop()?.toLowerCase() || ''; |
||||||
|
const type = ext === 'md' || ext === 'markdown' ? 'markdown' :
|
||||||
|
ext === 'adoc' || ext === 'asciidoc' ? 'asciidoc' : 'text'; |
||||||
|
|
||||||
|
logger.operation('File loaded', { npub, repo, filePath, size: content.length }); |
||||||
|
return { content, type }; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, npub, repo, branch, filePath }, 'Error loading file'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Load branches |
||||||
|
*/ |
||||||
|
export async function loadBranches(npub: string, repo: string): Promise<string[]> { |
||||||
|
try { |
||||||
|
logger.operation('Loading branches', { npub, repo }); |
||||||
|
|
||||||
|
const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { |
||||||
|
headers: buildApiHeaders() |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error(`Failed to load branches: ${response.statusText}`); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
logger.operation('Branches loaded', { npub, repo, count: data.branches?.length || 0 }); |
||||||
|
return data.branches || []; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, npub, repo }, 'Error loading branches'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Load commit history |
||||||
|
*/ |
||||||
|
export async function loadCommitHistory( |
||||||
|
npub: string, |
||||||
|
repo: string, |
||||||
|
branch: string, |
||||||
|
limit: number = 50 |
||||||
|
): Promise<Array<{ hash: string; message: string; author: string; date: string; files: string[] }>> { |
||||||
|
try { |
||||||
|
logger.operation('Loading commit history', { npub, repo, branch, limit }); |
||||||
|
|
||||||
|
const response = await fetch(`/api/repos/${npub}/${repo}/commits?branch=${branch}&limit=${limit}`, { |
||||||
|
headers: buildApiHeaders() |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error(`Failed to load commit history: ${response.statusText}`); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
logger.operation('Commit history loaded', { npub, repo, count: data.commits?.length || 0 }); |
||||||
|
return data.commits || []; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, npub, repo, branch }, 'Error loading commit history'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Load issues |
||||||
|
*/ |
||||||
|
export async function loadIssues(npub: string, repo: string): Promise<Array<any>> { |
||||||
|
try { |
||||||
|
logger.operation('Loading issues', { npub, repo }); |
||||||
|
|
||||||
|
const response = await fetch(`/api/repos/${npub}/${repo}/issues`, { |
||||||
|
headers: buildApiHeaders() |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error(`Failed to load issues: ${response.statusText}`); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
logger.operation('Issues loaded', { npub, repo, count: data.issues?.length || 0 }); |
||||||
|
return data.issues || []; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, npub, repo }, 'Error loading issues'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Load pull requests |
||||||
|
*/ |
||||||
|
export async function loadPRs(npub: string, repo: string): Promise<Array<any>> { |
||||||
|
try { |
||||||
|
logger.operation('Loading PRs', { npub, repo }); |
||||||
|
|
||||||
|
const response = await fetch(`/api/repos/${npub}/${repo}/prs`, { |
||||||
|
headers: buildApiHeaders() |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error(`Failed to load PRs: ${response.statusText}`); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
logger.operation('PRs loaded', { npub, repo, count: data.prs?.length || 0 }); |
||||||
|
return data.prs || []; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, npub, repo }, 'Error loading PRs'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Load patches |
||||||
|
*/ |
||||||
|
export async function loadPatches(npub: string, repo: string): Promise<Array<any>> { |
||||||
|
try { |
||||||
|
logger.operation('Loading patches', { npub, repo }); |
||||||
|
|
||||||
|
const response = await fetch(`/api/repos/${npub}/${repo}/patches`, { |
||||||
|
headers: buildApiHeaders() |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error(`Failed to load patches: ${response.statusText}`); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
logger.operation('Patches loaded', { npub, repo, count: data.patches?.length || 0 }); |
||||||
|
return data.patches || []; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, npub, repo }, 'Error loading patches'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
/** |
||||||
|
* Repository data hook |
||||||
|
* Manages loading and state for repository data |
||||||
|
*/ |
||||||
|
|
||||||
|
import { page } from '$app/stores'; |
||||||
|
import type { RepoState } from '../stores/repo-state.js'; |
||||||
|
import logger from '$lib/services/logger.js'; |
||||||
|
|
||||||
|
export interface RepoPageData { |
||||||
|
title?: string; |
||||||
|
description?: string; |
||||||
|
image?: string; |
||||||
|
banner?: string; |
||||||
|
repoUrl?: string; |
||||||
|
announcement?: any; |
||||||
|
gitDomain?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Initialize repository data from page store |
||||||
|
*/ |
||||||
|
export function useRepoData( |
||||||
|
state: RepoState, |
||||||
|
setPageData: (data: RepoPageData) => void |
||||||
|
): void { |
||||||
|
// Update pageData from $page when available (client-side)
|
||||||
|
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'); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
if (state.isMounted) { |
||||||
|
logger.warn({ error: err }, 'Failed to update pageData'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract repository parameters from page |
||||||
|
*/ |
||||||
|
export function useRepoParams( |
||||||
|
state: RepoState, |
||||||
|
setNpub: (npub: string) => void, |
||||||
|
setRepo: (repo: string) => void |
||||||
|
): void { |
||||||
|
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); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// If $page.params fails, try to parse from URL path
|
||||||
|
if (!state.isMounted) return; |
||||||
|
try { |
||||||
|
if (typeof window !== 'undefined') { |
||||||
|
const pathParts = window.location.pathname.split('/').filter(Boolean); |
||||||
|
if (pathParts[0] === 'repos' && pathParts[1] && pathParts[2] && state.isMounted) { |
||||||
|
setNpub(pathParts[1]); |
||||||
|
setRepo(pathParts[2]); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Ignore errors - params will be set eventually
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,180 @@ |
|||||||
|
/** |
||||||
|
* Repository page state management |
||||||
|
* Centralized state to prevent memory leaks and improve performance |
||||||
|
*/ |
||||||
|
|
||||||
|
export interface RepoState { |
||||||
|
// Loading states
|
||||||
|
loading: boolean; |
||||||
|
error: string | null; |
||||||
|
repoNotFound: boolean; |
||||||
|
|
||||||
|
// File system state
|
||||||
|
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; |
||||||
|
saving: boolean; |
||||||
|
|
||||||
|
// Branch state
|
||||||
|
branches: Array<string | { name: string; commit?: any }>; |
||||||
|
currentBranch: string | null; |
||||||
|
defaultBranch: string | null; |
||||||
|
|
||||||
|
// Commit state
|
||||||
|
commitMessage: string; |
||||||
|
showCommitDialog: boolean; |
||||||
|
|
||||||
|
// User state
|
||||||
|
userPubkey: string | null; |
||||||
|
userPubkeyHex: string | null; |
||||||
|
|
||||||
|
// UI state
|
||||||
|
activeTab: 'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs' | 'discussions' | 'patches' | 'releases' | 'code-search'; |
||||||
|
showRepoMenu: boolean; |
||||||
|
|
||||||
|
// Navigation
|
||||||
|
pathStack: string[]; |
||||||
|
|
||||||
|
// File creation
|
||||||
|
showCreateFileDialog: boolean; |
||||||
|
newFileName: string; |
||||||
|
newFileContent: string; |
||||||
|
|
||||||
|
// Branch creation
|
||||||
|
showCreateBranchDialog: boolean; |
||||||
|
newBranchName: string; |
||||||
|
newBranchFrom: string | null; |
||||||
|
defaultBranchName: string; |
||||||
|
|
||||||
|
// Commit history
|
||||||
|
commits: Array<{ |
||||||
|
hash: string; |
||||||
|
message: string; |
||||||
|
author: string; |
||||||
|
date: string; |
||||||
|
files: string[]; |
||||||
|
verification?: { |
||||||
|
valid: boolean; |
||||||
|
hasSignature?: boolean; |
||||||
|
error?: string; |
||||||
|
pubkey?: string; |
||||||
|
npub?: string; |
||||||
|
authorName?: string; |
||||||
|
authorEmail?: string; |
||||||
|
timestamp?: number; |
||||||
|
eventId?: string; |
||||||
|
}; |
||||||
|
}>; |
||||||
|
loadingCommits: boolean; |
||||||
|
selectedCommit: string | null; |
||||||
|
showDiff: boolean; |
||||||
|
diffData: Array<{ file: string; additions: number; deletions: number; diff: string }>; |
||||||
|
verifyingCommits: Set<string>; |
||||||
|
|
||||||
|
// Tags
|
||||||
|
tags: Array<{ name: string; hash: string; message?: string; date?: number }>; |
||||||
|
selectedTag: string | null; |
||||||
|
showCreateTagDialog: boolean; |
||||||
|
newTagName: string; |
||||||
|
newTagMessage: string; |
||||||
|
newTagRef: string; |
||||||
|
|
||||||
|
// Maintainer state
|
||||||
|
isMaintainer: boolean; |
||||||
|
loadingMaintainerStatus: boolean; |
||||||
|
allMaintainers: Array<{ pubkey: string; isOwner: boolean }>; |
||||||
|
loadingMaintainers: boolean; |
||||||
|
maintainersLoaded: boolean; |
||||||
|
|
||||||
|
// Clone state
|
||||||
|
isRepoCloned: boolean | null; |
||||||
|
checkingCloneStatus: boolean; |
||||||
|
cloning: boolean; |
||||||
|
copyingCloneUrl: boolean; |
||||||
|
apiFallbackAvailable: boolean | null; |
||||||
|
|
||||||
|
// Editor state
|
||||||
|
wordWrap: boolean; |
||||||
|
|
||||||
|
// Component lifecycle
|
||||||
|
isMounted: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export function createRepoState(): RepoState { |
||||||
|
return { |
||||||
|
loading: true, |
||||||
|
error: null, |
||||||
|
repoNotFound: false, |
||||||
|
files: [], |
||||||
|
currentPath: '', |
||||||
|
currentFile: null, |
||||||
|
fileContent: '', |
||||||
|
fileLanguage: 'text', |
||||||
|
editedContent: '', |
||||||
|
hasChanges: false, |
||||||
|
saving: false, |
||||||
|
branches: [], |
||||||
|
currentBranch: null, |
||||||
|
defaultBranch: null, |
||||||
|
commitMessage: '', |
||||||
|
showCommitDialog: false, |
||||||
|
userPubkey: null, |
||||||
|
userPubkeyHex: null, |
||||||
|
activeTab: 'files', |
||||||
|
showRepoMenu: false, |
||||||
|
pathStack: [], |
||||||
|
showCreateFileDialog: false, |
||||||
|
newFileName: '', |
||||||
|
newFileContent: '', |
||||||
|
showCreateBranchDialog: false, |
||||||
|
newBranchName: '', |
||||||
|
newBranchFrom: null, |
||||||
|
defaultBranchName: 'master', |
||||||
|
commits: [], |
||||||
|
loadingCommits: false, |
||||||
|
selectedCommit: null, |
||||||
|
showDiff: false, |
||||||
|
diffData: [], |
||||||
|
verifyingCommits: new Set(), |
||||||
|
tags: [], |
||||||
|
selectedTag: null, |
||||||
|
showCreateTagDialog: false, |
||||||
|
newTagName: '', |
||||||
|
newTagMessage: '', |
||||||
|
newTagRef: 'HEAD', |
||||||
|
isMaintainer: false, |
||||||
|
loadingMaintainerStatus: false, |
||||||
|
allMaintainers: [], |
||||||
|
loadingMaintainers: false, |
||||||
|
maintainersLoaded: false, |
||||||
|
isRepoCloned: null, |
||||||
|
checkingCloneStatus: false, |
||||||
|
cloning: false, |
||||||
|
copyingCloneUrl: false, |
||||||
|
apiFallbackAvailable: null, |
||||||
|
wordWrap: false, |
||||||
|
isMounted: true |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Safely update state only if component is still mounted |
||||||
|
*/ |
||||||
|
export function safeStateUpdate<T>( |
||||||
|
isMounted: boolean, |
||||||
|
updateFn: () => T |
||||||
|
): T | null { |
||||||
|
if (!isMounted) return null; |
||||||
|
try { |
||||||
|
return updateFn(); |
||||||
|
} catch (err) { |
||||||
|
if (isMounted) { |
||||||
|
console.warn('State update error (component may be destroying):', err); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,121 @@ |
|||||||
|
/** |
||||||
|
* Repository announcement utilities |
||||||
|
* Extracts and processes announcement data |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||||
|
|
||||||
|
export interface RepoAnnouncementData { |
||||||
|
name: string; |
||||||
|
description: string; |
||||||
|
cloneUrls: string[]; |
||||||
|
maintainers: string[]; |
||||||
|
ownerPubkey: string; |
||||||
|
language?: string; |
||||||
|
topics: string[]; |
||||||
|
website?: string; |
||||||
|
isPrivate: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract repository data from announcement event |
||||||
|
*/ |
||||||
|
export function extractRepoData( |
||||||
|
announcement: NostrEvent | null | undefined, |
||||||
|
fallbackRepo: string |
||||||
|
): RepoAnnouncementData { |
||||||
|
if (!announcement) { |
||||||
|
return { |
||||||
|
name: fallbackRepo, |
||||||
|
description: '', |
||||||
|
cloneUrls: [], |
||||||
|
maintainers: [], |
||||||
|
ownerPubkey: '', |
||||||
|
topics: [], |
||||||
|
isPrivate: false |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
const name = announcement.tags.find((t: string[]) => t[0] === 'name')?.[1] || fallbackRepo; |
||||||
|
const description = announcement.tags.find((t: string[]) => t[0] === 'description')?.[1] || ''; |
||||||
|
const cloneUrls = announcement.tags |
||||||
|
.filter((t: string[]) => t[0] === 'clone') |
||||||
|
.flatMap((t: string[]) => t.slice(1)) |
||||||
|
.filter((url: string) => url && typeof url === 'string') as string[]; |
||||||
|
const maintainers = announcement.tags |
||||||
|
.filter((t: string[]) => t[0] === 'maintainers') |
||||||
|
.flatMap((t: string[]) => t.slice(1)) |
||||||
|
.filter((m: string) => m && typeof m === 'string') as string[]; |
||||||
|
const ownerPubkey = announcement.pubkey || ''; |
||||||
|
const language = announcement.tags.find((t: string[]) => t[0] === 'language')?.[1]; |
||||||
|
const topics = announcement.tags |
||||||
|
.filter((t: string[]) => t[0] === 't' && t[1] !== 'private') |
||||||
|
.map((t: string[]) => t[1]) |
||||||
|
.filter((t: string) => t && typeof t === 'string') as string[]; |
||||||
|
const website = announcement.tags.find((t: string[]) => t[0] === 'website')?.[1]; |
||||||
|
const isPrivate = announcement.tags.some((t: string[]) => |
||||||
|
(t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private') |
||||||
|
) || false; |
||||||
|
|
||||||
|
return { |
||||||
|
name, |
||||||
|
description, |
||||||
|
cloneUrls, |
||||||
|
maintainers, |
||||||
|
ownerPubkey, |
||||||
|
language, |
||||||
|
topics, |
||||||
|
website, |
||||||
|
isPrivate |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get safe page URL for SSR |
||||||
|
*/ |
||||||
|
export function getSafePageUrl( |
||||||
|
pageData: { repoUrl?: string } | null, |
||||||
|
fallback?: () => string |
||||||
|
): string { |
||||||
|
try { |
||||||
|
if (pageData?.repoUrl && typeof pageData.repoUrl === 'string' && pageData.repoUrl.trim()) { |
||||||
|
return pageData.repoUrl; |
||||||
|
} |
||||||
|
|
||||||
|
if (typeof window === 'undefined') { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
if (fallback) { |
||||||
|
try { |
||||||
|
return fallback(); |
||||||
|
} catch { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (window?.location?.protocol && window?.location?.host && window?.location?.pathname) { |
||||||
|
return `${window.location.protocol}//${window.location.host}${window.location.pathname}`; |
||||||
|
} |
||||||
|
|
||||||
|
return ''; |
||||||
|
} catch { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get Twitter card type based on image availability |
||||||
|
*/ |
||||||
|
export function getTwitterCardType( |
||||||
|
banner?: string | null, |
||||||
|
image?: string | null |
||||||
|
): 'summary_large_image' | 'summary' { |
||||||
|
try { |
||||||
|
const hasImage = (banner && typeof banner === 'string' && banner.trim()) || |
||||||
|
(image && typeof image === 'string' && image.trim()); |
||||||
|
return hasImage ? 'summary_large_image' : 'summary'; |
||||||
|
} catch { |
||||||
|
return 'summary'; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue