Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/staged/src/lib/features/branches/BranchCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import type {
Branch,
BranchTimeline as BranchTimelineData,
HashtagItem,
ProjectRepo,
SessionStatusPayload,
} from '../../types';
Expand All @@ -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;
Expand Down Expand Up @@ -98,6 +100,20 @@
warnings: number;
};
let timelineReviewDetailsById = $state<Record<string, TimelineReviewDetails>>({});

// Hashtag items for rendering #type:id badges in timeline titles
let hashtagItems = $state<HashtagItem[]>([]);
$effect(() => {
const branchId = branch.id;
const projectId = branch.projectId;
let stale = false;
buildBranchHashtagItems(branchId, projectId).then((items) => {
Comment on lines +107 to +110
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute hashtag items from refreshed timeline state

This effect builds hashtagItems via a separate async fetch, but hashtagItems is never updated in loadTimeline() (the source of truth for live row data), so badge labels can become stale or fall back to raw IDs after new commits/notes/reviews arrive until the card remounts. It also introduces an extra timeline/project-notes request path per card instead of reusing already-loaded timeline data, which can noticeably increase load/revalidation work in branch lists.

Useful? React with 👍 / 👎.

if (!stale) hashtagItems = items;
});
return () => {
stale = true;
};
});
let reviewDetailsLoadVersion = 0;
let reviewDiffTarget = $state<{
commitSha: string;
Expand Down Expand Up @@ -969,6 +985,7 @@
<BranchTimeline
timeline={timeline ?? emptyTimeline}
repoDir={branch.worktreePath}
{hashtagItems}
pendingDropNotes={isLocal ? pendingDropNotes : undefined}
pendingItems={sessionMgr.pendingSessionItems}
{prunedSessionIds}
Expand Down
62 changes: 4 additions & 58 deletions apps/staged/src/lib/features/sessions/SessionModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,7 @@
import HashtagInput from './HashtagInput.svelte';
import {
buildBranchHashtagItems,
HASHTAG_TOKEN_RE,
hashtagTypeLabels,
hashtagTypeColors,
renderHashtagTokens as renderHashtagTokensShared,
} from './hashtagItems';
import { createBackdropDismissHandlers } from '../../shared/backdropDismiss';
import { subscribeDragDrop } from '../branches/dragDrop';
Expand Down Expand Up @@ -604,71 +602,18 @@
return sanitize(marked.parse(content) as string);
}

function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

/** 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<string, string>();
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<string, HashtagItem>();
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(
`<span class="hashtag-badge" style="background: var(${colors.bg}); color: var(${colors.color});">${escapeHtml(label)}: ${escapeHtml(title)}</span>`
);

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;
}
Expand Down Expand Up @@ -1605,6 +1550,7 @@
color: var(--text-primary);
line-height: 1.5;
word-break: break-word;
overflow: hidden;
}

.human-text {
Expand Down
61 changes: 61 additions & 0 deletions apps/staged/src/lib/features/sessions/hashtagItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,64 @@ function projectNotesToHashtagItems(notes: ProjectNote[]): HashtagItem[] {
bgColor: '--note-bg',
}));
}

// ── Shared rendering ─────────────────────────────────────────────────

function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

/** 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<string, HashtagItem>();
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(
`<span class="hashtag-badge" style="background: var(${colors.bg}); color: var(${colors.color});">${escapeHtml(label)}: ${escapeHtml(title)}</span>`
);

lastIndex = match.index + match[0].length;
}

if (lastIndex < text.length) {
parts.push(escapeHtml(text.slice(lastIndex)));
}

return parts.join('');
}
18 changes: 17 additions & 1 deletion apps/staged/src/lib/features/timeline/BranchTimeline.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -111,6 +114,7 @@
error,
onRetry,
provisioningLabel,
hashtagItems = [],
provisioningDetail,
footerActions,
}: Props = $props();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -518,6 +533,7 @@
<TimelineRow
type={item.type}
title={item.title}
titleHtml={item.titleHtml}
meta={item.meta}
secondaryMeta={item.secondaryMeta}
badges={item.badges}
Expand Down
24 changes: 21 additions & 3 deletions apps/staged/src/lib/features/timeline/TimelineRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
interface Props {
type: TimelineItemType;
title: string;
/** Pre-rendered HTML title with hashtag badges. When set, takes precedence over `title`. */
titleHtml?: string;
meta?: string;
secondaryMeta?: string;
badges?: TimelineBadge[];
Expand All @@ -61,6 +63,7 @@
let {
type,
title,
titleHtml,
meta,
secondaryMeta,
badges,
Expand Down Expand Up @@ -182,9 +185,15 @@
</div>
<div class="timeline-content">
<div class="timeline-info">
<span class="timeline-title" class:skeleton-title={isPending} class:failed-title={isFailed}
>{title}</span
>
{#if titleHtml}
<span class="timeline-title" class:skeleton-title={isPending} class:failed-title={isFailed}
>{@html titleHtml}</span
>
{:else}
<span class="timeline-title" class:skeleton-title={isPending} class:failed-title={isFailed}
>{title}</span
>
{/if}
{#if meta || secondaryMeta || (badges && badges.length > 0)}
<div class="timeline-meta">
{#if meta}
Expand Down Expand Up @@ -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);
}
Expand Down