diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index ff9b71eea..2df252cb5 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -983,6 +983,26 @@ fn update_repo_badge( return Err("Hue must be between 0 and 360".into()); } let store = get_store(&store)?; + + // Check if another badge already uses this short name. + if let Some(existing) = store + .get_repo_badge_by_short_name(&short_name) + .map_err(|e| e.to_string())? + { + let is_same_badge = existing.github_repo == github_repo && existing.subpath == subpath; + if !is_same_badge { + let owner = if existing.subpath.is_empty() { + existing.github_repo.clone() + } else { + format!("{} ({})", existing.github_repo, existing.subpath) + }; + return Err(format!( + "Short name '{}' is already used by {}", + short_name, owner + )); + } + } + store .update_repo_badge(&github_repo, &subpath, &short_name, hue) .map_err(|e| e.to_string())?; diff --git a/apps/staged/src-tauri/src/store/repo_badges.rs b/apps/staged/src-tauri/src/store/repo_badges.rs index 8d7885c13..ac2c89073 100644 --- a/apps/staged/src-tauri/src/store/repo_badges.rs +++ b/apps/staged/src-tauri/src/store/repo_badges.rs @@ -93,6 +93,32 @@ impl Store { Ok(()) } + /// Look up a single badge by its short_name. + pub fn get_repo_badge_by_short_name( + &self, + short_name: &str, + ) -> Result, StoreError> { + use rusqlite::OptionalExtension; + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT github_repo, subpath, short_name, hue, created_at + FROM repo_badges + WHERE short_name = ?1", + params![short_name], + |row| { + Ok(RepoBadge { + github_repo: row.get(0)?, + subpath: row.get(1)?, + short_name: row.get(2)?, + hue: row.get(3)?, + created_at: row.get(4)?, + }) + }, + ) + .optional() + .map_err(Into::into) + } + /// Delete a badge by (github_repo, subpath). pub fn delete_repo_badge(&self, github_repo: &str, subpath: &str) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); diff --git a/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte b/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte index a02e082a5..652f3b777 100644 --- a/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte +++ b/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte @@ -24,7 +24,7 @@ import * as commands from '../../api/commands'; import { detectRepoActions, type ActionType } from '../actions/actions'; import { repoBadgeStore } from '../../stores/repoBadges.svelte'; - import { matchesRepoContextSearch } from './repoContextSearch'; + import { matchesRepoSearch } from './repoContextSearch'; type RepoAttachment = { projectId: string; @@ -33,8 +33,16 @@ branchName: string; }; + /** A repo entry from either an action context, a badge, or both. */ + type RepoEntry = { + key: string; + githubRepo: string; + subpath: string; + context: ActionContext | null; + }; + let contexts = $state([]); - let selectedContextId = $state(null); + let selectedRepoKey = $state(null); let loadingContexts = $state(false); let loadingRepoAttachments = $state(false); let repoAttachmentsByContext = $state>({}); @@ -56,16 +64,7 @@ }); let badgeEditName = $state(''); let badgeEditHue = $state(0); - - let selectedContext = $derived(contexts.find((c) => c.id === selectedContextId) ?? null); - let selectedContextAttachments = $derived( - selectedContext ? (repoAttachmentsByContext[selectedContext.id] ?? []) : [] - ); - let selectedBadge = $derived( - selectedContext - ? repoBadgeStore.lookup(selectedContext.githubRepo, selectedContext.subpath) - : undefined - ); + let badgeError = $state(''); onMount(async () => { await repoBadgeStore.loadAll(); @@ -81,10 +80,11 @@ } $effect(() => { - // Only re-run when the selected context changes, not when badge values + // Only re-run when the selected entry changes, not when badge values // update in the store (which would clobber in-progress edits after save). - void selectedContextId; + void selectedRepoKey; untrack(() => { + badgeError = ''; const badge = selectedBadge; if (badge) { badgeEditName = badge.shortName; @@ -94,31 +94,69 @@ }); async function saveBadge() { - if (!selectedContext || !badgeEditName.trim()) return; + if (!selectedEntry || !badgeEditName.trim()) return; + badgeError = ''; try { await repoBadgeStore.update( - selectedContext.githubRepo, - selectedContext.subpath, + selectedEntry.githubRepo, + selectedEntry.subpath, badgeEditName.trim(), badgeEditHue ); } catch (e) { - console.error('Failed to update badge:', e); + const msg = typeof e === 'string' ? e : e instanceof Error ? e.message : String(e); + badgeError = msg; } } - function contextKey(githubRepo: string, subpath: string | null | undefined): string { + function repoKey(githubRepo: string, subpath: string | null | undefined): string { return `${githubRepo}::${subpath ?? ''}`; } - function contextDisplay(context: ActionContext): string { - return context.subpath ? `${context.githubRepo}/${context.subpath}` : context.githubRepo; + function repoDisplay(githubRepo: string, subpath: string | null | undefined): string { + return subpath ? `${githubRepo}/${subpath}` : githubRepo; } function formatProjectCount(count: number): string { return `${count} project${count === 1 ? '' : 's'}`; } + /** Merge action contexts and orphan badges into a single list. */ + let mergedEntries = $derived.by(() => { + const entries: RepoEntry[] = contexts.map((c) => ({ + key: repoKey(c.githubRepo, c.subpath), + githubRepo: c.githubRepo, + subpath: c.subpath ?? '', + context: c, + })); + + const contextKeys = new Set(entries.map((e) => e.key)); + for (const badge of repoBadgeStore.all()) { + const k = repoKey(badge.githubRepo, badge.subpath); + if (!contextKeys.has(k)) { + entries.push({ + key: k, + githubRepo: badge.githubRepo, + subpath: badge.subpath, + context: null, + }); + } + } + + return entries; + }); + + let selectedEntry = $derived(mergedEntries.find((e) => e.key === selectedRepoKey) ?? null); + let selectedContext = $derived(selectedEntry?.context ?? null); + let selectedContextAttachments = $derived( + selectedContext ? (repoAttachmentsByContext[selectedContext.id] ?? []) : [] + ); + let selectedBadge = $derived( + selectedEntry + ? repoBadgeStore.lookup(selectedEntry.githubRepo, selectedEntry.subpath) + : undefined + ); + async function loadRepoAttachments(actionContexts: ActionContext[]) { const generation = ++repoAttachmentLoadGeneration; loadingRepoAttachments = true; @@ -134,10 +172,7 @@ } const contextIdByRepo = new Map( - actionContexts.map((context) => [ - contextKey(context.githubRepo, context.subpath), - context.id, - ]) + actionContexts.map((context) => [repoKey(context.githubRepo, context.subpath), context.id]) ); const projects = await commands.listProjects(); const reposByProject = await Promise.all( @@ -149,7 +184,7 @@ for (const { project, repos } of reposByProject) { for (const repo of repos) { - const contextId = contextIdByRepo.get(contextKey(repo.githubRepo, repo.subpath)); + const contextId = contextIdByRepo.get(repoKey(repo.githubRepo, repo.subpath)); if (!contextId) continue; byContext[contextId] = [ ...byContext[contextId], @@ -184,10 +219,10 @@ const nextContexts = await commands.listActionContexts(); contexts = nextContexts; await loadRepoAttachments(nextContexts); - if (!selectedContextId && contexts.length > 0) { - selectedContextId = contexts[0].id; - } else if (selectedContextId && !contexts.some((c) => c.id === selectedContextId)) { - selectedContextId = contexts.length > 0 ? contexts[0].id : null; + if (!selectedRepoKey && mergedEntries.length > 0) { + selectedRepoKey = mergedEntries[0].key; + } else if (selectedRepoKey && !mergedEntries.some((e) => e.key === selectedRepoKey)) { + selectedRepoKey = mergedEntries.length > 0 ? mergedEntries[0].key : null; } await loadActions(); } catch (e) { @@ -217,7 +252,7 @@ } $effect(() => { - selectedContextId; + selectedRepoKey; loadActions(); }); @@ -227,7 +262,7 @@ // Capture context before the async gap so that switching repo contexts // while detection is in-flight doesn't cause actions to be saved to // the wrong context. - const contextId = selectedContextId; + const entryKey = selectedRepoKey; const githubRepo = selectedContext.githubRepo; const subpath = selectedContext.subpath ?? undefined; @@ -253,7 +288,7 @@ nextSortOrder++, suggestion.autoCommit ); - if (selectedContextId === contextId) { + if (selectedRepoKey === entryKey) { actions = [...actions, newAction]; } actionsAdded = true; @@ -297,7 +332,7 @@ // if the user switches repo contexts while the save is in-flight. const githubRepo = selectedContext.githubRepo; const subpath = selectedContext.subpath ?? undefined; - const contextId = selectedContextId; + const entryKey = selectedRepoKey; try { if (!editingAction?.id) { @@ -311,7 +346,7 @@ nextSortOrder, editForm.autoCommit ); - if (selectedContextId === contextId) { + if (selectedRepoKey === entryKey) { actions = [...actions, newAction]; } } else { @@ -357,14 +392,14 @@ async function deleteAllActions() { if (!selectedContext) return; - // Capture the context id before the await so a concurrent context + // Capture the entry key before the await so a concurrent context // switch doesn't clear the wrong context's actions from the UI. - const contextId = selectedContextId; + const entryKey = selectedRepoKey; const repoContextId = selectedContext.id; try { await commands.deleteAllRepoActions(repoContextId); - if (selectedContextId === contextId) { + if (selectedRepoKey === entryKey) { actions = []; } showDeleteAllConfirm = false; @@ -375,21 +410,27 @@ } async function deleteRepo() { - if (!selectedContext) return; + if (!selectedEntry) return; - const contextId = selectedContext.id; - const attachments = [...(repoAttachmentsByContext[contextId] ?? [])]; + const entry = selectedEntry; + const entryKey = entry.key; deletingRepo = true; try { - for (const attachment of attachments) { - await commands.removeProjectRepo(attachment.projectId, attachment.projectRepoId); + if (entry.context) { + const contextId = entry.context.id; + const attachments = [...(repoAttachmentsByContext[contextId] ?? [])]; + + for (const attachment of attachments) { + await commands.removeProjectRepo(attachment.projectId, attachment.projectRepoId); + } + + await commands.deleteActionContext(contextId); } - await commands.deleteActionContext(contextId); - await commands.deleteRepoBadge(selectedContext.githubRepo, selectedContext.subpath ?? ''); - repoBadgeStore.remove(selectedContext.githubRepo, selectedContext.subpath); - if (selectedContextId === contextId) { + await commands.deleteRepoBadge(entry.githubRepo, entry.subpath); + repoBadgeStore.remove(entry.githubRepo, entry.subpath); + if (selectedRepoKey === entryKey) { actions = []; } showDeleteRepoConfirm = false; @@ -423,19 +464,21 @@ } } - let sortedContexts = $derived.by(() => { - return [...contexts].sort((a, b) => { + let sortedEntries = $derived.by(() => { + return [...mergedEntries].sort((a, b) => { const aDisplay = a.subpath ? `${a.githubRepo}/${a.subpath}` : a.githubRepo; const bDisplay = b.subpath ? `${b.githubRepo}/${b.subpath}` : b.githubRepo; return aDisplay.localeCompare(bDisplay); }); }); - let filteredContexts = $derived.by(() => { + let filteredEntries = $derived.by(() => { const query = repoSearch.trim(); - if (!query) return sortedContexts; + if (!query) return sortedEntries; - return sortedContexts.filter((context) => matchesRepoContextSearch(context, query)); + return sortedEntries.filter((entry) => + matchesRepoSearch(entry.githubRepo, entry.subpath, query) + ); }); let groupedActions = $derived.by(() => { @@ -474,31 +517,33 @@ {#if loadingContexts}
Loading...
- {:else if contexts.length === 0} + {:else if mergedEntries.length === 0}
No repo contexts yet
- {:else if filteredContexts.length === 0} + {:else if filteredEntries.length === 0}
No repos match "{repoSearch.trim()}"
{:else}
- {#each filteredContexts as context (context.id)} - {@const badge = repoBadgeStore.lookup(context.githubRepo, context.subpath)} + {#each filteredEntries as entry (entry.key)} + {@const badge = repoBadgeStore.lookup(entry.githubRepo, entry.subpath)} - {#if actions.length > 0} + {#if selectedContext} + {#if actions.length > 0} + + {/if} + {/if} - -
- {#if loadingActions} + {#if !selectedContext} + + {:else if loadingActions}
Loading... @@ -702,12 +760,12 @@ {/if}
-{#if showDeleteRepoConfirm && selectedContext} +{#if showDeleteRepoConfirm && selectedEntry} 0 - ? `Delete "${contextDisplay(selectedContext)}" from Staged? This removes ${formatProjectCount(selectedContextAttachments.length)} and deletes tracked worktrees/workspaces tied to this repo.` - : `Delete "${contextDisplay(selectedContext)}" from Staged? This removes its repo settings and actions.`} + ? `Delete "${repoDisplay(selectedEntry.githubRepo, selectedEntry.subpath)}" from Staged? This removes ${formatProjectCount(selectedContextAttachments.length)} and deletes tracked worktrees/workspaces tied to this repo.` + : `Delete "${repoDisplay(selectedEntry.githubRepo, selectedEntry.subpath)}" from Staged? This removes its repo settings and actions.`} confirmLabel="Delete Repo" danger={true} onConfirm={deleteRepo} @@ -912,26 +970,25 @@ .badge-editor { display: flex; - align-items: center; - gap: 12px; + flex-direction: column; + gap: 6px; padding: 8px 10px; border: 1px solid var(--border-subtle); border-radius: 8px; background: var(--bg-primary); } - .badge-editor-preview { - flex-shrink: 0; - } - - .badge-editor-fields { + .badge-editor-row { display: flex; align-items: center; gap: 12px; - flex: 1; min-width: 0; } + .badge-editor-preview { + flex-shrink: 0; + } + .badge-field { display: flex; align-items: center; @@ -955,6 +1012,15 @@ font-size: var(--size-xs); } + .badge-input-error { + border-color: var(--ui-danger); + } + + .badge-error { + color: var(--ui-danger); + font-size: var(--size-xs); + } + .badge-hue-slider { width: 200px; cursor: pointer; diff --git a/apps/staged/src/lib/features/settings/repoContextSearch.ts b/apps/staged/src/lib/features/settings/repoContextSearch.ts index 20ce2dc20..f73cfcd94 100644 --- a/apps/staged/src/lib/features/settings/repoContextSearch.ts +++ b/apps/staged/src/lib/features/settings/repoContextSearch.ts @@ -1,19 +1,27 @@ import type { ActionContext } from '../../api/commands'; -function searchTerms(context: ActionContext): string[] { - const githubRepo = context.githubRepo.toLowerCase(); - const [org = '', repoName = ''] = githubRepo.split('/'); - const subpath = context.subpath?.toLowerCase() ?? ''; - const subpathParts = subpath.split('/').filter(Boolean); +function searchTerms(githubRepo: string, subpath: string | null | undefined): string[] { + const repo = githubRepo.toLowerCase(); + const [org = '', repoName = ''] = repo.split('/'); + const sub = subpath?.toLowerCase() ?? ''; + const subpathParts = sub.split('/').filter(Boolean); - return [githubRepo, org, repoName, subpath, ...subpathParts].filter(Boolean); + return [repo, org, repoName, sub, ...subpathParts].filter(Boolean); } export function matchesRepoContextSearch(context: ActionContext, query: string): boolean { + return matchesRepoSearch(context.githubRepo, context.subpath, query); +} + +export function matchesRepoSearch( + githubRepo: string, + subpath: string | null | undefined, + query: string +): boolean { const tokens = query.toLowerCase().trim().split(/\s+/).filter(Boolean); if (tokens.length === 0) return true; - const terms = searchTerms(context); + const terms = searchTerms(githubRepo, subpath); return tokens.every((token) => terms.some((term) => term.includes(token))); } diff --git a/apps/staged/src/lib/stores/repoBadges.svelte.ts b/apps/staged/src/lib/stores/repoBadges.svelte.ts index f57241ec6..713e72888 100644 --- a/apps/staged/src/lib/stores/repoBadges.svelte.ts +++ b/apps/staged/src/lib/stores/repoBadges.svelte.ts @@ -15,6 +15,11 @@ function badgeKey(githubRepo: string, subpath: string): string { class RepoBadgeStore { private badges = $state>(new Map()); + /** Return all loaded badges. */ + all(): RepoBadge[] { + return Array.from(this.badges.values()); + } + /** Look up a badge for a repo+subpath. Returns undefined if not yet loaded. */ lookup(githubRepo: string, subpath: string | null | undefined): RepoBadge | undefined { return this.badges.get(badgeKey(githubRepo, subpath ?? ''));