refactor: consolidate copy-to-clipboard logic and icons

This commit is contained in:
weishu
2025-12-28 13:13:38 +08:00
parent 50d5c4fd35
commit 0eab45c49c
6 changed files with 63 additions and 191 deletions
+5 -65
View File
@@ -1,86 +1,26 @@
import { useState } from 'react'
import { usePlatform } from '@/hooks/usePlatform'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { useShikiHighlighter } from '@/lib/shiki'
function safeCopyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text)
}
return Promise.reject(new Error('Clipboard API not available'))
}
function CopyIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)
}
function CheckIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polyline points="20 6 9 17 4 12" />
</svg>
)
}
import { CopyIcon, CheckIcon } from '@/components/icons'
export function CodeBlock(props: {
code: string
language?: string
showCopyButton?: boolean
}) {
const { haptic } = usePlatform()
const showCopyButton = props.showCopyButton ?? true
const [copied, setCopied] = useState(false)
const { copied, copy } = useCopyToClipboard()
const highlighted = useShikiHighlighter(props.code, props.language)
const handleCopy = async () => {
try {
await safeCopyToClipboard(props.code)
haptic.notification('success')
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
haptic.notification('error')
}
}
return (
<div className="relative min-w-0 max-w-full">
{showCopyButton ? (
<button
type="button"
onClick={handleCopy}
onClick={() => copy(props.code)}
className="absolute right-1.5 top-1.5 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title="Copy"
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
) : null}
@@ -1,5 +1,4 @@
import type { ComponentPropsWithoutRef } from 'react'
import { useState } from 'react'
import {
MarkdownTextPrimitive,
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
@@ -7,75 +6,17 @@ import {
type CodeHeaderProps,
} from '@assistant-ui/react-markdown'
import remarkGfm from 'remark-gfm'
import { getPlatform } from '@/hooks/usePlatform'
import { cn } from '@/lib/utils'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { CopyIcon, CheckIcon } from '@/components/icons'
export const MARKDOWN_PLUGINS = [remarkGfm]
function safeCopyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text)
}
return Promise.reject(new Error('Clipboard API not available'))
}
function CopyIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)
}
function CheckIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polyline points="20 6 9 17 4 12" />
</svg>
)
}
function CodeHeader(props: CodeHeaderProps) {
const [copied, setCopied] = useState(false)
const { copied, copy } = useCopyToClipboard()
const language = props.language && props.language !== 'unknown' ? props.language : ''
const handleCopy = async () => {
const { haptic } = getPlatform()
try {
await safeCopyToClipboard(props.code)
haptic.notification('success')
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
haptic.notification('error')
}
}
return (
<div className="aui-md-codeheader flex items-center justify-between rounded-t-md bg-[var(--app-code-bg)] px-2 py-1">
<div className="min-w-0 flex-1 pr-2 text-xs font-mono text-[var(--app-hint)]">
@@ -83,11 +24,11 @@ function CodeHeader(props: CodeHeaderProps) {
</div>
<button
type="button"
onClick={handleCopy}
onClick={() => copy(props.code)}
className="shrink-0 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title="Copy"
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
</div>
)
+19
View File
@@ -41,3 +41,22 @@ export function PlusCircleIcon(props: IconProps) {
props
)
}
export function CopyIcon(props: IconProps) {
return createIcon(
<>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</>,
props,
2
)
}
export function CheckIcon(props: IconProps) {
return createIcon(
<polyline points="20 6 9 17 4 12" />,
props,
2
)
}
+23
View File
@@ -0,0 +1,23 @@
import { useState, useCallback } from 'react'
import { usePlatform } from './usePlatform'
import { safeCopyToClipboard } from '@/lib/clipboard'
export function useCopyToClipboard(resetDelay = 1500) {
const [copied, setCopied] = useState(false)
const { haptic } = usePlatform()
const copy = useCallback(async (text: string) => {
try {
await safeCopyToClipboard(text)
haptic.notification('success')
setCopied(true)
setTimeout(() => setCopied(false), resetDelay)
return true
} catch {
haptic.notification('error')
return false
}
}, [haptic, resetDelay])
return { copied, copy }
}
+6
View File
@@ -0,0 +1,6 @@
export function safeCopyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text)
}
return Promise.reject(new Error('Clipboard API not available'))
}
+5 -62
View File
@@ -3,9 +3,10 @@ import { useQuery } from '@tanstack/react-query'
import { useParams, useSearch } from '@tanstack/react-router'
import type { GitCommandResponse } from '@/types/api'
import { FileIcon } from '@/components/FileIcon'
import { CopyIcon, CheckIcon } from '@/components/icons'
import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { usePlatform } from '@/hooks/usePlatform'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { queryKeys } from '@/lib/query-keys'
import { langAlias, useShikiHighlighter } from '@/lib/shiki'
@@ -120,55 +121,9 @@ function extractCommandError(result: GitCommandResponse | undefined): string | n
return result.error ?? result.stderr ?? 'Failed to load diff'
}
function safeCopyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text)
}
return Promise.reject(new Error('Clipboard API not available'))
}
function CopyIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)
}
function CheckIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polyline points="20 6 9 17 4 12" />
</svg>
)
}
export default function FilePage() {
const { api } = useAppContext()
const { haptic } = usePlatform()
const { copied, copy } = useCopyToClipboard()
const goBack = useAppGoBack()
const { sessionId } = useParams({ from: '/sessions/$sessionId/file' })
const search = useSearch({ from: '/sessions/$sessionId/file' })
@@ -218,18 +173,6 @@ export default function FilePage() {
const highlighted = useShikiHighlighter(decodedContent, language)
const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff')
const [copied, setCopied] = useState(false)
const handleCopyPath = async () => {
try {
await safeCopyToClipboard(filePath)
haptic.notification('success')
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
haptic.notification('error')
}
}
useEffect(() => {
if (diffSuccess && !diffContent) {
@@ -272,11 +215,11 @@ export default function FilePage() {
<span className="min-w-0 flex-1 truncate text-xs text-[var(--app-hint)]">{filePath}</span>
<button
type="button"
onClick={handleCopyPath}
onClick={() => copy(filePath)}
className="shrink-0 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title="Copy path"
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
</div>
</div>