diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index e807313f..e45c855b 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -19,6 +19,7 @@ import type { Branch, BranchTimeline as BranchTimelineData, + HashtagItem, ProjectRepo, SessionStatusPayload, } from '../../types'; @@ -39,6 +40,7 @@ import RemoteWorkspaceStatusBadge from './RemoteWorkspaceStatusBadge.svelte'; import RemoteWorkspaceStatusView from './RemoteWorkspaceStatusView.svelte'; import { alerts } from '../../shared/alerts.svelte'; + import { buildBranchHashtagItems } from '../sessions/hashtagItems'; interface Props { branch: Branch; @@ -98,6 +100,20 @@ warnings: number; }; let timelineReviewDetailsById = $state>({}); + + // Hashtag items for rendering #type:id badges in timeline titles + let hashtagItems = $state([]); + $effect(() => { + const branchId = branch.id; + const projectId = branch.projectId; + let stale = false; + buildBranchHashtagItems(branchId, projectId).then((items) => { + if (!stale) hashtagItems = items; + }); + return () => { + stale = true; + }; + }); let reviewDetailsLoadVersion = 0; let reviewDiffTarget = $state<{ commitSha: string; @@ -969,6 +985,7 @@ /g, '>') - .replace(/"/g, '"'); - } - - /** Replace #type:id tokens in plain text with inline badge HTML. - * Regex-matches on raw text first, then escapes non-token segments individually - * so that IDs containing HTML-special characters are looked up correctly. - * Results are memoized per text string; the cache is invalidated when - * hashtagItems changes (tracked via prevHashtagItems). */ + /** Memoized wrapper around the shared renderHashtagTokens. */ const hashtagTokenCache = new Map(); let prevHashtagItems: HashtagItem[] | null = null; function renderHashtagTokens(text: string, items: HashtagItem[]): string { - // Invalidate cache when the hashtag items array identity changes if (items !== prevHashtagItems) { hashtagTokenCache.clear(); prevHashtagItems = items; } - const cached = hashtagTokenCache.get(text); if (cached !== undefined) return cached; - - const itemsByKey = new Map(); - for (const item of items) { - itemsByKey.set(`${item.type}:${item.id}`, item); - } - - const regex = new RegExp(HASHTAG_TOKEN_RE.source, 'g'); - const parts: string[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; - - while ((match = regex.exec(text)) !== null) { - // Escape the plain-text segment before this token - if (match.index > lastIndex) { - parts.push(escapeHtml(text.slice(lastIndex, match.index))); - } - - const type = match[1]; - const id = match[2]; - const label = hashtagTypeLabels[type] ?? type; - const colors = hashtagTypeColors[type] ?? { color: '--text-muted', bg: '--bg-secondary' }; - const item = itemsByKey.get(`${type}:${id}`); - const title = item - ? item.title - : type === 'commit' && id.length > 12 - ? id.slice(0, 8) + '…' - : id; - parts.push( - `${escapeHtml(label)}: ${escapeHtml(title)}` - ); - - lastIndex = match.index + match[0].length; - } - - // Escape any trailing plain text - if (lastIndex < text.length) { - parts.push(escapeHtml(text.slice(lastIndex))); - } - - const result = parts.join(''); + const result = renderHashtagTokensShared(text, items); hashtagTokenCache.set(text, result); return result; } @@ -1605,6 +1550,7 @@ color: var(--text-primary); line-height: 1.5; word-break: break-word; + overflow: hidden; } .human-text { diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.ts b/apps/staged/src/lib/features/sessions/hashtagItems.ts index 3bcd3c61..ba1aa742 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.ts @@ -127,3 +127,64 @@ function projectNotesToHashtagItems(notes: ProjectNote[]): HashtagItem[] { bgColor: '--note-bg', })); } + +// ── Shared rendering ───────────────────────────────────────────────── + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Returns true when `text` contains at least one `#type:id` hashtag token. */ +export function hasHashtagTokens(text: string): boolean { + const re = new RegExp(HASHTAG_TOKEN_RE.source); + return re.test(text); +} + +/** + * Replace `#type:id` tokens in plain text with inline badge HTML. + * Plain-text segments are HTML-escaped; badge spans use CSS custom-property + * colours from `hashtagTypeColors`. + */ +export function renderHashtagTokens(text: string, items: HashtagItem[]): string { + const itemsByKey = new Map(); + for (const item of items) { + itemsByKey.set(`${item.type}:${item.id}`, item); + } + + const regex = new RegExp(HASHTAG_TOKEN_RE.source, 'g'); + const parts: string[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(escapeHtml(text.slice(lastIndex, match.index))); + } + + const type = match[1]; + const id = match[2]; + const label = hashtagTypeLabels[type] ?? type; + const colors = hashtagTypeColors[type] ?? { color: '--text-muted', bg: '--bg-secondary' }; + const item = itemsByKey.get(`${type}:${id}`); + const title = item + ? item.title + : type === 'commit' && id.length > 12 + ? id.slice(0, 8) + '…' + : id; + parts.push( + `${escapeHtml(label)}: ${escapeHtml(title)}` + ); + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push(escapeHtml(text.slice(lastIndex))); + } + + return parts.join(''); +} diff --git a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte index 6b894ff3..efe3cd28 100644 --- a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte +++ b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte @@ -10,9 +10,10 @@ import type { Snippet } from 'svelte'; import { slide } from 'svelte/transition'; import { FileText, GitCommitVertical, FileSearch } from 'lucide-svelte'; - import type { BranchTimeline as BranchTimelineData } from '../../types'; + import type { BranchTimeline as BranchTimelineData, HashtagItem } from '../../types'; import TimelineRow from './TimelineRow.svelte'; import type { TimelineItemType, TimelineBadge } from './TimelineRow.svelte'; + import { hasHashtagTokens, renderHashtagTokens } from '../sessions/hashtagItems'; import { formatRelativeTime, formatRelativeTimeSeconds, @@ -81,6 +82,8 @@ provisioningLabel?: string; /** Optional detail text for the provisioning row (e.g. git progress). */ provisioningDetail?: string | null; + /** Hashtag items for rendering #type:id badges in timeline titles. */ + hashtagItems?: HashtagItem[]; footerActions?: Snippet; } @@ -111,6 +114,7 @@ error, onRetry, provisioningLabel, + hashtagItems = [], provisioningDetail, footerActions, }: Props = $props(); @@ -175,6 +179,8 @@ key: string; type: TimelineItemType; title: string; + /** Pre-rendered HTML title with hashtag badges (set when title contains tokens). */ + titleHtml?: string; meta?: string; secondaryMeta?: string; deleting?: boolean; @@ -415,6 +421,15 @@ }); } + // Render hashtag badges in titles that contain #type:id tokens + if (hashtagItems.length > 0) { + for (const item of all) { + if (hasHashtagTokens(item.title)) { + item.titleHtml = renderHashtagTokens(item.title, hashtagItems); + } + } + } + // Sort by timestamp ascending; pending/generating items at bottom, queued after those all.sort((a, b) => { const isProvisioning = (item: DisplayItem) => item.type === 'provisioning'; @@ -518,6 +533,7 @@
- {title} + {#if titleHtml} + {@html titleHtml} + {:else} + {title} + {/if} {#if meta || secondaryMeta || (badges && badges.length > 0)}
{#if meta} @@ -393,6 +402,15 @@ line-height: 1.4; } + .timeline-title :global(.hashtag-badge) { + display: inline; + padding: 1px 6px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; + white-space: nowrap; + } + .skeleton-title { color: var(--text-muted); }