534 lines
24 KiB
TypeScript
534 lines
24 KiB
TypeScript
/**
|
||
* 「提交」tab(底部第四个):浏览当前工单 PR 的提交 → 提交内文件 → 原生 diff。
|
||
*
|
||
* - 上半区:提交列表(sha 前 7 位 + 消息首行 + 作者 + 时间),单选高亮。
|
||
* - 下半区:选中提交的文件清单,渲染成目录树(合并单子目录链),文件行状态徽标
|
||
* (A/M/D/R 着色)+ 文件名,点击触发 `vscode.diff` 比较该文件在 parentSha → sha
|
||
* 之间的改动;`tests/` 目录下的文件不展示。
|
||
*
|
||
* 自带 usePrCommits hook,直接经 lib/vscode 收发消息,不依赖上层回调。
|
||
*/
|
||
|
||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||
import type { Issue } from '../types'
|
||
import { Check, ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { usePrCommits } from '../hooks/usePrCommits'
|
||
|
||
interface PrCommitsPanelProps {
|
||
issue: Issue | null
|
||
}
|
||
|
||
/** 提交时间格式化,与 ManagedSessionsPanel 风格一致;无效值回空串。 */
|
||
function formatDate(iso: string): string {
|
||
if (!iso)
|
||
return ''
|
||
const d = new Date(iso)
|
||
if (Number.isNaN(d.getTime()))
|
||
return ''
|
||
return d.toLocaleString(undefined, {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
/** 状态首字母徽标(大写),未知状态取首字母兜底。 */
|
||
function statusBadge(status: string): string {
|
||
const s = status.toLowerCase()
|
||
if (s.startsWith('add'))
|
||
return 'A'
|
||
if (s.startsWith('modif'))
|
||
return 'M'
|
||
if (s.startsWith('delet') || s.startsWith('remov'))
|
||
return 'D'
|
||
if (s.startsWith('renam'))
|
||
return 'R'
|
||
if (s.startsWith('cop'))
|
||
return 'C'
|
||
return (status[0] ?? '?').toUpperCase()
|
||
}
|
||
|
||
/** 徽标颜色用 VS Code git 装饰主题变量,与编辑器源码管理视图一致。 */
|
||
function statusColorVar(status: string): string {
|
||
const s = status.toLowerCase()
|
||
if (s.startsWith('add') || s.startsWith('cop'))
|
||
return 'var(--vscode-gitDecoration-addedResourceForeground)'
|
||
if (s.startsWith('delet') || s.startsWith('remov'))
|
||
return 'var(--vscode-gitDecoration-deletedResourceForeground)'
|
||
if (s.startsWith('renam'))
|
||
return 'var(--vscode-gitDecoration-renamedResourceForeground, var(--vscode-gitDecoration-modifiedResourceForeground))'
|
||
return 'var(--vscode-gitDecoration-modifiedResourceForeground)'
|
||
}
|
||
|
||
/** PR diff 里跳过测试代码:路径任一段为 `tests` 即视为忽略(匹配 `tests/…` 或 `…/tests/…`)。 */
|
||
function isIgnoredPath(path: string): boolean {
|
||
return path.split('/').some(seg => seg === 'tests')
|
||
}
|
||
|
||
/**
|
||
* 拍平后的可渲染行:
|
||
* - 目录行带层级、(合并单子目录链后的)显示名、完整路径(折叠状态的唯一 key)、
|
||
* 当前是否折叠,以及其子树下所有可见文件的完整路径(用于目录级「已确认」级联);
|
||
* - 文件行额外带完整路径与状态。
|
||
*/
|
||
type TreeRow
|
||
= | { kind: 'dir', name: string, depth: number, path: string, collapsed: boolean, descendantFiles: string[] }
|
||
| { kind: 'file', name: string, depth: number, path: string, status: string }
|
||
|
||
interface FileLeaf { name: string, path: string, status: string }
|
||
|
||
/** 构树用的中间目录节点:子目录按名索引,文件平铺。 */
|
||
interface DirNode {
|
||
dirs: Map<string, DirNode>
|
||
files: FileLeaf[]
|
||
}
|
||
|
||
function newDirNode(): DirNode {
|
||
return { dirs: new Map(), files: [] }
|
||
}
|
||
|
||
/**
|
||
* 把 `{ path, status }[]` 构建成嵌套目录树(只构树,不拍平、不合并)。
|
||
*
|
||
* 路径按 `/` 切分,最后一段是文件、其余是目录层级;折叠的拍平交给 `flattenTree`。
|
||
*/
|
||
function buildDirTree(files: ReadonlyArray<{ path: string, status: string }>): DirNode {
|
||
const root = newDirNode()
|
||
for (const f of files) {
|
||
const segs = f.path.split('/')
|
||
const fileName = segs[segs.length - 1]
|
||
let node = root
|
||
for (let i = 0; i < segs.length - 1; i++) {
|
||
const seg = segs[i]
|
||
let child = node.dirs.get(seg)
|
||
if (!child) {
|
||
child = newDirNode()
|
||
node.dirs.set(seg, child)
|
||
}
|
||
node = child
|
||
}
|
||
node.files.push({ name: fileName, path: f.path, status: f.status })
|
||
}
|
||
return root
|
||
}
|
||
|
||
/**
|
||
* DFS 拍平目录树成可渲染行列表,并感知折叠状态。
|
||
*
|
||
* - 合并单子目录链(VS Code "compact folders" 风格):目录只有 1 个子目录且无文件时,
|
||
* 两级目录名用 `/` 连接成一行,递归直到分叉或遇到文件;同时累计该目录行的完整路径
|
||
* (从根拼起、含所有被合并的段),作为折叠状态的唯一 key。
|
||
* - 每层先目录后文件,各自按 `localeCompare` 升序,DFS 顺序输出,`depth` 从 0 起。
|
||
* - 目录被折叠(`collapsed` 含其完整路径)时,只 push 目录行本身,不再递归其子节点。
|
||
*/
|
||
/** 收集一个目录子树下所有文件的完整路径(含各级子目录),用于目录级确认级联。 */
|
||
function collectDescendantFiles(node: DirNode): string[] {
|
||
const out: string[] = []
|
||
const walk = (n: DirNode) => {
|
||
for (const f of n.files)
|
||
out.push(f.path)
|
||
for (const child of n.dirs.values())
|
||
walk(child)
|
||
}
|
||
walk(node)
|
||
return out
|
||
}
|
||
|
||
function flattenTree(root: DirNode, collapsed: ReadonlySet<string>): TreeRow[] {
|
||
const rows: TreeRow[] = []
|
||
// prefix 是父目录的完整路径(含尾部 `/`),用于拼出当前目录行的折叠 key。
|
||
const emit = (node: DirNode, depth: number, prefix: string) => {
|
||
const dirNames = [...node.dirs.keys()].sort((a, b) => a.localeCompare(b))
|
||
for (const name of dirNames) {
|
||
// 合并单子目录链:沿途只要恰好 1 个子目录且本级无文件,就把目录名拼起来;
|
||
// path 同步累计每个被合并的段,作为折叠状态的唯一标识。
|
||
let label = name
|
||
let path = prefix + name
|
||
let cur = node.dirs.get(name)!
|
||
while (cur.files.length === 0 && cur.dirs.size === 1) {
|
||
const [childName, childNode] = [...cur.dirs.entries()][0]
|
||
label += `/${childName}`
|
||
path += `/${childName}`
|
||
cur = childNode
|
||
}
|
||
const isCollapsed = collapsed.has(path)
|
||
rows.push({
|
||
kind: 'dir',
|
||
name: label,
|
||
depth,
|
||
path,
|
||
collapsed: isCollapsed,
|
||
descendantFiles: collectDescendantFiles(cur),
|
||
})
|
||
// 折叠则隐藏其下所有内容,不再递归。
|
||
if (!isCollapsed)
|
||
emit(cur, depth + 1, `${path}/`)
|
||
}
|
||
const sortedFiles = [...node.files].sort((a, b) => a.name.localeCompare(b.name))
|
||
for (const f of sortedFiles)
|
||
rows.push({ kind: 'file', name: f.name, depth, path: f.path, status: f.status })
|
||
}
|
||
emit(root, 0, '')
|
||
return rows
|
||
}
|
||
|
||
export function PrCommitsPanel({ issue }: PrCommitsPanelProps) {
|
||
const issueNumber = issue?.number
|
||
const {
|
||
commits,
|
||
commitsError,
|
||
loadingCommits,
|
||
filesBySha,
|
||
parentShaBySha,
|
||
filesErrorBySha,
|
||
confirmedBySha,
|
||
confirmedCommits,
|
||
getFiles,
|
||
openDiff,
|
||
setConfirmed,
|
||
markCommitsConfirmed,
|
||
} = usePrCommits(issueNumber)
|
||
|
||
const [selectedSha, setSelectedSha] = useState<string | undefined>(undefined)
|
||
// 已折叠目录的完整路径集合;不在集合内即展开(默认全展开)。
|
||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||
// JS 驱动的悬停态:Tailwind v4 把 group-hover 包进 @media(hover:hover),
|
||
// Wayland 下的 VS Code webview 不满足该条件,故改用鼠标事件控制按钮显隐。
|
||
const [hoveredPath, setHoveredPath] = useState<string | null>(null)
|
||
// 提交行的悬停态(与文件行的 hoveredPath 分开),同样 JS 驱动确认按钮显隐。
|
||
const [hoveredCommit, setHoveredCommit] = useState<string | null>(null)
|
||
// 提交列表 <ul>,用于方向键切换后把选中项滚进可视区。
|
||
const commitListRef = useRef<HTMLUListElement | null>(null)
|
||
|
||
// 切工单时清掉选中态(hook 已清缓存,这里只管 UI 选中)。
|
||
useEffect(() => {
|
||
setSelectedSha(undefined)
|
||
}, [issueNumber])
|
||
|
||
// 切提交后文件树默认全展开,避免沿用上一提交的折叠状态。
|
||
useEffect(() => {
|
||
setCollapsed(new Set())
|
||
}, [selectedSha])
|
||
|
||
// 选中提交且尚无文件缓存 → 拉一次文件清单。
|
||
useEffect(() => {
|
||
if (selectedSha && !(selectedSha in filesBySha))
|
||
getFiles(selectedSha)
|
||
}, [selectedSha, filesBySha, getFiles])
|
||
|
||
// 方向键切换选中后,把选中的提交行滚进可视区(按 dataset 匹配,避免 sha 进选择器)。
|
||
useEffect(() => {
|
||
if (!selectedSha)
|
||
return
|
||
const ul = commitListRef.current
|
||
if (!ul)
|
||
return
|
||
for (const li of ul.children) {
|
||
if ((li as HTMLElement).dataset.sha === selectedSha) {
|
||
(li as HTMLElement).scrollIntoView({ block: 'nearest' })
|
||
break
|
||
}
|
||
}
|
||
}, [selectedSha])
|
||
|
||
const selectedFiles = selectedSha ? filesBySha[selectedSha] : undefined
|
||
const selectedFilesError = selectedSha ? filesErrorBySha[selectedSha] : undefined
|
||
const selectedParentSha = selectedSha ? parentShaBySha[selectedSha] : undefined
|
||
|
||
// 过滤掉 tests/ 后构嵌套树;selectedFiles 还没加载(undefined)时给空列表。
|
||
// 构树只依赖文件本身,折叠状态变化不必重构树,只重新拍平。
|
||
const dirTree = useMemo(
|
||
() => buildDirTree((selectedFiles ?? []).filter(f => !isIgnoredPath(f.path))),
|
||
[selectedFiles],
|
||
)
|
||
const fileTreeRows = useMemo(() => flattenTree(dirTree, collapsed), [dirTree, collapsed])
|
||
|
||
// 当前提交已确认的文件路径集合(目录确认靠「其下文件全在此集合」推导)。
|
||
const confirmed = useMemo(
|
||
() => new Set(confirmedBySha[selectedSha ?? ''] ?? []),
|
||
[confirmedBySha, selectedSha],
|
||
)
|
||
|
||
// 当前工单已确认的提交 sha 集合(提交行勾标记 + 行淡化用)。
|
||
const confirmedCommitSet = useMemo(() => new Set(confirmedCommits), [confirmedCommits])
|
||
|
||
// 所有 hook 必须在任何 early return 之前调用完。否则工单空/有无 PR 之间切换时
|
||
// hook 数量变化,触发 React #300(表现为面板空白 / 渲染出错)。
|
||
if (!issue) {
|
||
return (
|
||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
||
未选中工单
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!issue.pr) {
|
||
return (
|
||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
||
该工单还没有 PR
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 提交列表方向键切换:未选中时 ArrowDown 选首、ArrowUp 选末;已选中则按下标夹取边界移动。
|
||
const onCommitListKeyDown = (e: ReactKeyboardEvent<HTMLUListElement>) => {
|
||
if (commits.length === 0)
|
||
return
|
||
const cur = selectedSha ? commits.findIndex(c => c.sha === selectedSha) : -1
|
||
let next: number | undefined
|
||
if (e.key === 'ArrowDown')
|
||
next = cur < 0 ? 0 : Math.min(cur + 1, commits.length - 1)
|
||
else if (e.key === 'ArrowUp')
|
||
next = cur < 0 ? commits.length - 1 : Math.max(cur - 1, 0)
|
||
else if (e.key === 'Home')
|
||
next = 0
|
||
else if (e.key === 'End')
|
||
next = commits.length - 1
|
||
if (next === undefined)
|
||
return
|
||
e.preventDefault()
|
||
setSelectedSha(commits[next].sha)
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full w-full flex-col overflow-hidden bg-[var(--vscode-editor-background)] text-[var(--vscode-foreground)]">
|
||
{/* 上半区:提交列表 */}
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden border-b border-[var(--vscode-panel-border)]">
|
||
<div className="shrink-0 px-2 py-1 text-[10px] uppercase tracking-wide text-[var(--vscode-descriptionForeground)]">
|
||
{`提交 #${issue.pr}`}
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-auto">
|
||
{commitsError
|
||
? (
|
||
<div className="p-3 text-xs text-[var(--vscode-errorForeground)]">
|
||
{commitsError}
|
||
</div>
|
||
)
|
||
: loadingCommits
|
||
? (
|
||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
||
加载中…
|
||
</div>
|
||
)
|
||
: commits.length === 0
|
||
? (
|
||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
||
该 PR 暂无提交
|
||
</div>
|
||
)
|
||
: (
|
||
<ul
|
||
ref={commitListRef}
|
||
tabIndex={0}
|
||
role="listbox"
|
||
aria-label="PR 提交列表"
|
||
onKeyDown={onCommitListKeyDown}
|
||
className="flex flex-col outline-none"
|
||
>
|
||
{commits.map((c) => {
|
||
const isConfirmed = confirmedCommitSet.has(c.sha)
|
||
return (
|
||
<li
|
||
key={c.sha}
|
||
data-sha={c.sha}
|
||
role="option"
|
||
aria-selected={selectedSha === c.sha}
|
||
onMouseEnter={() => setHoveredCommit(c.sha)}
|
||
onMouseLeave={() => setHoveredCommit(p => (p === c.sha ? null : p))}
|
||
onClick={() => {
|
||
setSelectedSha(c.sha)
|
||
// 选中后把焦点给列表,方向键即时可用(不被看板抢走)。
|
||
commitListRef.current?.focus()
|
||
}}
|
||
className={`flex cursor-pointer flex-col gap-0.5 border-b border-[var(--vscode-panel-border)] px-2 py-1.5 ${
|
||
selectedSha === c.sha
|
||
? 'bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]'
|
||
: 'hover:bg-[var(--vscode-list-hoverBackground)]'
|
||
} ${isConfirmed ? 'opacity-60' : ''}`}
|
||
>
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<span className="shrink-0 font-mono text-[10px] text-[var(--vscode-descriptionForeground)]">
|
||
{c.sha.slice(0, 7)}
|
||
</span>
|
||
<span className="min-w-0 truncate text-xs" title={c.message}>
|
||
{c.message}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const next = isConfirmed
|
||
? confirmedCommits.filter(s => s !== c.sha)
|
||
: [...confirmedCommits, c.sha]
|
||
markCommitsConfirmed(next)
|
||
}}
|
||
title={isConfirmed ? '已确认,点击取消' : '标记为已确认'}
|
||
className="shrink-0 transition-opacity"
|
||
style={{
|
||
opacity: isConfirmed || hoveredCommit === c.sha ? 1 : 0,
|
||
color: isConfirmed
|
||
? 'var(--vscode-gitDecoration-addedResourceForeground)'
|
||
: 'var(--vscode-descriptionForeground)',
|
||
}}
|
||
>
|
||
<Check className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
<div className="truncate text-[10px] text-[var(--vscode-descriptionForeground)]">
|
||
{[c.authorName, formatDate(c.date)].filter(Boolean).join(' · ')}
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 下半区:选中提交的文件清单 */}
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
<div className="shrink-0 px-2 py-1 text-[10px] uppercase tracking-wide text-[var(--vscode-descriptionForeground)]">
|
||
文件
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-auto">
|
||
{!selectedSha
|
||
? (
|
||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
||
选择一个提交查看文件
|
||
</div>
|
||
)
|
||
: selectedFilesError
|
||
? (
|
||
<div className="p-3 text-xs text-[var(--vscode-errorForeground)]">
|
||
{selectedFilesError}
|
||
</div>
|
||
)
|
||
: selectedFiles === undefined
|
||
? (
|
||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
||
加载中…
|
||
</div>
|
||
)
|
||
: selectedFiles.length === 0
|
||
? (
|
||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
||
该提交无文件改动
|
||
</div>
|
||
)
|
||
// selectedFiles 非空但全被 tests/ 过滤掉,行列表才会为空。
|
||
: fileTreeRows.length === 0
|
||
? (
|
||
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
|
||
该提交改动均在 tests/ 目录(已忽略)
|
||
</div>
|
||
)
|
||
: (
|
||
<ul className="flex flex-col py-0.5">
|
||
{fileTreeRows.map((row) => {
|
||
const indentStyle = { paddingLeft: row.depth * 12 + 8 }
|
||
if (row.kind === 'dir') {
|
||
// 目录确认 = 其下所有可见文件都已确认(无可见文件则视为未确认)。
|
||
const isConfirmed
|
||
= row.descendantFiles.length > 0
|
||
&& row.descendantFiles.every(p => confirmed.has(p))
|
||
return (
|
||
// 目录行:点击切换折叠态;左侧箭头随折叠态翻转,path 作为唯一 key。
|
||
<li
|
||
key={row.path}
|
||
onMouseEnter={() => setHoveredPath(row.path)}
|
||
onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))}
|
||
onClick={() => setCollapsed((prev) => {
|
||
const n = new Set(prev)
|
||
n.has(row.path) ? n.delete(row.path) : n.add(row.path)
|
||
return n
|
||
})}
|
||
className={`group flex cursor-pointer items-center gap-1.5 py-0.5 pr-2 text-xs text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-list-hoverBackground)] ${
|
||
isConfirmed ? 'opacity-60' : ''
|
||
}`}
|
||
style={indentStyle}
|
||
>
|
||
{row.collapsed
|
||
? <ChevronRight className="size-3.5 shrink-0" />
|
||
: <ChevronDown className="size-3.5 shrink-0" />}
|
||
<Folder
|
||
className="size-3.5 shrink-0"
|
||
style={{ color: 'var(--vscode-descriptionForeground)' }}
|
||
/>
|
||
<span className="min-w-0 truncate">{row.name}</span>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const next = isConfirmed
|
||
? [...confirmed].filter(p => !row.descendantFiles.includes(p))
|
||
: [...new Set([...confirmed, ...row.descendantFiles])]
|
||
setConfirmed(selectedSha, next)
|
||
}}
|
||
title={isConfirmed ? '已确认,点击取消' : '标记为已确认'}
|
||
className="shrink-0 transition-opacity"
|
||
style={{
|
||
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
|
||
color: isConfirmed
|
||
? 'var(--vscode-gitDecoration-addedResourceForeground)'
|
||
: 'var(--vscode-descriptionForeground)',
|
||
}}
|
||
>
|
||
<Check className="size-3.5" />
|
||
</button>
|
||
</li>
|
||
)
|
||
}
|
||
const isConfirmed = confirmed.has(row.path)
|
||
return (
|
||
// 文件行:点击触发 diff,title 用完整路径。
|
||
<li
|
||
key={row.path}
|
||
onMouseEnter={() => setHoveredPath(row.path)}
|
||
onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))}
|
||
onClick={() => openDiff(selectedSha, selectedParentSha, row.path, row.status)}
|
||
title={`${row.status} · ${row.path}`}
|
||
className={`group flex cursor-pointer items-center gap-2 py-0.5 pr-2 hover:bg-[var(--vscode-list-hoverBackground)] ${
|
||
isConfirmed ? 'opacity-60' : ''
|
||
}`}
|
||
style={indentStyle}
|
||
>
|
||
<span
|
||
className="w-4 shrink-0 text-center font-mono text-xs font-semibold"
|
||
style={{ color: statusColorVar(row.status) }}
|
||
>
|
||
{statusBadge(row.status)}
|
||
</span>
|
||
<span className="min-w-0 truncate text-xs">{row.name}</span>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const next = isConfirmed
|
||
? [...confirmed].filter(p => p !== row.path)
|
||
: [...confirmed, row.path]
|
||
setConfirmed(selectedSha, next)
|
||
}}
|
||
title={isConfirmed ? '已确认,点击取消' : '标记为已确认'}
|
||
className="shrink-0 transition-opacity"
|
||
style={{
|
||
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
|
||
color: isConfirmed
|
||
? 'var(--vscode-gitDecoration-addedResourceForeground)'
|
||
: 'var(--vscode-descriptionForeground)',
|
||
}}
|
||
>
|
||
<Check className="size-3.5" />
|
||
</button>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|