diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl
index 4d69858..2dfa373 100644
--- a/nostr/commit-signatures.jsonl
+++ b/nostr/commit-signatures.jsonl
@@ -21,3 +21,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771604372,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"9a1ba983e0b0db8cff3675a078a376df5c9ad351c3988ea893f3e8084a65a1e6","sig":"724a326cbd6a33f1ff6a2c37b242c7571e35149281609e9eb1c6a197422a13834d9ac2f5d0719026bc66126bd0022df49adf50aa08af93dd95076f407b0f0456"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771607520,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"2040e0adbed520ee9a21c6a1c7df48fae27021c1d3474b584388cd5ddafc6a49","sig":"893b4881e3876c0f556e3be991e9c6e99c9f5933bc9755e4075c1d0bfea95750b2318f3d3409d689c7e9a862cf053db0e7d3083ee28cf48ffbe794583c3ad783"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771612082,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","harmonize user.name and user.email\nadd settings menu\nautosave OFF 10 min"]],"content":"Signed commit: harmonize user.name and user.email\nadd settings menu\nautosave OFF 10 min","id":"80834df600e5ad22f44fc26880333d28054895b7b5fde984921fab008a27ce6d","sig":"41991d089d26e3f90094dcebd1dee7504c59cadd0ea2f4dfe8693106d9000a528157fb905aec9001e0b8f3ef9e8590557f3df6961106859775d9416b546a44c0"}
+{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771612354,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove theme button from bar"]],"content":"Signed commit: remove theme button from bar","id":"fc758a0681c072108b196911bbeee6d49df1efe635d5d78427b7874be4d6e657","sig":"6c0e991e960a29c623c936ab2a31478a85907780eda692c035762deabc740ca0a76df113f5ce853a6d839b023e2b483ce2d7686c40b91c4cea5f32945799a31f"}
diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte
index 9d2524c..9f81c67 100644
--- a/src/lib/components/SettingsModal.svelte
+++ b/src/lib/components/SettingsModal.svelte
@@ -16,6 +16,7 @@
let userName = $state('');
let userEmail = $state('');
let theme = $state<'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black'>('gitrepublic-dark');
+ let defaultBranch = $state('master');
let loading = $state(true);
let saving = $state(false);
let loadingPresets = $state(false);
@@ -37,6 +38,7 @@
userName = settings.userName;
userEmail = settings.userEmail;
theme = settings.theme;
+ defaultBranch = settings.defaultBranch;
} catch (err) {
console.error('Failed to load settings:', err);
} finally {
@@ -91,7 +93,8 @@
autoSave,
userName: userName.trim() || '', // Empty string means use preset
userEmail: userEmail.trim() || '', // Empty string means use preset
- theme
+ theme,
+ defaultBranch: defaultBranch.trim() || 'master'
});
// Apply theme immediately
@@ -230,6 +233,23 @@
+
+
+
+
+
+ Default branch name to use when creating new repositories. This will be used as the base branch when creating the first branch in a new repo.
+
+
+
diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts
index f23adc3..29d05c4 100644
--- a/src/lib/services/git/file-manager.ts
+++ b/src/lib/services/git/file-manager.ts
@@ -1068,19 +1068,65 @@ export class FileManager {
}
try {
- // Use git worktree instead of cloning (much more efficient)
- const workDir = await this.getWorktree(repoPath, fromBranch, npub, repoName);
- const workGit: SimpleGit = simpleGit(workDir);
+ const git: SimpleGit = simpleGit(repoPath);
+
+ // Check if repo has any branches
+ let hasBranches = false;
+ try {
+ const branches = await git.branch(['-a']);
+ const branchList = branches.all
+ .map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, ''))
+ .filter(b => !b.includes('HEAD') && !b.startsWith('*'));
+ hasBranches = branchList.length > 0;
+ } catch {
+ // If branch listing fails, assume no branches exist
+ hasBranches = false;
+ }
+
+ // If no branches exist, create an orphan branch (branch with no parent)
+ if (!hasBranches) {
+ // Create worktree for the new branch directly (orphan branch)
+ const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`);
+ const worktreePath = resolve(join(worktreeRoot, branchName));
+ const { mkdir, rm } = await import('fs/promises');
+
+ if (!existsSync(worktreeRoot)) {
+ await mkdir(worktreeRoot, { recursive: true });
+ }
+
+ // Remove existing worktree if it exists
+ if (existsSync(worktreePath)) {
+ try {
+ await git.raw(['worktree', 'remove', worktreePath, '--force']);
+ } catch {
+ await rm(worktreePath, { recursive: true, force: true });
+ }
+ }
+
+ // Create worktree with orphan branch
+ await git.raw(['worktree', 'add', worktreePath, '--orphan', branchName]);
+
+ // Set the default branch to the new branch in the bare repo
+ await git.raw(['symbolic-ref', 'HEAD', `refs/heads/${branchName}`]);
+
+ // Clean up worktree
+ await this.removeWorktree(repoPath, worktreePath);
+ } else {
+ // Repo has branches - use normal branch creation
+ // Use git worktree instead of cloning (much more efficient)
+ const workDir = await this.getWorktree(repoPath, fromBranch, npub, repoName);
+ const workGit: SimpleGit = simpleGit(workDir);
- // Create and checkout new branch
- await workGit.checkout(['-b', branchName]);
+ // Create and checkout new branch
+ await workGit.checkout(['-b', branchName]);
- // Note: No push needed - worktrees of bare repos share the same object database,
- // so the branch is already in the bare repository. We don't push to remote origin
- // to avoid requiring remote authentication and to keep changes local-only.
+ // Note: No push needed - worktrees of bare repos share the same object database,
+ // so the branch is already in the bare repository. We don't push to remote origin
+ // to avoid requiring remote authentication and to keep changes local-only.
- // Clean up worktree
- await this.removeWorktree(repoPath, workDir);
+ // Clean up worktree
+ await this.removeWorktree(repoPath, workDir);
+ }
} catch (error) {
logger.error({ error, repoPath, branchName, npub }, 'Error creating branch');
throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`);
diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts
index 0b6a23d..a263f92 100644
--- a/src/lib/services/nostr/nostr-client.ts
+++ b/src/lib/services/nostr/nostr-client.ts
@@ -12,6 +12,32 @@ import { KIND } from '../../types/nostr.js';
// Replaceable event kinds (only latest per pubkey matters)
const REPLACEABLE_KINDS = [0, 3, 10002]; // Profile, Contacts, Relay List
+/**
+ * Check if an event is a parameterized replaceable event (NIP-33)
+ * Parameterized replaceable events have kind >= 10000 && kind < 20000 and a 'd' tag
+ */
+function isParameterizedReplaceable(event: NostrEvent): boolean {
+ return event.kind >= 10000 && event.kind < 20000 &&
+ event.tags.some(t => t[0] === 'd' && t[1]);
+}
+
+/**
+ * Get the deduplication key for an event
+ * For replaceable events: kind:pubkey
+ * For parameterized replaceable events: kind:pubkey:d-tag
+ * For regular events: event.id
+ */
+function getDeduplicationKey(event: NostrEvent): string {
+ if (REPLACEABLE_KINDS.includes(event.kind)) {
+ return `${event.kind}:${event.pubkey}`;
+ }
+ if (isParameterizedReplaceable(event)) {
+ const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
+ return `${event.kind}:${event.pubkey}:${dTag}`;
+ }
+ return event.id;
+}
+
// Lazy load persistent cache (only in browser)
let persistentEventCache: typeof import('./persistent-event-cache.js').persistentEventCache | null = null;
async function getPersistentCache() {
@@ -146,13 +172,196 @@ async function createWebSocketWithTor(url: string): Promise
{
}
}
+// Connection pool for WebSocket connections
+interface RelayConnection {
+ ws: WebSocket;
+ lastUsed: number;
+ pendingRequests: number;
+ reconnectAttempts: number;
+ messageHandlers: Map void>; // subscription ID -> handler
+ nextSubscriptionId: number;
+}
+
export class NostrClient {
private relays: string[] = [];
private authenticatedRelays: Set = new Set();
private processingDeletions: boolean = false; // Guard to prevent recursive deletion processing
+ private connectionPool: Map = new Map();
+ private readonly CONNECTION_TIMEOUT = 30000; // Close idle connections after 30 seconds
+ private readonly MAX_RECONNECT_ATTEMPTS = 3;
+ private readonly RECONNECT_DELAY = 2000; // 2 seconds between reconnect attempts
+ private connectionAttempts: Map = new Map();
+ private readonly MAX_CONCURRENT_CONNECTIONS = 3; // Max concurrent connections per relay
+ private readonly CONNECTION_BACKOFF_BASE = 1000; // Base backoff in ms
constructor(relays: string[]) {
this.relays = relays;
+ // Clean up idle connections periodically
+ if (typeof window !== 'undefined') {
+ setInterval(() => this.cleanupIdleConnections(), 10000); // Check every 10 seconds
+ }
+ }
+
+ /**
+ * Clean up idle connections that haven't been used recently
+ */
+ private cleanupIdleConnections(): void {
+ const now = Date.now();
+ for (const [relay, conn] of this.connectionPool.entries()) {
+ // Close connections that are idle and have no pending requests
+ if (conn.pendingRequests === 0 &&
+ now - conn.lastUsed > this.CONNECTION_TIMEOUT &&
+ (conn.ws.readyState === WebSocket.OPEN || conn.ws.readyState === WebSocket.CLOSED)) {
+ try {
+ if (conn.ws.readyState === WebSocket.OPEN) {
+ conn.ws.close();
+ }
+ } catch {
+ // Ignore errors
+ }
+ this.connectionPool.delete(relay);
+ }
+ }
+ }
+
+ /**
+ * Get or create a WebSocket connection to a relay
+ */
+ private async getConnection(relay: string): Promise {
+ const existing = this.connectionPool.get(relay);
+
+ // Reuse existing connection if it's open
+ if (existing && existing.ws.readyState === WebSocket.OPEN) {
+ existing.lastUsed = Date.now();
+ existing.pendingRequests++;
+ return existing.ws;
+ }
+
+ // Check connection attempt throttling
+ const attemptInfo = this.connectionAttempts.get(relay) || { count: 0, lastAttempt: 0 };
+ const now = Date.now();
+ const timeSinceLastAttempt = now - attemptInfo.lastAttempt;
+
+ // If we've had too many recent failures, apply exponential backoff
+ if (attemptInfo.count > 0) {
+ const backoffTime = this.CONNECTION_BACKOFF_BASE * Math.pow(2, Math.min(attemptInfo.count - 1, 5));
+ if (timeSinceLastAttempt < backoffTime) {
+ logger.debug({ relay, backoffTime, timeSinceLastAttempt }, 'Throttling connection attempt');
+ return null; // Don't attempt connection yet
+ }
+ }
+
+ // Check if we have too many concurrent connections to this relay
+ const openConnections = Array.from(this.connectionPool.values())
+ .filter(c => c.ws === existing?.ws || (c.ws.readyState === WebSocket.OPEN || c.ws.readyState === WebSocket.CONNECTING))
+ .length;
+
+ if (openConnections >= this.MAX_CONCURRENT_CONNECTIONS) {
+ logger.debug({ relay, openConnections }, 'Too many concurrent connections, skipping');
+ return null;
+ }
+
+ // Remove dead connection
+ if (existing) {
+ this.connectionPool.delete(relay);
+ try {
+ if (existing.ws.readyState !== WebSocket.CLOSED) {
+ existing.ws.close();
+ }
+ } catch {
+ // Ignore errors
+ }
+ }
+
+ // Update attempt tracking
+ this.connectionAttempts.set(relay, { count: attemptInfo.count + 1, lastAttempt: now });
+
+ // Create new connection
+ try {
+ const ws = await createWebSocketWithTor(relay);
+ const conn: RelayConnection = {
+ ws,
+ lastUsed: Date.now(),
+ pendingRequests: 1,
+ reconnectAttempts: 0,
+ messageHandlers: new Map(),
+ nextSubscriptionId: 1
+ };
+
+ // Set up shared message handler for routing
+ ws.onmessage = (event: MessageEvent) => {
+ try {
+ const message = JSON.parse(event.data);
+
+ // Route to appropriate handler based on message type
+ if (message[0] === 'EVENT' && message[1]) {
+ // message[1] is the subscription ID
+ const handler = conn.messageHandlers.get(message[1]);
+ if (handler) {
+ handler(message);
+ }
+ } else if (message[0] === 'EOSE' && message[1]) {
+ // message[1] is the subscription ID
+ const handler = conn.messageHandlers.get(message[1]);
+ if (handler) {
+ handler(message);
+ }
+ } else if (message[0] === 'AUTH') {
+ // AUTH challenge - broadcast to all handlers (they'll handle it)
+ for (const handler of conn.messageHandlers.values()) {
+ handler(message);
+ }
+ } else if (message[0] === 'OK' && message[1] === 'auth') {
+ // AUTH response - broadcast to all handlers
+ for (const handler of conn.messageHandlers.values()) {
+ handler(message);
+ }
+ }
+ } catch (error) {
+ // Ignore parse errors
+ }
+ };
+
+ // Handle connection close/error
+ ws.onclose = () => {
+ // Remove from pool when closed
+ const poolConn = this.connectionPool.get(relay);
+ if (poolConn && poolConn.ws === ws) {
+ this.connectionPool.delete(relay);
+ }
+ };
+
+ ws.onerror = () => {
+ // Remove from pool on error
+ const poolConn = this.connectionPool.get(relay);
+ if (poolConn && poolConn.ws === ws) {
+ this.connectionPool.delete(relay);
+ }
+ };
+
+ this.connectionPool.set(relay, conn);
+
+ // Reset attempt count on successful connection
+ ws.onopen = () => {
+ this.connectionAttempts.set(relay, { count: 0, lastAttempt: Date.now() });
+ };
+
+ return ws;
+ } catch (error) {
+ logger.debug({ error, relay }, 'Failed to create WebSocket connection');
+ return null;
+ }
+ }
+
+ /**
+ * Release a connection (decrement pending requests counter)
+ */
+ private releaseConnection(relay: string): void {
+ const conn = this.connectionPool.get(relay);
+ if (conn) {
+ conn.pendingRequests = Math.max(0, conn.pendingRequests - 1);
+ conn.lastUsed = Date.now();
+ }
}
/**
@@ -281,43 +490,53 @@ export class NostrClient {
}
}
- // Merge with existing events - never delete valid events
+ // Merge with existing events - handle replaceable and parameterized replaceable events
+ // Map: deduplication key -> latest event
const eventMap = new Map();
+ const eventsToDelete = new Set(); // Event IDs to delete from cache
- // Add existing events first
+ // Add existing events first, indexed by deduplication key
for (const event of existingEvents) {
- eventMap.set(event.id, event);
+ const key = getDeduplicationKey(event);
+ const existing = eventMap.get(key);
+ // Keep the newest if there are duplicates
+ if (!existing || event.created_at > existing.created_at) {
+ if (existing) {
+ eventsToDelete.add(existing.id); // Mark older event for deletion
+ }
+ eventMap.set(key, event);
+ } else {
+ eventsToDelete.add(event.id); // This one is older
+ }
}
// Add/update with new events from relays
- // For replaceable events (kind 0, 3, 10002), use latest per pubkey
- const replaceableEvents = new Map(); // pubkey -> latest event
-
for (const event of events) {
- if (REPLACEABLE_KINDS.includes(event.kind)) {
- // Replaceable event - only keep latest per pubkey
- const existing = replaceableEvents.get(event.pubkey);
- if (!existing || event.created_at > existing.created_at) {
- replaceableEvents.set(event.pubkey, event);
+ const key = getDeduplicationKey(event);
+ const existing = eventMap.get(key);
+
+ if (!existing || event.created_at > existing.created_at) {
+ // New event is newer (or first occurrence)
+ if (existing) {
+ eventsToDelete.add(existing.id); // Mark older event for deletion
}
+ eventMap.set(key, event);
} else {
- // Regular event - add if newer or doesn't exist
- const existing = eventMap.get(event.id);
- if (!existing || event.created_at > existing.created_at) {
- eventMap.set(event.id, event);
- }
+ // Existing event is newer, mark this one for deletion
+ eventsToDelete.add(event.id);
}
}
- // Add replaceable events to the map (replacing older versions)
- for (const [pubkey, event] of replaceableEvents.entries()) {
- // Remove any existing replaceable events for this pubkey
- for (const [id, existingEvent] of eventMap.entries()) {
- if (existingEvent.pubkey === pubkey && REPLACEABLE_KINDS.includes(existingEvent.kind)) {
- eventMap.delete(id);
+ // Remove events that should be deleted
+ for (const eventId of eventsToDelete) {
+ eventMap.delete(eventId); // Remove by ID if it was keyed by ID
+ // Also remove from map if it's keyed by deduplication key
+ for (const [key, event] of eventMap.entries()) {
+ if (event.id === eventId) {
+ eventMap.delete(key);
+ break;
}
}
- eventMap.set(event.id, event);
}
const finalEvents = Array.from(eventMap.values());
@@ -328,6 +547,15 @@ export class NostrClient {
// Get persistent cache once (if available)
const persistentCache = await getPersistentCache();
+ // Delete older events from cache if we have newer ones
+ if (persistentCache && eventsToDelete.size > 0) {
+ for (const eventId of eventsToDelete) {
+ persistentCache.deleteEvent(eventId).catch((err: unknown) => {
+ logger.debug({ error: err, eventId }, 'Failed to delete old event from cache');
+ });
+ }
+ }
+
// Cache in persistent cache (has built-in in-memory layer)
// For kind 0 (profile) events, also cache individually by pubkey
const profileEvents = finalEvents.filter(e => e.kind === 0);
@@ -444,6 +672,7 @@ export class NostrClient {
let timeoutId: ReturnType | null = null;
let connectionTimeoutId: ReturnType | null = null;
let authHandled = false;
+ let isNewConnection = false;
const cleanup = () => {
if (timeoutId) {
@@ -454,12 +683,17 @@ export class NostrClient {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
- if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
+ // Only close if it's a new connection we created (not from pool)
+ // Pool connections are managed separately
+ if (isNewConnection && ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
try {
ws.close();
} catch {
// Ignore errors during cleanup
}
+ } else {
+ // Release connection back to pool
+ self.releaseConnection(relay);
}
};
@@ -473,93 +707,168 @@ export class NostrClient {
let authPromise: Promise | null = null;
- // Create WebSocket connection (with Tor support if needed)
- createWebSocketWithTor(relay).then(websocket => {
+ // Get connection from pool or create new one
+ this.getConnection(relay).then(websocket => {
+ if (!websocket) {
+ resolveOnce([]);
+ return;
+ }
ws = websocket;
+ isNewConnection = false; // From pool
setupWebSocketHandlers();
}).catch(error => {
- // Connection failed immediately
- resolveOnce([]);
+ // Connection failed, try creating new one
+ createWebSocketWithTor(relay).then(websocket => {
+ ws = websocket;
+ isNewConnection = true; // New connection
+ setupWebSocketHandlers();
+ }).catch(err => {
+ // Connection failed immediately
+ resolveOnce([]);
+ });
});
function setupWebSocketHandlers() {
if (!ws) return;
+ const conn = self.connectionPool.get(relay);
+ if (!conn) {
+ resolveOnce([]);
+ return;
+ }
+
+ // Get unique subscription ID for this request
+ const subscriptionId = `sub${conn.nextSubscriptionId++}`;
+
// Connection timeout - if we can't connect within 3 seconds, give up
connectionTimeoutId = setTimeout(() => {
if (!resolved && ws && ws.readyState !== WebSocket.OPEN) {
+ conn.messageHandlers.delete(subscriptionId);
resolveOnce([]);
}
}, 3000);
- ws.onopen = () => {
- if (connectionTimeoutId) {
- clearTimeout(connectionTimeoutId);
- connectionTimeoutId = null;
- }
- // Connection opened, wait for AUTH challenge or proceed
- // If no AUTH challenge comes within 1 second, send REQ
- setTimeout(() => {
- if (!authHandled && ws && ws.readyState === WebSocket.OPEN) {
- try {
- ws.send(JSON.stringify(['REQ', 'sub', ...filters]));
- } catch {
- // Connection might have closed
- resolveOnce(events);
+ // Set up message handler for this subscription
+ const messageHandler = async (message: any) => {
+ try {
+ // Handle AUTH challenge
+ if (message[0] === 'AUTH' && message[1] && !authHandled) {
+ authHandled = true;
+ authPromise = self.handleAuthChallenge(ws!, relay, message[1]);
+ const authenticated = await authPromise;
+ // After authentication, send the REQ
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ try {
+ ws.send(JSON.stringify(['REQ', subscriptionId, ...filters]));
+ } catch {
+ conn.messageHandlers.delete(subscriptionId);
+ resolveOnce(events);
+ }
+ }
+ return;
+ }
+
+ // Handle AUTH OK response
+ if (message[0] === 'OK' && message[1] === 'auth' && ws) {
+ // AUTH completed, send REQ if not already sent
+ if (ws.readyState === WebSocket.OPEN && !authHandled) {
+ setTimeout(() => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ try {
+ ws.send(JSON.stringify(['REQ', subscriptionId, ...filters]));
+ } catch {
+ conn.messageHandlers.delete(subscriptionId);
+ resolveOnce(events);
+ }
+ }
+ }, 100);
+ }
+ return;
+ }
+
+ // Wait for auth to complete before processing other messages
+ if (authPromise) {
+ await authPromise;
}
+
+ // Only process messages for this subscription
+ if (message[1] === subscriptionId) {
+ if (message[0] === 'EVENT') {
+ events.push(message[2]);
+ } else if (message[0] === 'EOSE') {
+ conn.messageHandlers.delete(subscriptionId);
+ resolveOnce(events);
+ }
+ }
+ } catch (error) {
+ // Ignore parse errors, continue receiving events
}
- }, 1000);
- };
-
- ws.onmessage = async (event: MessageEvent) => {
- try {
- const message = JSON.parse(event.data);
-
- // Handle AUTH challenge
- if (message[0] === 'AUTH' && message[1] && !authHandled) {
- authHandled = true;
- authPromise = self.handleAuthChallenge(ws!, relay, message[1]);
- const authenticated = await authPromise;
- // After authentication, send the REQ
- if (ws && ws.readyState === WebSocket.OPEN) {
+ };
+
+ conn.messageHandlers.set(subscriptionId, messageHandler);
+
+ // If connection is already open, send REQ immediately
+ if (ws.readyState === WebSocket.OPEN) {
+ // Wait a bit for AUTH challenge if needed
+ setTimeout(() => {
+ if (!authHandled && ws && ws.readyState === WebSocket.OPEN) {
try {
- ws.send(JSON.stringify(['REQ', 'sub', ...filters]));
+ ws.send(JSON.stringify(['REQ', subscriptionId, ...filters]));
} catch {
+ conn.messageHandlers.delete(subscriptionId);
resolveOnce(events);
}
}
- return;
- }
-
- // Wait for auth to complete before processing other messages
- if (authPromise) {
- await authPromise;
- }
-
- if (message[0] === 'EVENT') {
- events.push(message[2]);
- } else if (message[0] === 'EOSE') {
- resolveOnce(events);
- }
- } catch (error) {
- // Ignore parse errors, continue receiving events
- }
- };
-
- ws.onerror = () => {
- // Silently handle connection errors - some relays may be down
- // Don't log or reject, just resolve with empty results
- if (!resolved) {
- resolveOnce([]);
+ }, 1000);
+ } else {
+ // Wait for connection to open
+ ws.onopen = () => {
+ if (connectionTimeoutId) {
+ clearTimeout(connectionTimeoutId);
+ connectionTimeoutId = null;
+ }
+ // Connection opened, wait for AUTH challenge or proceed
+ // If no AUTH challenge comes within 1 second, send REQ
+ setTimeout(() => {
+ if (!authHandled && ws && ws.readyState === WebSocket.OPEN) {
+ try {
+ ws.send(JSON.stringify(['REQ', subscriptionId, ...filters]));
+ } catch {
+ conn.messageHandlers.delete(subscriptionId);
+ resolveOnce(events);
+ }
+ }
+ }, 1000);
+ };
}
- };
+
+ // Error and close handlers are set on the connection itself
+ // But we need to clean up our handler
+ if (ws) {
+ const wsRef = ws; // Capture for closure
+ const originalOnError = ws.onerror;
+ ws.onerror = () => {
+ conn.messageHandlers.delete(subscriptionId);
+ if (originalOnError) {
+ originalOnError.call(wsRef, new Event('error'));
+ }
+ if (!resolved) {
+ resolveOnce([]);
+ }
+ };
- ws.onclose = () => {
- // If we haven't resolved yet, resolve with what we have
- if (!resolved) {
- resolveOnce(events);
+ const originalOnClose = ws.onclose;
+ ws.onclose = () => {
+ conn.messageHandlers.delete(subscriptionId);
+ if (originalOnClose) {
+ originalOnClose.call(wsRef, new CloseEvent('close'));
+ }
+ // If we haven't resolved yet, resolve with what we have
+ if (!resolved) {
+ resolveOnce(events);
+ }
+ };
}
- };
// Overall timeout - resolve with what we have after 8 seconds
timeoutId = setTimeout(() => {
diff --git a/src/lib/services/nostr/persistent-event-cache.ts b/src/lib/services/nostr/persistent-event-cache.ts
index 1610b62..1858089 100644
--- a/src/lib/services/nostr/persistent-event-cache.ts
+++ b/src/lib/services/nostr/persistent-event-cache.ts
@@ -24,6 +24,32 @@ const STORE_PROFILES = 'profiles'; // Optimized storage for kind 0 events
// Replaceable event kinds (only latest per pubkey matters)
const REPLACEABLE_KINDS = [0, 3, 10002]; // Profile, Contacts, Relay List
+/**
+ * Check if an event is a parameterized replaceable event (NIP-33)
+ * Parameterized replaceable events have kind >= 10000 && kind < 20000 and a 'd' tag
+ */
+function isParameterizedReplaceable(event: NostrEvent): boolean {
+ return event.kind >= 10000 && event.kind < 20000 &&
+ event.tags.some(t => t[0] === 'd' && t[1]);
+}
+
+/**
+ * Get the deduplication key for an event
+ * For replaceable events: kind:pubkey
+ * For parameterized replaceable events: kind:pubkey:d-tag
+ * For regular events: event.id
+ */
+function getDeduplicationKey(event: NostrEvent): string {
+ if (REPLACEABLE_KINDS.includes(event.kind)) {
+ return `${event.kind}:${event.pubkey}`;
+ }
+ if (isParameterizedReplaceable(event)) {
+ const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
+ return `${event.kind}:${event.pubkey}:${dTag}`;
+ }
+ return event.id;
+}
+
interface CachedEvent {
event: NostrEvent;
cachedAt: number;
@@ -279,22 +305,18 @@ export class PersistentEventCache {
}
}
- // For replaceable events, ensure we only return the latest per pubkey
- const replaceableEvents = new Map();
- const regularEvents: NostrEvent[] = [];
+ // For replaceable and parameterized replaceable events, ensure we only return the latest per deduplication key
+ const deduplicatedEvents = new Map(); // deduplication key -> latest event
for (const event of events) {
- if (REPLACEABLE_KINDS.includes(event.kind)) {
- const existing = replaceableEvents.get(event.pubkey);
- if (!existing || event.created_at > existing.created_at) {
- replaceableEvents.set(event.pubkey, event);
- }
- } else {
- regularEvents.push(event);
+ const key = getDeduplicationKey(event);
+ const existing = deduplicatedEvents.get(key);
+ if (!existing || event.created_at > existing.created_at) {
+ deduplicatedEvents.set(key, event);
}
}
- const result = [...Array.from(replaceableEvents.values()), ...regularEvents];
+ const result = Array.from(deduplicatedEvents.values());
// Sort by created_at descending
result.sort((a, b) => b.created_at - a.created_at);
@@ -501,25 +523,81 @@ export class PersistentEventCache {
const profileStore = transaction.objectStore(STORE_PROFILES);
const filterStore = transaction.objectStore(STORE_FILTERS);
- const newEventIds: string[] = [];
+ let newEventIds: string[] = [];
+ const eventsToDelete = new Set();
- // Process all events in the transaction
+ // Group events by deduplication key to find the newest per key
+ const eventsByKey = new Map();
for (const event of events) {
- // For replaceable events, check if we have a newer version for this pubkey
- if (REPLACEABLE_KINDS.includes(event.kind)) {
- // Check if we already have a newer replaceable event for this pubkey
- // Use the same transaction instead of calling getProfile (which creates a new transaction)
- const existingProfile = await new Promise((resolve) => {
- const req = profileStore.get(event.pubkey);
- req.onsuccess = () => resolve(req.result);
- req.onerror = () => resolve(undefined);
- });
+ const key = getDeduplicationKey(event);
+ const existing = eventsByKey.get(key);
+ if (!existing || event.created_at > existing.created_at) {
+ if (existing) {
+ eventsToDelete.add(existing.id); // Mark older version for deletion
+ }
+ eventsByKey.set(key, event);
+ } else {
+ eventsToDelete.add(event.id); // This one is older
+ }
+ }
+
+ // Check existing events in cache for same deduplication keys and mark older ones for deletion
+ for (const eventId of existingEventIds) {
+ const existingEventRequest = eventStore.get(eventId);
+ const existingCached = await new Promise((resolve) => {
+ existingEventRequest.onsuccess = () => resolve(existingEventRequest.result);
+ existingEventRequest.onerror = () => resolve(undefined);
+ });
+
+ if (existingCached) {
+ const existingEvent = existingCached.event;
+ const key = getDeduplicationKey(existingEvent);
+ const newEvent = eventsByKey.get(key);
- if (existingProfile && existingProfile.event.kind === event.kind && existingProfile.event.created_at >= event.created_at) {
- // Existing event is newer or same, skip
- if (existingEventIds.has(existingProfile.event.id)) {
- newEventIds.push(existingProfile.event.id);
+ // If we have a newer event with the same key, mark the old one for deletion
+ if (newEvent && newEvent.id !== existingEvent.id && newEvent.created_at > existingEvent.created_at) {
+ eventsToDelete.add(existingEvent.id);
+ }
+ }
+ }
+
+ // Process all events in the transaction (only the newest per deduplication key)
+ for (const event of Array.from(eventsByKey.values())) {
+ const key = getDeduplicationKey(event);
+
+ // For replaceable events (kind 0, 3, 10002), check profile store (only kind 0 uses it, but check all)
+ if (REPLACEABLE_KINDS.includes(event.kind)) {
+ // For kind 0, check profile store
+ if (event.kind === 0) {
+ const existingProfile = await new Promise((resolve) => {
+ const req = profileStore.get(event.pubkey);
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => resolve(undefined);
+ });
+
+ if (existingProfile && existingProfile.event.kind === event.kind && existingProfile.event.created_at >= event.created_at) {
+ // Existing event is newer or same, skip
+ if (existingEventIds.has(existingProfile.event.id)) {
+ newEventIds.push(existingProfile.event.id);
+ }
+ // Mark this one for deletion if it's different
+ if (existingProfile.event.id !== event.id) {
+ eventsToDelete.add(event.id);
+ }
+ continue;
+ }
+ } else {
+ // For kind 3 and 10002, check if we already have a newer one in events store
+ // We already checked above, so just continue if it's already in existingEventIds
+ if (existingEventIds.has(event.id)) {
+ newEventIds.push(event.id);
+ continue;
}
+ }
+ } else if (isParameterizedReplaceable(event)) {
+ // For parameterized replaceable events, check if we already have this event
+ if (existingEventIds.has(event.id)) {
+ newEventIds.push(event.id);
continue;
}
} else {
@@ -604,8 +682,42 @@ export class PersistentEventCache {
}
}
- // Merge with existing event IDs (don't delete valid events)
- const mergedEventIds = Array.from(new Set([...existingEntry?.eventIds || [], ...newEventIds]));
+ // Delete older events that have been superseded
+ for (const eventId of eventsToDelete) {
+ try {
+ await new Promise((resolve, reject) => {
+ const req = eventStore.delete(eventId);
+ req.onsuccess = () => resolve();
+ req.onerror = () => reject(req.error);
+ });
+ // Remove from existing event IDs if present
+ existingEventIds.delete(eventId);
+ newEventIds = newEventIds.filter(id => id !== eventId);
+
+ // Also remove from profile store if it's a kind 0 event
+ const deleteProfileRequest = profileStore.openCursor();
+ await new Promise((resolve) => {
+ deleteProfileRequest.onsuccess = (event) => {
+ const cursor = (event.target as IDBRequest).result;
+ if (cursor) {
+ const cached = cursor.value as CachedEvent;
+ if (cached.event.id === eventId) {
+ cursor.delete();
+ }
+ cursor.continue();
+ } else {
+ resolve();
+ }
+ };
+ deleteProfileRequest.onerror = () => resolve();
+ });
+ } catch (error) {
+ logger.debug({ error, eventId }, 'Failed to delete old event from cache');
+ }
+ }
+
+ // Merge with existing event IDs (excluding deleted ones)
+ const mergedEventIds = Array.from(new Set([...existingEntry?.eventIds.filter(id => !eventsToDelete.has(id)) || [], ...newEventIds]));
// Update filter cache entry (using same transaction)
const filterEntry: FilterCacheEntry = {
@@ -1262,6 +1374,59 @@ export class PersistentEventCache {
throw error;
}
}
+
+ /**
+ * Delete a single event from the cache by event ID
+ */
+ async deleteEvent(eventId: string): Promise {
+ await this.init();
+
+ if (!this.db) {
+ return;
+ }
+
+ try {
+ const transaction = this.db.transaction([STORE_EVENTS, STORE_FILTERS], 'readwrite');
+ const eventStore = transaction.objectStore(STORE_EVENTS);
+ const filterStore = transaction.objectStore(STORE_FILTERS);
+
+ // Delete from events store
+ await new Promise((resolve, reject) => {
+ const req = eventStore.delete(eventId);
+ req.onsuccess = () => resolve();
+ req.onerror = () => reject(req.error);
+ });
+
+ // Remove from all filter entries that reference this event
+ const filterCursor = filterStore.openCursor();
+ await new Promise((resolve, reject) => {
+ filterCursor.onsuccess = (evt) => {
+ const cursor = (evt.target as IDBRequest).result;
+ if (cursor) {
+ const filterEntry = cursor.value;
+ if (filterEntry.eventIds && filterEntry.eventIds.includes(eventId)) {
+ filterEntry.eventIds = filterEntry.eventIds.filter((id: string) => id !== eventId);
+ cursor.update(filterEntry);
+ }
+ cursor.continue();
+ } else {
+ resolve();
+ }
+ };
+ filterCursor.onerror = () => reject(filterCursor.error);
+ });
+
+ // Also remove from memory cache
+ for (const [filterKey, cacheEntry] of this.memoryCache.entries()) {
+ const index = cacheEntry.events.findIndex(e => e.id === eventId);
+ if (index !== -1) {
+ cacheEntry.events.splice(index, 1);
+ }
+ }
+ } catch (error) {
+ logger.debug({ error, eventId }, 'Error deleting event from cache');
+ }
+ }
}
// Singleton instance
diff --git a/src/lib/services/settings-store.ts b/src/lib/services/settings-store.ts
index bda77f5..58b8038 100644
--- a/src/lib/services/settings-store.ts
+++ b/src/lib/services/settings-store.ts
@@ -14,13 +14,15 @@ interface Settings {
userName: string;
userEmail: string;
theme: 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black';
+ defaultBranch: string;
}
const DEFAULT_SETTINGS: Settings = {
autoSave: false,
userName: '',
userEmail: '',
- theme: 'gitrepublic-dark'
+ theme: 'gitrepublic-dark',
+ defaultBranch: 'master'
};
export class SettingsStore {
diff --git a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts
index dc3018e..6f24f7c 100644
--- a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts
+++ b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts
@@ -140,7 +140,18 @@ export const POST: RequestHandler = createRepoPostHandler(
}
// Get default branch if fromBranch not provided
- const sourceBranch = fromBranch || await fileManager.getDefaultBranch(context.npub, context.repo);
+ // If repo has no branches, use 'master' as default
+ let sourceBranch = fromBranch;
+ if (!sourceBranch) {
+ try {
+ sourceBranch = await fileManager.getDefaultBranch(context.npub, context.repo);
+ } catch (err) {
+ // If getDefaultBranch fails (e.g., no branches exist), use 'master' as default
+ logger.debug({ error: err, npub: context.npub, repo: context.repo }, 'No default branch found, using master');
+ sourceBranch = 'master';
+ }
+ }
+
await fileManager.createBranch(context.npub, context.repo, branchName, sourceBranch);
return json({ success: true, message: 'Branch created successfully' });
},
diff --git a/src/routes/docs/+page.svelte b/src/routes/docs/+page.svelte
index 29ea25b..03473bf 100644
--- a/src/routes/docs/+page.svelte
+++ b/src/routes/docs/+page.svelte
@@ -30,7 +30,56 @@
}
});
- content = md.render(docContent);
+ let rendered = md.render(docContent);
+
+ // Add IDs to headings for anchor links
+ rendered = rendered.replace(/(.*?)<\/h[1-6]>/g, (match, level, text) => {
+ // Extract text content (remove any HTML tags)
+ const textContent = text.replace(/<[^>]*>/g, '').trim();
+ // Create slug from text
+ const slug = textContent
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '') // Remove special characters
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
+ .replace(/-+/g, '-') // Replace multiple hyphens with single
+ .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
+
+ return `${text}`;
+ });
+
+ content = rendered;
+
+ // Handle anchor links after content is rendered
+ setTimeout(() => {
+ // Handle initial hash in URL
+ if (window.location.hash) {
+ const id = window.location.hash.substring(1);
+ const element = document.getElementById(id);
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ }
+
+ // Handle clicks on anchor links
+ const markdownContent = document.querySelector('.markdown-content');
+ if (markdownContent) {
+ markdownContent.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+ if (target.tagName === 'A' && target.getAttribute('href')?.startsWith('#')) {
+ const id = target.getAttribute('href')?.substring(1);
+ if (id) {
+ const element = document.getElementById(id);
+ if (element) {
+ e.preventDefault();
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ // Update URL without scrolling
+ window.history.pushState(null, '', `#${id}`);
+ }
+ }
+ }
+ });
+ }
+ }, 100);
} else {
error = $page.data.error || 'Failed to load documentation';
}
@@ -81,6 +130,7 @@
border-bottom: 2px solid var(--border-color);
padding-bottom: 0.5rem;
color: var(--text-primary);
+ scroll-margin-top: 1rem;
}
:global(.markdown-content h2) {
@@ -88,6 +138,7 @@
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: var(--text-primary);
+ scroll-margin-top: 1rem;
}
:global(.markdown-content h3) {
@@ -95,6 +146,24 @@
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
+ scroll-margin-top: 1rem;
+ }
+
+ :global(.markdown-content h4) {
+ scroll-margin-top: 1rem;
+ }
+
+ :global(.markdown-content h5) {
+ scroll-margin-top: 1rem;
+ }
+
+ :global(.markdown-content h6) {
+ scroll-margin-top: 1rem;
+ }
+
+ /* Smooth scrolling for anchor links */
+ :global(.markdown-content) {
+ scroll-behavior: smooth;
}
:global(.markdown-content code) {
diff --git a/src/routes/docs/nip34/+page.svelte b/src/routes/docs/nip34/+page.svelte
index baebff7..d793178 100644
--- a/src/routes/docs/nip34/+page.svelte
+++ b/src/routes/docs/nip34/+page.svelte
@@ -30,7 +30,56 @@
}
});
- content = md.render(docContent);
+ let rendered = md.render(docContent);
+
+ // Add IDs to headings for anchor links
+ rendered = rendered.replace(/(.*?)<\/h[1-6]>/g, (match, level, text) => {
+ // Extract text content (remove any HTML tags)
+ const textContent = text.replace(/<[^>]*>/g, '').trim();
+ // Create slug from text
+ const slug = textContent
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '') // Remove special characters
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
+ .replace(/-+/g, '-') // Replace multiple hyphens with single
+ .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
+
+ return `${text}`;
+ });
+
+ content = rendered;
+
+ // Handle anchor links after content is rendered
+ setTimeout(() => {
+ // Handle initial hash in URL
+ if (window.location.hash) {
+ const id = window.location.hash.substring(1);
+ const element = document.getElementById(id);
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ }
+
+ // Handle clicks on anchor links
+ const markdownContent = document.querySelector('.markdown-content');
+ if (markdownContent) {
+ markdownContent.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+ if (target.tagName === 'A' && target.getAttribute('href')?.startsWith('#')) {
+ const id = target.getAttribute('href')?.substring(1);
+ if (id) {
+ const element = document.getElementById(id);
+ if (element) {
+ e.preventDefault();
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ // Update URL without scrolling
+ window.history.pushState(null, '', `#${id}`);
+ }
+ }
+ }
+ });
+ }
+ }, 100);
} else {
error = $page.data.error || 'Failed to load NIP-34 documentation';
}
@@ -87,6 +136,7 @@
border-bottom: 2px solid var(--border-color);
padding-bottom: 0.5rem;
color: var(--text-primary);
+ scroll-margin-top: 1rem;
}
:global(.markdown-content h2) {
@@ -94,6 +144,7 @@
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: var(--text-primary);
+ scroll-margin-top: 1rem;
}
:global(.markdown-content h3) {
@@ -101,6 +152,24 @@
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
+ scroll-margin-top: 1rem;
+ }
+
+ :global(.markdown-content h4) {
+ scroll-margin-top: 1rem;
+ }
+
+ :global(.markdown-content h5) {
+ scroll-margin-top: 1rem;
+ }
+
+ :global(.markdown-content h6) {
+ scroll-margin-top: 1rem;
+ }
+
+ /* Smooth scrolling for anchor links */
+ :global(.markdown-content) {
+ scroll-behavior: smooth;
}
:global(.markdown-content code) {
diff --git a/src/routes/docs/nip34/spec/+page.svelte b/src/routes/docs/nip34/spec/+page.svelte
index 23c360a..2eeda09 100644
--- a/src/routes/docs/nip34/spec/+page.svelte
+++ b/src/routes/docs/nip34/spec/+page.svelte
@@ -30,7 +30,56 @@
}
});
- content = md.render(docContent);
+ let rendered = md.render(docContent);
+
+ // Add IDs to headings for anchor links
+ rendered = rendered.replace(/(.*?)<\/h[1-6]>/g, (match, level, text) => {
+ // Extract text content (remove any HTML tags)
+ const textContent = text.replace(/<[^>]*>/g, '').trim();
+ // Create slug from text
+ const slug = textContent
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '') // Remove special characters
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
+ .replace(/-+/g, '-') // Replace multiple hyphens with single
+ .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
+
+ return `${text}`;
+ });
+
+ content = rendered;
+
+ // Handle anchor links after content is rendered
+ setTimeout(() => {
+ // Handle initial hash in URL
+ if (window.location.hash) {
+ const id = window.location.hash.substring(1);
+ const element = document.getElementById(id);
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ }
+
+ // Handle clicks on anchor links
+ const markdownContent = document.querySelector('.markdown-content');
+ if (markdownContent) {
+ markdownContent.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+ if (target.tagName === 'A' && target.getAttribute('href')?.startsWith('#')) {
+ const id = target.getAttribute('href')?.substring(1);
+ if (id) {
+ const element = document.getElementById(id);
+ if (element) {
+ e.preventDefault();
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ // Update URL without scrolling
+ window.history.pushState(null, '', `#${id}`);
+ }
+ }
+ }
+ });
+ }
+ }, 100);
} else {
error = $page.data.error || 'Failed to load NIP-34 specification';
}
@@ -87,6 +136,7 @@
border-bottom: 2px solid var(--border-color);
padding-bottom: 0.5rem;
color: var(--text-primary);
+ scroll-margin-top: 1rem;
}
:global(.markdown-content h2) {
@@ -94,6 +144,7 @@
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: var(--text-primary);
+ scroll-margin-top: 1rem;
}
:global(.markdown-content h3) {
@@ -101,6 +152,24 @@
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
+ scroll-margin-top: 1rem;
+ }
+
+ :global(.markdown-content h4) {
+ scroll-margin-top: 1rem;
+ }
+
+ :global(.markdown-content h5) {
+ scroll-margin-top: 1rem;
+ }
+
+ :global(.markdown-content h6) {
+ scroll-margin-top: 1rem;
+ }
+
+ /* Smooth scrolling for anchor links */
+ :global(.markdown-content) {
+ scroll-behavior: smooth;
}
:global(.markdown-content code) {
diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte
index a95274f..4309af3 100644
--- a/src/routes/repos/[npub]/[repo]/+page.svelte
+++ b/src/routes/repos/[npub]/[repo]/+page.svelte
@@ -193,6 +193,7 @@
let showCreateBranchDialog = $state(false);
let newBranchName = $state('');
let newBranchFrom = $state(null);
+ let defaultBranchName = $state('master'); // Default branch from settings
// Commit history
let commits = $state>([]);
@@ -2709,6 +2710,13 @@
error = null;
try {
+ // If no branches exist, use default branch from settings
+ let fromBranch = newBranchFrom || currentBranch;
+ if (!fromBranch && branches.length === 0) {
+ const settings = await settingsStore.getSettings();
+ fromBranch = settings.defaultBranch || 'master';
+ }
+
const response = await fetch(`/api/repos/${npub}/${repo}/branches`, {
method: 'POST',
headers: {
@@ -2717,7 +2725,7 @@
},
body: JSON.stringify({
branchName: newBranchName,
- fromBranch: newBranchFrom || currentBranch
+ fromBranch: fromBranch || 'master' // Final fallback
})
});
@@ -3237,8 +3245,15 @@
{/if}
{#if isMaintainer}