Skip to content

Commit d5a111c

Browse files
feat(web): add copy button to lightweight code highlighter
Adds an optional isCopyButtonVisible prop (default false) that renders a copy-to-clipboard button in the top-right corner, visible on hover. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d656485 commit d5a111c

File tree

2 files changed

+61
-38
lines changed

2 files changed

+61
-38
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111
- Fixed line numbers being selectable in Safari in the lightweight code highlighter. [#1037](https://github.com/sourcebot-dev/sourcebot/pull/1037)
1212

13+
### Added
14+
- Added optional copy button to the lightweight code highlighter (`isCopyButtonVisible` prop), shown on hover. [#1037](https://github.com/sourcebot-dev/sourcebot/pull/1037)
15+
1316
## [4.16.1] - 2026-03-24
1417

1518
### Fixed

packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx

Lines changed: 58 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { Parser } from '@lezer/common'
22
import { LanguageDescription, StreamLanguage } from '@codemirror/language'
33
import { Highlighter, highlightTree } from '@lezer/highlight'
44
import { languages as builtinLanguages } from '@codemirror/language-data'
5-
import { memo, useEffect, useMemo, useState } from 'react'
5+
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
66
import { useCodeMirrorHighlighter } from '@/hooks/useCodeMirrorHighlighter'
77
import tailwind from '@/tailwind'
88
import { measure } from '@/lib/utils'
99
import { SourceRange } from '@/features/search'
10+
import { CopyIconButton } from './copyIconButton'
1011

1112
// Define a plain text language
1213
const plainTextLanguage = StreamLanguage.define({
@@ -25,6 +26,7 @@ interface LightweightCodeHighlighter {
2526
/* 1-based line number offset */
2627
lineNumbersOffset?: number;
2728
renderWhitespace?: boolean;
29+
isCopyButtonVisible?: boolean;
2830
}
2931

3032
// The maximum number of characters per line that we will display in the preview.
@@ -46,6 +48,7 @@ export const LightweightCodeHighlighter = memo<LightweightCodeHighlighter>((prop
4648
lineNumbers = false,
4749
lineNumbersOffset = 1,
4850
renderWhitespace = false,
51+
isCopyButtonVisible = false,
4952
} = props;
5053

5154
const unhighlightedLines = useMemo(() => {
@@ -110,6 +113,15 @@ export const LightweightCodeHighlighter = memo<LightweightCodeHighlighter>((prop
110113
isFileTooLargeToDisplay,
111114
]);
112115

116+
const onCopy = useCallback(() => {
117+
try {
118+
navigator.clipboard.writeText(code);
119+
return true;
120+
} catch {
121+
return false;
122+
}
123+
}, [code]);
124+
113125
const lineCount = (highlightedLines ?? unhighlightedLines).length + lineNumbersOffset;
114126
const lineNumberDigits = String(lineCount).length;
115127
const lineNumberWidth = `${lineNumberDigits + 2}ch`; // +2 for padding
@@ -123,47 +135,55 @@ export const LightweightCodeHighlighter = memo<LightweightCodeHighlighter>((prop
123135
}
124136

125137
return (
126-
<div
127-
style={{
128-
fontFamily: tailwind.theme.fontFamily.editor,
129-
fontSize: tailwind.theme.fontSize.editor,
130-
whiteSpace: renderWhitespace ? 'pre-wrap' : 'none',
131-
wordBreak: 'break-all',
132-
}}
133-
>
134-
{(highlightedLines ?? unhighlightedLines).map((line, index) => (
135-
<div
136-
key={index}
137-
className="flex"
138-
>
139-
{lineNumbers && (
138+
<div className="relative group">
139+
{isCopyButtonVisible && (
140+
<CopyIconButton
141+
onCopy={onCopy}
142+
className="absolute top-1 right-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity group-hover:bg-background"
143+
/>
144+
)}
145+
<div
146+
style={{
147+
fontFamily: tailwind.theme.fontFamily.editor,
148+
fontSize: tailwind.theme.fontSize.editor,
149+
whiteSpace: renderWhitespace ? 'pre-wrap' : 'none',
150+
wordBreak: 'break-all',
151+
}}
152+
>
153+
{(highlightedLines ?? unhighlightedLines).map((line, index) => (
154+
<div
155+
key={index}
156+
className="flex"
157+
>
158+
{lineNumbers && (
159+
<span
160+
style={{
161+
width: lineNumberWidth,
162+
minWidth: lineNumberWidth,
163+
display: 'inline-block',
164+
textAlign: 'left',
165+
paddingLeft: '5px',
166+
userSelect: 'none',
167+
WebkitUserSelect: 'none',
168+
fontFamily: tailwind.theme.fontFamily.editor,
169+
color: tailwind.theme.colors.editor.gutterForeground,
170+
}}
171+
>
172+
{index + lineNumbersOffset}
173+
</span>
174+
)}
140175
<span
141176
style={{
142-
width: lineNumberWidth,
143-
minWidth: lineNumberWidth,
144-
display: 'inline-block',
145-
textAlign: 'left',
146-
paddingLeft: '5px',
147-
userSelect: 'none',
148-
WebkitUserSelect: 'none',
149-
fontFamily: tailwind.theme.fontFamily.editor,
150-
color: tailwind.theme.colors.editor.gutterForeground,
177+
flex: 1,
178+
paddingLeft: '6px',
179+
paddingRight: '2px',
151180
}}
152181
>
153-
{index + lineNumbersOffset}
182+
{line}
154183
</span>
155-
)}
156-
<span
157-
style={{
158-
flex: 1,
159-
paddingLeft: '6px',
160-
paddingRight: '2px',
161-
}}
162-
>
163-
{line}
164-
</span>
165-
</div>
166-
))}
184+
</div>
185+
))}
186+
</div>
167187
</div>
168188
)
169189
})
@@ -185,7 +205,7 @@ async function getCodeParser(
185205
return null;
186206
}
187207

188-
if (!found.support) {
208+
if (!found.support) {
189209
await found.load();
190210
}
191211
return found.support ? found.support.language.parser : null;

0 commit comments

Comments
 (0)