Files
codeg/src/components/layout/aux-panel-session-files-tab.tsx
2026-03-07 13:12:18 +08:00

421 lines
14 KiB
TypeScript

"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { ChevronRight, FileIcon } from "lucide-react"
import { useTranslations } from "next-intl"
import { useFolderContext } from "@/contexts/folder-context"
import { useTabContext } from "@/contexts/tab-context"
import type { LiveMessage } from "@/contexts/acp-connections-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useConnection } from "@/hooks/use-connection"
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
import { extractSessionFilesGrouped } from "@/lib/session-files"
import { getPendingPromptText } from "@/lib/pending-prompt-text"
import {
inferLiveToolName,
normalizeToolName,
} from "@/lib/tool-call-normalization"
import type { ConnectionStatus, MessageTurn } from "@/lib/types"
import {
CommitFileAdditions,
CommitFileDeletions,
} from "@/components/ai-elements/commit"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
const LIVE_FILE_WRITE_OPS = new Set(["edit", "write", "apply_patch"])
function isRemovedFileDiff(diff: string | null): boolean {
if (!diff) return false
return (
/^\*\*\* Delete File:\s+/m.test(diff) ||
/^deleted file mode\b/m.test(diff) ||
/^\+\+\+\s+\/dev\/null$/m.test(diff)
)
}
function normalizeSlashPath(path: string): string {
return path.replace(/\\/g, "/")
}
function toFolderRelativePath(filePath: string, folderPath?: string): string {
const normalizedFilePath = normalizeSlashPath(filePath)
if (!folderPath) return normalizedFilePath
const normalizedFolderPath = normalizeSlashPath(folderPath).replace(
/\/+$/,
""
)
if (!normalizedFolderPath) return normalizedFilePath
const folderPrefix = `${normalizedFolderPath}/`
if (normalizedFilePath.startsWith(folderPrefix)) {
return normalizedFilePath.slice(folderPrefix.length)
}
return normalizedFilePath
}
function extractTurnText(turn: MessageTurn | null): string | null {
if (!turn || turn.role !== "user") return null
for (const block of turn.blocks) {
if (block.type !== "text") continue
const text = block.text.trim()
if (text) return text
}
return null
}
function mergeLiveTurns(params: {
turns: MessageTurn[]
liveMessage: LiveMessage | null
connStatus: ConnectionStatus | null
pendingPromptText: string | null
fallbackPromptText: string
}): MessageTurn[] {
const {
turns,
liveMessage,
connStatus,
pendingPromptText,
fallbackPromptText,
} = params
if (!liveMessage || connStatus !== "prompting") return turns
const liveBlocks = liveMessage.content.flatMap((block) => {
if (block.type !== "tool_call") return []
const toolName = inferLiveToolName({
title: block.info.title,
kind: block.info.kind,
rawInput: block.info.raw_input,
})
const normalizedToolName = normalizeToolName(toolName)
if (!LIVE_FILE_WRITE_OPS.has(normalizedToolName)) return []
return [
{
type: "tool_use" as const,
tool_use_id: block.info.tool_call_id,
tool_name: toolName,
input_preview: block.info.raw_input,
},
]
})
if (liveBlocks.length === 0) return turns
const now = new Date().toISOString()
const mergedTurns = [...turns]
const lastTurn = mergedTurns[mergedTurns.length - 1]
const lastUserTurn =
[...mergedTurns].reverse().find((turn) => turn.role === "user") ?? null
const pendingText = pendingPromptText?.trim() ?? ""
const shouldReuseExistingUserTurn =
pendingText.length > 0 && extractTurnText(lastUserTurn) === pendingText
if ((!lastTurn || lastTurn.role !== "user") && !shouldReuseExistingUserTurn) {
mergedTurns.push({
id: `live-user-${liveMessage.id}`,
role: "user",
blocks: [
{ type: "text", text: pendingPromptText?.trim() || fallbackPromptText },
],
timestamp: now,
})
}
mergedTurns.push({
id: `live-assistant-${liveMessage.id}`,
role: "assistant",
blocks: liveBlocks,
timestamp: now,
})
return mergedTurns
}
function SessionFilesContent({
conversationId,
liveMessage,
connStatus,
pendingPromptText,
}: {
conversationId: number
liveMessage: LiveMessage | null
connStatus: ConnectionStatus | null
pendingPromptText: string | null
}) {
const t = useTranslations("Folder.sessionFiles")
const { detail, loading, refetch } = useDbMessageDetail(conversationId)
const { openSessionFileDiff } = useWorkspaceContext()
const { folder } = useFolderContext()
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
const prevStatusRef = useRef(connStatus)
useEffect(() => {
const prev = prevStatusRef.current
prevStatusRef.current = connStatus
if (prev === "prompting" && connStatus && connStatus !== "prompting") {
refetch()
}
}, [connStatus, refetch])
const turns = useMemo(
() =>
mergeLiveTurns({
turns: detail?.turns ?? [],
liveMessage,
connStatus,
pendingPromptText,
fallbackPromptText: t("currentResponse"),
}),
[detail?.turns, liveMessage, connStatus, pendingPromptText, t]
)
const groups = useMemo(
() => (turns.length > 0 ? extractSessionFilesGrouped(turns) : []),
[turns]
)
const handleFileClick = (
filePath: string,
diffContent: string | null,
groupIndex: number,
changeIndex: number
) => {
openSessionFileDiff(
filePath,
diffContent ?? t("noDiffDataAvailable", { filePath }),
`msg-${groupIndex + 1}-chg-${changeIndex + 1}`
)
}
if (loading) {
return (
<div className="flex items-center justify-center h-full p-4">
<p className="text-xs text-muted-foreground text-center">
{t("loading")}
</p>
</div>
)
}
if (groups.length === 0) {
return (
<div className="flex items-center justify-center h-full p-4">
<p className="text-xs text-muted-foreground text-center">
{t("noFileChangesInConversation")}
</p>
</div>
)
}
return (
<div className="space-y-3 p-3">
{groups.map((group, groupIndex) => {
const groupKey = `${group.userTurnId}-${group.timestamp}-${groupIndex}`
const isOpen = openGroups[groupKey] ?? false
const totalAdditions = group.files.reduce(
(sum, f) => sum + f.additions,
0
)
const totalDeletions = group.files.reduce(
(sum, f) => sum + f.deletions,
0
)
const uniqueFileCount = new Set(
group.files.map((file) => file.path.replace(/\\/g, "/"))
).size
return (
<Collapsible
key={groupKey}
className="overflow-hidden rounded-xl border border-border bg-card text-card-foreground"
open={isOpen}
onOpenChange={(open) =>
setOpenGroups((prev) => ({
...prev,
[groupKey]: open,
}))
}
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-accent/40"
>
<span
className={cn(
"mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-md border border-border bg-muted/30 text-muted-foreground transition-colors",
isOpen && "bg-accent text-accent-foreground"
)}
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
isOpen && "rotate-90"
)}
/>
</span>
<div className="flex-1 min-w-0">
<p className="line-clamp-1 text-xs leading-5 text-foreground">
{group.userMessage}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<span className="rounded-md border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t("changeCount", { count: group.files.length })}
</span>
<span className="rounded-md border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t("fileCount", { count: uniqueFileCount })}
</span>
<span className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-[10px] text-foreground">
<CommitFileAdditions
count={totalAdditions}
className="text-[10px]"
/>
<CommitFileDeletions
count={totalDeletions}
className="text-[10px]"
/>
</span>
</div>
</div>
<span className="mt-0.5 shrink-0 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] tabular-nums text-muted-foreground">
#{groupIndex + 1}
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-border bg-card">
<ul className="space-y-2 p-3">
{group.files.map((file, fileIndex) => {
const normalizedDisplayPath = toFolderRelativePath(
file.path,
folder?.path
)
const lastSlash = normalizedDisplayPath.lastIndexOf("/")
const fileName =
lastSlash >= 0
? normalizedDisplayPath.slice(lastSlash + 1)
: normalizedDisplayPath
const isRemoved = isRemovedFileDiff(file.diff)
return (
<li key={file.id}>
<button
type="button"
className={cn(
"flex w-full items-center gap-2 rounded-lg border px-2.5 py-2 text-left min-w-0",
isRemoved
? "border-destructive/30 bg-destructive/10 cursor-not-allowed"
: "border-border bg-card transition-colors hover:bg-accent/40"
)}
disabled={isRemoved}
onClick={
isRemoved
? undefined
: () =>
handleFileClick(
file.path,
file.diff,
groupIndex,
fileIndex
)
}
title={normalizedDisplayPath}
>
<FileIcon
className={cn(
"h-3.5 w-3.5 shrink-0",
isRemoved
? "text-destructive"
: "text-muted-foreground"
)}
/>
<p
className={cn(
"min-w-0 flex-1 truncate text-xs",
isRemoved ? "text-destructive" : "text-foreground"
)}
>
{fileName}
</p>
{isRemoved ? (
<span className="inline-flex shrink-0 items-center rounded-md border border-destructive/30 bg-destructive/10 px-1.5 py-0.5 font-mono text-[10px] text-destructive">
{t("remove")}
</span>
) : (
<span className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-[10px] text-foreground">
<CommitFileAdditions
count={file.additions}
className="text-[10px]"
/>
<CommitFileDeletions
count={file.deletions}
className="text-[10px]"
/>
</span>
)}
</button>
</li>
)
})}
</ul>
</CollapsibleContent>
</Collapsible>
)
})}
</div>
)
}
export function SessionFilesTab() {
const t = useTranslations("Folder.sessionFiles")
const { tabs, activeTabId } = useTabContext()
const activeTab = tabs.find((t) => t.id === activeTabId)
const conversationId = activeTab?.conversationId
const contextKey = activeTab?.id ?? "__session-files-tab__"
const conn = useConnection(contextKey)
const pendingPromptText = getPendingPromptText(contextKey)
if (!activeTab) {
return (
<div className="flex items-center justify-center h-full p-4">
<p className="text-xs text-muted-foreground text-center">
{t("openConversationToSeeChanges")}
</p>
</div>
)
}
if (!conversationId) {
return (
<div className="flex items-center justify-center h-full p-4">
<p className="text-xs text-muted-foreground text-center">
{t("noFileChangesInConversation")}
</p>
</div>
)
}
return (
<div className="flex flex-col h-full">
<div className="flex-1 min-h-0 overflow-y-auto">
<SessionFilesContent
conversationId={conversationId}
liveMessage={conn.liveMessage}
connStatus={conn.status}
pendingPromptText={pendingPromptText}
/>
</div>
</div>
)
}