234 lines
7.2 KiB
TypeScript
234 lines
7.2 KiB
TypeScript
import { useEffect, useMemo, useState } from "react"
|
||
import { Loader2 } from "lucide-react"
|
||
|
||
import { Badge } from "~/components/ui/badge"
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "~/components/ui/dialog"
|
||
import { Input } from "~/components/ui/input"
|
||
import { api } from "~/lib/api"
|
||
import type { FileContent } from "~/lib/api"
|
||
import { cn } from "~/lib/utils"
|
||
|
||
// 查看来源:本地映射文件或服务器 latest。
|
||
type ViewSource = "local" | "server"
|
||
|
||
/**
|
||
* 把字节数格式化为可读大小。
|
||
*/
|
||
function formatSize(size: number): string {
|
||
if (size < 1024) {
|
||
return `${size} B`
|
||
}
|
||
if (size < 1024 * 1024) {
|
||
return `${(size / 1024).toFixed(1)} KB`
|
||
}
|
||
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||
}
|
||
|
||
/**
|
||
* 文件查看弹窗:只读展示本地、服务器 latest 或指定 changeset 版本内容,
|
||
* 支持来源切换、行号与搜索高亮;二进制 / 超大 / 非映射路径给出明确提示。
|
||
*/
|
||
export function FileViewDialog({
|
||
serverPath,
|
||
localPath,
|
||
changeset,
|
||
onClose,
|
||
}: {
|
||
serverPath: string
|
||
localPath: string | null
|
||
/** 指定 changeset 时固定展示该版本内容,不提供来源切换。 */
|
||
changeset?: number
|
||
onClose: () => void
|
||
}) {
|
||
const [source, setSource] = useState<ViewSource>(localPath && changeset == null ? "local" : "server")
|
||
const [content, setContent] = useState<FileContent | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [search, setSearch] = useState("")
|
||
|
||
// 加载所选来源的文件内容;本地内容仅在已映射时可用;指定版本固定按 changeset 读取。
|
||
useEffect(() => {
|
||
let active = true
|
||
setLoading(true)
|
||
setError(null)
|
||
void (async () => {
|
||
const result =
|
||
changeset != null
|
||
? await api.getFileContent({ serverPath, changeset })
|
||
: source === "local" && localPath
|
||
? await api.getFileContent({ localPath })
|
||
: await api.getFileContent({ serverPath })
|
||
if (!active) {
|
||
return
|
||
}
|
||
setLoading(false)
|
||
if (!result.ok || !result.data) {
|
||
setContent(null)
|
||
setError(result.errorMessage ?? "文件内容加载失败")
|
||
return
|
||
}
|
||
setContent(result.data.content)
|
||
})()
|
||
return () => {
|
||
active = false
|
||
}
|
||
}, [source, serverPath, localPath, changeset])
|
||
|
||
const lines = useMemo(
|
||
() => (content && !content.binary && !content.tooLarge ? content.content.split(/\r?\n/) : []),
|
||
[content],
|
||
)
|
||
const keyword = search.trim().toLowerCase()
|
||
const matchCount = useMemo(
|
||
() =>
|
||
keyword.length === 0
|
||
? 0
|
||
: lines.filter((line) => line.toLowerCase().includes(keyword)).length,
|
||
[lines, keyword],
|
||
)
|
||
|
||
return (
|
||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||
<DialogContent className="flex h-[90svh] max-h-[90svh] flex-col sm:max-w-[92vw]">
|
||
<DialogHeader>
|
||
<DialogTitle>查看文件</DialogTitle>
|
||
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||
{changeset != null ? (
|
||
<Badge variant="secondary" className="rounded-md">
|
||
版本 C{changeset}
|
||
</Badge>
|
||
) : (
|
||
<div className="flex items-center rounded-md border p-0.5">
|
||
<SourceTab
|
||
label="本地文件"
|
||
active={source === "local"}
|
||
disabled={!localPath}
|
||
disabledReason="未映射到本地"
|
||
onClick={() => setSource("local")}
|
||
/>
|
||
<SourceTab
|
||
label="服务器 latest"
|
||
active={source === "server"}
|
||
onClick={() => setSource("server")}
|
||
/>
|
||
</div>
|
||
)}
|
||
{content && (
|
||
<span className="text-xs text-muted-foreground">
|
||
{formatSize(content.size)}
|
||
{content.changeset > 0 && ` · 版本 ${content.changeset}`}
|
||
</span>
|
||
)}
|
||
<Input
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
placeholder="搜索内容…"
|
||
className="ml-auto h-7 w-48 text-xs"
|
||
/>
|
||
{keyword.length > 0 && (
|
||
<Badge variant="secondary" className="rounded-md">
|
||
{matchCount} 行命中
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/20">
|
||
{loading ? (
|
||
<div className="flex h-60 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||
<Loader2 className="size-4 animate-spin" />
|
||
正在加载内容…
|
||
</div>
|
||
) : error ? (
|
||
<div className="flex h-60 items-center justify-center p-6 text-center text-sm text-destructive">
|
||
{error}
|
||
</div>
|
||
) : content?.binary ? (
|
||
<Notice text={`二进制文件不支持预览(${formatSize(content.size)})`} />
|
||
) : content?.tooLarge ? (
|
||
<Notice text={`文件过大(${formatSize(content.size)}),不直接渲染内容`} />
|
||
) : (
|
||
<pre className="min-w-full font-mono text-xs leading-5">
|
||
{lines.map((line, index) => {
|
||
const hit = keyword.length > 0 && line.toLowerCase().includes(keyword)
|
||
return (
|
||
<div
|
||
key={index}
|
||
className={cn("flex", hit && "bg-amber-500/15")}
|
||
>
|
||
<span className="w-12 shrink-0 border-r pr-2 text-right text-muted-foreground/60 select-none">
|
||
{index + 1}
|
||
</span>
|
||
<span className="whitespace-pre-wrap break-all pl-2">{line}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
|
||
<p className="shrink-0 text-xs text-muted-foreground">
|
||
只读查看 · 来源:
|
||
{changeset != null
|
||
? `changeset ${changeset} 版本`
|
||
: source === "local"
|
||
? `本地 ${localPath ?? ""}`
|
||
: "服务器 latest"}
|
||
</p>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 来源切换小标签按钮。
|
||
*/
|
||
function SourceTab({
|
||
label,
|
||
active,
|
||
disabled,
|
||
disabledReason,
|
||
onClick,
|
||
}: {
|
||
label: string
|
||
active: boolean
|
||
disabled?: boolean
|
||
disabledReason?: string
|
||
onClick: () => void
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
title={disabled ? disabledReason : undefined}
|
||
onClick={onClick}
|
||
className={cn(
|
||
"rounded-[5px] px-2 py-0.5 text-xs",
|
||
active ? "bg-primary/10 font-medium text-foreground" : "text-muted-foreground",
|
||
disabled && "cursor-not-allowed opacity-50",
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 内容区占位提示(二进制 / 超大文件)。
|
||
*/
|
||
function Notice({ text }: { text: string }) {
|
||
return (
|
||
<div className="flex h-60 items-center justify-center p-6 text-center text-sm text-muted-foreground">
|
||
{text}
|
||
</div>
|
||
)
|
||
}
|