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
18 changes: 2 additions & 16 deletions webview-ui/src/components/history/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,16 @@ type CopyButtonProps = {
className?: string
}

/**
* Strips only history highlight spans from text while preserving other HTML
* Targets: <span class="history-item-highlight">content</span>
* @param text - Text that may contain highlight spans
* @returns Text with highlight spans removed but content preserved
*/
const stripHistoryHighlightSpans = (text: string): string => {
// Match opening tag, capture content until closing tag
// The [\s\S]*? pattern matches any character (including newlines) non-greedily,
// which properly handles content with < characters
return text.replace(/<span\s+class="history-item-highlight">([\s\S]*?)<\/span>/g, "$1")
}

export const CopyButton = ({ itemTask, className }: CopyButtonProps) => {
const { isCopied, copy } = useClipboard()
const { t } = useAppTranslation()

const onCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()

if (!isCopied) {
// Strip only history highlight spans before copying to clipboard
const cleanText = stripHistoryHighlightSpans(itemTask)
copy(cleanText)
copy(itemTask)
}
},
[isCopied, copy, itemTask],
Expand Down
10 changes: 7 additions & 3 deletions webview-ui/src/components/history/TaskItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import { useAppTranslation } from "@/i18n/TranslationContext"
import TaskItemHeader from "./TaskItemHeader"
import TaskItemFooter from "./TaskItemFooter"

interface DisplayHistoryItem extends HistoryItem {
highlight?: string
}

interface TaskItemProps {
item: HistoryItem
item: DisplayHistoryItem
variant: "compact" | "full"
showWorkspace?: boolean
isSelectionMode?: boolean
Expand Down Expand Up @@ -103,8 +107,8 @@ const TaskItem = ({
overflowWrap: "anywhere",
}}
data-testid={isCompact ? undefined : "task-content"}
{...(isCompact ? {} : { dangerouslySetInnerHTML: { __html: item.task } })}>
{isCompact ? item.task : undefined}
{...(item.highlight ? { dangerouslySetInnerHTML: { __html: item.highlight } } : {})}>
{item.highlight ? undefined : item.task}
</div>

{/* Task Item Footer */}
Expand Down
2 changes: 1 addition & 1 deletion webview-ui/src/components/history/useTaskSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const useTaskSearch = () => {

return {
...result.item,
task: highlightFzfMatch(
highlight: highlightFzfMatch(
result.item.task,
positions.filter((p) => p < taskEndIndex),
),
Expand Down
31 changes: 30 additions & 1 deletion webview-ui/src/utils/highlight.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
import { LRUCache } from "lru-cache"

// LRU cache for escapeHtml with reasonable size limit
const escapeHtmlCache = new LRUCache<string, string>({ max: 500 })

function escapeHtml(text: string): string {
// Check cache first
const cached = escapeHtmlCache.get(text)
if (cached !== undefined) {
return cached
}

// Compute escaped text
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")

// Cache the result
escapeHtmlCache.set(text, escaped)

return escaped
}

export function highlightFzfMatch(
text: string,
positions: number[],
Expand Down Expand Up @@ -39,6 +65,9 @@ export function highlightFzfMatch(

// Build final string
return parts
.map((part) => (part.highlight ? `<span class="${highlightClassName}">${part.text}</span>` : part.text))
.map((part) => {
const escapedText = escapeHtml(part.text)
return part.highlight ? `<span class="${highlightClassName}">${escapedText}</span>` : escapedText
})
.join("")
}
Loading