Browse Source
Nostr-Signature: 533e9f7acbdd4dc16dbe304245469d57d8d37f0c0cce53b60d99719e2acf4502 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 0fad2d7c44f086ceb06ce40ea8cea2d4d002ebe8caec7d78e83483348b1404cfb6256d8d3796ebd9ae6f7866a431ec4a1abe84e417d3e238b9b554b4a32481e4main
32 changed files with 8004 additions and 2966 deletions
@ -0,0 +1,287 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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