Initial commit
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import type { AgentType } from "@/lib/types"
|
||||
import { AGENT_COLORS } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import ClaudeColor from "@lobehub/icons/es/Claude/components/Color"
|
||||
import GeminiColor from "@lobehub/icons/es/Gemini/components/Color"
|
||||
import GithubCopilotMono from "@lobehub/icons/es/GithubCopilot/components/Mono"
|
||||
import QwenColor from "@lobehub/icons/es/Qwen/components/Color"
|
||||
import KimiColor from "@lobehub/icons/es/Kimi/components/Color"
|
||||
import MistralColor from "@lobehub/icons/es/Mistral/components/Color"
|
||||
import OpenClawColor from "@lobehub/icons/es/OpenClaw/components/Color"
|
||||
import { OpenAI, OpenCode } from "@lobehub/icons"
|
||||
|
||||
interface AgentIconProps {
|
||||
agentType: AgentType
|
||||
className?: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyIcon = React.ComponentType<any>
|
||||
|
||||
const COLOR_ICONS: Partial<Record<AgentType, AnyIcon>> = {
|
||||
claude_code: ClaudeColor,
|
||||
gemini: GeminiColor,
|
||||
qwen_code: QwenColor,
|
||||
kimi: KimiColor,
|
||||
mistral_vibe: MistralColor,
|
||||
open_claw: OpenClawColor,
|
||||
}
|
||||
|
||||
const MONO_ICONS: Partial<Record<AgentType, AnyIcon>> = {
|
||||
codex: OpenAI,
|
||||
open_code: OpenCode,
|
||||
github_copilot: GithubCopilotMono,
|
||||
}
|
||||
|
||||
// Text-color versions for Mono icons and SVG fallbacks
|
||||
const AGENT_TEXT_COLORS: Partial<Record<AgentType, string>> = {
|
||||
open_code: "text-blue-500",
|
||||
auggie: "text-purple-500",
|
||||
github_copilot: "text-gray-700 dark:text-gray-300",
|
||||
junie: "text-pink-500",
|
||||
qoder: "text-teal-500",
|
||||
factory_droid: "text-yellow-600",
|
||||
}
|
||||
|
||||
function FallbackIcon({
|
||||
agentType,
|
||||
className,
|
||||
}: {
|
||||
agentType: AgentType
|
||||
className?: string
|
||||
}) {
|
||||
const cls = cn("shrink-0", AGENT_TEXT_COLORS[agentType], className)
|
||||
|
||||
switch (agentType) {
|
||||
case "auggie":
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={cls}>
|
||||
<path
|
||||
d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
case "junie":
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={cls}>
|
||||
<path
|
||||
d="M12 2l8 10-8 10-8-10z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
case "qoder":
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={cls}>
|
||||
<path
|
||||
d="M8 3H6a2 2 0 00-2 2v14a2 2 0 002 2h2M16 3h2a2 2 0 012 2v14a2 2 0 01-2 2h-2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
case "factory_droid":
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={cls}>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full shrink-0",
|
||||
AGENT_COLORS[agentType],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentIcon({ agentType, className }: AgentIconProps) {
|
||||
const ColorIcon = COLOR_ICONS[agentType]
|
||||
if (ColorIcon) {
|
||||
return (
|
||||
<span className={cn("inline-flex shrink-0", className)}>
|
||||
<ColorIcon size="100%" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const MonoIcon = MONO_ICONS[agentType]
|
||||
if (MonoIcon) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0",
|
||||
AGENT_TEXT_COLORS[agentType],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<MonoIcon size="100%" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <FallbackIcon agentType={agentType} className={className} />
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
"use client"
|
||||
|
||||
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react"
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemedToken,
|
||||
} from "shiki"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, CopyIcon } from "lucide-react"
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { createHighlighter } from "shiki"
|
||||
|
||||
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
|
||||
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
|
||||
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1
|
||||
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2
|
||||
const isUnderline = (fontStyle: number | undefined) =>
|
||||
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
fontStyle && fontStyle & 4
|
||||
|
||||
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
||||
interface KeyedToken {
|
||||
token: ThemedToken
|
||||
key: string
|
||||
}
|
||||
interface KeyedLine {
|
||||
tokens: KeyedToken[]
|
||||
key: string
|
||||
}
|
||||
|
||||
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
|
||||
lines.map((line, lineIdx) => ({
|
||||
key: `line-${lineIdx}`,
|
||||
tokens: line.map((token, tokenIdx) => ({
|
||||
key: `line-${lineIdx}-${tokenIdx}`,
|
||||
token,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Token rendering component
|
||||
const TokenSpan = ({ token }: { token: ThemedToken }) => (
|
||||
<span
|
||||
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
|
||||
style={
|
||||
{
|
||||
backgroundColor: token.bgColor,
|
||||
color: token.color,
|
||||
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
|
||||
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
|
||||
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
|
||||
...token.htmlStyle,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
)
|
||||
|
||||
// Line rendering component
|
||||
const LineSpan = ({
|
||||
keyedLine,
|
||||
showLineNumbers,
|
||||
}: {
|
||||
keyedLine: KeyedLine
|
||||
showLineNumbers: boolean
|
||||
}) => (
|
||||
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
|
||||
{keyedLine.tokens.length === 0
|
||||
? "\n"
|
||||
: keyedLine.tokens.map(({ token, key }) => (
|
||||
<TokenSpan key={key} token={token} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
|
||||
// Types
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string
|
||||
language: BundledLanguage
|
||||
showLineNumbers?: boolean
|
||||
}
|
||||
|
||||
interface TokenizedCode {
|
||||
tokens: ThemedToken[][]
|
||||
fg: string
|
||||
bg: string
|
||||
}
|
||||
|
||||
interface CodeBlockContextType {
|
||||
code: string
|
||||
}
|
||||
|
||||
// Context
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
})
|
||||
|
||||
// Highlighter cache (singleton per language)
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>()
|
||||
|
||||
// Token cache
|
||||
const tokensCache = new Map<string, TokenizedCode>()
|
||||
|
||||
// Subscribers for async token updates
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>()
|
||||
|
||||
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||
const start = code.slice(0, 100)
|
||||
const end = code.length > 100 ? code.slice(-100) : ""
|
||||
return `${language}:${code.length}:${start}:${end}`
|
||||
}
|
||||
|
||||
const getHighlighter = (
|
||||
language: BundledLanguage
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||
const cached = highlighterCache.get(language)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const highlighterPromise = createHighlighter({
|
||||
langs: [language],
|
||||
themes: ["github-light", "github-dark"],
|
||||
})
|
||||
|
||||
highlighterCache.set(language, highlighterPromise)
|
||||
return highlighterPromise
|
||||
}
|
||||
|
||||
// Create raw tokens for immediate display while highlighting loads
|
||||
const createRawTokens = (code: string): TokenizedCode => ({
|
||||
bg: "transparent",
|
||||
fg: "inherit",
|
||||
tokens: code.split("\n").map((line) =>
|
||||
line === ""
|
||||
? []
|
||||
: [
|
||||
{
|
||||
color: "inherit",
|
||||
content: line,
|
||||
} as ThemedToken,
|
||||
]
|
||||
),
|
||||
})
|
||||
|
||||
// Synchronous highlight with callback for async results
|
||||
export const highlightCode = (
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
callback?: (result: TokenizedCode) => void
|
||||
): TokenizedCode | null => {
|
||||
const tokensCacheKey = getTokensCacheKey(code, language)
|
||||
|
||||
// Return cached result if available
|
||||
const cached = tokensCache.get(tokensCacheKey)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// Subscribe callback if provided
|
||||
if (callback) {
|
||||
if (!subscribers.has(tokensCacheKey)) {
|
||||
subscribers.set(tokensCacheKey, new Set())
|
||||
}
|
||||
subscribers.get(tokensCacheKey)?.add(callback)
|
||||
}
|
||||
|
||||
// Start highlighting in background - fire-and-forget async pattern
|
||||
getHighlighter(language)
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
||||
.then((highlighter) => {
|
||||
const availableLangs = highlighter.getLoadedLanguages()
|
||||
const langToUse = availableLangs.includes(language) ? language : "text"
|
||||
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: langToUse,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
})
|
||||
|
||||
const tokenized: TokenizedCode = {
|
||||
bg: result.bg ?? "transparent",
|
||||
fg: result.fg ?? "inherit",
|
||||
tokens: result.tokens,
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
tokensCache.set(tokensCacheKey, tokenized)
|
||||
|
||||
// Notify all subscribers
|
||||
const subs = subscribers.get(tokensCacheKey)
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
sub(tokenized)
|
||||
}
|
||||
subscribers.delete(tokensCacheKey)
|
||||
}
|
||||
})
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
.catch((error) => {
|
||||
console.error("Failed to highlight code:", error)
|
||||
subscribers.delete(tokensCacheKey)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Line number styles using CSS counters
|
||||
const LINE_NUMBER_CLASSES = cn(
|
||||
"block",
|
||||
"before:content-[counter(line)]",
|
||||
"before:inline-block",
|
||||
"before:[counter-increment:line]",
|
||||
"before:w-8",
|
||||
"before:mr-4",
|
||||
"before:text-right",
|
||||
"before:text-muted-foreground/50",
|
||||
"before:font-mono",
|
||||
"before:select-none"
|
||||
)
|
||||
|
||||
const CodeBlockBody = memo(
|
||||
({
|
||||
tokenized,
|
||||
showLineNumbers,
|
||||
className,
|
||||
}: {
|
||||
tokenized: TokenizedCode
|
||||
showLineNumbers: boolean
|
||||
className?: string
|
||||
}) => {
|
||||
const preStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: tokenized.bg,
|
||||
color: tokenized.fg,
|
||||
}),
|
||||
[tokenized.bg, tokenized.fg]
|
||||
)
|
||||
|
||||
const keyedLines = useMemo(
|
||||
() => addKeysToTokens(tokenized.tokens),
|
||||
[tokenized.tokens]
|
||||
)
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
|
||||
className
|
||||
)}
|
||||
style={preStyle}
|
||||
>
|
||||
<code
|
||||
className={cn(
|
||||
"font-mono text-sm",
|
||||
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
|
||||
)}
|
||||
>
|
||||
{keyedLines.map((keyedLine) => (
|
||||
<LineSpan
|
||||
key={keyedLine.key}
|
||||
keyedLine={keyedLine}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
},
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.tokenized === nextProps.tokenized &&
|
||||
prevProps.showLineNumbers === nextProps.showLineNumbers &&
|
||||
prevProps.className === nextProps.className
|
||||
)
|
||||
|
||||
CodeBlockBody.displayName = "CodeBlockBody"
|
||||
|
||||
export const CodeBlockContainer = ({
|
||||
className,
|
||||
language,
|
||||
style,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
data-language={language}
|
||||
style={{
|
||||
containIntrinsicSize: "auto 200px",
|
||||
contentVisibility: "auto",
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export const CodeBlockHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const CodeBlockTitle = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const CodeBlockFilename = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn("font-mono", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
export const CodeBlockActions = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const CodeBlockContent = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
}: {
|
||||
code: string
|
||||
language: BundledLanguage
|
||||
showLineNumbers?: boolean
|
||||
}) => {
|
||||
// Memoized raw tokens for immediate display
|
||||
const rawTokens = useMemo(() => createRawTokens(code), [code])
|
||||
|
||||
// Synchronous cached-or-raw value, recomputed when code/language changes
|
||||
const syncTokenized = useMemo(
|
||||
() => highlightCode(code, language) ?? rawTokens,
|
||||
[code, language, rawTokens]
|
||||
)
|
||||
|
||||
// Async highlighted result, tagged with its source code/language
|
||||
const [asyncState, setAsyncState] = useState<{
|
||||
code: string
|
||||
language: string
|
||||
tokenized: TokenizedCode
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
// Subscribe to async highlighting result
|
||||
highlightCode(code, language, (result) => {
|
||||
if (!cancelled) {
|
||||
setAsyncState({ code, language, tokenized: result })
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [code, language])
|
||||
|
||||
// Use async result only if it matches current code/language
|
||||
const tokenized =
|
||||
asyncState?.code === code && asyncState?.language === language
|
||||
? asyncState.tokenized
|
||||
: syncTokenized
|
||||
|
||||
return (
|
||||
<div className="relative overflow-auto">
|
||||
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const contextValue = useMemo(() => ({ code }), [code])
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={contextValue}>
|
||||
<CodeBlockContainer className={className} language={language} {...props}>
|
||||
{children}
|
||||
<CodeBlockContent
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
</CodeBlockContainer>
|
||||
</CodeBlockContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void
|
||||
onError?: (error: Error) => void
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const timeoutRef = useRef<number>(0)
|
||||
const { code } = useContext(CodeBlockContext)
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setIsCopied(true)
|
||||
onCopy?.()
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [code, onCopy, onError, timeout, isCopied])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>
|
||||
|
||||
export const CodeBlockLanguageSelector = (
|
||||
props: CodeBlockLanguageSelectorProps
|
||||
) => <Select {...props} />
|
||||
|
||||
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
|
||||
typeof SelectTrigger
|
||||
>
|
||||
|
||||
export const CodeBlockLanguageSelectorTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorTriggerProps) => (
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
|
||||
className
|
||||
)}
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
|
||||
typeof SelectValue
|
||||
>
|
||||
|
||||
export const CodeBlockLanguageSelectorValue = (
|
||||
props: CodeBlockLanguageSelectorValueProps
|
||||
) => <SelectValue {...props} />
|
||||
|
||||
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
|
||||
typeof SelectContent
|
||||
>
|
||||
|
||||
export const CodeBlockLanguageSelectorContent = ({
|
||||
align = "end",
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorContentProps) => (
|
||||
<SelectContent align={align} {...props} />
|
||||
)
|
||||
|
||||
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
|
||||
typeof SelectItem
|
||||
>
|
||||
|
||||
export const CodeBlockLanguageSelectorItem = (
|
||||
props: CodeBlockLanguageSelectorItemProps
|
||||
) => <SelectItem {...props} />
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client"
|
||||
|
||||
import type {
|
||||
ComponentProps,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
} from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FileIcon,
|
||||
GitCommitIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type CommitProps = ComponentProps<typeof Collapsible>
|
||||
|
||||
export const Commit = ({ className, children, ...props }: CommitProps) => (
|
||||
<Collapsible
|
||||
className={cn("rounded-lg border bg-background", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
)
|
||||
|
||||
export type CommitHeaderProps = ComponentProps<typeof CollapsibleTrigger>
|
||||
|
||||
export const CommitHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitHeaderProps) => (
|
||||
<CollapsibleTrigger asChild {...props}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center justify-between gap-4 p-3 text-left transition-colors hover:opacity-80",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
|
||||
export type CommitHashProps = HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const CommitHash = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitHashProps) => (
|
||||
<span className={cn("font-mono text-xs", className)} {...props}>
|
||||
<GitCommitIcon className="mr-1 inline-block size-3" />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
export type CommitMessageProps = HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const CommitMessage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitMessageProps) => (
|
||||
<span className={cn("font-medium text-sm", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
export type CommitMetadataProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CommitMetadata = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitMetadataProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type CommitSeparatorProps = HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const CommitSeparator = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitSeparatorProps) => (
|
||||
<span className={className} {...props}>
|
||||
{children ?? "•"}
|
||||
</span>
|
||||
)
|
||||
|
||||
export type CommitInfoProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CommitInfo = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitInfoProps) => (
|
||||
<div className={cn("flex flex-1 flex-col", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type CommitAuthorProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CommitAuthor = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitAuthorProps) => (
|
||||
<div className={cn("flex items-center", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type CommitAuthorAvatarProps = ComponentProps<typeof Avatar> & {
|
||||
initials: string
|
||||
}
|
||||
|
||||
export const CommitAuthorAvatar = ({
|
||||
initials,
|
||||
className,
|
||||
...props
|
||||
}: CommitAuthorAvatarProps) => (
|
||||
<Avatar className={cn("size-8", className)} {...props}>
|
||||
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
|
||||
export type CommitTimestampProps = HTMLAttributes<HTMLTimeElement> & {
|
||||
date: Date
|
||||
}
|
||||
|
||||
export const CommitTimestamp = ({
|
||||
date,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitTimestampProps) => {
|
||||
const formatted = date.toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
return (
|
||||
<time
|
||||
className={cn("text-xs", className)}
|
||||
dateTime={date.toISOString()}
|
||||
{...props}
|
||||
>
|
||||
{children ?? formatted}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
|
||||
export type CommitActionsProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const handleActionsClick = (e: MouseEvent) => e.stopPropagation()
|
||||
const handleActionsKeyDown = (e: KeyboardEvent) => e.stopPropagation()
|
||||
|
||||
export const CommitActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitActionsProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-1", className)}
|
||||
onClick={handleActionsClick}
|
||||
onKeyDown={handleActionsKeyDown}
|
||||
role="group"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type CommitCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
hash: string
|
||||
onCopy?: () => void
|
||||
onError?: (error: Error) => void
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export const CommitCopyButton = ({
|
||||
hash,
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CommitCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const timeoutRef = useRef<number>(0)
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(hash)
|
||||
setIsCopied(true)
|
||||
onCopy?.()
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [hash, onCopy, onError, timeout, isCopied])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("size-7 shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type CommitContentProps = ComponentProps<typeof CollapsibleContent>
|
||||
|
||||
export const CommitContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitContentProps) => (
|
||||
<CollapsibleContent className={cn("border-t p-3", className)} {...props}>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
)
|
||||
|
||||
export type CommitFilesProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CommitFiles = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFilesProps) => (
|
||||
<div className={cn("space-y-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type CommitFileProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CommitFile = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type CommitFileInfoProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CommitFileInfo = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileInfoProps) => (
|
||||
<div className={cn("flex min-w-0 items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const fileStatusStyles = {
|
||||
added: "text-green-600 dark:text-green-400",
|
||||
deleted: "text-red-600 dark:text-red-400",
|
||||
modified: "text-yellow-600 dark:text-yellow-400",
|
||||
renamed: "text-blue-600 dark:text-blue-400",
|
||||
}
|
||||
|
||||
const fileStatusLabels = {
|
||||
added: "A",
|
||||
deleted: "D",
|
||||
modified: "M",
|
||||
renamed: "R",
|
||||
}
|
||||
|
||||
export type CommitFileStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
status: "added" | "modified" | "deleted" | "renamed"
|
||||
}
|
||||
|
||||
export const CommitFileStatus = ({
|
||||
status,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileStatusProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium font-mono text-xs",
|
||||
fileStatusStyles[status],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? fileStatusLabels[status]}
|
||||
</span>
|
||||
)
|
||||
|
||||
export type CommitFileIconProps = ComponentProps<typeof FileIcon>
|
||||
|
||||
export const CommitFileIcon = ({
|
||||
className,
|
||||
...props
|
||||
}: CommitFileIconProps) => (
|
||||
<FileIcon
|
||||
className={cn("size-3.5 shrink-0 text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type CommitFilePathProps = HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const CommitFilePath = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFilePathProps) => (
|
||||
<span className={cn("truncate font-mono text-xs", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
export type CommitFileChangesProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CommitFileChanges = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileChangesProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1 font-mono text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type CommitFileAdditionsProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
count: number
|
||||
}
|
||||
|
||||
export const CommitFileAdditions = ({
|
||||
count,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileAdditionsProps) => {
|
||||
if (count <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("text-green-600 dark:text-green-400", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<PlusIcon className="inline-block size-3" />
|
||||
{count}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export type CommitFileDeletionsProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
count: number
|
||||
}
|
||||
|
||||
export const CommitFileDeletions = ({
|
||||
count,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileDeletionsProps) => {
|
||||
if (count <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("text-red-600 dark:text-red-400", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<MinusIcon className="inline-block size-3" />
|
||||
{count}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client"
|
||||
|
||||
import type { HTMLAttributes, ReactNode } from "react"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
interface FileTreeContextType {
|
||||
expandedPaths: Set<string>
|
||||
togglePath: (path: string) => void
|
||||
selectedPath?: string
|
||||
onSelect?: (path: string) => void
|
||||
}
|
||||
|
||||
// Default noop for context default value
|
||||
// oxlint-disable-next-line eslint(no-empty-function)
|
||||
const noop = () => {}
|
||||
|
||||
const FileTreeContext = createContext<FileTreeContextType>({
|
||||
// oxlint-disable-next-line eslint-plugin-unicorn(no-new-builtin)
|
||||
expandedPaths: new Set(),
|
||||
togglePath: noop,
|
||||
})
|
||||
|
||||
export type FileTreeProps = Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> & {
|
||||
expanded?: Set<string>
|
||||
defaultExpanded?: Set<string>
|
||||
selectedPath?: string
|
||||
onSelect?: (path: string) => void
|
||||
onExpandedChange?: (expanded: Set<string>) => void
|
||||
}
|
||||
|
||||
export const FileTree = ({
|
||||
expanded: controlledExpanded,
|
||||
defaultExpanded = new Set(),
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onExpandedChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeProps) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
|
||||
const expandedPaths = controlledExpanded ?? internalExpanded
|
||||
|
||||
const togglePath = useCallback(
|
||||
(path: string) => {
|
||||
const newExpanded = new Set(expandedPaths)
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path)
|
||||
} else {
|
||||
newExpanded.add(path)
|
||||
}
|
||||
setInternalExpanded(newExpanded)
|
||||
onExpandedChange?.(newExpanded)
|
||||
},
|
||||
[expandedPaths, onExpandedChange]
|
||||
)
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ expandedPaths, onSelect, selectedPath, togglePath }),
|
||||
[expandedPaths, onSelect, selectedPath, togglePath]
|
||||
)
|
||||
|
||||
return (
|
||||
<FileTreeContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-background font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
role="tree"
|
||||
{...props}
|
||||
>
|
||||
<div className="w-max min-w-full p-2">{children}</div>
|
||||
</div>
|
||||
</FileTreeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileTreeFolderContextType {
|
||||
path: string
|
||||
name: string
|
||||
isExpanded: boolean
|
||||
}
|
||||
|
||||
const FileTreeFolderContext = createContext<FileTreeFolderContextType>({
|
||||
isExpanded: false,
|
||||
name: "",
|
||||
path: "",
|
||||
})
|
||||
|
||||
export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
path: string
|
||||
name: string
|
||||
nameClassName?: string
|
||||
iconClassName?: string
|
||||
suffix?: ReactNode
|
||||
suffixClassName?: string
|
||||
}
|
||||
|
||||
export const FileTreeFolder = ({
|
||||
path,
|
||||
name,
|
||||
nameClassName,
|
||||
iconClassName,
|
||||
suffix,
|
||||
suffixClassName,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeFolderProps) => {
|
||||
const { expandedPaths, togglePath, selectedPath, onSelect } =
|
||||
useContext(FileTreeContext)
|
||||
const isExpanded = expandedPaths.has(path)
|
||||
const isSelected = selectedPath === path
|
||||
|
||||
const handleOpenChange = useCallback(() => {
|
||||
togglePath(path)
|
||||
}, [togglePath, path])
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
onSelect?.(path)
|
||||
}, [onSelect, path])
|
||||
|
||||
const folderContextValue = useMemo(
|
||||
() => ({ isExpanded, name, path }),
|
||||
[isExpanded, name, path]
|
||||
)
|
||||
|
||||
return (
|
||||
<FileTreeFolderContext.Provider value={folderContextValue}>
|
||||
<Collapsible onOpenChange={handleOpenChange} open={isExpanded}>
|
||||
<div
|
||||
className={cn("", className)}
|
||||
aria-selected={isSelected}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-max min-w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50",
|
||||
isSelected && "bg-muted"
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<FileTreeIcon>
|
||||
{isExpanded ? (
|
||||
<FolderOpenIcon
|
||||
className={cn("size-4 text-blue-500", iconClassName)}
|
||||
/>
|
||||
) : (
|
||||
<FolderIcon
|
||||
className={cn("size-4 text-blue-500", iconClassName)}
|
||||
/>
|
||||
)}
|
||||
</FileTreeIcon>
|
||||
<FileTreeName className={nameClassName}>{name}</FileTreeName>
|
||||
{suffix ? (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 shrink-0 whitespace-nowrap text-muted-foreground/60",
|
||||
suffixClassName
|
||||
)}
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="ml-4 border-l pl-2">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</FileTreeFolderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileTreeFileContextType {
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const FileTreeFileContext = createContext<FileTreeFileContextType>({
|
||||
name: "",
|
||||
path: "",
|
||||
})
|
||||
|
||||
export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {
|
||||
path: string
|
||||
name: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export const FileTreeFile = ({
|
||||
path,
|
||||
name,
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeFileProps) => {
|
||||
const { selectedPath, onSelect } = useContext(FileTreeContext)
|
||||
const isSelected = selectedPath === path
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect?.(path)
|
||||
}, [onSelect, path])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onSelect?.(path)
|
||||
}
|
||||
},
|
||||
[onSelect, path]
|
||||
)
|
||||
|
||||
const fileContextValue = useMemo(() => ({ name, path }), [name, path])
|
||||
|
||||
return (
|
||||
<FileTreeFileContext.Provider value={fileContextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-max min-w-full cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
|
||||
isSelected && "bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-selected={isSelected}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{/* Spacer for alignment */}
|
||||
<span className="size-4" />
|
||||
<FileTreeIcon>
|
||||
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
|
||||
</FileTreeIcon>
|
||||
<FileTreeName>{name}</FileTreeName>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FileTreeFileContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileTreeIconProps = HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const FileTreeIcon = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeIconProps) => (
|
||||
<span className={cn("shrink-0", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
export type FileTreeNameProps = HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const FileTreeName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeNameProps) => (
|
||||
<span className={cn("shrink-0 whitespace-nowrap", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
export type FileTreeActionsProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const stopPropagation = (e: React.SyntheticEvent) => e.stopPropagation()
|
||||
|
||||
export const FileTreeActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeActionsProps) => (
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation required for nested interactions
|
||||
// biome-ignore lint/a11y/useSemanticElements: fieldset doesn't fit this UI pattern
|
||||
<div
|
||||
className={cn("ml-auto flex items-center gap-1", className)}
|
||||
onClick={stopPropagation}
|
||||
onKeyDown={stopPropagation}
|
||||
role="group"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowDownIcon, DownloadIcon } from "lucide-react"
|
||||
import { useCallback } from "react"
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"
|
||||
|
||||
export type MessageThreadProps = ComponentProps<typeof StickToBottom>
|
||||
|
||||
export const MessageThread = ({ className, ...props }: MessageThreadProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type MessageThreadContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>
|
||||
|
||||
export const MessageThreadContent = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageThreadContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type MessageThreadEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const MessageThreadEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: MessageThreadEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type MessageThreadScrollButtonProps = ComponentProps<typeof Button>
|
||||
|
||||
export const MessageThreadScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageThreadScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom()
|
||||
}, [scrollToBottom])
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export interface ThreadMessage {
|
||||
role: "user" | "assistant" | "system" | "data" | "tool"
|
||||
content: string
|
||||
}
|
||||
|
||||
export type MessageThreadDownloadProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"onClick"
|
||||
> & {
|
||||
messages: ThreadMessage[]
|
||||
filename?: string
|
||||
formatMessage?: (message: ThreadMessage, index: number) => string
|
||||
}
|
||||
|
||||
const defaultFormatMessage = (message: ThreadMessage): string => {
|
||||
const roleLabel = message.role.charAt(0).toUpperCase() + message.role.slice(1)
|
||||
return `**${roleLabel}:** ${message.content}`
|
||||
}
|
||||
|
||||
export const messagesToMarkdown = (
|
||||
messages: ThreadMessage[],
|
||||
formatMessage: (
|
||||
message: ThreadMessage,
|
||||
index: number
|
||||
) => string = defaultFormatMessage
|
||||
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n")
|
||||
|
||||
export const MessageThreadDownload = ({
|
||||
messages,
|
||||
filename = "conversation.md",
|
||||
formatMessage = defaultFormatMessage,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageThreadDownloadProps) => {
|
||||
const handleDownload = useCallback(() => {
|
||||
const markdown = messagesToMarkdown(messages, formatMessage)
|
||||
const blob = new Blob([markdown], { type: "text/markdown" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [messages, filename, formatMessage])
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleDownload}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <DownloadIcon className="size-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
"use client"
|
||||
|
||||
import type { UIMessage } from "ai"
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cjk } from "@streamdown/cjk"
|
||||
import { code } from "@streamdown/code"
|
||||
import { math } from "@streamdown/math"
|
||||
import { mermaid } from "@streamdown/mermaid"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { Streamdown } from "streamdown"
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"]
|
||||
}
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full flex-col gap-2",
|
||||
from === "user"
|
||||
? "is-user ml-auto justify-end max-w-[80%]"
|
||||
: "is-assistant",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex min-w-0 flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:w-fit group-[.is-user]:max-w-full group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
interface MessageBranchContextType {
|
||||
currentBranch: number
|
||||
totalBranches: number
|
||||
goToPrevious: () => void
|
||||
goToNext: () => void
|
||||
branches: ReactElement[]
|
||||
setBranches: (branches: ReactElement[]) => void
|
||||
}
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number
|
||||
onBranchChange?: (branchIndex: number) => void
|
||||
}
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch)
|
||||
const [branches, setBranches] = useState<ReactElement[]>([])
|
||||
|
||||
const handleBranchChange = useCallback(
|
||||
(newBranch: number) => {
|
||||
setCurrentBranch(newBranch)
|
||||
onBranchChange?.(newBranch)
|
||||
},
|
||||
[onBranchChange]
|
||||
)
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1
|
||||
handleBranchChange(newBranch)
|
||||
}, [currentBranch, branches.length, handleBranchChange])
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0
|
||||
handleBranchChange(newBranch)
|
||||
}, [currentBranch, branches.length, handleBranchChange])
|
||||
|
||||
const contextValue = useMemo<MessageBranchContextType>(
|
||||
() => ({
|
||||
branches,
|
||||
currentBranch,
|
||||
goToNext,
|
||||
goToPrevious,
|
||||
setBranches,
|
||||
totalBranches: branches.length,
|
||||
}),
|
||||
[branches, currentBranch, goToNext, goToPrevious]
|
||||
)
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch()
|
||||
const childrenArray = useMemo(
|
||||
() => (Array.isArray(children) ? children : [children]),
|
||||
[children]
|
||||
)
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray)
|
||||
}
|
||||
}, [childrenArray, branches, setBranches])
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch()
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className={cn(
|
||||
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
|
||||
className
|
||||
)}
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch()
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
)
|
||||
}
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid }
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
)
|
||||
|
||||
MessageResponse.displayName = "MessageResponse"
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client"
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cjk } from "@streamdown/cjk"
|
||||
import { code } from "@streamdown/code"
|
||||
import { math } from "@streamdown/math"
|
||||
import { mermaid } from "@streamdown/mermaid"
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react"
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { Streamdown } from "streamdown"
|
||||
|
||||
import { Shimmer } from "./shimmer"
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
duration: number | undefined
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext)
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000
|
||||
const MS_IN_S = 1000
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const resolvedDefaultOpen = defaultOpen ?? isStreaming
|
||||
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
||||
const isExplicitlyClosed = defaultOpen === false
|
||||
|
||||
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||
defaultProp: resolvedDefaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
})
|
||||
const [duration, setDuration] = useControllableState<number | undefined>({
|
||||
defaultProp: undefined,
|
||||
prop: durationProp,
|
||||
})
|
||||
|
||||
const hasEverStreamedRef = useRef(isStreaming)
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||
const startTimeRef = useRef<number | null>(null)
|
||||
|
||||
// Track when streaming starts and compute duration
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
hasEverStreamedRef.current = true
|
||||
if (startTimeRef.current === null) {
|
||||
startTimeRef.current = Date.now()
|
||||
}
|
||||
} else if (startTimeRef.current !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S))
|
||||
startTimeRef.current = null
|
||||
}
|
||||
}, [isStreaming, setDuration])
|
||||
|
||||
// Auto-open when streaming starts (unless explicitly closed)
|
||||
useEffect(() => {
|
||||
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||
setIsOpen(true)
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed])
|
||||
|
||||
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasEverStreamedRef.current &&
|
||||
!isStreaming &&
|
||||
isOpen &&
|
||||
!hasAutoClosed
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
setHasAutoClosed(true)
|
||||
}, AUTO_CLOSE_DELAY)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
setIsOpen(newOpen)
|
||||
},
|
||||
[setIsOpen]
|
||||
)
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
||||
[duration, isOpen, isStreaming, setIsOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider value={contextValue}>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
|
||||
}
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>
|
||||
}
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning()
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string
|
||||
}
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid }
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown plugins={streamdownPlugins} {...props}>
|
||||
{children}
|
||||
</Streamdown>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
)
|
||||
|
||||
Reasoning.displayName = "Reasoning"
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger"
|
||||
ReasoningContent.displayName = "ReasoningContent"
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import type { MotionProps } from "motion/react"
|
||||
import type { CSSProperties, ElementType, JSX } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { motion } from "motion/react"
|
||||
import { memo, useMemo } from "react"
|
||||
|
||||
type MotionHTMLProps = MotionProps & Record<string, unknown>
|
||||
|
||||
// Cache motion components at module level to avoid creating during render
|
||||
const motionComponentCache = new Map<
|
||||
keyof JSX.IntrinsicElements,
|
||||
React.ComponentType<MotionHTMLProps>
|
||||
>()
|
||||
|
||||
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
|
||||
let component = motionComponentCache.get(element)
|
||||
if (!component) {
|
||||
component = motion.create(element)
|
||||
motionComponentCache.set(element, component)
|
||||
}
|
||||
return component
|
||||
}
|
||||
|
||||
export interface TextShimmerProps {
|
||||
children: string
|
||||
as?: ElementType
|
||||
className?: string
|
||||
duration?: number
|
||||
spread?: number
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = useMemo(
|
||||
() => getMotionComponent(Component as keyof JSX.IntrinsicElements),
|
||||
[Component]
|
||||
)
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
)
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react-hooks/static-components -- component is cached at module level via motionComponentCache
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
duration,
|
||||
ease: "linear",
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
)
|
||||
}
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent)
|
||||
@@ -0,0 +1,311 @@
|
||||
"use client"
|
||||
|
||||
import type { ComponentProps, HTMLAttributes } from "react"
|
||||
import Ansi from "ansi-to-react"
|
||||
import { CheckIcon, CopyIcon, TerminalIcon, Trash2Icon } from "lucide-react"
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Shimmer } from "./shimmer"
|
||||
|
||||
interface TerminalContextType {
|
||||
output: string
|
||||
isStreaming: boolean
|
||||
autoScroll: boolean
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
const TerminalContext = createContext<TerminalContextType>({
|
||||
output: "",
|
||||
isStreaming: false,
|
||||
autoScroll: true,
|
||||
})
|
||||
|
||||
function normalizeTerminalOutput(output: string): string {
|
||||
if (!output) return output
|
||||
|
||||
// Some runtimes deliver ANSI escapes as literal "\\u001b"/"\\x1b".
|
||||
// Decode them so ansi-to-react can apply terminal colors.
|
||||
const hasEscapedAnsi =
|
||||
output.includes("\\u001b") ||
|
||||
output.includes("\\u001B") ||
|
||||
output.includes("\\x1b") ||
|
||||
output.includes("\\x1B") ||
|
||||
output.includes("\\u009b") ||
|
||||
output.includes("\\u009B")
|
||||
|
||||
if (!hasEscapedAnsi) return output
|
||||
|
||||
return output
|
||||
.replace(/\\u001b/gi, "\u001b")
|
||||
.replace(/\\x1b/gi, "\u001b")
|
||||
.replace(/\\u009b/gi, "\u009b")
|
||||
}
|
||||
|
||||
export type TerminalProps = HTMLAttributes<HTMLDivElement> & {
|
||||
output: string
|
||||
isStreaming?: boolean
|
||||
autoScroll?: boolean
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export function Terminal({
|
||||
output,
|
||||
isStreaming = false,
|
||||
autoScroll = true,
|
||||
onClear,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalProps) {
|
||||
const contextValue = useMemo(
|
||||
() => ({ output, isStreaming, autoScroll, onClear }),
|
||||
[output, isStreaming, autoScroll, onClear]
|
||||
)
|
||||
|
||||
return (
|
||||
<TerminalContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<TerminalHeader>
|
||||
<TerminalTitle />
|
||||
<div className="flex items-center gap-1">
|
||||
<TerminalStatus />
|
||||
<TerminalActions>
|
||||
<TerminalCopyButton />
|
||||
{onClear && <TerminalClearButton />}
|
||||
</TerminalActions>
|
||||
</div>
|
||||
</TerminalHeader>
|
||||
<TerminalContent />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TerminalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type TerminalHeaderProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function TerminalHeader({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b border-border bg-muted/30 px-4 py-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type TerminalTitleProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function TerminalTitle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalTitleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
{children ?? "Terminal"}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type TerminalStatusProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function TerminalStatus({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalStatusProps) {
|
||||
const { isStreaming } = useContext(TerminalContext)
|
||||
|
||||
if (!isStreaming) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Shimmer>Running</Shimmer>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type TerminalActionsProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function TerminalActions({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalActionsProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type TerminalCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void
|
||||
onError?: (error: Error) => void
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export function TerminalCopyButton({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TerminalCopyButtonProps) {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const timeoutRef = useRef<number>(0)
|
||||
const { output } = useContext(TerminalContext)
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(output)
|
||||
setIsCopied(true)
|
||||
onCopy?.()
|
||||
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout)
|
||||
} catch (error) {
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}, [output, onCopy, onError, timeout])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type TerminalClearButtonProps = ComponentProps<typeof Button>
|
||||
|
||||
export function TerminalClearButton({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TerminalClearButtonProps) {
|
||||
const { onClear } = useContext(TerminalContext)
|
||||
|
||||
if (!onClear) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
onClick={onClear}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Trash2Icon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type TerminalContentProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function TerminalContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalContentProps) {
|
||||
const { output, isStreaming, autoScroll } = useContext(TerminalContext)
|
||||
const normalizedOutput = useMemo(
|
||||
() => normalizeTerminalOutput(output),
|
||||
[output]
|
||||
)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||
}
|
||||
}, [output, autoScroll])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-96 overflow-auto p-4 font-mono text-sm leading-relaxed",
|
||||
className
|
||||
)}
|
||||
ref={containerRef}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
<Ansi>{normalizedOutput}</Ansi>
|
||||
{isStreaming && (
|
||||
<span className="ml-0.5 inline-block h-4 w-2 animate-pulse bg-foreground" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
"use client"
|
||||
|
||||
import type { DynamicToolUIPart, ToolUIPart } from "ai"
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { isValidElement } from "react"
|
||||
|
||||
import { CodeBlock } from "./code-block"
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("group mb-4 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ToolPart = ToolUIPart | DynamicToolUIPart
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: ReactNode
|
||||
titleSuffix?: ReactNode
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
} & (
|
||||
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
|
||||
| {
|
||||
type: DynamicToolUIPart["type"]
|
||||
state: DynamicToolUIPart["state"]
|
||||
toolName: string
|
||||
}
|
||||
)
|
||||
|
||||
const statusLabels: Record<ToolPart["state"], string> = {
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"input-available": "Running",
|
||||
"input-streaming": "Pending",
|
||||
"output-available": "Completed",
|
||||
"output-denied": "Denied",
|
||||
"output-error": "Error",
|
||||
}
|
||||
|
||||
const statusIcons: Record<ToolPart["state"], ReactNode> = {
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
}
|
||||
|
||||
export const getStatusBadge = (status: ToolPart["state"]) => (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{statusIcons[status]}
|
||||
{statusLabels[status]}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
titleSuffix,
|
||||
icon,
|
||||
type,
|
||||
state,
|
||||
toolName,
|
||||
...props
|
||||
}: ToolHeaderProps) => {
|
||||
const derivedName =
|
||||
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-")
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0">
|
||||
{icon ?? <WrenchIcon className="size-4 text-muted-foreground" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate whitespace-nowrap font-medium text-sm">
|
||||
{title ?? derivedName}
|
||||
</span>
|
||||
{titleSuffix ? <span className="shrink-0">{titleSuffix}</span> : null}
|
||||
<span className="shrink-0">{getStatusBadge(state)}</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolPart["input"]
|
||||
}
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => {
|
||||
const formattedCode = (() => {
|
||||
if (typeof input === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(input)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
}
|
||||
return JSON.stringify(input, null, 2)
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={formattedCode} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function detectOutputLanguage(text: string) {
|
||||
const trimmed = text.trimStart()
|
||||
if (
|
||||
(trimmed.startsWith("{") || trimmed.startsWith("[")) &&
|
||||
(() => {
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
) {
|
||||
return "json" as const
|
||||
}
|
||||
if (trimmed.includes("diff --git") || trimmed.includes("@@")) {
|
||||
return "diff" as const
|
||||
}
|
||||
if (trimmed.startsWith("<")) {
|
||||
return "xml" as const
|
||||
}
|
||||
return "log" as const
|
||||
}
|
||||
|
||||
const ERROR_LIKE_KEYS = [
|
||||
"error",
|
||||
"message",
|
||||
"stderr",
|
||||
"detail",
|
||||
"details",
|
||||
"reason",
|
||||
"text",
|
||||
"output",
|
||||
"formatted_output",
|
||||
"aggregated_output",
|
||||
"result",
|
||||
]
|
||||
|
||||
function stripErrorPrefix(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/^error:\s*/i, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeErrorForCompare(text: string): string {
|
||||
return stripErrorPrefix(text).replace(/\s+/g, " ")
|
||||
}
|
||||
|
||||
function collectErrorCandidates(value: unknown): string[] {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return [value]
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => collectErrorCandidates(item))
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
return []
|
||||
}
|
||||
|
||||
const obj = value as Record<string, unknown>
|
||||
const candidates: string[] = []
|
||||
|
||||
for (const key of ERROR_LIKE_KEYS) {
|
||||
if (!(key in obj)) continue
|
||||
candidates.push(...collectErrorCandidates(obj[key]))
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function parseJson(value: string): unknown | null {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorFieldValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function renderErrorText(errorText: string): ReactNode {
|
||||
const parsed = parseJson(errorText.trim())
|
||||
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
const entries = Object.entries(parsed as Record<string, unknown>)
|
||||
if (entries.length > 0) {
|
||||
return (
|
||||
<div className="space-y-2 p-3">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key} className="space-y-1">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-destructive/80">
|
||||
{key}
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-destructive">
|
||||
{formatErrorFieldValue(value)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap break-words p-3 font-mono text-xs text-destructive">
|
||||
{errorText}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function isDuplicateErrorOutput(
|
||||
output: ToolPart["output"],
|
||||
normalizedErrorText: string | null
|
||||
): boolean {
|
||||
if (!normalizedErrorText || !output) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rawCandidates: string[] = []
|
||||
if (typeof output === "string") {
|
||||
rawCandidates.push(output)
|
||||
const parsed = parseJson(output)
|
||||
if (parsed) {
|
||||
rawCandidates.push(...collectErrorCandidates(parsed))
|
||||
}
|
||||
} else if (typeof output === "object" && !isValidElement(output)) {
|
||||
rawCandidates.push(...collectErrorCandidates(output))
|
||||
}
|
||||
|
||||
return rawCandidates.some((candidate) => {
|
||||
const normalizedCandidate = normalizeErrorForCompare(candidate)
|
||||
return (
|
||||
normalizedCandidate.length > 0 &&
|
||||
normalizedCandidate === normalizedErrorText
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolPart["output"]
|
||||
errorText: ToolPart["errorText"]
|
||||
}
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedErrorText =
|
||||
typeof errorText === "string" ? normalizeErrorForCompare(errorText) : null
|
||||
const hasDuplicateErrorOutput = isDuplicateErrorOutput(
|
||||
output,
|
||||
normalizedErrorText
|
||||
)
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
|
||||
)
|
||||
} else if (typeof output === "string") {
|
||||
const language = detectOutputLanguage(output)
|
||||
Output = <CodeBlock code={output} language={language} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{typeof errorText === "string" && renderErrorText(errorText)}
|
||||
{!hasDuplicateErrorOutput && Output}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||
import type { PlanEntryInfo } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CircleDashedIcon,
|
||||
ListTodoIcon,
|
||||
Loader2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
interface AgentPlanOverlayProps {
|
||||
message?: LiveMessage | null
|
||||
entries?: PlanEntryInfo[] | null
|
||||
planKey?: string | null
|
||||
visible?: boolean
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
function getLatestPlanEntries(message: LiveMessage | null): PlanEntryInfo[] {
|
||||
if (!message) return []
|
||||
|
||||
for (let i = message.content.length - 1; i >= 0; i -= 1) {
|
||||
const block = message.content[i]
|
||||
if (block.type === "plan") {
|
||||
return block.entries
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "in_progress":
|
||||
return "In Progress"
|
||||
case "pending":
|
||||
return "Pending"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityLabel(priority: string): string {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return "High"
|
||||
case "medium":
|
||||
return "Medium"
|
||||
case "low":
|
||||
return "Low"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityClassName(priority: string): string {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return "text-red-700 bg-red-500/10 border-red-500/20 dark:text-red-300"
|
||||
case "medium":
|
||||
return "text-amber-700 bg-amber-500/10 border-amber-500/20 dark:text-amber-300"
|
||||
case "low":
|
||||
return "text-slate-700 bg-slate-500/10 border-slate-500/20 dark:text-slate-300"
|
||||
default:
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: string }) {
|
||||
if (status === "completed") {
|
||||
return <CheckCircle2Icon className="h-3.5 w-3.5 text-emerald-500" />
|
||||
}
|
||||
|
||||
if (status === "in_progress") {
|
||||
return <Loader2Icon className="h-3.5 w-3.5 text-blue-500 animate-spin" />
|
||||
}
|
||||
|
||||
return <CircleDashedIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
}
|
||||
|
||||
export const AgentPlanOverlay = memo(function AgentPlanOverlay({
|
||||
message,
|
||||
entries,
|
||||
planKey,
|
||||
visible = true,
|
||||
defaultExpanded = true,
|
||||
}: AgentPlanOverlayProps) {
|
||||
const liveEntries = useMemo(
|
||||
() => getLatestPlanEntries(message ?? null),
|
||||
[message]
|
||||
)
|
||||
const resolvedEntries = useMemo(
|
||||
() => (liveEntries.length > 0 ? liveEntries : (entries ?? [])),
|
||||
[liveEntries, entries]
|
||||
)
|
||||
const hasPlan = visible && resolvedEntries.length > 0
|
||||
const fallbackPlanKey = useMemo(() => {
|
||||
if (resolvedEntries.length === 0) return null
|
||||
return resolvedEntries
|
||||
.map((entry) => `${entry.status}:${entry.priority}:${entry.content}`)
|
||||
.join("|")
|
||||
}, [resolvedEntries])
|
||||
const currentPlanKey = planKey ?? message?.id ?? fallbackPlanKey
|
||||
|
||||
const completedCount = useMemo(
|
||||
() =>
|
||||
resolvedEntries.filter((entry) => entry.status === "completed").length,
|
||||
[resolvedEntries]
|
||||
)
|
||||
const hasIncompleteEntries = completedCount < resolvedEntries.length
|
||||
const resolvedDefaultExpanded = defaultExpanded && hasIncompleteEntries
|
||||
const currentPlanStateKey = currentPlanKey ?? "__plan__default__"
|
||||
const [collapsedByPlanKey, setCollapsedByPlanKey] = useState<
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
const isExpanded = !(
|
||||
collapsedByPlanKey[currentPlanStateKey] ?? !resolvedDefaultExpanded
|
||||
)
|
||||
|
||||
if (!hasPlan) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute right-8 top-4 z-20 flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="cursor-pointer pointer-events-auto shadow-md bg-secondary/70 hover:bg-secondary"
|
||||
onClick={() =>
|
||||
setCollapsedByPlanKey((prev) => ({
|
||||
...prev,
|
||||
[currentPlanStateKey]: false,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<ListTodoIcon className="h-4 w-4" />
|
||||
Plan {completedCount}/{resolvedEntries.length}
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute right-8 top-4 z-20 flex max-w-[min(22rem,calc(100%-2rem))]"
|
||||
data-plan-key={currentPlanKey ?? undefined}
|
||||
>
|
||||
<div className="pointer-events-auto w-72 max-w-full rounded-xl border bg-card/60 hover:bg-card/95 shadow-lg backdrop-blur transition-colors supports-[backdrop-filter]:bg-card/50 supports-[backdrop-filter]:hover:bg-card/85">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ListTodoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate">Agent Plan</span>
|
||||
<Badge variant="secondary" className="h-5">
|
||||
{completedCount}/{resolvedEntries.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Collapse plan"
|
||||
onClick={() =>
|
||||
setCollapsedByPlanKey((prev) => ({
|
||||
...prev,
|
||||
[currentPlanStateKey]: true,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto p-3 space-y-2">
|
||||
{resolvedEntries.map((entry, index) => (
|
||||
<div
|
||||
key={`${entry.content}-${index}`}
|
||||
className="rounded-lg border bg-transparent px-2.5 py-2"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={entry.status} />
|
||||
<p
|
||||
className={cn(
|
||||
"min-w-0 flex-1 text-sm leading-5 break-words [overflow-wrap:anywhere]",
|
||||
entry.status === "completed"
|
||||
? "text-muted-foreground line-through"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{entry.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 pl-5">
|
||||
<Badge variant="outline" className="h-5 text-[10px] uppercase">
|
||||
{getStatusLabel(entry.status)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-5 text-[10px] uppercase",
|
||||
getPriorityClassName(entry.priority)
|
||||
)}
|
||||
>
|
||||
{getPriorityLabel(entry.priority)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { acpListAgents } from "@/lib/tauri"
|
||||
import type { AgentType, AcpAgentInfo } from "@/lib/types"
|
||||
import { AGENT_LABELS } from "@/lib/types"
|
||||
import { AgentIcon } from "@/components/agent-icon"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface AgentSelectorProps {
|
||||
defaultAgentType?: AgentType
|
||||
onSelect: (agentType: AgentType) => void
|
||||
onAgentsLoaded?: (agents: AcpAgentInfo[]) => void
|
||||
onOpenAgentsSettings?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function AgentSelector({
|
||||
defaultAgentType,
|
||||
onSelect,
|
||||
onAgentsLoaded,
|
||||
onOpenAgentsSettings,
|
||||
disabled = false,
|
||||
}: AgentSelectorProps) {
|
||||
const [agents, setAgents] = useState<AcpAgentInfo[]>([])
|
||||
const [selected, setSelected] = useState<AgentType | null>(
|
||||
defaultAgentType ?? null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
acpListAgents()
|
||||
.then((list) => {
|
||||
if (cancelled) return
|
||||
const sorted = [...list].sort(
|
||||
(a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name)
|
||||
)
|
||||
const visible = sorted.filter((a) => a.enabled)
|
||||
setAgents(visible)
|
||||
onAgentsLoaded?.(visible)
|
||||
// Auto-select default if it exists in the list
|
||||
if (defaultAgentType) {
|
||||
const found = visible.find(
|
||||
(a) => a.agent_type === defaultAgentType && a.available
|
||||
)
|
||||
if (found) {
|
||||
setSelected(found.agent_type)
|
||||
} else {
|
||||
// Fall back to first available
|
||||
const first = visible.find((a) => a.available)
|
||||
if (first) {
|
||||
setSelected(first.agent_type)
|
||||
onSelect(first.agent_type)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const first = visible.find((a) => a.available)
|
||||
if (first) {
|
||||
setSelected(first.agent_type)
|
||||
onSelect(first.agent_type)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAgents([])
|
||||
onAgentsLoaded?.([])
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [defaultAgentType, onAgentsLoaded, onSelect])
|
||||
|
||||
const handleSelect = (agentType: AgentType) => {
|
||||
setSelected(agentType)
|
||||
onSelect(agentType)
|
||||
}
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-3 text-center text-sm text-muted-foreground">
|
||||
<div>暂无已启用的 Agent</div>
|
||||
{onOpenAgentsSettings ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAgentsSettings}
|
||||
className="mt-2 inline-flex items-center rounded-md border px-2 py-1 text-xs text-foreground transition-colors hover:bg-accent cursor-pointer"
|
||||
>
|
||||
打开 Agents 设置
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.agent_type}
|
||||
disabled={disabled || !agent.available}
|
||||
onClick={() => handleSelect(agent.agent_type)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
"border",
|
||||
disabled || !agent.available
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: "cursor-pointer hover:bg-accent",
|
||||
selected === agent.agent_type
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<AgentIcon agentType={agent.agent_type} className="w-3.5 h-3.5" />
|
||||
{AGENT_LABELS[agent.agent_type]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
PromptDraft,
|
||||
SessionConfigOptionInfo,
|
||||
SessionModeInfo,
|
||||
AvailableCommandInfo,
|
||||
} from "@/lib/types"
|
||||
import { MessageInput } from "@/components/chat/message-input"
|
||||
|
||||
interface ChatInputProps {
|
||||
status: ConnectionStatus | null
|
||||
defaultPath?: string
|
||||
onFocus?: () => void
|
||||
onSend: (draft: PromptDraft, modeId?: string | null) => void
|
||||
onCancel: () => void
|
||||
modes?: SessionModeInfo[]
|
||||
configOptions?: SessionConfigOptionInfo[]
|
||||
modeLoading?: boolean
|
||||
configOptionsLoading?: boolean
|
||||
selectedModeId?: string | null
|
||||
onModeChange?: (modeId: string) => void
|
||||
onConfigOptionChange?: (configId: string, valueId: string) => void
|
||||
availableCommands?: AvailableCommandInfo[] | null
|
||||
attachmentTabId?: string | null
|
||||
draftStorageKey?: string | null
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
status,
|
||||
defaultPath,
|
||||
onFocus,
|
||||
onSend,
|
||||
onCancel,
|
||||
modes,
|
||||
configOptions,
|
||||
modeLoading = false,
|
||||
configOptionsLoading = false,
|
||||
selectedModeId,
|
||||
onModeChange,
|
||||
onConfigOptionChange,
|
||||
availableCommands,
|
||||
attachmentTabId,
|
||||
draftStorageKey,
|
||||
}: ChatInputProps) {
|
||||
const isConnected = status === "connected"
|
||||
const isPrompting = status === "prompting"
|
||||
const isConnecting = status === "connecting" || status === "downloading"
|
||||
|
||||
return (
|
||||
<div className="p-4 pt-0">
|
||||
<MessageInput
|
||||
onSend={onSend}
|
||||
onFocus={onFocus}
|
||||
defaultPath={defaultPath}
|
||||
disabled={!isConnected}
|
||||
isPrompting={isPrompting}
|
||||
onCancel={onCancel}
|
||||
modes={modes}
|
||||
configOptions={configOptions}
|
||||
modeLoading={modeLoading}
|
||||
configOptionsLoading={configOptionsLoading}
|
||||
selectedModeId={selectedModeId}
|
||||
onModeChange={onModeChange}
|
||||
onConfigOptionChange={onConfigOptionChange}
|
||||
availableCommands={availableCommands}
|
||||
attachmentTabId={attachmentTabId}
|
||||
draftStorageKey={draftStorageKey}
|
||||
placeholder={
|
||||
isConnecting
|
||||
? "Connecting..."
|
||||
: isPrompting
|
||||
? "Agent is responding..."
|
||||
: "Send a message..."
|
||||
}
|
||||
className="min-h-28 max-h-60"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ConnectionStatus } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
|
||||
connected: { color: "bg-green-500", label: "Agent connected" },
|
||||
connecting: {
|
||||
color: "bg-blue-500 animate-pulse",
|
||||
label: "Connecting...",
|
||||
},
|
||||
prompting: {
|
||||
color: "bg-yellow-500 animate-pulse",
|
||||
label: "Agent responding...",
|
||||
},
|
||||
error: { color: "bg-red-500", label: "Connection error" },
|
||||
}
|
||||
|
||||
interface ConnectionStatusIndicatorProps {
|
||||
status: ConnectionStatus | null | undefined
|
||||
}
|
||||
|
||||
export function ConnectionStatusIndicator({
|
||||
status,
|
||||
}: ConnectionStatusIndicatorProps) {
|
||||
if (!status || status === "disconnected") return null
|
||||
|
||||
const config = STATUS_CONFIG[status]
|
||||
if (!config) return null
|
||||
|
||||
return (
|
||||
<div className="px-4 py-1 text-xs text-muted-foreground border-t border-border flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn("inline-block h-1.5 w-1.5 rounded-full", config.color)}
|
||||
/>
|
||||
{config.label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
PromptDraft,
|
||||
SessionConfigOptionInfo,
|
||||
SessionModeInfo,
|
||||
AvailableCommandInfo,
|
||||
} from "@/lib/types"
|
||||
import type { PendingPermission } from "@/contexts/acp-connections-context"
|
||||
import { ChatInput } from "@/components/chat/chat-input"
|
||||
import { PermissionDialog } from "@/components/chat/permission-dialog"
|
||||
|
||||
interface ConversationShellProps {
|
||||
status: ConnectionStatus | null
|
||||
defaultPath?: string
|
||||
error: string | null
|
||||
pendingPermission: PendingPermission | null
|
||||
onFocus: () => void
|
||||
onSend: (draft: PromptDraft, modeId?: string | null) => void
|
||||
onCancel: () => void
|
||||
onRespondPermission: (requestId: string, optionId: string) => void
|
||||
children: ReactNode
|
||||
modes?: SessionModeInfo[]
|
||||
configOptions?: SessionConfigOptionInfo[]
|
||||
modeLoading?: boolean
|
||||
configOptionsLoading?: boolean
|
||||
selectedModeId?: string | null
|
||||
onModeChange?: (modeId: string) => void
|
||||
onConfigOptionChange?: (configId: string, valueId: string) => void
|
||||
availableCommands?: AvailableCommandInfo[] | null
|
||||
attachmentTabId?: string | null
|
||||
draftStorageKey?: string | null
|
||||
}
|
||||
|
||||
export function ConversationShell({
|
||||
status,
|
||||
defaultPath,
|
||||
error,
|
||||
pendingPermission,
|
||||
onFocus,
|
||||
onSend,
|
||||
onCancel,
|
||||
onRespondPermission,
|
||||
children,
|
||||
modes,
|
||||
configOptions,
|
||||
modeLoading = false,
|
||||
configOptionsLoading = false,
|
||||
selectedModeId,
|
||||
onModeChange,
|
||||
onConfigOptionChange,
|
||||
availableCommands,
|
||||
attachmentTabId,
|
||||
draftStorageKey,
|
||||
}: ConversationShellProps) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex-1 min-h-0">{children}</div>
|
||||
|
||||
<PermissionDialog
|
||||
permission={pendingPermission}
|
||||
onRespond={onRespondPermission}
|
||||
/>
|
||||
|
||||
<ChatInput
|
||||
status={status}
|
||||
defaultPath={defaultPath}
|
||||
onFocus={onFocus}
|
||||
onSend={onSend}
|
||||
onCancel={onCancel}
|
||||
modes={modes}
|
||||
configOptions={configOptions}
|
||||
modeLoading={modeLoading}
|
||||
configOptionsLoading={configOptionsLoading}
|
||||
selectedModeId={selectedModeId}
|
||||
onModeChange={onModeChange}
|
||||
onConfigOptionChange={onConfigOptionChange}
|
||||
availableCommands={availableCommands}
|
||||
attachmentTabId={attachmentTabId}
|
||||
draftStorageKey={draftStorageKey}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-2 text-xs text-destructive bg-destructive/5 border-t border-destructive/20">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
interface DropdownRadioItemContentProps {
|
||||
label: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export function DropdownRadioItemContent({
|
||||
label,
|
||||
description,
|
||||
}: DropdownRadioItemContentProps) {
|
||||
const normalizedDescription = description?.trim()
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 pr-2">
|
||||
<p className="truncate">{label}</p>
|
||||
{normalizedDescription ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs leading-snug whitespace-pre-wrap wrap-break-word">
|
||||
{normalizedDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useMemo } from "react"
|
||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||
import { ContentPartsRenderer } from "@/components/message/content-parts-renderer"
|
||||
import { adaptLiveMessageFromAcp } from "@/lib/adapters/ai-elements-adapter"
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message"
|
||||
|
||||
interface LiveMessageBlockProps {
|
||||
message: LiveMessage
|
||||
}
|
||||
|
||||
export const LiveMessageBlock = memo(function LiveMessageBlock({
|
||||
message,
|
||||
}: LiveMessageBlockProps) {
|
||||
const hasContent = message.content.length > 0
|
||||
const adapted = useMemo(() => adaptLiveMessageFromAcp(message), [message])
|
||||
|
||||
return (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
{hasContent ? (
|
||||
<ContentPartsRenderer parts={adapted.content} role="assistant" />
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-muted-foreground py-1"
|
||||
aria-label="Assistant is thinking"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-bounce [animation-delay:-0.3s]" />
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-bounce [animation-delay:-0.15s]" />
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-bounce" />
|
||||
</div>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,481 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { FileSearch, Plus, Send, Square, X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type {
|
||||
AvailableCommandInfo,
|
||||
PromptDraft,
|
||||
PromptInputBlock,
|
||||
SessionConfigOptionInfo,
|
||||
SessionModeInfo,
|
||||
} from "@/lib/types"
|
||||
import {
|
||||
ATTACH_FILE_TO_SESSION_EVENT,
|
||||
type AttachFileToSessionDetail,
|
||||
} from "@/lib/session-attachment-events"
|
||||
import { ModeSelector } from "@/components/chat/mode-selector"
|
||||
import { SessionConfigSelector } from "@/components/chat/session-config-selector"
|
||||
import { SlashCommandMenu } from "@/components/chat/slash-command-menu"
|
||||
import {
|
||||
clearMessageInputDraft,
|
||||
loadMessageInputDraft,
|
||||
saveMessageInputDraft,
|
||||
} from "@/lib/message-input-draft"
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (draft: PromptDraft, modeId?: string | null) => void
|
||||
placeholder?: string
|
||||
defaultPath?: string
|
||||
disabled?: boolean
|
||||
autoFocus?: boolean
|
||||
onFocus?: () => void
|
||||
className?: string
|
||||
isPrompting?: boolean
|
||||
onCancel?: () => void
|
||||
modes?: SessionModeInfo[]
|
||||
configOptions?: SessionConfigOptionInfo[]
|
||||
modeLoading?: boolean
|
||||
configOptionsLoading?: boolean
|
||||
selectedModeId?: string | null
|
||||
onModeChange?: (modeId: string) => void
|
||||
onConfigOptionChange?: (configId: string, valueId: string) => void
|
||||
availableCommands?: AvailableCommandInfo[] | null
|
||||
attachmentTabId?: string | null
|
||||
draftStorageKey?: string | null
|
||||
}
|
||||
|
||||
interface InputAttachment {
|
||||
path: string
|
||||
uri: string
|
||||
name: string
|
||||
mimeType: string | null
|
||||
}
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
txt: "text/plain",
|
||||
md: "text/markdown",
|
||||
json: "application/json",
|
||||
yaml: "application/yaml",
|
||||
yml: "application/yaml",
|
||||
csv: "text/csv",
|
||||
html: "text/html",
|
||||
css: "text/css",
|
||||
js: "text/javascript",
|
||||
mjs: "text/javascript",
|
||||
cjs: "text/javascript",
|
||||
ts: "text/typescript",
|
||||
tsx: "text/tsx",
|
||||
jsx: "text/jsx",
|
||||
py: "text/x-python",
|
||||
rs: "text/rust",
|
||||
go: "text/x-go",
|
||||
java: "text/x-java-source",
|
||||
xml: "application/xml",
|
||||
toml: "application/toml",
|
||||
pdf: "application/pdf",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
}
|
||||
|
||||
function fileNameFromPath(path: string): string {
|
||||
return path.split(/[/\\]/).pop() || path
|
||||
}
|
||||
|
||||
function mimeTypeFromPath(path: string): string | null {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? ""
|
||||
return MIME_BY_EXT[ext] ?? null
|
||||
}
|
||||
|
||||
function toFileUri(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/")
|
||||
const encoded = normalized.split("/").map(encodeURIComponent).join("/")
|
||||
if (normalized.startsWith("/")) {
|
||||
return `file://${encoded}`
|
||||
}
|
||||
return `file:///${encoded}`
|
||||
}
|
||||
|
||||
function SelectorLoadingChip({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageInput({
|
||||
onSend,
|
||||
placeholder = "Ask anything...",
|
||||
defaultPath,
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
onFocus,
|
||||
className,
|
||||
isPrompting = false,
|
||||
onCancel,
|
||||
modes,
|
||||
configOptions,
|
||||
modeLoading = false,
|
||||
configOptionsLoading = false,
|
||||
selectedModeId,
|
||||
onModeChange,
|
||||
onConfigOptionChange,
|
||||
availableCommands,
|
||||
attachmentTabId,
|
||||
draftStorageKey,
|
||||
}: MessageInputProps) {
|
||||
const effectiveDraftStorageKey = draftStorageKey ?? attachmentTabId ?? null
|
||||
const [text, setText] = useState(() => {
|
||||
if (!effectiveDraftStorageKey) return ""
|
||||
return loadMessageInputDraft(effectiveDraftStorageKey) ?? ""
|
||||
})
|
||||
const [attachments, setAttachments] = useState<InputAttachment[]>([])
|
||||
const composingRef = useRef(false)
|
||||
const textRef = useRef(text)
|
||||
|
||||
useEffect(() => {
|
||||
textRef.current = text
|
||||
}, [text])
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveDraftStorageKey) return
|
||||
saveMessageInputDraft(effectiveDraftStorageKey, text)
|
||||
}, [effectiveDraftStorageKey, text])
|
||||
|
||||
const availableModes = useMemo(() => modes ?? [], [modes])
|
||||
const availableConfigOptions = useMemo(
|
||||
() => configOptions ?? [],
|
||||
[configOptions]
|
||||
)
|
||||
const hasConfigOptions = availableConfigOptions.length > 0
|
||||
const hasModes = availableModes.length > 0
|
||||
|
||||
const effectiveModeId = useMemo(() => {
|
||||
if (!hasModes) return null
|
||||
if (
|
||||
selectedModeId &&
|
||||
availableModes.some((mode) => mode.id === selectedModeId)
|
||||
) {
|
||||
return selectedModeId
|
||||
}
|
||||
return availableModes[0]?.id ?? null
|
||||
}, [hasModes, selectedModeId, availableModes])
|
||||
const showModeSelector =
|
||||
hasModes && Boolean(effectiveModeId) && !hasConfigOptions
|
||||
const showModeLoading = modeLoading && !hasConfigOptions && !showModeSelector
|
||||
const showConfigLoading = configOptionsLoading && !hasConfigOptions
|
||||
const hasAttachments = attachments.length > 0
|
||||
const hasSendableContent = text.trim().length > 0 || hasAttachments
|
||||
|
||||
// ── Slash command autocomplete ──
|
||||
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
||||
const slashCommands = useMemo(
|
||||
() => availableCommands ?? [],
|
||||
[availableCommands]
|
||||
)
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
if (!slashMenuOpen || slashCommands.length === 0) return []
|
||||
const match = text.match(/^\/(\S*)$/)
|
||||
if (!match) return []
|
||||
const filter = match[1].toLowerCase()
|
||||
return slashCommands.filter((cmd) =>
|
||||
cmd.name.toLowerCase().startsWith(filter)
|
||||
)
|
||||
}, [slashMenuOpen, slashCommands, text])
|
||||
|
||||
const appendAttachments = useCallback((paths: string[]) => {
|
||||
setAttachments((prev) => {
|
||||
const seen = new Set(prev.map((item) => item.path))
|
||||
const next = [...prev]
|
||||
for (const path of paths) {
|
||||
if (typeof path !== "string" || !path || seen.has(path)) continue
|
||||
seen.add(path)
|
||||
next.push({
|
||||
path,
|
||||
uri: toFileUri(path),
|
||||
name: fileNameFromPath(path),
|
||||
mimeType: mimeTypeFromPath(path),
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showModeSelector) return
|
||||
if (!effectiveModeId || !onModeChange) return
|
||||
if (effectiveModeId !== selectedModeId) {
|
||||
onModeChange(effectiveModeId)
|
||||
}
|
||||
}, [showModeSelector, effectiveModeId, selectedModeId, onModeChange])
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(modeId: string) => {
|
||||
onModeChange?.(modeId)
|
||||
},
|
||||
[onModeChange]
|
||||
)
|
||||
|
||||
const handleSlashSelect = useCallback((cmd: AvailableCommandInfo) => {
|
||||
setText(`/${cmd.name} `)
|
||||
setSlashMenuOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
setText(value)
|
||||
if (slashCommands.length > 0 && /^\/(\S*)$/.test(value)) {
|
||||
setSlashSelectedIndex(0)
|
||||
setSlashMenuOpen(true)
|
||||
} else {
|
||||
setSlashMenuOpen(false)
|
||||
}
|
||||
},
|
||||
[slashCommands.length]
|
||||
)
|
||||
|
||||
const handlePickFiles = useCallback(async () => {
|
||||
if (disabled) return
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
directory: false,
|
||||
defaultPath: defaultPath || undefined,
|
||||
})
|
||||
if (!selected) return
|
||||
const picked = Array.isArray(selected) ? selected : [selected]
|
||||
appendAttachments(picked.filter((item): item is string => !!item))
|
||||
} catch (error) {
|
||||
console.error("[MessageInput] pick files failed:", error)
|
||||
}
|
||||
}, [appendAttachments, defaultPath, disabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!attachmentTabId) return
|
||||
|
||||
const handleAttachFile = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<AttachFileToSessionDetail>
|
||||
if (!customEvent.detail) return
|
||||
if (customEvent.detail.tabId !== attachmentTabId) return
|
||||
appendAttachments([customEvent.detail.path])
|
||||
}
|
||||
|
||||
window.addEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
|
||||
return () => {
|
||||
window.removeEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
|
||||
}
|
||||
}, [appendAttachments, attachmentTabId])
|
||||
|
||||
const removeAttachment = useCallback((path: string) => {
|
||||
setAttachments((prev) => prev.filter((item) => item.path !== path))
|
||||
}, [])
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = textRef.current.trim()
|
||||
if (!trimmed && attachments.length === 0) return
|
||||
|
||||
const blocks: PromptInputBlock[] = []
|
||||
if (trimmed) {
|
||||
blocks.push({ type: "text", text: trimmed })
|
||||
}
|
||||
for (const attachment of attachments) {
|
||||
blocks.push({
|
||||
type: "resource_link",
|
||||
uri: attachment.uri,
|
||||
name: attachment.name,
|
||||
mime_type: attachment.mimeType,
|
||||
description: null,
|
||||
})
|
||||
}
|
||||
|
||||
const displayText =
|
||||
trimmed ||
|
||||
`Attached ${attachments.length} resource${attachments.length > 1 ? "s" : ""}`
|
||||
onSend({ blocks, displayText }, showModeSelector ? effectiveModeId : null)
|
||||
if (effectiveDraftStorageKey) {
|
||||
clearMessageInputDraft(effectiveDraftStorageKey)
|
||||
}
|
||||
setText("")
|
||||
setAttachments([])
|
||||
}, [
|
||||
attachments,
|
||||
onSend,
|
||||
effectiveModeId,
|
||||
showModeSelector,
|
||||
effectiveDraftStorageKey,
|
||||
])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (
|
||||
e.nativeEvent.isComposing ||
|
||||
composingRef.current ||
|
||||
e.key === "Process" ||
|
||||
e.keyCode === 229
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (slashMenuOpen && filteredSlashCommands.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setSlashSelectedIndex((i) =>
|
||||
i < filteredSlashCommands.length - 1 ? i + 1 : 0
|
||||
)
|
||||
return
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setSlashSelectedIndex((i) =>
|
||||
i > 0 ? i - 1 : filteredSlashCommands.length - 1
|
||||
)
|
||||
return
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
handleSlashSelect(filteredSlashCommands[slashSelectedIndex])
|
||||
return
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setSlashMenuOpen(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (!disabled) handleSend()
|
||||
}
|
||||
},
|
||||
[
|
||||
disabled,
|
||||
handleSend,
|
||||
slashMenuOpen,
|
||||
filteredSlashCommands,
|
||||
slashSelectedIndex,
|
||||
handleSlashSelect,
|
||||
]
|
||||
)
|
||||
|
||||
const bottomPaddingClass = "pb-10"
|
||||
const topPaddingClass = hasAttachments ? "pt-10" : ""
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{slashMenuOpen && filteredSlashCommands.length > 0 && (
|
||||
<SlashCommandMenu
|
||||
commands={filteredSlashCommands}
|
||||
selectedIndex={slashSelectedIndex}
|
||||
onSelect={handleSlashSelect}
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => (composingRef.current = true)}
|
||||
onCompositionEnd={() => (composingRef.current = false)}
|
||||
onFocus={onFocus}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"text-sm pr-12 resize-none bg-transparent",
|
||||
topPaddingClass,
|
||||
bottomPaddingClass,
|
||||
className
|
||||
)}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{hasAttachments && (
|
||||
<div className="absolute left-2 right-2 top-2">
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.path}
|
||||
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<FileSearch className="h-3 w-3" />
|
||||
<span className="max-w-40 truncate">{attachment.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(attachment.path)}
|
||||
className="rounded-sm p-0.5 hover:bg-muted-foreground/15"
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 right-24 bottom-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
<Button
|
||||
onClick={handlePickFiles}
|
||||
disabled={disabled || isPrompting}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
title="Attach files"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
{showConfigLoading && (
|
||||
<SelectorLoadingChip label="Loading settings..." />
|
||||
)}
|
||||
{hasConfigOptions &&
|
||||
availableConfigOptions.map((option) => (
|
||||
<SessionConfigSelector
|
||||
key={option.id}
|
||||
option={option}
|
||||
onSelect={(configId, valueId) =>
|
||||
onConfigOptionChange?.(configId, valueId)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{showModeLoading && <SelectorLoadingChip label="Loading mode..." />}
|
||||
{showModeSelector && effectiveModeId && (
|
||||
<ModeSelector
|
||||
modes={availableModes}
|
||||
selectedModeId={effectiveModeId}
|
||||
onSelect={handleModeSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isPrompting && onCancel ? (
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute right-2 bottom-2"
|
||||
title="Cancel"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !hasSendableContent}
|
||||
size="icon"
|
||||
className="absolute right-2 bottom-2"
|
||||
title="Send"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronUp } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
|
||||
import type { SessionModeInfo } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ModeSelectorProps {
|
||||
modes: SessionModeInfo[]
|
||||
selectedModeId: string | null
|
||||
onSelect: (modeId: string) => void
|
||||
}
|
||||
|
||||
export function ModeSelector({
|
||||
modes,
|
||||
selectedModeId,
|
||||
onSelect,
|
||||
}: ModeSelectorProps) {
|
||||
const selectedMode = modes.find((m) => m.id === selectedModeId)
|
||||
const label = selectedMode?.name ?? "Mode"
|
||||
const isActive = Boolean(selectedMode)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn("gap-1", isActive && "text-primary")}
|
||||
title={selectedMode?.description ?? selectedMode?.name}
|
||||
>
|
||||
{label}
|
||||
<ChevronUp className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="min-w-72">
|
||||
<DropdownMenuRadioGroup
|
||||
value={selectedModeId ?? ""}
|
||||
onValueChange={onSelect}
|
||||
>
|
||||
{modes.map((mode) => (
|
||||
<DropdownMenuRadioItem key={mode.id} value={mode.id}>
|
||||
<DropdownRadioItemContent
|
||||
label={mode.name}
|
||||
description={mode.description}
|
||||
/>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
FilePenLine,
|
||||
ListTodo,
|
||||
Compass,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { CodeBlock } from "@/components/ai-elements/code-block"
|
||||
import type { PendingPermission } from "@/contexts/acp-connections-context"
|
||||
import { parsePermissionToolCall } from "@/lib/permission-request"
|
||||
|
||||
interface PermissionDialogProps {
|
||||
permission: PendingPermission | null
|
||||
onRespond: (requestId: string, optionId: string) => void
|
||||
}
|
||||
|
||||
function formatKindLabel(kind: string): string {
|
||||
const normalized = kind.replace(/_/g, " ").trim()
|
||||
return normalized.length > 0 ? normalized : "tool"
|
||||
}
|
||||
|
||||
export function PermissionDialog({
|
||||
permission,
|
||||
onRespond,
|
||||
}: PermissionDialogProps) {
|
||||
const parsed = useMemo(
|
||||
() => parsePermissionToolCall(permission?.tool_call),
|
||||
[permission?.tool_call]
|
||||
)
|
||||
if (!permission) return null
|
||||
|
||||
const hasFileChanges = parsed.fileChanges.length > 0
|
||||
const hasPlan =
|
||||
parsed.planEntries.length > 0 || Boolean(parsed.planExplanation)
|
||||
const hasStructured =
|
||||
Boolean(parsed.command) ||
|
||||
hasFileChanges ||
|
||||
hasPlan ||
|
||||
Boolean(parsed.modeTarget)
|
||||
|
||||
return (
|
||||
<div className="mx-4 mb-3 rounded-xl border border-border/70 bg-card/95 p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span className="truncate">{parsed.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Agent requests permission to continue this turn.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{formatKindLabel(parsed.normalizedKind)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 max-h-[min(36vh,18rem)] space-y-2 overflow-y-auto pr-1">
|
||||
{parsed.command && (
|
||||
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
<span>Command</span>
|
||||
</div>
|
||||
<CodeBlock code={parsed.command} language="bash" />
|
||||
{parsed.cwd && (
|
||||
<div className="break-all text-xs text-muted-foreground">
|
||||
CWD: {parsed.cwd}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFileChanges && (
|
||||
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<FilePenLine className="h-3.5 w-3.5" />
|
||||
<span>Files: {parsed.fileChanges.length}</span>
|
||||
{(parsed.additions > 0 || parsed.deletions > 0) && (
|
||||
<span>
|
||||
+{parsed.additions} / -{parsed.deletions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 rounded-md bg-muted/40 p-2">
|
||||
{parsed.fileChanges.slice(0, 8).map((change, index) => (
|
||||
<div
|
||||
key={`${change.path}-${index}`}
|
||||
className="break-all font-mono text-xs text-foreground/90"
|
||||
>
|
||||
{change.path}
|
||||
</div>
|
||||
))}
|
||||
{parsed.fileChanges.length > 8 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{parsed.fileChanges.length - 8} more files
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{parsed.diffPreview && (
|
||||
<CodeBlock code={parsed.diffPreview} language="diff" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPlan && (
|
||||
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ListTodo className="h-3.5 w-3.5" />
|
||||
<span>Plan</span>
|
||||
</div>
|
||||
{parsed.planExplanation && (
|
||||
<p className="text-xs text-foreground/90">
|
||||
{parsed.planExplanation}
|
||||
</p>
|
||||
)}
|
||||
{parsed.planEntries.length > 0 && (
|
||||
<div className="space-y-1 rounded-md bg-muted/40 p-2">
|
||||
{parsed.planEntries.map((entry, index) => (
|
||||
<div key={`${entry.text}-${index}`} className="text-xs">
|
||||
<span className="text-foreground/90">{entry.text}</span>
|
||||
{entry.status && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
({entry.status})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed.modeTarget && (
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 p-2 text-xs">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Compass className="h-3.5 w-3.5" />
|
||||
<span>Target mode: {parsed.modeTarget}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasStructured && (
|
||||
<pre className="rounded-md border border-border/60 bg-muted/20 p-2 text-xs whitespace-pre-wrap break-all text-foreground/90">
|
||||
{parsed.jsonPreview}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{permission.options.map((opt) => {
|
||||
const isReject = opt.kind.startsWith("reject")
|
||||
return (
|
||||
<Button
|
||||
key={opt.option_id}
|
||||
variant={isReject ? "outline" : "default"}
|
||||
className="h-auto min-h-9 whitespace-normal break-words text-left"
|
||||
onClick={() => onRespond(permission.request_id, opt.option_id)}
|
||||
>
|
||||
{opt.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment } from "react"
|
||||
import { ChevronUp } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
|
||||
import type { SessionConfigOptionInfo } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SessionConfigSelectorProps {
|
||||
option: SessionConfigOptionInfo
|
||||
onSelect: (configId: string, valueId: string) => void
|
||||
}
|
||||
|
||||
export function SessionConfigSelector({
|
||||
option,
|
||||
onSelect,
|
||||
}: SessionConfigSelectorProps) {
|
||||
if (option.kind.type !== "select") return null
|
||||
|
||||
const selected = option.kind.options.find(
|
||||
(item) => item.value === option.kind.current_value
|
||||
)
|
||||
const label = selected?.name ?? option.kind.current_value
|
||||
const isActive = Boolean(selected)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn("gap-1 shrink-0", isActive && "text-primary")}
|
||||
title={option.description ?? option.name}
|
||||
>
|
||||
{label}
|
||||
<ChevronUp className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="min-w-72">
|
||||
<DropdownMenuRadioGroup
|
||||
value={option.kind.current_value}
|
||||
onValueChange={(value) => onSelect(option.id, value)}
|
||||
>
|
||||
{option.kind.groups.length > 0
|
||||
? option.kind.groups.map((group, index) => (
|
||||
<Fragment key={group.group}>
|
||||
{index > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel>{group.name}</DropdownMenuLabel>
|
||||
{group.options.map((item) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={`${group.group}-${item.value}`}
|
||||
value={item.value}
|
||||
>
|
||||
<DropdownRadioItemContent
|
||||
label={item.name}
|
||||
description={item.description}
|
||||
/>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</Fragment>
|
||||
))
|
||||
: option.kind.options.map((item) => (
|
||||
<DropdownMenuRadioItem key={item.value} value={item.value}>
|
||||
<DropdownRadioItemContent
|
||||
label={item.name}
|
||||
description={item.description}
|
||||
/>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { AvailableCommandInfo } from "@/lib/types"
|
||||
|
||||
interface SlashCommandMenuProps {
|
||||
commands: AvailableCommandInfo[]
|
||||
selectedIndex: number
|
||||
onSelect: (command: AvailableCommandInfo) => void
|
||||
}
|
||||
|
||||
export function SlashCommandMenu({
|
||||
commands,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
}: SlashCommandMenuProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.children[selectedIndex] as
|
||||
| HTMLElement
|
||||
| undefined
|
||||
el?.scrollIntoView({ block: "nearest" })
|
||||
}, [selectedIndex])
|
||||
|
||||
if (commands.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute bottom-full left-0 right-0 mb-1 z-50 max-h-48 overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-lg"
|
||||
>
|
||||
{commands.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.name}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm",
|
||||
i === selectedIndex
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
onSelect(cmd)
|
||||
}}
|
||||
>
|
||||
<span className="shrink-0 font-mono text-primary">/{cmd.name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{cmd.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { MessageInput } from "@/components/chat/message-input"
|
||||
import type { AgentType, PromptDraft, SessionStats } from "@/lib/types"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||
import { useAcpActions } from "@/contexts/acp-connections-context"
|
||||
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
||||
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
||||
import {
|
||||
adaptLiveMessageFromAcp,
|
||||
adaptMessageTurns,
|
||||
} from "@/lib/adapters/ai-elements-adapter"
|
||||
import {
|
||||
buildUserMessageTextPartsFromDraft,
|
||||
extractUserResourcesFromDraft,
|
||||
getPromptDraftDisplayText,
|
||||
} from "@/lib/prompt-draft"
|
||||
import {
|
||||
buildPlanKey,
|
||||
extractLatestPlanEntriesFromMessages,
|
||||
} from "@/lib/agent-plan"
|
||||
import {
|
||||
buildConversationDraftStorageKey,
|
||||
buildNewConversationDraftStorageKey,
|
||||
moveMessageInputDraft,
|
||||
} from "@/lib/message-input-draft"
|
||||
import {
|
||||
createConversation,
|
||||
getFolderConversation,
|
||||
openSettingsWindow,
|
||||
updateConversationStatus,
|
||||
updateConversationExternalId,
|
||||
} from "@/lib/tauri"
|
||||
import { AgentSelector } from "@/components/chat/agent-selector"
|
||||
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
||||
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
||||
import { LiveTurnStats } from "@/components/message/live-turn-stats"
|
||||
import { TurnStats } from "@/components/message/turn-stats"
|
||||
import { UserResourceLinks } from "@/components/message/user-resource-links"
|
||||
import { ConversationShell } from "@/components/chat/conversation-shell"
|
||||
import {
|
||||
MessageThread,
|
||||
MessageThreadContent,
|
||||
} from "@/components/ai-elements/message-thread"
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message"
|
||||
import { ContentPartsRenderer } from "@/components/message/content-parts-renderer"
|
||||
|
||||
interface WelcomeInputPanelProps {
|
||||
defaultAgentType?: AgentType
|
||||
workingDir?: string
|
||||
tabId?: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function isExpectedAutoLinkError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false
|
||||
return (error as { alerted?: unknown }).alerted === true
|
||||
}
|
||||
|
||||
function buildInlineAutoConnectErrorMessage(raw: string): string {
|
||||
const normalized = raw.trim().replace(/[。.!?,,;;::]+$/u, "")
|
||||
if (!normalized) return "点击前往设置 > Agents 管理安装。"
|
||||
const hasSdkNotInstalled = /SDK\s*尚未安装$/u.test(normalized)
|
||||
const message =
|
||||
!hasSdkNotInstalled && normalized.endsWith("尚未安装")
|
||||
? normalized.replace(/尚未安装$/u, "SDK 尚未安装")
|
||||
: normalized
|
||||
if (message.includes("设置 > Agents 管理安装")) {
|
||||
return `${message}。`
|
||||
}
|
||||
return `${message},点击前往设置 > Agents 管理安装。`
|
||||
}
|
||||
|
||||
export function WelcomeInputPanel({
|
||||
defaultAgentType,
|
||||
workingDir,
|
||||
tabId,
|
||||
isActive = true,
|
||||
}: WelcomeInputPanelProps) {
|
||||
const fallbackContextId = useMemo(() => crypto.randomUUID(), [])
|
||||
const contextKey = tabId ?? `new-${fallbackContextId}`
|
||||
|
||||
const { folderId, refreshConversations } = useFolderContext()
|
||||
const { promoteNewConversationTab, linkTabConversation } = useTabContext()
|
||||
const { setSessionStats } = useSessionStats()
|
||||
const { migrateContextKey } = useAcpActions()
|
||||
const latestSessionStatsRef = useRef<SessionStats | null>(null)
|
||||
const isActiveRef = useRef(isActive)
|
||||
const statsRefreshSeqRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
isActiveRef.current = isActive
|
||||
}, [isActive])
|
||||
|
||||
// Reset or restore token stats when tab becomes active
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setSessionStats(latestSessionStatsRef.current)
|
||||
}
|
||||
}, [isActive, setSessionStats])
|
||||
|
||||
const applySessionStats = useCallback(
|
||||
(stats: SessionStats | null) => {
|
||||
latestSessionStatsRef.current = stats
|
||||
if (isActiveRef.current) {
|
||||
setSessionStats(stats)
|
||||
}
|
||||
},
|
||||
[setSessionStats]
|
||||
)
|
||||
|
||||
const hasTokenStats = useCallback((stats: SessionStats | null): boolean => {
|
||||
if (!stats) return false
|
||||
return (
|
||||
stats.total_usage !== null ||
|
||||
stats.total_tokens != null ||
|
||||
stats.context_window_used_tokens != null ||
|
||||
stats.context_window_max_tokens != null
|
||||
)
|
||||
}, [])
|
||||
|
||||
const hasAssistantUsage = useCallback(
|
||||
(messages: AdaptedMessage[]): boolean => {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i]
|
||||
if (message.role !== "assistant") continue
|
||||
return message.usage != null
|
||||
}
|
||||
return false
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const refreshConversationFromDb = useCallback(
|
||||
async (expectedTurnCount?: number) => {
|
||||
const conversationId = dbConvIdRef.current
|
||||
if (!conversationId) return
|
||||
|
||||
const refreshSeq = ++statsRefreshSeqRef.current
|
||||
const maxAttempts = 10
|
||||
const retryDelayMs = 400
|
||||
let latestMessages: AdaptedMessage[] | null = null
|
||||
let latestStats: SessionStats | null = null
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
if (refreshSeq !== statsRefreshSeqRef.current) return
|
||||
|
||||
try {
|
||||
const detail = await getFolderConversation(conversationId)
|
||||
if (refreshSeq !== statsRefreshSeqRef.current) return
|
||||
|
||||
const messages = adaptMessageTurns(detail.turns)
|
||||
const stats = detail.session_stats ?? null
|
||||
latestMessages = messages
|
||||
latestStats = stats
|
||||
|
||||
const hasExpectedTurns =
|
||||
expectedTurnCount == null ||
|
||||
detail.turns.length >= expectedTurnCount
|
||||
const canShowTurnTokenStats = hasAssistantUsage(messages)
|
||||
const canShowSessionTokenStats = hasTokenStats(stats)
|
||||
if (
|
||||
hasExpectedTurns &&
|
||||
(canShowTurnTokenStats || canShowSessionTokenStats)
|
||||
) {
|
||||
setHistory(messages)
|
||||
if (canShowSessionTokenStats) {
|
||||
applySessionStats(stats)
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient read failures while session file is syncing.
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs))
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshSeq !== statsRefreshSeqRef.current) return
|
||||
if (latestMessages) {
|
||||
setHistory(latestMessages)
|
||||
}
|
||||
if (latestStats && hasTokenStats(latestStats)) {
|
||||
applySessionStats(latestStats)
|
||||
}
|
||||
},
|
||||
[applySessionStats, hasAssistantUsage, hasTokenStats]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
statsRefreshSeqRef.current += 1
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [phase, setPhase] = useState<"welcome" | "conversation">("welcome")
|
||||
const [selectedAgent, setSelectedAgent] = useState<AgentType>(
|
||||
defaultAgentType ?? "codex"
|
||||
)
|
||||
const [history, setHistory] = useState<AdaptedMessage[]>([])
|
||||
const historyRef = useRef<AdaptedMessage[]>([])
|
||||
useEffect(() => {
|
||||
historyRef.current = history
|
||||
}, [history])
|
||||
const historicalPlanEntries = useMemo(
|
||||
() => extractLatestPlanEntriesFromMessages(history),
|
||||
[history]
|
||||
)
|
||||
const historicalPlanKey = useMemo(
|
||||
() => buildPlanKey(historicalPlanEntries),
|
||||
[historicalPlanEntries]
|
||||
)
|
||||
const [modeId, setModeId] = useState<string | null>(null)
|
||||
const [dbConversationId, setDbConversationId] = useState<number | null>(null)
|
||||
const [agentsLoaded, setAgentsLoaded] = useState(false)
|
||||
const [usableAgentCount, setUsableAgentCount] = useState(0)
|
||||
const [agentConnectError, setAgentConnectError] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const canAutoConnect = agentsLoaded && usableAgentCount > 0
|
||||
const pendingPromptRef = useRef<{
|
||||
draft: PromptDraft
|
||||
modeId: string | null
|
||||
} | null>(null)
|
||||
const newConversationDraftStorageKey = useMemo(
|
||||
() =>
|
||||
buildNewConversationDraftStorageKey({
|
||||
folderId,
|
||||
}),
|
||||
[folderId]
|
||||
)
|
||||
const activeDraftStorageKey = useMemo(() => {
|
||||
if (dbConversationId != null) {
|
||||
return buildConversationDraftStorageKey(selectedAgent, dbConversationId)
|
||||
}
|
||||
return newConversationDraftStorageKey
|
||||
}, [dbConversationId, newConversationDraftStorageKey, selectedAgent])
|
||||
|
||||
// DB persistence state
|
||||
const dbConvIdRef = useRef<number | null>(null)
|
||||
const statusUpdatedRef = useRef(false)
|
||||
const tabPromotedRef = useRef(false)
|
||||
const tabIdRef = useRef(tabId)
|
||||
const selectedAgentRef = useRef(selectedAgent)
|
||||
const convTitleRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
tabIdRef.current = tabId
|
||||
}, [tabId])
|
||||
useEffect(() => {
|
||||
selectedAgentRef.current = selectedAgent
|
||||
}, [selectedAgent])
|
||||
|
||||
const {
|
||||
conn,
|
||||
modeLoading,
|
||||
configOptionsLoading,
|
||||
autoConnectError,
|
||||
handleFocus,
|
||||
handleSend: lifecycleSend,
|
||||
handleSetConfigOption,
|
||||
handleCancel,
|
||||
handleRespondPermission,
|
||||
} = useConnectionLifecycle({
|
||||
contextKey,
|
||||
agentType: selectedAgent,
|
||||
isActive: isActive && canAutoConnect,
|
||||
workingDir,
|
||||
})
|
||||
|
||||
// Destructure stable callback + volatile status separately.
|
||||
// conn.connect is stable (depends only on actions + contextKey).
|
||||
// conn.status changes on state transitions (~5/turn), NOT on every
|
||||
// streaming delta (hundreds/sec) — much cheaper than depending on `conn`.
|
||||
const {
|
||||
status: connStatus,
|
||||
connect: connConnect,
|
||||
disconnect: connDisconnect,
|
||||
sessionId: connSessionId,
|
||||
} = conn
|
||||
const connectionModes = useMemo(
|
||||
() => conn.modes?.available_modes ?? [],
|
||||
[conn.modes?.available_modes]
|
||||
)
|
||||
const connectionConfigOptions = useMemo(
|
||||
() => conn.configOptions ?? [],
|
||||
[conn.configOptions]
|
||||
)
|
||||
const connectionCommands = useMemo(
|
||||
() => conn.availableCommands ?? [],
|
||||
[conn.availableCommands]
|
||||
)
|
||||
const selectedModeId = useMemo(() => {
|
||||
if (connectionModes.length === 0) return null
|
||||
if (modeId && connectionModes.some((mode) => mode.id === modeId)) {
|
||||
return modeId
|
||||
}
|
||||
return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null
|
||||
}, [conn.modes?.current_mode_id, connectionModes, modeId])
|
||||
|
||||
// Persist the agent-assigned session ID as external_id once both
|
||||
// the DB conversation ID and the ACP session ID are available.
|
||||
const externalIdSavedRef = useRef(false)
|
||||
const sessionIdRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (connSessionId) {
|
||||
sessionIdRef.current = connSessionId
|
||||
}
|
||||
}, [connSessionId])
|
||||
|
||||
const trySaveExternalId = useCallback(() => {
|
||||
if (
|
||||
externalIdSavedRef.current ||
|
||||
!dbConvIdRef.current ||
|
||||
!sessionIdRef.current
|
||||
)
|
||||
return
|
||||
externalIdSavedRef.current = true
|
||||
updateConversationExternalId(
|
||||
dbConvIdRef.current,
|
||||
sessionIdRef.current
|
||||
).catch((e: unknown) =>
|
||||
console.error("[WelcomePanel] update external_id:", e)
|
||||
)
|
||||
}, [])
|
||||
|
||||
// Trigger when session ID arrives from ACP
|
||||
useEffect(() => {
|
||||
if (connSessionId) trySaveExternalId()
|
||||
}, [connSessionId, trySaveExternalId])
|
||||
|
||||
const isConnecting =
|
||||
connStatus === "connecting" || connStatus === "downloading"
|
||||
|
||||
const prevStatusRef = useRef(connStatus)
|
||||
|
||||
// Accumulate history when prompting completes
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current
|
||||
prevStatusRef.current = connStatus
|
||||
|
||||
if (prev === "prompting" && connStatus !== "prompting") {
|
||||
if (conn.liveMessage && conn.liveMessage.content.length > 0) {
|
||||
const adapted = adaptLiveMessageFromAcp(conn.liveMessage, {
|
||||
isLiveStreaming: false,
|
||||
})
|
||||
|
||||
setHistory((h) => [...h, adapted])
|
||||
}
|
||||
// Agent turn ended — mark as pending_review unless it's a terminal state
|
||||
if (
|
||||
dbConvIdRef.current &&
|
||||
connStatus !== "disconnected" &&
|
||||
connStatus !== "error"
|
||||
) {
|
||||
updateConversationStatus(dbConvIdRef.current, "pending_review")
|
||||
.then(() => refreshConversations())
|
||||
.catch((e: unknown) =>
|
||||
console.error("[WelcomePanel] update status:", e)
|
||||
)
|
||||
}
|
||||
|
||||
void refreshConversationFromDb(
|
||||
historyRef.current.length + (conn.liveMessage ? 1 : 0)
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- conn.liveMessage, lifecycleSend intentionally omitted: effect only fires on status transitions
|
||||
}, [connStatus, refreshConversations, refreshConversationFromDb])
|
||||
|
||||
// When connection becomes "connected" and we have a pending prompt, send it
|
||||
useEffect(() => {
|
||||
if (connStatus === "connected" && pendingPromptRef.current) {
|
||||
const pending = pendingPromptRef.current
|
||||
pendingPromptRef.current = null
|
||||
lifecycleSend(pending.draft, pending.modeId)
|
||||
}
|
||||
}, [connStatus, lifecycleSend])
|
||||
|
||||
// Promote tab helper — call once when conversation ends or component unmounts
|
||||
const promoteTab = useCallback(() => {
|
||||
if (tabPromotedRef.current || !dbConvIdRef.current) return
|
||||
tabPromotedRef.current = true
|
||||
const tid = tabIdRef.current
|
||||
const convId = dbConvIdRef.current
|
||||
const agent = selectedAgentRef.current
|
||||
const title = convTitleRef.current || "Untitled"
|
||||
const canonicalContextKey = `conv-${agent}-${convId}`
|
||||
|
||||
// Keep in-flight stream/state attached when this new-conversation view
|
||||
// is closed and later reopened as a canonical conversation tab.
|
||||
migrateContextKey(contextKey, canonicalContextKey)
|
||||
|
||||
if (tid) {
|
||||
promoteNewConversationTab(tid, convId, agent, title)
|
||||
}
|
||||
refreshConversations()
|
||||
}, [
|
||||
promoteNewConversationTab,
|
||||
refreshConversations,
|
||||
migrateContextKey,
|
||||
contextKey,
|
||||
])
|
||||
|
||||
// Update conversation status on disconnect/error + promote tab
|
||||
useEffect(() => {
|
||||
if (!dbConvIdRef.current || statusUpdatedRef.current) return
|
||||
if (connStatus === "disconnected") {
|
||||
statusUpdatedRef.current = true
|
||||
updateConversationStatus(dbConvIdRef.current, "completed").catch((e) =>
|
||||
console.error("[WelcomePanel] update status:", e)
|
||||
)
|
||||
promoteTab()
|
||||
} else if (connStatus === "error") {
|
||||
statusUpdatedRef.current = true
|
||||
updateConversationStatus(dbConvIdRef.current, "cancelled").catch((e) =>
|
||||
console.error("[WelcomePanel] update status:", e)
|
||||
)
|
||||
promoteTab()
|
||||
}
|
||||
}, [connStatus, promoteTab])
|
||||
|
||||
// Promote tab on unmount if not yet promoted (e.g. user closes tab)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
promoteTab()
|
||||
}
|
||||
}, [promoteTab])
|
||||
|
||||
const handleAgentSelect = useCallback(
|
||||
(agentType: AgentType) => {
|
||||
if (agentType === selectedAgent) return
|
||||
setSelectedAgent(agentType)
|
||||
setModeId(null)
|
||||
setAgentConnectError(null)
|
||||
connDisconnect()
|
||||
.catch((e) => console.error("[WelcomePanel] disconnect old agent:", e))
|
||||
.finally(() => {
|
||||
connConnect(agentType, workingDir, undefined, {
|
||||
source: "auto_link",
|
||||
})
|
||||
.then(() => {
|
||||
setAgentConnectError(null)
|
||||
})
|
||||
.catch((e) => {
|
||||
setAgentConnectError(normalizeErrorMessage(e))
|
||||
if (!isExpectedAutoLinkError(e)) {
|
||||
console.error("[WelcomePanel] switch agent:", e)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
[selectedAgent, connConnect, connDisconnect, workingDir]
|
||||
)
|
||||
|
||||
// Welcome phase: submit first message.
|
||||
const handleWelcomeSend = useCallback(
|
||||
(draft: PromptDraft, selectedModeId?: string | null) => {
|
||||
const displayText = getPromptDraftDisplayText(draft)
|
||||
const userMsg: AdaptedMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: buildUserMessageTextPartsFromDraft(draft),
|
||||
userResources: extractUserResourcesFromDraft(draft),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
setHistory([userMsg])
|
||||
setPhase("conversation")
|
||||
applySessionStats(null)
|
||||
statsRefreshSeqRef.current += 1
|
||||
|
||||
// If already connected, send directly; otherwise queue for when connected
|
||||
if (connStatus === "connected") {
|
||||
lifecycleSend(draft, selectedModeId)
|
||||
} else {
|
||||
pendingPromptRef.current = {
|
||||
draft,
|
||||
modeId: selectedModeId ?? null,
|
||||
}
|
||||
// Ensure connection is being established
|
||||
if (
|
||||
!connStatus ||
|
||||
connStatus === "disconnected" ||
|
||||
connStatus === "error"
|
||||
) {
|
||||
connConnect(selectedAgent, workingDir, undefined, {
|
||||
source: "auto_link",
|
||||
}).catch((e) => {
|
||||
setAgentConnectError(normalizeErrorMessage(e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DB persistence: create conversation
|
||||
const title = displayText.slice(0, 80)
|
||||
convTitleRef.current = title
|
||||
createConversation(folderId, selectedAgent, title)
|
||||
.then((convId) => {
|
||||
dbConvIdRef.current = convId
|
||||
setDbConversationId(convId)
|
||||
moveMessageInputDraft(
|
||||
newConversationDraftStorageKey,
|
||||
buildConversationDraftStorageKey(selectedAgent, convId)
|
||||
)
|
||||
// Link tab to DB conversation so status dot updates and tab is persisted
|
||||
if (tabIdRef.current) {
|
||||
linkTabConversation(tabIdRef.current, convId, selectedAgent, title)
|
||||
}
|
||||
// If ACP session ID already arrived, save external_id now
|
||||
trySaveExternalId()
|
||||
refreshConversations()
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
console.error("[WelcomePanel] create conversation:", e)
|
||||
)
|
||||
},
|
||||
[
|
||||
selectedAgent,
|
||||
workingDir,
|
||||
connStatus,
|
||||
connConnect,
|
||||
lifecycleSend,
|
||||
folderId,
|
||||
refreshConversations,
|
||||
linkTabConversation,
|
||||
trySaveExternalId,
|
||||
applySessionStats,
|
||||
newConversationDraftStorageKey,
|
||||
]
|
||||
)
|
||||
|
||||
// Conversation phase: prepend user message to history before sending
|
||||
const handleSendWithHistory = useCallback(
|
||||
(draft: PromptDraft, selectedModeId?: string | null) => {
|
||||
const userMsg: AdaptedMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: buildUserMessageTextPartsFromDraft(draft),
|
||||
userResources: extractUserResourcesFromDraft(draft),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
setHistory((h) => [...h, userMsg])
|
||||
lifecycleSend(draft, selectedModeId)
|
||||
|
||||
// Update status
|
||||
if (dbConvIdRef.current) {
|
||||
updateConversationStatus(dbConvIdRef.current, "in_progress")
|
||||
.then(() => refreshConversations())
|
||||
.catch((e: unknown) =>
|
||||
console.error("[WelcomePanel] update status:", e)
|
||||
)
|
||||
statusUpdatedRef.current = false
|
||||
}
|
||||
},
|
||||
[lifecycleSend, refreshConversations]
|
||||
)
|
||||
|
||||
const handleOpenAgentsSettings = useCallback(() => {
|
||||
openSettingsWindow("agents", { agentType: selectedAgent }).catch((err) => {
|
||||
console.error("[WelcomePanel] failed to open settings window:", err)
|
||||
})
|
||||
}, [selectedAgent])
|
||||
|
||||
// Track live message visibility across turn completion.
|
||||
// Hooks must be called before any conditional returns.
|
||||
const prevConnStatusForLiveRef = useRef(connStatus)
|
||||
const showLiveTransitionRef = useRef(false)
|
||||
const prevHistoryLenRef = useRef(history.length)
|
||||
|
||||
if (connStatus === "prompting") {
|
||||
showLiveTransitionRef.current = false
|
||||
} else if (prevConnStatusForLiveRef.current === "prompting") {
|
||||
showLiveTransitionRef.current = true
|
||||
}
|
||||
prevConnStatusForLiveRef.current = connStatus
|
||||
|
||||
// Once the effect adds the adapted message to history, hide the live block.
|
||||
if (
|
||||
history.length > prevHistoryLenRef.current &&
|
||||
showLiveTransitionRef.current
|
||||
) {
|
||||
showLiveTransitionRef.current = false
|
||||
}
|
||||
prevHistoryLenRef.current = history.length
|
||||
|
||||
// ── Welcome phase ──
|
||||
if (phase === "welcome") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4">
|
||||
<div className="w-full max-w-2xl space-y-6">
|
||||
<AgentSelector
|
||||
defaultAgentType={selectedAgent}
|
||||
onSelect={handleAgentSelect}
|
||||
onAgentsLoaded={(agents) => {
|
||||
setAgentsLoaded(true)
|
||||
setUsableAgentCount(
|
||||
agents.filter((agent) => agent.enabled && agent.available)
|
||||
.length
|
||||
)
|
||||
}}
|
||||
onOpenAgentsSettings={handleOpenAgentsSettings}
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
|
||||
{autoConnectError || agentConnectError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenAgentsSettings}
|
||||
className="w-full cursor-pointer rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-center text-xs text-destructive transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
{(() => {
|
||||
const inlineMessage = buildInlineAutoConnectErrorMessage(
|
||||
autoConnectError ?? agentConnectError ?? ""
|
||||
)
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden text-ellipsis whitespace-nowrap text-center"
|
||||
title={inlineMessage}
|
||||
>
|
||||
{inlineMessage}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<MessageInput
|
||||
key={newConversationDraftStorageKey}
|
||||
onSend={handleWelcomeSend}
|
||||
defaultPath={workingDir}
|
||||
placeholder={
|
||||
agentsLoaded && usableAgentCount === 0
|
||||
? "请先启用至少一个 Agent 后开始会话..."
|
||||
: "Ask anything..."
|
||||
}
|
||||
autoFocus
|
||||
attachmentTabId={tabId ?? null}
|
||||
modes={connectionModes}
|
||||
configOptions={connectionConfigOptions}
|
||||
modeLoading={modeLoading}
|
||||
configOptionsLoading={configOptionsLoading}
|
||||
selectedModeId={selectedModeId}
|
||||
onModeChange={setModeId}
|
||||
onConfigOptionChange={handleSetConfigOption}
|
||||
availableCommands={connectionCommands}
|
||||
disabled={!canAutoConnect || isConnecting}
|
||||
className="min-h-28 max-h-60"
|
||||
draftStorageKey={newConversationDraftStorageKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Conversation phase ──
|
||||
|
||||
const showLive = Boolean(
|
||||
conn.liveMessage &&
|
||||
(connStatus === "prompting" ||
|
||||
(conn.liveMessage.content.length > 0 && showLiveTransitionRef.current))
|
||||
)
|
||||
|
||||
return (
|
||||
<ConversationShell
|
||||
status={connStatus}
|
||||
defaultPath={workingDir}
|
||||
error={conn.error}
|
||||
pendingPermission={conn.pendingPermission}
|
||||
onFocus={handleFocus}
|
||||
onSend={handleSendWithHistory}
|
||||
onCancel={handleCancel}
|
||||
onRespondPermission={handleRespondPermission}
|
||||
modes={connectionModes}
|
||||
configOptions={connectionConfigOptions}
|
||||
modeLoading={modeLoading}
|
||||
configOptionsLoading={configOptionsLoading}
|
||||
selectedModeId={selectedModeId}
|
||||
onModeChange={setModeId}
|
||||
onConfigOptionChange={handleSetConfigOption}
|
||||
availableCommands={connectionCommands}
|
||||
attachmentTabId={tabId ?? null}
|
||||
draftStorageKey={activeDraftStorageKey}
|
||||
>
|
||||
<div className="relative flex flex-col h-full">
|
||||
<MessageThread className="flex-1 min-h-0">
|
||||
<MessageThreadContent className="p-4 max-w-3xl mx-auto">
|
||||
{history.map((msg) => (
|
||||
<div key={msg.id}>
|
||||
<Message from={msg.role === "tool" ? "assistant" : msg.role}>
|
||||
<MessageContent>
|
||||
<ContentPartsRenderer parts={msg.content} role={msg.role} />
|
||||
</MessageContent>
|
||||
{msg.role === "user" && msg.userResources?.length ? (
|
||||
<UserResourceLinks
|
||||
resources={msg.userResources}
|
||||
className="self-end"
|
||||
/>
|
||||
) : null}
|
||||
</Message>
|
||||
{msg.role === "assistant" && (
|
||||
<TurnStats
|
||||
usage={msg.usage}
|
||||
duration_ms={msg.duration_ms}
|
||||
model={msg.model}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showLive && <LiveMessageBlock message={conn.liveMessage!} />}
|
||||
</MessageThreadContent>
|
||||
</MessageThread>
|
||||
{showLive && <LiveTurnStats message={conn.liveMessage!} />}
|
||||
<AgentPlanOverlay
|
||||
message={conn.liveMessage}
|
||||
entries={historicalPlanEntries}
|
||||
planKey={historicalPlanKey}
|
||||
/>
|
||||
</div>
|
||||
</ConversationShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Plus, RefreshCw, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
||||
import { MessageListView } from "@/components/message/message-list-view"
|
||||
import { ConversationShell } from "@/components/chat/conversation-shell"
|
||||
import { WelcomeInputPanel } from "@/components/chat/welcome-input-panel"
|
||||
import { updateConversationStatus } from "@/lib/tauri"
|
||||
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
|
||||
import type { AgentType, PromptDraft } from "@/lib/types"
|
||||
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
||||
import {
|
||||
buildUserMessageTextPartsFromDraft,
|
||||
extractUserResourcesFromDraft,
|
||||
} from "@/lib/prompt-draft"
|
||||
import { buildConversationDraftStorageKey } from "@/lib/message-input-draft"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
|
||||
interface ExistingConversationViewProps {
|
||||
tabId: string
|
||||
conversationId: number
|
||||
agentType: AgentType
|
||||
isActive: boolean
|
||||
reloadSignal: number
|
||||
}
|
||||
|
||||
const ExistingConversationView = memo(function ExistingConversationView({
|
||||
tabId,
|
||||
conversationId,
|
||||
agentType,
|
||||
isActive,
|
||||
reloadSignal,
|
||||
}: ExistingConversationViewProps) {
|
||||
const { refreshConversations, folder } = useFolderContext()
|
||||
const contextKey = `conv-${agentType}-${conversationId}`
|
||||
|
||||
// Get external_id to resume existing agent session via LoadSessionRequest.
|
||||
// Gate workingDir on loading so auto-connect waits for sessionId to resolve.
|
||||
const {
|
||||
detail,
|
||||
loading: detailLoading,
|
||||
error: detailError,
|
||||
refetch: refetchConversationDetail,
|
||||
} = useDbMessageDetail(conversationId)
|
||||
const externalId = detail?.summary.external_id ?? undefined
|
||||
const latestReloadSignal = useRef(reloadSignal)
|
||||
const pendingReloadState = useRef<{
|
||||
signal: number
|
||||
sawLoading: boolean
|
||||
} | null>(null)
|
||||
|
||||
const {
|
||||
conn,
|
||||
modeLoading,
|
||||
configOptionsLoading,
|
||||
handleFocus,
|
||||
handleSend,
|
||||
handleSetConfigOption,
|
||||
handleCancel,
|
||||
handleRespondPermission,
|
||||
} = useConnectionLifecycle({
|
||||
contextKey,
|
||||
agentType,
|
||||
isActive,
|
||||
workingDir: detailLoading ? undefined : folder?.path,
|
||||
sessionId: externalId,
|
||||
})
|
||||
|
||||
const [pendingMessages, setPendingMessages] = useState<AdaptedMessage[]>([])
|
||||
const [modeId, setModeId] = useState<string | null>(null)
|
||||
const clearPending = useCallback(() => setPendingMessages([]), [])
|
||||
|
||||
const connectionModes = useMemo(
|
||||
() => conn.modes?.available_modes ?? [],
|
||||
[conn.modes?.available_modes]
|
||||
)
|
||||
const connectionConfigOptions = useMemo(
|
||||
() => conn.configOptions ?? [],
|
||||
[conn.configOptions]
|
||||
)
|
||||
const connectionCommands = useMemo(
|
||||
() => conn.availableCommands ?? [],
|
||||
[conn.availableCommands]
|
||||
)
|
||||
const selectedModeId = useMemo(() => {
|
||||
if (connectionModes.length === 0) return null
|
||||
if (modeId && connectionModes.some((mode) => mode.id === modeId)) {
|
||||
return modeId
|
||||
}
|
||||
return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null
|
||||
}, [conn.modes?.current_mode_id, connectionModes, modeId])
|
||||
|
||||
// Track status transitions for updating conversation metadata
|
||||
const prevStatusRef = useRef(conn.status)
|
||||
const statusUpdatedRef = useRef(false)
|
||||
|
||||
// Wrap handleSend to update status
|
||||
const handleSendWithPersist = useCallback(
|
||||
(draft: PromptDraft, selectedModeId?: string | null) => {
|
||||
setPendingMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `pending-${Date.now()}`,
|
||||
role: "user",
|
||||
content: buildUserMessageTextPartsFromDraft(draft),
|
||||
userResources: extractUserResourcesFromDraft(draft),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
updateConversationStatus(conversationId, "in_progress")
|
||||
.then(() => refreshConversations())
|
||||
.catch((e) => console.error("[ExistingConv] update status:", e))
|
||||
statusUpdatedRef.current = false
|
||||
handleSend(draft, selectedModeId)
|
||||
},
|
||||
[conversationId, handleSend, refreshConversations]
|
||||
)
|
||||
|
||||
// Update status on turn complete
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current
|
||||
prevStatusRef.current = conn.status
|
||||
|
||||
if (prev === "prompting" && conn.status !== "prompting") {
|
||||
// Mark as pending_review unless it's a terminal state
|
||||
if (conn.status !== "disconnected" && conn.status !== "error") {
|
||||
updateConversationStatus(conversationId, "pending_review")
|
||||
.then(() => refreshConversations())
|
||||
.catch((e: unknown) =>
|
||||
console.error("[ExistingConv] update status:", e)
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [conn.status, conversationId, refreshConversations])
|
||||
|
||||
// Update status on disconnect/error
|
||||
useEffect(() => {
|
||||
if (conn.status === "connected" || conn.status === "prompting") {
|
||||
statusUpdatedRef.current = false
|
||||
return
|
||||
}
|
||||
if (statusUpdatedRef.current) return
|
||||
if (conn.status === "disconnected") {
|
||||
statusUpdatedRef.current = true
|
||||
updateConversationStatus(conversationId, "completed")
|
||||
.then(() => {
|
||||
setPendingMessages([])
|
||||
refreshConversations()
|
||||
})
|
||||
.catch((e) => console.error("[ExistingConv] update status:", e))
|
||||
} else if (conn.status === "error") {
|
||||
statusUpdatedRef.current = true
|
||||
updateConversationStatus(conversationId, "cancelled")
|
||||
.then(() => {
|
||||
setPendingMessages([])
|
||||
refreshConversations()
|
||||
})
|
||||
.catch((e) => console.error("[ExistingConv] update status:", e))
|
||||
}
|
||||
}, [conn.status, conversationId, refreshConversations])
|
||||
|
||||
useEffect(() => {
|
||||
if (reloadSignal === latestReloadSignal.current) return
|
||||
latestReloadSignal.current = reloadSignal
|
||||
pendingReloadState.current = {
|
||||
signal: reloadSignal,
|
||||
sawLoading: false,
|
||||
}
|
||||
refetchConversationDetail()
|
||||
}, [reloadSignal, refetchConversationDetail])
|
||||
|
||||
useEffect(() => {
|
||||
const pending = pendingReloadState.current
|
||||
if (!pending) return
|
||||
|
||||
if (detailLoading) {
|
||||
pending.sawLoading = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!pending.sawLoading) return
|
||||
|
||||
pendingReloadState.current = null
|
||||
|
||||
if (detailError) {
|
||||
toast.error(`会话重新加载失败:${detailError}`)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("当前会话已重新加载")
|
||||
}, [detailLoading, detailError])
|
||||
|
||||
return (
|
||||
<ConversationShell
|
||||
status={conn.status}
|
||||
defaultPath={folder?.path}
|
||||
error={conn.error}
|
||||
pendingPermission={conn.pendingPermission}
|
||||
onFocus={handleFocus}
|
||||
onSend={handleSendWithPersist}
|
||||
onCancel={handleCancel}
|
||||
onRespondPermission={handleRespondPermission}
|
||||
modes={connectionModes}
|
||||
configOptions={connectionConfigOptions}
|
||||
modeLoading={modeLoading}
|
||||
configOptionsLoading={configOptionsLoading}
|
||||
selectedModeId={selectedModeId}
|
||||
onModeChange={setModeId}
|
||||
onConfigOptionChange={handleSetConfigOption}
|
||||
availableCommands={connectionCommands}
|
||||
attachmentTabId={tabId}
|
||||
draftStorageKey={buildConversationDraftStorageKey(
|
||||
agentType,
|
||||
conversationId
|
||||
)}
|
||||
>
|
||||
<MessageListView
|
||||
conversationId={conversationId}
|
||||
liveMessage={conn.liveMessage}
|
||||
connStatus={conn.status}
|
||||
pendingMessages={pendingMessages}
|
||||
onPendingClear={clearPending}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</ConversationShell>
|
||||
)
|
||||
})
|
||||
|
||||
export function ConversationDetailPanel() {
|
||||
const { folder, newConversation } = useFolderContext()
|
||||
const { tabs, activeTabId, openNewConversationTab, closeTab } =
|
||||
useTabContext()
|
||||
const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({})
|
||||
|
||||
const conversationTabs = useMemo(
|
||||
() =>
|
||||
tabs.filter((t) => t.kind === "conversation" && t.conversationId != null),
|
||||
[tabs]
|
||||
)
|
||||
|
||||
const newConvTabs = useMemo(
|
||||
() => tabs.filter((t) => t.kind === "new_conversation"),
|
||||
[tabs]
|
||||
)
|
||||
const hasNoTabs =
|
||||
conversationTabs.length === 0 && newConvTabs.length === 0 && !activeTabId
|
||||
const activeConversationTab = useMemo(
|
||||
() =>
|
||||
tabs.find(
|
||||
(tab) =>
|
||||
tab.id === activeTabId &&
|
||||
tab.kind === "conversation" &&
|
||||
tab.conversationId != null
|
||||
) ?? null,
|
||||
[tabs, activeTabId]
|
||||
)
|
||||
const canReloadActiveConversation = activeConversationTab != null
|
||||
const handleReloadActiveConversation = useCallback(() => {
|
||||
if (!activeConversationTab) return
|
||||
setReloadByTabId((prev) => ({
|
||||
...prev,
|
||||
[activeConversationTab.id]: (prev[activeConversationTab.id] ?? 0) + 1,
|
||||
}))
|
||||
}, [activeConversationTab])
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (!folder) return
|
||||
openNewConversationTab("codex", folder.path)
|
||||
}, [folder, openNewConversationTab])
|
||||
|
||||
const handleCloseActiveTab = useCallback(() => {
|
||||
if (!activeTabId) return
|
||||
closeTab(activeTabId)
|
||||
}, [activeTabId, closeTab])
|
||||
|
||||
// Ensure no-tab state is immediately bridged to a real new-conversation tab.
|
||||
useEffect(() => {
|
||||
if (!folder) return
|
||||
|
||||
if (hasNoTabs) {
|
||||
openNewConversationTab(
|
||||
newConversation?.agentType ?? "codex",
|
||||
newConversation?.workingDir ?? folder.path
|
||||
)
|
||||
}
|
||||
}, [
|
||||
folder,
|
||||
hasNoTabs,
|
||||
newConversation?.agentType,
|
||||
newConversation?.workingDir,
|
||||
openNewConversationTab,
|
||||
])
|
||||
|
||||
// Empty state: no tabs at all — show full-screen welcome
|
||||
if (hasNoTabs) {
|
||||
return (
|
||||
<WelcomeInputPanel
|
||||
defaultAgentType={newConversation?.agentType ?? "codex"}
|
||||
workingDir={newConversation?.workingDir ?? folder?.path}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="relative h-full min-h-0 overflow-hidden">
|
||||
{conversationTabs.map((tab) => {
|
||||
const active = tab.id === activeTabId
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={
|
||||
active
|
||||
? "h-full"
|
||||
: "absolute inset-0 invisible pointer-events-none"
|
||||
}
|
||||
>
|
||||
<ExistingConversationView
|
||||
tabId={tab.id}
|
||||
conversationId={tab.conversationId!}
|
||||
agentType={tab.agentType}
|
||||
isActive={active}
|
||||
reloadSignal={reloadByTabId[tab.id] ?? 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{newConvTabs.map((tab) => {
|
||||
const active = tab.id === activeTabId
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={
|
||||
active
|
||||
? "h-full"
|
||||
: "absolute inset-0 invisible pointer-events-none"
|
||||
}
|
||||
>
|
||||
<WelcomeInputPanel
|
||||
defaultAgentType={tab.agentType ?? "codex"}
|
||||
workingDir={tab.workingDir ?? folder?.path}
|
||||
tabId={tab.id}
|
||||
isActive={active}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
disabled={!canReloadActiveConversation}
|
||||
onSelect={handleReloadActiveConversation}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
重新加载
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={!folder?.path}
|
||||
onSelect={handleNewConversation}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建会话
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={!activeTabId}
|
||||
onSelect={handleCloseActiveTab}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
关闭会话
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { listFolderConversations } from "@/lib/tauri"
|
||||
import type {
|
||||
AgentType,
|
||||
ConversationStatus,
|
||||
DbConversationSummary,
|
||||
} from "@/lib/types"
|
||||
import { AGENT_LABELS, STATUS_COLORS, compareAgentType } from "@/lib/types"
|
||||
import { AgentIcon } from "@/components/agent-icon"
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SearchCommandDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function SearchCommandDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SearchCommandDialogProps) {
|
||||
const { folderId, conversations } = useFolderContext()
|
||||
const { openTab } = useTabContext()
|
||||
|
||||
const [query, setQuery] = useState("")
|
||||
const [agentFilter, setAgentFilter] = useState<AgentType | null>(null)
|
||||
const [results, setResults] = useState<DbConversationSummary[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
// Compute which agent types exist in current folder
|
||||
const availableAgents = Array.from(
|
||||
new Set(conversations.map((c) => c.agent_type))
|
||||
).sort(compareAgentType)
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (q: string, agent: AgentType | null) => {
|
||||
if (!q.trim() && !agent) {
|
||||
setResults([])
|
||||
setSearching(false)
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
try {
|
||||
const data = await listFolderConversations({
|
||||
folder_id: folderId,
|
||||
search: q.trim() || null,
|
||||
agent_type: agent,
|
||||
})
|
||||
setResults(data)
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
},
|
||||
[folderId]
|
||||
)
|
||||
|
||||
// Debounced search on query change
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
doSearch(query, agentFilter)
|
||||
}, 300)
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [query, agentFilter, doSearch])
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("")
|
||||
setAgentFilter(null)
|
||||
setResults([])
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSelect = (conv: DbConversationSummary) => {
|
||||
openTab(conv.id, conv.agent_type, true)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
title="Search conversations"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="Search conversations..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
{availableAgents.length > 1 && (
|
||||
<div className="flex items-center gap-1 px-3 py-2 border-b">
|
||||
<button
|
||||
onClick={() => setAgentFilter(null)}
|
||||
className={cn(
|
||||
"h-6 text-xs px-2 rounded-md transition-colors",
|
||||
agentFilter === null
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{availableAgents.map((at) => (
|
||||
<button
|
||||
key={at}
|
||||
onClick={() => setAgentFilter(at)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 h-6 text-xs px-2 rounded-md transition-colors",
|
||||
agentFilter === at
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<AgentIcon agentType={at} className="w-3.5 h-3.5" />
|
||||
{AGENT_LABELS[at]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<CommandList className="min-h-96">
|
||||
<CommandEmpty>
|
||||
{searching
|
||||
? "Searching..."
|
||||
: !query.trim() && !agentFilter
|
||||
? "键入搜索以查询会话"
|
||||
: "No results found."}
|
||||
</CommandEmpty>
|
||||
{results.length > 0 && (
|
||||
<CommandGroup>
|
||||
{results.map((conv) => (
|
||||
<CommandItem
|
||||
key={conv.id}
|
||||
value={`${conv.id}-${conv.title ?? ""}`}
|
||||
onSelect={() => handleSelect(conv)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
STATUS_COLORS[conv.status as ConversationStatus] ??
|
||||
"bg-gray-400"
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 truncate">
|
||||
{conv.title || "Untitled conversation"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{AGENT_LABELS[conv.agent_type]}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatDistanceToNow(new Date(conv.created_at), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useState, useCallback } from "react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { GitBranch, Pencil, Trash2, Circle, Download, Plus } from "lucide-react"
|
||||
import type { DbConversationSummary, ConversationStatus } from "@/lib/types"
|
||||
import { STATUS_COLORS, STATUS_LABELS } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AgentIcon } from "@/components/agent-icon"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSeparator,
|
||||
} from "@/components/ui/context-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
const ALL_STATUSES: ConversationStatus[] = [
|
||||
"in_progress",
|
||||
"pending_review",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
interface SidebarConversationCardProps {
|
||||
conversation: DbConversationSummary
|
||||
isSelected: boolean
|
||||
onSelect: (id: number, agentType: string) => void
|
||||
onDoubleClick?: (id: number, agentType: string) => void
|
||||
onRename: (id: number, newTitle: string) => Promise<void>
|
||||
onDelete: (id: number, agentType: string) => Promise<void>
|
||||
onStatusChange: (id: number, status: ConversationStatus) => Promise<void>
|
||||
onNewConversation?: () => void
|
||||
onImport?: () => void
|
||||
importing?: boolean
|
||||
}
|
||||
|
||||
export const SidebarConversationCard = memo(function SidebarConversationCard({
|
||||
conversation,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDoubleClick,
|
||||
onRename,
|
||||
onDelete,
|
||||
onStatusChange,
|
||||
onNewConversation,
|
||||
onImport,
|
||||
importing,
|
||||
}: SidebarConversationCardProps) {
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState("")
|
||||
|
||||
const timeAgo = formatDistanceToNow(new Date(conversation.updated_at), {
|
||||
addSuffix: true,
|
||||
})
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(conversation.id, conversation.agent_type)
|
||||
}, [onSelect, conversation.id, conversation.agent_type])
|
||||
|
||||
const handleDblClick = useCallback(() => {
|
||||
onDoubleClick?.(conversation.id, conversation.agent_type)
|
||||
}, [onDoubleClick, conversation.id, conversation.agent_type])
|
||||
|
||||
const handleRenameOpen = useCallback(() => {
|
||||
setRenameValue(conversation.title || "")
|
||||
setRenameOpen(true)
|
||||
}, [conversation.title])
|
||||
|
||||
const handleRenameConfirm = useCallback(async () => {
|
||||
const trimmed = renameValue.trim()
|
||||
if (trimmed && trimmed !== conversation.title) {
|
||||
await onRename(conversation.id, trimmed)
|
||||
}
|
||||
setRenameOpen(false)
|
||||
}, [renameValue, conversation.id, conversation.title, onRename])
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
await onDelete(conversation.id, conversation.agent_type)
|
||||
setDeleteOpen(false)
|
||||
}, [conversation.id, conversation.agent_type, onDelete])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
data-conversation-id={conversation.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDblClick}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2.5 rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent/50",
|
||||
isSelected && "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<AgentIcon
|
||||
agentType={conversation.agent_type}
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{conversation.title || "Untitled conversation"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<span>{timeAgo}</span>
|
||||
{conversation.git_branch && (
|
||||
<span className="flex items-center gap-0.5 truncate">
|
||||
<GitBranch className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{conversation.git_branch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{onNewConversation && (
|
||||
<>
|
||||
<ContextMenuItem onSelect={onNewConversation}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Conversation
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onSelect={handleRenameOpen}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<Circle className="h-4 w-4" />
|
||||
Status
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{ALL_STATUSES.filter((s) => s !== conversation.status).map(
|
||||
(s) => (
|
||||
<ContextMenuItem
|
||||
key={s}
|
||||
onSelect={() => onStatusChange(conversation.id, s)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
STATUS_COLORS[s]
|
||||
)}
|
||||
/>
|
||||
{STATUS_LABELS[s]}
|
||||
</ContextMenuItem>
|
||||
)
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
{onImport && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={importing} onSelect={onImport}>
|
||||
<Download className="h-4 w-4" />
|
||||
{importing ? "Importing..." : "Import local sessions"}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename conversation</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.nativeEvent.isComposing || e.key === "Process") return
|
||||
if (e.key === "Enter") handleRenameConfirm()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRenameConfirm}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete conversation?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete “
|
||||
{conversation.title || "Untitled conversation"}”. This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,463 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Ref,
|
||||
} from "react"
|
||||
import { toast } from "sonner"
|
||||
import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useTaskContext } from "@/contexts/task-context"
|
||||
import {
|
||||
importLocalConversations,
|
||||
updateConversationTitle,
|
||||
updateConversationStatus,
|
||||
deleteConversation,
|
||||
} from "@/lib/tauri"
|
||||
import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
|
||||
import { STATUS_ORDER, STATUS_LABELS, STATUS_COLORS } from "@/lib/types"
|
||||
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from "@/components/ui/context-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function parseTimestamp(value: string): number {
|
||||
const timestamp = Date.parse(value)
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp
|
||||
}
|
||||
|
||||
function compareByUpdatedAtDesc(
|
||||
left: DbConversationSummary,
|
||||
right: DbConversationSummary
|
||||
): number {
|
||||
const updatedDiff =
|
||||
parseTimestamp(right.updated_at) - parseTimestamp(left.updated_at)
|
||||
if (updatedDiff !== 0) return updatedDiff
|
||||
|
||||
const createdDiff =
|
||||
parseTimestamp(right.created_at) - parseTimestamp(left.created_at)
|
||||
if (createdDiff !== 0) return createdDiff
|
||||
|
||||
return right.id - left.id
|
||||
}
|
||||
|
||||
export interface SidebarConversationListHandle {
|
||||
scrollToActive: () => void
|
||||
expandAll: () => void
|
||||
collapseAll: () => void
|
||||
}
|
||||
|
||||
export function SidebarConversationList({
|
||||
ref,
|
||||
}: {
|
||||
ref?: Ref<SidebarConversationListHandle>
|
||||
}) {
|
||||
const {
|
||||
folder,
|
||||
conversations,
|
||||
loading,
|
||||
refreshing,
|
||||
error,
|
||||
selectedConversation,
|
||||
folderId,
|
||||
refreshConversations,
|
||||
} = useFolderContext()
|
||||
|
||||
const { openTab, closeTab, openNewConversationTab } = useTabContext()
|
||||
const { addTask, updateTask } = useTaskContext()
|
||||
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [completeReviewOpen, setCompleteReviewOpen] = useState(false)
|
||||
const [completingReview, setCompletingReview] = useState(false)
|
||||
const [groupExpanded, setGroupExpanded] = useState<
|
||||
Record<ConversationStatus, boolean>
|
||||
>({
|
||||
in_progress: true,
|
||||
pending_review: true,
|
||||
completed: false,
|
||||
cancelled: false,
|
||||
})
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToActive() {
|
||||
if (!selectedConversation) return
|
||||
const conv = conversations.find(
|
||||
(c) =>
|
||||
c.id === selectedConversation.id &&
|
||||
c.agent_type === selectedConversation.agentType
|
||||
)
|
||||
if (!conv) return
|
||||
const status = conv.status as ConversationStatus
|
||||
if (!groupExpanded[status]) {
|
||||
setGroupExpanded((prev) => ({ ...prev, [status]: true }))
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollContainerRef.current?.querySelector(
|
||||
`[data-conversation-id="${selectedConversation.id}"]`
|
||||
)
|
||||
el?.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
})
|
||||
} else {
|
||||
const el = scrollContainerRef.current?.querySelector(
|
||||
`[data-conversation-id="${selectedConversation.id}"]`
|
||||
)
|
||||
el?.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
}
|
||||
},
|
||||
expandAll() {
|
||||
setGroupExpanded({
|
||||
in_progress: true,
|
||||
pending_review: true,
|
||||
completed: true,
|
||||
cancelled: true,
|
||||
})
|
||||
},
|
||||
collapseAll() {
|
||||
setGroupExpanded({
|
||||
in_progress: false,
|
||||
pending_review: false,
|
||||
completed: false,
|
||||
cancelled: false,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<ConversationStatus, DbConversationSummary[]>()
|
||||
for (const conv of conversations) {
|
||||
const status = conv.status as ConversationStatus
|
||||
const list = map.get(status)
|
||||
if (list) {
|
||||
list.push(conv)
|
||||
} else {
|
||||
map.set(status, [conv])
|
||||
}
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort(compareByUpdatedAtDesc)
|
||||
}
|
||||
return map
|
||||
}, [conversations])
|
||||
|
||||
const reviewConversations = useMemo(
|
||||
() => grouped.get("pending_review") ?? [],
|
||||
[grouped]
|
||||
)
|
||||
const reviewConversationCount = reviewConversations.length
|
||||
|
||||
const toggleGroup = useCallback((status: ConversationStatus) => {
|
||||
setGroupExpanded((prev) => ({ ...prev, [status]: !prev[status] }))
|
||||
}, [])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: number, agentType: string) => {
|
||||
openTab(id, agentType as Parameters<typeof openTab>[1], false)
|
||||
},
|
||||
[openTab]
|
||||
)
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(id: number, agentType: string) => {
|
||||
openTab(id, agentType as Parameters<typeof openTab>[1], true)
|
||||
},
|
||||
[openTab]
|
||||
)
|
||||
|
||||
const handleRename = useCallback(
|
||||
async (id: number, newTitle: string) => {
|
||||
await updateConversationTitle(id, newTitle)
|
||||
refreshConversations()
|
||||
},
|
||||
[refreshConversations]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: number, agentType: string) => {
|
||||
await deleteConversation(id)
|
||||
closeTab(`conv-${agentType}-${id}`)
|
||||
refreshConversations()
|
||||
},
|
||||
[closeTab, refreshConversations]
|
||||
)
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
async (id: number, status: ConversationStatus) => {
|
||||
await updateConversationStatus(id, status)
|
||||
refreshConversations()
|
||||
},
|
||||
[refreshConversations]
|
||||
)
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (!folder) return
|
||||
openNewConversationTab("codex", folder.path)
|
||||
}, [folder, openNewConversationTab])
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (importing) return
|
||||
setImporting(true)
|
||||
const taskId = `import-${folderId}-${Date.now()}`
|
||||
addTask(taskId, "Importing local sessions")
|
||||
updateTask(taskId, { status: "running" })
|
||||
try {
|
||||
const result = await importLocalConversations(folderId)
|
||||
updateTask(taskId, { status: "completed" })
|
||||
refreshConversations()
|
||||
if (result.imported > 0) {
|
||||
toast.success(
|
||||
`Imported ${result.imported} session(s), skipped ${result.skipped}`
|
||||
)
|
||||
} else {
|
||||
toast.info(`No new sessions found (skipped ${result.skipped})`)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
updateTask(taskId, { status: "failed", error: msg })
|
||||
toast.error(`Import failed: ${msg}`)
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}, [importing, folderId, addTask, updateTask, refreshConversations])
|
||||
|
||||
const handleCompleteAllReview = useCallback(async () => {
|
||||
if (completingReview || reviewConversationCount === 0) return
|
||||
setCompletingReview(true)
|
||||
try {
|
||||
await Promise.all(
|
||||
reviewConversations.map((conversation) =>
|
||||
updateConversationStatus(conversation.id, "completed")
|
||||
)
|
||||
)
|
||||
refreshConversations()
|
||||
toast.success(
|
||||
`Marked ${reviewConversationCount} review session(s) as completed`
|
||||
)
|
||||
setCompleteReviewOpen(false)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
toast.error(`Failed to complete review sessions: ${msg}`)
|
||||
} finally {
|
||||
setCompletingReview(false)
|
||||
}
|
||||
}, [
|
||||
completingReview,
|
||||
reviewConversationCount,
|
||||
reviewConversations,
|
||||
refreshConversations,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{(loading || refreshing) && (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !refreshing ? (
|
||||
<div className="px-3 space-y-1.5 overflow-hidden">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex-1 flex items-center justify-center px-3">
|
||||
<p className="text-destructive text-xs">Error: {error}</p>
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-3 gap-3">
|
||||
<p className="text-muted-foreground text-xs text-center">
|
||||
No conversations found.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={importing}
|
||||
onClick={handleImport}
|
||||
>
|
||||
{importing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{importing ? "Importing..." : "Import local sessions"}
|
||||
</Button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={handleNewConversation}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Conversation
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={importing} onSelect={handleImport}>
|
||||
<Download className="h-4 w-4" />
|
||||
{importing ? "Importing..." : "Import local sessions"}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto px-1.5",
|
||||
"[&::-webkit-scrollbar]:w-1.5",
|
||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-border"
|
||||
)}
|
||||
>
|
||||
{STATUS_ORDER.map((status) => {
|
||||
const items = grouped.get(status)
|
||||
if (!items || items.length === 0) return null
|
||||
const isOpen = groupExpanded[status]
|
||||
const groupHeader = (
|
||||
<CollapsibleTrigger className="sticky top-0 z-10 bg-sidebar flex items-center gap-1.5 w-full px-1.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 transition-transform",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
STATUS_COLORS[status]
|
||||
)}
|
||||
/>
|
||||
<span>{STATUS_LABELS[status]}</span>
|
||||
<span className="ml-auto text-muted-foreground/60 tabular-nums">
|
||||
{items.length}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
return (
|
||||
<Collapsible
|
||||
key={status}
|
||||
open={isOpen}
|
||||
onOpenChange={() => toggleGroup(status)}
|
||||
>
|
||||
{status === "pending_review" ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
{groupHeader}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
disabled={
|
||||
reviewConversationCount === 0 || completingReview
|
||||
}
|
||||
onSelect={() => setCompleteReviewOpen(true)}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Complete all sessions
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
groupHeader
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-0.5 pb-1">
|
||||
{items.map((conversation) => {
|
||||
const isSelected =
|
||||
selectedConversation?.agentType ===
|
||||
conversation.agent_type &&
|
||||
selectedConversation?.id === conversation.id
|
||||
return (
|
||||
<SidebarConversationCard
|
||||
key={conversation.id}
|
||||
conversation={conversation}
|
||||
isSelected={isSelected}
|
||||
onSelect={handleSelect}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
onStatusChange={handleStatusChange}
|
||||
onNewConversation={handleNewConversation}
|
||||
onImport={handleImport}
|
||||
importing={importing}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={handleNewConversation}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Conversation
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={importing} onSelect={handleImport}>
|
||||
<Download className="h-4 w-4" />
|
||||
{importing ? "Importing..." : "Import local sessions"}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
<AlertDialog
|
||||
open={completeReviewOpen}
|
||||
onOpenChange={(open) =>
|
||||
!completingReview && setCompleteReviewOpen(open)
|
||||
}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Complete all review sessions?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will mark all {reviewConversationCount} session(s) in Review
|
||||
as completed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={completingReview}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={completingReview || reviewConversationCount === 0}
|
||||
onClick={handleCompleteAllReview}
|
||||
>
|
||||
{completingReview ? "Completing..." : "Confirm"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from "react"
|
||||
import dynamic from "next/dynamic"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import type { DiffOnMount } from "@monaco-editor/react"
|
||||
import type { editor as MonacoEditorNs } from "monaco-editor"
|
||||
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const MonacoDiffEditor = dynamic(
|
||||
async () => {
|
||||
const mod = await import("@monaco-editor/react")
|
||||
return { default: mod.DiffEditor }
|
||||
},
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
export interface DiffViewerProps {
|
||||
original: string
|
||||
modified: string
|
||||
originalLabel?: string
|
||||
modifiedLabel?: string
|
||||
language?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DiffViewer({
|
||||
original,
|
||||
modified,
|
||||
originalLabel = "Original",
|
||||
modifiedLabel = "Modified",
|
||||
language = "plaintext",
|
||||
className,
|
||||
}: DiffViewerProps) {
|
||||
const editorTheme = useMonacoThemeSync()
|
||||
const diffEditorRef = useRef<MonacoEditorNs.IStandaloneDiffEditor | null>(
|
||||
null
|
||||
)
|
||||
const [diffChanges, setDiffChanges] = useState<MonacoEditorNs.ILineChange[]>(
|
||||
[]
|
||||
)
|
||||
const [currentChangeIndex, setCurrentChangeIndex] = useState(-1)
|
||||
|
||||
const handleEditorMount: DiffOnMount = useCallback((editor) => {
|
||||
diffEditorRef.current = editor
|
||||
let scrolledToFirst = false
|
||||
|
||||
const updateDiffs = () => {
|
||||
const changes = editor.getLineChanges()
|
||||
setDiffChanges(changes ?? [])
|
||||
if (changes && changes.length > 0) {
|
||||
setCurrentChangeIndex(0)
|
||||
// Auto-scroll to the first change only once
|
||||
if (!scrolledToFirst) {
|
||||
scrolledToFirst = true
|
||||
const first = changes[0]
|
||||
const lineNumber =
|
||||
first.modifiedStartLineNumber || first.originalStartLineNumber || 1
|
||||
const modifiedEditor = editor.getModifiedEditor()
|
||||
modifiedEditor.revealLineInCenter(lineNumber)
|
||||
modifiedEditor.setPosition({ lineNumber, column: 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editor.onDidUpdateDiff(updateDiffs)
|
||||
setTimeout(updateDiffs, 300)
|
||||
}, [])
|
||||
|
||||
const navigateToChange = useCallback(
|
||||
(index: number) => {
|
||||
const editor = diffEditorRef.current
|
||||
if (!editor || diffChanges.length === 0) return
|
||||
|
||||
const clampedIndex = Math.max(0, Math.min(index, diffChanges.length - 1))
|
||||
setCurrentChangeIndex(clampedIndex)
|
||||
|
||||
const change = diffChanges[clampedIndex]
|
||||
const lineNumber =
|
||||
change.modifiedStartLineNumber || change.originalStartLineNumber || 1
|
||||
|
||||
const modifiedEditor = editor.getModifiedEditor()
|
||||
modifiedEditor.revealLineInCenter(lineNumber)
|
||||
modifiedEditor.setPosition({ lineNumber, column: 1 })
|
||||
},
|
||||
[diffChanges]
|
||||
)
|
||||
|
||||
const handlePrevChange = useCallback(() => {
|
||||
if (currentChangeIndex > 0) {
|
||||
navigateToChange(currentChangeIndex - 1)
|
||||
}
|
||||
}, [currentChangeIndex, navigateToChange])
|
||||
|
||||
const handleNextChange = useCallback(() => {
|
||||
if (currentChangeIndex < diffChanges.length - 1) {
|
||||
navigateToChange(currentChangeIndex + 1)
|
||||
}
|
||||
}, [currentChangeIndex, diffChanges.length, navigateToChange])
|
||||
|
||||
const { additions, deletions } = useMemo(() => {
|
||||
let add = 0
|
||||
let del = 0
|
||||
for (const change of diffChanges) {
|
||||
// Monaco ILineChange: endLineNumber === 0 means no lines on that side
|
||||
// Pure insertion: originalEndLineNumber === 0
|
||||
// Pure deletion: modifiedEndLineNumber === 0
|
||||
const isInsertion = change.originalEndLineNumber === 0
|
||||
const isDeletion = change.modifiedEndLineNumber === 0
|
||||
|
||||
if (isInsertion) {
|
||||
add += change.modifiedEndLineNumber - change.modifiedStartLineNumber + 1
|
||||
} else if (isDeletion) {
|
||||
del += change.originalEndLineNumber - change.originalStartLineNumber + 1
|
||||
} else {
|
||||
del += change.originalEndLineNumber - change.originalStartLineNumber + 1
|
||||
add += change.modifiedEndLineNumber - change.modifiedStartLineNumber + 1
|
||||
}
|
||||
}
|
||||
return { additions: add, deletions: del }
|
||||
}, [diffChanges])
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
<div className="flex items-center gap-3 border-b bg-muted/50 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-medium">{originalLabel}</span>
|
||||
<span className="text-muted-foreground/60">↔</span>
|
||||
<span className="font-medium">{modifiedLabel}</span>
|
||||
{diffChanges.length > 0 && (
|
||||
<>
|
||||
<span className="ml-2 font-mono text-green-600 dark:text-green-400">
|
||||
+{additions}
|
||||
</span>
|
||||
<span className="font-mono text-red-600 dark:text-red-400">
|
||||
-{deletions}
|
||||
</span>
|
||||
<span>
|
||||
{diffChanges.length}{" "}
|
||||
{diffChanges.length === 1 ? "change" : "changes"}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrevChange}
|
||||
disabled={currentChangeIndex <= 0}
|
||||
className="rounded border border-border bg-background px-2 py-0.5 text-[10px] disabled:opacity-40 hover:bg-muted transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
Prev
|
||||
</button>
|
||||
<span className="tabular-nums text-[10px]">
|
||||
{currentChangeIndex + 1} / {diffChanges.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextChange}
|
||||
disabled={currentChangeIndex >= diffChanges.length - 1}
|
||||
className="rounded border border-border bg-background px-2 py-0.5 text-[10px] disabled:opacity-40 hover:bg-muted transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<MonacoDiffEditor
|
||||
original={original}
|
||||
modified={modified}
|
||||
language={language}
|
||||
theme={editorTheme}
|
||||
beforeMount={defineMonacoThemes}
|
||||
onMount={handleEditorMount}
|
||||
loading={
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||
Loading diff viewer...
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
renderSideBySideInlineBreakpoint: 0,
|
||||
automaticLayout: true,
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
renderOverviewRuler: false,
|
||||
ignoreTrimWhitespace: true,
|
||||
renderIndicators: true,
|
||||
originalEditable: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import dynamic from "next/dynamic"
|
||||
import type { editor as MonacoEditorNs } from "monaco-editor"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type RowMarker = "none" | "added" | "deleted" | "modified"
|
||||
type DiffFileMode = "modified" | "added" | "deleted" | "renamed"
|
||||
|
||||
interface RawDiffRow {
|
||||
kind: "context" | "add" | "del"
|
||||
text: string
|
||||
oldLine: number | null
|
||||
newLine: number | null
|
||||
}
|
||||
|
||||
interface ParsedDiffRow {
|
||||
type: "context" | "added" | "deleted" | "modified"
|
||||
text: string
|
||||
sign: " " | "+" | "-"
|
||||
oldLine: number | null
|
||||
newLine: number | null
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
key: string
|
||||
oldStart: number | null
|
||||
oldCount: number | null
|
||||
newStart: number | null
|
||||
newCount: number | null
|
||||
rows: ParsedDiffRow[]
|
||||
}
|
||||
|
||||
interface ParsedDiffFile {
|
||||
key: string
|
||||
path: string
|
||||
oldPath: string | null
|
||||
newPath: string | null
|
||||
mode: DiffFileMode
|
||||
additions: number
|
||||
deletions: number
|
||||
hunks: ParsedDiffHunk[]
|
||||
}
|
||||
|
||||
interface WorkingHunk {
|
||||
key: string
|
||||
oldStart: number | null
|
||||
oldCount: number | null
|
||||
newStart: number | null
|
||||
newCount: number | null
|
||||
rows: RawDiffRow[]
|
||||
}
|
||||
|
||||
interface WorkingFile {
|
||||
key: string
|
||||
path: string
|
||||
oldPath: string | null
|
||||
newPath: string | null
|
||||
mode: DiffFileMode
|
||||
additions: number
|
||||
deletions: number
|
||||
hunks: WorkingHunk[]
|
||||
}
|
||||
|
||||
interface HunkPreviewLine {
|
||||
text: string
|
||||
marker: RowMarker
|
||||
}
|
||||
|
||||
const MonacoEditor = dynamic(async () => import("@monaco-editor/react"), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
function normalizePath(raw: string): string | null {
|
||||
const trimmed = raw.trim().replace(/^"|"$/g, "")
|
||||
if (!trimmed || trimmed === "/dev/null") return null
|
||||
if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) {
|
||||
return trimmed.slice(2).replace(/\\/g, "/")
|
||||
}
|
||||
return trimmed.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function parsePathFromDiffGitLine(line: string): string | null {
|
||||
const match = line.match(/^diff --git\s+(.+?)\s+(.+)$/)
|
||||
if (!match) return null
|
||||
return normalizePath(match[2]) ?? normalizePath(match[1])
|
||||
}
|
||||
|
||||
function parseApplyPatchMarker(line: string): {
|
||||
path: string | null
|
||||
mode: DiffFileMode
|
||||
} | null {
|
||||
if (line.startsWith("*** Update File: ")) {
|
||||
return {
|
||||
path: normalizePath(line.slice("*** Update File: ".length)),
|
||||
mode: "modified",
|
||||
}
|
||||
}
|
||||
if (line.startsWith("*** Add File: ")) {
|
||||
return {
|
||||
path: normalizePath(line.slice("*** Add File: ".length)),
|
||||
mode: "added",
|
||||
}
|
||||
}
|
||||
if (line.startsWith("*** Delete File: ")) {
|
||||
return {
|
||||
path: normalizePath(line.slice("*** Delete File: ".length)),
|
||||
mode: "deleted",
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseHunkHeader(line: string): {
|
||||
oldStart: number | null
|
||||
oldCount: number | null
|
||||
newStart: number | null
|
||||
newCount: number | null
|
||||
} | null {
|
||||
if (!line.startsWith("@@")) return null
|
||||
|
||||
const match = line.match(/^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@/)
|
||||
if (!match) {
|
||||
return {
|
||||
oldStart: null,
|
||||
oldCount: null,
|
||||
newStart: null,
|
||||
newCount: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oldStart: Number(match[1]),
|
||||
oldCount: match[2] ? Number(match[2]) : 1,
|
||||
newStart: Number(match[3]),
|
||||
newCount: match[4] ? Number(match[4]) : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function classifyRows(rows: RawDiffRow[]): ParsedDiffRow[] {
|
||||
const parsed: ParsedDiffRow[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < rows.length) {
|
||||
const current = rows[index]
|
||||
if (!current) break
|
||||
|
||||
if (current.kind === "context") {
|
||||
parsed.push({
|
||||
type: "context",
|
||||
text: current.text,
|
||||
sign: " ",
|
||||
oldLine: current.oldLine,
|
||||
newLine: current.newLine,
|
||||
})
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (current.kind === "add") {
|
||||
let addEnd = index
|
||||
while (addEnd < rows.length && rows[addEnd]?.kind === "add") {
|
||||
const row = rows[addEnd]
|
||||
if (!row) break
|
||||
parsed.push({
|
||||
type: "added",
|
||||
text: row.text,
|
||||
sign: "+",
|
||||
oldLine: row.oldLine,
|
||||
newLine: row.newLine,
|
||||
})
|
||||
addEnd += 1
|
||||
}
|
||||
index = addEnd
|
||||
continue
|
||||
}
|
||||
|
||||
let delEnd = index
|
||||
while (delEnd < rows.length && rows[delEnd]?.kind === "del") {
|
||||
delEnd += 1
|
||||
}
|
||||
|
||||
let addEnd = delEnd
|
||||
while (addEnd < rows.length && rows[addEnd]?.kind === "add") {
|
||||
addEnd += 1
|
||||
}
|
||||
|
||||
const delRows = rows.slice(index, delEnd)
|
||||
const addRows = rows.slice(delEnd, addEnd)
|
||||
const modifiedPairs = Math.min(delRows.length, addRows.length)
|
||||
|
||||
for (const [delta, row] of delRows.entries()) {
|
||||
parsed.push({
|
||||
type: delta < modifiedPairs ? "modified" : "deleted",
|
||||
text: row.text,
|
||||
sign: "-",
|
||||
oldLine: row.oldLine,
|
||||
newLine: row.newLine,
|
||||
})
|
||||
}
|
||||
|
||||
for (const [delta, row] of addRows.entries()) {
|
||||
parsed.push({
|
||||
type: delta < modifiedPairs ? "modified" : "added",
|
||||
text: row.text,
|
||||
sign: "+",
|
||||
oldLine: row.oldLine,
|
||||
newLine: row.newLine,
|
||||
})
|
||||
}
|
||||
|
||||
index = addEnd
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function resolveFileMode(file: WorkingFile): DiffFileMode {
|
||||
if (file.mode !== "modified") return file.mode
|
||||
if (file.oldPath && !file.newPath) return "deleted"
|
||||
if (!file.oldPath && file.newPath) return "added"
|
||||
if (file.oldPath && file.newPath && file.oldPath !== file.newPath) {
|
||||
return "renamed"
|
||||
}
|
||||
return "modified"
|
||||
}
|
||||
|
||||
function parseUnifiedDiff(diffText: string): ParsedDiffFile[] {
|
||||
const lines = diffText.replace(/\r\n/g, "\n").split("\n")
|
||||
const files: WorkingFile[] = []
|
||||
|
||||
let fileIndex = 1
|
||||
let hunkIndex = 1
|
||||
let currentFile: WorkingFile | null = null
|
||||
let currentHunk: WorkingHunk | null = null
|
||||
let oldLineCursor: number | null = null
|
||||
let newLineCursor: number | null = null
|
||||
let inferredOldCursorForNextHunk = 1
|
||||
let inferredNewCursorForNextHunk = 1
|
||||
|
||||
const getActiveFile = (): WorkingFile | null =>
|
||||
currentFile ?? files[files.length - 1] ?? null
|
||||
const getActiveHunk = (): WorkingHunk | null => currentHunk
|
||||
const getOldLineCursor = (): number | null => oldLineCursor
|
||||
const getNewLineCursor = (): number | null => newLineCursor
|
||||
|
||||
const flushHunk = () => {
|
||||
const file = getActiveFile()
|
||||
if (!file || !currentHunk) return
|
||||
file.hunks.push(currentHunk)
|
||||
if (oldLineCursor !== null) {
|
||||
inferredOldCursorForNextHunk = Math.max(1, oldLineCursor)
|
||||
}
|
||||
if (newLineCursor !== null) {
|
||||
inferredNewCursorForNextHunk = Math.max(1, newLineCursor)
|
||||
}
|
||||
currentHunk = null
|
||||
}
|
||||
|
||||
const startFile = (
|
||||
path: string | null,
|
||||
mode: DiffFileMode = "modified"
|
||||
): WorkingFile => {
|
||||
flushHunk()
|
||||
currentFile = {
|
||||
key: `file-${fileIndex}`,
|
||||
path: path ?? `Diff #${fileIndex}`,
|
||||
oldPath: null,
|
||||
newPath: null,
|
||||
mode,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
}
|
||||
files.push(currentFile)
|
||||
fileIndex += 1
|
||||
inferredOldCursorForNextHunk = 1
|
||||
inferredNewCursorForNextHunk = 1
|
||||
return currentFile
|
||||
}
|
||||
|
||||
const ensureFile = () => getActiveFile() ?? startFile(null)
|
||||
|
||||
const startHunk = (line: string) => {
|
||||
const file = ensureFile()
|
||||
flushHunk()
|
||||
|
||||
const parsed = parseHunkHeader(line)
|
||||
const resolvedOldStart = parsed?.oldStart ?? inferredOldCursorForNextHunk
|
||||
const resolvedNewStart = parsed?.newStart ?? inferredNewCursorForNextHunk
|
||||
oldLineCursor = resolvedOldStart
|
||||
newLineCursor = resolvedNewStart
|
||||
|
||||
currentHunk = {
|
||||
key: `${file.key}:hunk-${hunkIndex}`,
|
||||
oldStart: resolvedOldStart,
|
||||
oldCount: parsed?.oldCount ?? null,
|
||||
newStart: resolvedNewStart,
|
||||
newCount: parsed?.newCount ?? null,
|
||||
rows: [],
|
||||
}
|
||||
hunkIndex += 1
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("diff --git ")) {
|
||||
startFile(parsePathFromDiffGitLine(line))
|
||||
continue
|
||||
}
|
||||
|
||||
const applyPatchMarker = parseApplyPatchMarker(line)
|
||||
if (applyPatchMarker) {
|
||||
startFile(applyPatchMarker.path, applyPatchMarker.mode)
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("*** Move to: ")) {
|
||||
const movedPath = normalizePath(line.slice("*** Move to: ".length))
|
||||
const file = getActiveFile()
|
||||
if (file && movedPath) {
|
||||
file.newPath = movedPath
|
||||
file.path = movedPath
|
||||
file.mode = "renamed"
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("--- ")) {
|
||||
const file = ensureFile()
|
||||
const oldPath = normalizePath(line.slice(4))
|
||||
file.oldPath = oldPath
|
||||
if (!file.newPath && oldPath) file.path = oldPath
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("+++ ")) {
|
||||
const file = ensureFile()
|
||||
const newPath = normalizePath(line.slice(4))
|
||||
file.newPath = newPath
|
||||
if (newPath) file.path = newPath
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
startHunk(line)
|
||||
continue
|
||||
}
|
||||
|
||||
const hunk = getActiveHunk()
|
||||
if (!hunk) continue
|
||||
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
hunk.rows.push({
|
||||
kind: "add",
|
||||
text: line.slice(1),
|
||||
oldLine: null,
|
||||
newLine: newLineCursor,
|
||||
})
|
||||
const cursor = getNewLineCursor()
|
||||
if (cursor !== null) newLineCursor = cursor + 1
|
||||
const file = getActiveFile()
|
||||
if (file) file.additions += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
hunk.rows.push({
|
||||
kind: "del",
|
||||
text: line.slice(1),
|
||||
oldLine: oldLineCursor,
|
||||
newLine: null,
|
||||
})
|
||||
const cursor = getOldLineCursor()
|
||||
if (cursor !== null) oldLineCursor = cursor + 1
|
||||
const file = getActiveFile()
|
||||
if (file) file.deletions += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith(" ")) {
|
||||
hunk.rows.push({
|
||||
kind: "context",
|
||||
text: line.slice(1),
|
||||
oldLine: oldLineCursor,
|
||||
newLine: newLineCursor,
|
||||
})
|
||||
const nextOldCursor = getOldLineCursor()
|
||||
if (nextOldCursor !== null) oldLineCursor = nextOldCursor + 1
|
||||
const nextNewCursor = getNewLineCursor()
|
||||
if (nextNewCursor !== null) newLineCursor = nextNewCursor + 1
|
||||
}
|
||||
}
|
||||
|
||||
flushHunk()
|
||||
|
||||
return files
|
||||
.map((file) => ({
|
||||
...file,
|
||||
mode: resolveFileMode(file),
|
||||
hunks: file.hunks
|
||||
.filter((hunk) => hunk.rows.length > 0)
|
||||
.map((hunk) => ({
|
||||
key: hunk.key,
|
||||
oldStart: hunk.oldStart,
|
||||
oldCount: hunk.oldCount,
|
||||
newStart: hunk.newStart,
|
||||
newCount: hunk.newCount,
|
||||
rows: classifyRows(hunk.rows),
|
||||
})),
|
||||
}))
|
||||
.filter((file) => file.hunks.length > 0)
|
||||
}
|
||||
|
||||
function modeLabel(mode: DiffFileMode): string {
|
||||
if (mode === "added") return "新增"
|
||||
if (mode === "deleted") return "删除"
|
||||
if (mode === "renamed") return "重命名"
|
||||
return "修改"
|
||||
}
|
||||
|
||||
function toDisplayPath(filePath: string, folderPath: string | null): string {
|
||||
const normalizedPath = filePath.replace(/\\/g, "/")
|
||||
if (!folderPath) return normalizedPath
|
||||
|
||||
const normalizedFolder = folderPath.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||
if (!normalizedFolder) return normalizedPath
|
||||
|
||||
const prefix = `${normalizedFolder}/`
|
||||
if (normalizedPath.startsWith(prefix)) {
|
||||
return normalizedPath.slice(prefix.length)
|
||||
}
|
||||
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
function hunkLabel(hunk: ParsedDiffHunk, index: number): string {
|
||||
void hunk
|
||||
return `Hunk ${index + 1}`
|
||||
}
|
||||
|
||||
function countHunkChanges(hunk: ParsedDiffHunk): {
|
||||
additions: number
|
||||
deletions: number
|
||||
} {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const row of hunk.rows) {
|
||||
if (row.sign === "+") additions += 1
|
||||
if (row.sign === "-") deletions += 1
|
||||
}
|
||||
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
function buildHunkPreviewLines(rows: ParsedDiffRow[]): {
|
||||
lines: HunkPreviewLine[]
|
||||
} {
|
||||
const lines: HunkPreviewLine[] = rows.map((row) => {
|
||||
let marker: RowMarker = "none"
|
||||
if (row.type === "added") marker = "added"
|
||||
else if (row.type === "deleted") marker = "deleted"
|
||||
else if (row.type === "modified") marker = "modified"
|
||||
|
||||
return {
|
||||
text: `${row.sign}${row.text}`,
|
||||
marker,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
function HunkMonacoPreview({
|
||||
hunk,
|
||||
modelId,
|
||||
theme,
|
||||
}: {
|
||||
hunk: ParsedDiffHunk
|
||||
modelId: string
|
||||
theme: string
|
||||
}) {
|
||||
const editorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(null)
|
||||
const decorationsRef = useRef<string[]>([])
|
||||
|
||||
const { lines } = useMemo(() => buildHunkPreviewLines(hunk.rows), [hunk.rows])
|
||||
|
||||
const renderedContent = useMemo(
|
||||
() => lines.map((line) => line.text).join("\n"),
|
||||
[lines]
|
||||
)
|
||||
|
||||
const applyDecorations = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
const model = editor.getModel()
|
||||
if (!model) return
|
||||
|
||||
const maxLine = model.getLineCount()
|
||||
const decorations: MonacoEditorNs.IModelDeltaDecoration[] = []
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const lineNumber = index + 1
|
||||
if (lineNumber > maxLine) continue
|
||||
|
||||
let cls: string | null = null
|
||||
if (line.marker === "added") {
|
||||
cls = "codeg-session-diff-line-added"
|
||||
} else if (line.marker === "modified") {
|
||||
cls = "codeg-session-diff-line-modified"
|
||||
} else if (line.marker === "deleted") {
|
||||
cls = "codeg-session-diff-line-deleted"
|
||||
}
|
||||
|
||||
if (!cls) continue
|
||||
|
||||
decorations.push({
|
||||
range: {
|
||||
startLineNumber: lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: lineNumber,
|
||||
endColumn: 1,
|
||||
},
|
||||
options: {
|
||||
isWholeLine: true,
|
||||
className: cls,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
decorationsRef.current = editor.deltaDecorations(
|
||||
decorationsRef.current,
|
||||
decorations
|
||||
)
|
||||
}, [lines])
|
||||
|
||||
useEffect(() => {
|
||||
applyDecorations()
|
||||
}, [applyDecorations])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
editor.deltaDecorations(decorationsRef.current, [])
|
||||
decorationsRef.current = []
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<MonacoEditor
|
||||
beforeMount={defineMonacoThemes}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor
|
||||
applyDecorations()
|
||||
}}
|
||||
path={`inmemory://session-hunk/${encodeURIComponent(modelId)}`}
|
||||
value={renderedContent}
|
||||
language="plaintext"
|
||||
theme={theme}
|
||||
loading={
|
||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">
|
||||
Loading hunk...
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
automaticLayout: true,
|
||||
fontSize: 12,
|
||||
lineNumbers: "off",
|
||||
lineDecorationsWidth: 10,
|
||||
glyphMargin: false,
|
||||
wordWrap: "off",
|
||||
scrollBeyondLastLine: false,
|
||||
renderLineHighlight: "none",
|
||||
contextmenu: false,
|
||||
folding: false,
|
||||
scrollbar: {
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
padding: { top: 6, bottom: 6 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function UnifiedDiffPreview({
|
||||
diffText,
|
||||
modelId,
|
||||
className,
|
||||
}: {
|
||||
diffText: string
|
||||
modelId?: string
|
||||
className?: string
|
||||
}) {
|
||||
const { folder } = useFolderContext()
|
||||
const files = useMemo(() => parseUnifiedDiff(diffText), [diffText])
|
||||
const theme = useMonacoThemeSync()
|
||||
|
||||
if (!diffText.trim()) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex items-center justify-center text-xs text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
No diff data
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className={cn("h-full overflow-auto p-3", className)}>
|
||||
<pre className="font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground">
|
||||
{diffText}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("h-full overflow-auto p-3", className)}>
|
||||
<div className="space-y-3">
|
||||
{files.map((file) => (
|
||||
<section
|
||||
key={file.key}
|
||||
className="overflow-hidden rounded-lg border border-border bg-background"
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 text-[11px]">
|
||||
<span className="shrink-0 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{modeLabel(file.mode)}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-foreground"
|
||||
title={file.path}
|
||||
>
|
||||
{toDisplayPath(file.path, folder?.path ?? null)}
|
||||
</span>
|
||||
<span className="ml-auto inline-flex shrink-0 items-center gap-2 font-mono">
|
||||
<span className="text-green-700 dark:text-green-400">
|
||||
+{file.additions}
|
||||
</span>
|
||||
<span className="text-red-700 dark:text-red-400">
|
||||
-{file.deletions}
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="space-y-2 p-2">
|
||||
{file.hunks.map((hunk, index) => {
|
||||
const hunkStats = countHunkChanges(hunk)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hunk.key}
|
||||
className="rounded-md border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
|
||||
<span>{hunkLabel(hunk, index)}</span>
|
||||
<span className="ml-auto inline-flex items-center gap-2">
|
||||
<span className="text-green-700 dark:text-green-400">
|
||||
+{hunkStats.additions}
|
||||
</span>
|
||||
<span className="text-red-700 dark:text-red-400">
|
||||
-{hunkStats.deletions}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-[7rem]"
|
||||
style={{
|
||||
height: `${Math.max(120, hunk.rows.length * 20 + 18)}px`,
|
||||
}}
|
||||
>
|
||||
<HunkMonacoPreview
|
||||
hunk={hunk}
|
||||
modelId={`${modelId ?? "session"}:${file.key}:${hunk.key}`}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Reorder } from "motion/react"
|
||||
import { FileText, GitCompare, X } from "lucide-react"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
|
||||
export function FileWorkspaceTabBar() {
|
||||
const {
|
||||
mode,
|
||||
activePane,
|
||||
fileTabs,
|
||||
activeFileTabId,
|
||||
switchFileTab,
|
||||
closeFileTab,
|
||||
closeOtherFileTabs,
|
||||
closeAllFileTabs,
|
||||
reorderFileTabs,
|
||||
} = useWorkspaceContext()
|
||||
const { shortcuts } = useShortcutSettings()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (e.deltaY !== 0 && scrollRef.current) {
|
||||
e.preventDefault()
|
||||
scrollRef.current.scrollLeft += e.deltaY
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeFileTabId || !scrollRef.current) return
|
||||
const el = scrollRef.current.querySelector(
|
||||
`[data-file-tab-id="${activeFileTabId}"]`
|
||||
)
|
||||
el?.scrollIntoView({ block: "nearest", inline: "nearest" })
|
||||
}, [activeFileTabId])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const shouldHandleShortcut =
|
||||
mode === "files" || (mode === "fusion" && activePane === "files")
|
||||
if (!shouldHandleShortcut) return
|
||||
if (matchShortcutEvent(event, shortcuts.close_all_file_tabs)) {
|
||||
event.preventDefault()
|
||||
closeAllFileTabs()
|
||||
return
|
||||
}
|
||||
if (!matchShortcutEvent(event, shortcuts.close_current_tab)) return
|
||||
|
||||
if (!activeFileTabId) return
|
||||
event.preventDefault()
|
||||
closeFileTab(activeFileTabId)
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown)
|
||||
}
|
||||
}, [
|
||||
activeFileTabId,
|
||||
closeAllFileTabs,
|
||||
closeFileTab,
|
||||
mode,
|
||||
activePane,
|
||||
shortcuts.close_all_file_tabs,
|
||||
shortcuts.close_current_tab,
|
||||
])
|
||||
|
||||
if (fileTabs.length === 0) {
|
||||
return (
|
||||
<div className="h-10 px-3 flex items-center border-b border-border text-xs text-muted-foreground">
|
||||
Files
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
ref={scrollRef}
|
||||
role="tablist"
|
||||
axis="x"
|
||||
values={fileTabs}
|
||||
onReorder={reorderFileTabs}
|
||||
onWheel={handleWheel}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={cn(
|
||||
"h-10 pt-1.5 px-1.5 flex items-stretch gap-1.5 border-b border-border",
|
||||
"overflow-x-scroll",
|
||||
isHovered
|
||||
? [
|
||||
"pb-0.5",
|
||||
"[&::-webkit-scrollbar]:h-1",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-border",
|
||||
]
|
||||
: ["pb-1.5", "[&::-webkit-scrollbar]:h-0"]
|
||||
)}
|
||||
>
|
||||
{fileTabs.map((tab) => {
|
||||
const active = tab.id === activeFileTabId
|
||||
const isDiff = tab.kind === "diff" || tab.kind === "rich-diff"
|
||||
const isDirty = tab.kind === "file" && Boolean(tab.isDirty)
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
key={tab.id}
|
||||
as="div"
|
||||
value={tab}
|
||||
data-file-tab-id={tab.id}
|
||||
className="shrink-0 rounded-full cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => switchFileTab(tab.id)}
|
||||
className={cn(
|
||||
"group/filetab relative flex items-center h-full gap-1.5 px-3 text-xs rounded-full",
|
||||
"cursor-pointer select-none shrink-0 hover:bg-primary/8 transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
title={tab.description ?? tab.title}
|
||||
>
|
||||
{isDiff ? (
|
||||
<GitCompare className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="truncate max-w-[180px]">
|
||||
{tab.title}
|
||||
{isDirty ? " *" : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-full p-0.5 hover:bg-muted",
|
||||
active
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/filetab:opacity-100"
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
closeFileTab(tab.id)
|
||||
}}
|
||||
aria-label="Close file tab"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => closeFileTab(tab.id)}>
|
||||
关闭
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => closeOtherFileTabs(tab.id)}>
|
||||
关闭其它
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={closeAllFileTabs}>
|
||||
关闭所有
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</Reorder.Item>
|
||||
)
|
||||
})}
|
||||
</Reorder.Group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { usePlatform } from "@/hooks/use-platform"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { WindowControls } from "./window-controls"
|
||||
|
||||
interface AppTitleBarProps {
|
||||
left?: ReactNode
|
||||
center?: ReactNode
|
||||
right?: ReactNode
|
||||
className?: string
|
||||
rowClassName?: string
|
||||
centerInteractive?: boolean
|
||||
showWindowControls?: boolean
|
||||
}
|
||||
|
||||
export function AppTitleBar({
|
||||
left,
|
||||
center,
|
||||
right,
|
||||
className,
|
||||
rowClassName,
|
||||
centerInteractive = false,
|
||||
showWindowControls = true,
|
||||
}: AppTitleBarProps) {
|
||||
const { isMac, isWindows } = usePlatform()
|
||||
|
||||
const rowPadding = cn(
|
||||
"px-3",
|
||||
isMac && "pl-[76px]",
|
||||
isWindows && showWindowControls && "pr-[138px]"
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-8 shrink-0 border-b bg-muted/70 select-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div data-tauri-drag-region className="absolute inset-0" />
|
||||
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
"relative z-10 flex h-full items-center",
|
||||
rowPadding,
|
||||
rowClassName
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">{left}</div>
|
||||
{right ? (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto shrink-0",
|
||||
isWindows && showWindowControls && "mr-4"
|
||||
)}
|
||||
>
|
||||
{right}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{center ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className={cn(centerInteractive && "pointer-events-auto")}>
|
||||
{center}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showWindowControls && isWindows ? (
|
||||
<div className="absolute right-0 top-0 z-30">
|
||||
<WindowControls />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,409 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ChevronRight, FileIcon } from "lucide-react"
|
||||
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
|
||||
}): MessageTurn[] {
|
||||
const { turns, liveMessage, connStatus, pendingPromptText } = 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() || "Current response" },
|
||||
],
|
||||
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 { 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,
|
||||
}),
|
||||
[detail?.turns, liveMessage, connStatus, pendingPromptText]
|
||||
)
|
||||
const groups = useMemo(
|
||||
() => (turns.length > 0 ? extractSessionFilesGrouped(turns) : []),
|
||||
[turns]
|
||||
)
|
||||
|
||||
const handleFileClick = (
|
||||
filePath: string,
|
||||
diffContent: string | null,
|
||||
groupIndex: number,
|
||||
changeIndex: number
|
||||
) => {
|
||||
openSessionFileDiff(
|
||||
filePath,
|
||||
diffContent ?? `No diff data available for ${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">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">
|
||||
No file changes found in this conversation
|
||||
</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">
|
||||
{group.files.length}{" "}
|
||||
{group.files.length === 1 ? "change" : "changes"}
|
||||
</span>
|
||||
<span className="rounded-md border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{uniqueFileCount}{" "}
|
||||
{uniqueFileCount === 1 ? "File" : "Files"}
|
||||
</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">
|
||||
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 { 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">
|
||||
Open a conversation to see its file changes
|
||||
</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">
|
||||
No file changes found in this conversation
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { FileDiff, Folder, FolderPen, GitCommit } from "lucide-react"
|
||||
import {
|
||||
useAuxPanelContext,
|
||||
type AuxPanelTab,
|
||||
} from "@/contexts/aux-panel-context"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { FileTreeTab } from "./aux-panel-file-tree-tab"
|
||||
import { GitChangesTab } from "./aux-panel-git-changes-tab"
|
||||
import { GitLogTab } from "./aux-panel-git-log-tab"
|
||||
import { SessionFilesTab } from "./aux-panel-session-files-tab"
|
||||
|
||||
export function AuxPanel() {
|
||||
const { isOpen, activeTab, setActiveTab } = useAuxPanelContext()
|
||||
const [hasMountedFileTree, setHasMountedFileTree] = useState(
|
||||
activeTab === "file_tree"
|
||||
)
|
||||
const [hasMountedChanges, setHasMountedChanges] = useState(
|
||||
activeTab === "changes"
|
||||
)
|
||||
const [hasMountedGitLog, setHasMountedGitLog] = useState(
|
||||
activeTab === "git_log"
|
||||
)
|
||||
|
||||
const handleTabValueChange = useCallback(
|
||||
(value: string) => {
|
||||
const nextTab = value as AuxPanelTab
|
||||
setActiveTab(nextTab)
|
||||
if (nextTab === "file_tree") {
|
||||
setHasMountedFileTree(true)
|
||||
}
|
||||
if (nextTab === "changes") {
|
||||
setHasMountedChanges(true)
|
||||
}
|
||||
if (nextTab === "git_log") {
|
||||
setHasMountedGitLog(true)
|
||||
}
|
||||
},
|
||||
[setActiveTab]
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<aside className="group/aux-panel flex h-full min-h-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground select-none">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabValueChange}
|
||||
className="flex h-full flex-col gap-0"
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="h-10 w-full shrink-0 justify-start border-b border-border px-3 group-data-horizontal/tabs:h-10"
|
||||
>
|
||||
<TabsTrigger value="session_files" title="Diff" aria-label="Diff">
|
||||
<FileDiff className="h-3.5 w-3.5" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="file_tree" title="Files" aria-label="Files">
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="changes" title="Changes" aria-label="Changes">
|
||||
<FolderPen className="h-3.5 w-3.5" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="git_log" title="Commits" aria-label="Commits">
|
||||
<GitCommit className="h-3.5 w-3.5" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="session_files"
|
||||
className="mt-0 flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
<SessionFilesTab />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="file_tree"
|
||||
forceMount
|
||||
className="mt-0 flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
{hasMountedFileTree ? <FileTreeTab /> : null}
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="changes"
|
||||
forceMount
|
||||
className="mt-0 flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
{hasMountedChanges ? <GitChangesTab /> : null}
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="git_log"
|
||||
forceMount
|
||||
className="mt-0 flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
{hasMountedGitLog ? <GitLogTab /> : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useCallback, useMemo, useEffect } from "react"
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
import {
|
||||
GitBranch,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ArrowDownToLine,
|
||||
Upload,
|
||||
GitBranchPlus,
|
||||
GitCommitHorizontal,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
GitFork,
|
||||
GitMerge,
|
||||
GitPullRequestArrow,
|
||||
Trash2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
ArrowLeftRight,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import {
|
||||
gitInit,
|
||||
gitPull,
|
||||
gitFetch,
|
||||
gitPush,
|
||||
gitNewBranch,
|
||||
gitWorktreeAdd,
|
||||
gitCheckout,
|
||||
gitListAllBranches,
|
||||
gitMerge,
|
||||
gitRebase,
|
||||
gitDeleteBranch,
|
||||
gitStash,
|
||||
gitStashPop,
|
||||
openFolderWindow,
|
||||
openCommitWindow,
|
||||
setFolderParentBranch,
|
||||
} from "@/lib/tauri"
|
||||
import type { GitBranchList } from "@/lib/types"
|
||||
import { toast } from "sonner"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useTaskContext } from "@/contexts/task-context"
|
||||
import { useAlertContext } from "@/contexts/alert-context"
|
||||
|
||||
interface BranchDropdownProps {
|
||||
branch: string | null
|
||||
parentBranch: string | null
|
||||
onBranchChange: () => void
|
||||
}
|
||||
|
||||
type ConfirmAction = {
|
||||
type: "merge" | "rebase" | "delete"
|
||||
branchName: string
|
||||
}
|
||||
|
||||
interface GitCommitSucceededEventPayload {
|
||||
folder_id: number
|
||||
committed_files: number
|
||||
}
|
||||
|
||||
export function BranchDropdown({
|
||||
branch,
|
||||
parentBranch,
|
||||
onBranchChange,
|
||||
}: BranchDropdownProps) {
|
||||
const { folder } = useFolderContext()
|
||||
const folderPath = folder?.path ?? ""
|
||||
const { addTask, updateTask, removeTask } = useTaskContext()
|
||||
const { pushAlert } = useAlertContext()
|
||||
const [branchList, setBranchList] = useState<GitBranchList>({
|
||||
local: [],
|
||||
remote: [],
|
||||
worktree_branches: [],
|
||||
})
|
||||
const [newBranchOpen, setNewBranchOpen] = useState(false)
|
||||
const [newBranchName, setNewBranchName] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const [branchLoading, setBranchLoading] = useState(false)
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
const [remoteOpen, setRemoteOpen] = useState(false)
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null)
|
||||
const [expandedBranch, setExpandedBranch] = useState<string | null>(null)
|
||||
const [worktreeOpen, setWorktreeOpen] = useState(false)
|
||||
const [worktreeBranchName, setWorktreeBranchName] = useState("")
|
||||
const [worktreePath, setWorktreePath] = useState("")
|
||||
const taskSeq = useRef(0)
|
||||
const worktreeBranchSet = useMemo(
|
||||
() => new Set(branchList.worktree_branches),
|
||||
[branchList.worktree_branches]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!folder) return
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
|
||||
listen<GitCommitSucceededEventPayload>(
|
||||
"folder://git-commit-succeeded",
|
||||
(event) => {
|
||||
if (event.payload.folder_id !== folder.id) return
|
||||
toast.success("提交代码完成", {
|
||||
description: `已提交 ${event.payload.committed_files} 个文件`,
|
||||
})
|
||||
onBranchChange()
|
||||
}
|
||||
)
|
||||
.then((fn) => {
|
||||
unlisten = fn
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[BranchDropdown] failed to listen commit event:", err)
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten()
|
||||
}
|
||||
}, [folder, onBranchChange])
|
||||
|
||||
async function runGitTask<T>(
|
||||
label: string,
|
||||
action: () => Promise<T>,
|
||||
getSuccessDescription?: (result: T) => string | undefined
|
||||
) {
|
||||
const taskId = `git-${++taskSeq.current}-${Date.now()}`
|
||||
setLoading(true)
|
||||
addTask(taskId, label)
|
||||
updateTask(taskId, { status: "running" })
|
||||
try {
|
||||
const result = await action()
|
||||
const successDescription = getSuccessDescription?.(result)
|
||||
updateTask(taskId, { status: "completed" })
|
||||
onBranchChange()
|
||||
toast.success(
|
||||
`${label} 完成`,
|
||||
successDescription
|
||||
? {
|
||||
description: successDescription,
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
} catch (err) {
|
||||
removeTask(taskId)
|
||||
pushAlert("error", `${label}失败`, String(err))
|
||||
toast.error(`${label} 失败`, { description: String(err) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAllBranches = useCallback(async () => {
|
||||
setBranchLoading(true)
|
||||
try {
|
||||
const list = await gitListAllBranches(folderPath)
|
||||
setBranchList(list)
|
||||
} catch {
|
||||
setBranchList({ local: [], remote: [], worktree_branches: [] })
|
||||
} finally {
|
||||
setBranchLoading(false)
|
||||
}
|
||||
}, [folderPath])
|
||||
|
||||
function handleDropdownOpenChange(open: boolean) {
|
||||
setDropdownOpen(open)
|
||||
if (open && branch !== null) {
|
||||
loadAllBranches()
|
||||
}
|
||||
if (!open) {
|
||||
setLocalOpen(false)
|
||||
setRemoteOpen(false)
|
||||
setExpandedBranch(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewBranch() {
|
||||
const name = newBranchName.trim()
|
||||
if (!name) return
|
||||
setNewBranchOpen(false)
|
||||
setNewBranchName("")
|
||||
await runGitTask(`新建分支 ${name}`, () => gitNewBranch(folderPath, name))
|
||||
}
|
||||
|
||||
function handleOpenWorktreeDialog() {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
let random = ""
|
||||
for (let i = 0; i < 6; i++) {
|
||||
random += chars[Math.floor(Math.random() * chars.length)]
|
||||
}
|
||||
const folderName = folderPath.split("/").filter(Boolean).pop() ?? "project"
|
||||
const currentBranch = branch ?? "main"
|
||||
const defaultBranch = `cv-${currentBranch}-${random}`
|
||||
const parentDir = folderPath.substring(0, folderPath.lastIndexOf("/"))
|
||||
setWorktreeBranchName(defaultBranch)
|
||||
setWorktreePath(`${parentDir}/${folderName}-${currentBranch}-${random}`)
|
||||
setWorktreeOpen(true)
|
||||
}
|
||||
|
||||
function handleWorktreeBranchChange(name: string) {
|
||||
setWorktreeBranchName(name)
|
||||
}
|
||||
|
||||
async function handleBrowseWorktreePath() {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
if (selected) {
|
||||
setWorktreePath(selected)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewWorktree() {
|
||||
const name = worktreeBranchName.trim()
|
||||
const wtPath = worktreePath.trim()
|
||||
if (!name || !wtPath) return
|
||||
setWorktreeOpen(false)
|
||||
await runGitTask(`新建工作树 ${name}`, async () => {
|
||||
await gitWorktreeAdd(folderPath, name, wtPath)
|
||||
await openFolderWindow(wtPath)
|
||||
await setFolderParentBranch(wtPath, branch)
|
||||
})
|
||||
}
|
||||
|
||||
function handleMergeParent() {
|
||||
if (!parentBranch) return
|
||||
setConfirmAction({ type: "merge", branchName: parentBranch })
|
||||
}
|
||||
|
||||
async function handleCheckout(branchName: string) {
|
||||
setDropdownOpen(false)
|
||||
await runGitTask(`切换到 ${branchName}`, () =>
|
||||
gitCheckout(folderPath, branchName)
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCheckoutRemote(remoteBranch: string) {
|
||||
const localName = remoteBranch.replace(/^[^/]+\//, "")
|
||||
setDropdownOpen(false)
|
||||
await runGitTask(`切换到 ${localName}`, () =>
|
||||
gitCheckout(folderPath, localName)
|
||||
)
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!confirmAction) return
|
||||
const { type, branchName } = confirmAction
|
||||
setConfirmAction(null)
|
||||
|
||||
switch (type) {
|
||||
case "merge":
|
||||
await runGitTask(
|
||||
`合并 ${branchName}`,
|
||||
() => gitMerge(folderPath, branchName),
|
||||
(result) => {
|
||||
if (result.merged_commits === 0) {
|
||||
return `${branchName} 没有新的提交`
|
||||
}
|
||||
return `已合并 ${result.merged_commits} 个提交`
|
||||
}
|
||||
)
|
||||
break
|
||||
case "rebase":
|
||||
await runGitTask(`变基到 ${branchName}`, () =>
|
||||
gitRebase(folderPath, branchName)
|
||||
)
|
||||
break
|
||||
case "delete":
|
||||
await runGitTask(`删除分支 ${branchName}`, () =>
|
||||
gitDeleteBranch(folderPath, branchName)
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function getConfirmTitle() {
|
||||
if (!confirmAction) return ""
|
||||
switch (confirmAction.type) {
|
||||
case "merge":
|
||||
return "合并分支"
|
||||
case "rebase":
|
||||
return "变基分支"
|
||||
case "delete":
|
||||
return "删除分支"
|
||||
}
|
||||
}
|
||||
|
||||
function getConfirmDescription() {
|
||||
if (!confirmAction) return ""
|
||||
switch (confirmAction.type) {
|
||||
case "merge":
|
||||
return `确定将 ${confirmAction.branchName} 合并到当前分支 ${branch} 吗?`
|
||||
case "rebase":
|
||||
return `确定将当前分支 ${branch} 变基到 ${confirmAction.branchName} 吗?`
|
||||
case "delete":
|
||||
return `确定删除分支 ${confirmAction.branchName} 吗?此操作不可恢复。`
|
||||
}
|
||||
}
|
||||
|
||||
function renderBranchItem(b: string, isRemote: boolean) {
|
||||
const isCurrent = b === branch
|
||||
const isWorktree = worktreeBranchSet.has(
|
||||
isRemote ? b.replace(/^[^/]+\//, "") : b
|
||||
)
|
||||
const BranchIcon = isWorktree ? FolderGit2 : GitBranch
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<div
|
||||
key={b}
|
||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm opacity-50 select-none"
|
||||
>
|
||||
<BranchIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{b}</span>
|
||||
<span className="ml-auto text-xs">当前</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub
|
||||
key={b}
|
||||
open={expandedBranch === b}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setExpandedBranch(null)
|
||||
}}
|
||||
>
|
||||
<DropdownMenuSubTrigger
|
||||
className="hover:bg-accent hover:text-accent-foreground"
|
||||
disabled={loading}
|
||||
onClick={() => setExpandedBranch(expandedBranch === b ? null : b)}
|
||||
onPointerMove={(e) => {
|
||||
e.preventDefault()
|
||||
if (expandedBranch !== null && expandedBranch !== b) {
|
||||
setExpandedBranch(null)
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerLeave={(e) => e.preventDefault()}
|
||||
>
|
||||
<BranchIcon className="h-3.5 w-3.5" />
|
||||
{b}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
if (isRemote) {
|
||||
handleCheckoutRemote(b)
|
||||
} else {
|
||||
handleCheckout(b)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
切换到此分支
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDropdownOpen(false)
|
||||
setConfirmAction({ type: "merge", branchName: b })
|
||||
}}
|
||||
>
|
||||
<GitMerge className="h-3.5 w-3.5" />将 {b} 合并到 {branch}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDropdownOpen(false)
|
||||
setConfirmAction({ type: "rebase", branchName: b })
|
||||
}}
|
||||
>
|
||||
<GitPullRequestArrow className="h-3.5 w-3.5" />将 {branch} 变基到{" "}
|
||||
{b}
|
||||
</DropdownMenuItem>
|
||||
{!isRemote && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => {
|
||||
setDropdownOpen(false)
|
||||
setConfirmAction({ type: "delete", branchName: b })
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除分支
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
if (branch === null) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-sm tracking-tight hover:text-foreground/80 transition-colors outline-none cursor-default">
|
||||
<GitFork className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">版本控制</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-64" align="start">
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() =>
|
||||
runGitTask("初始化 Git 仓库", () => gitInit(folderPath))
|
||||
}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
初始化 Git 仓库
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-sm tracking-tight hover:text-foreground/80 transition-colors outline-none cursor-default">
|
||||
<GitBranch className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{branch}</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-64" align="start">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() =>
|
||||
runGitTask(
|
||||
"更新代码",
|
||||
() => gitPull(folderPath),
|
||||
(result) => {
|
||||
if (result.updated_files === 0) {
|
||||
return "所有文件均为最新版本"
|
||||
}
|
||||
return `已更新 ${result.updated_files} 个文件`
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<ArrowDownToLine className="h-3.5 w-3.5" />
|
||||
更新代码
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() =>
|
||||
runGitTask("获取信息", () => gitFetch(folderPath))
|
||||
}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
提取远程分支
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() => {
|
||||
if (!folder) return
|
||||
setDropdownOpen(false)
|
||||
openCommitWindow(folder.id).catch((err) => {
|
||||
pushAlert("error", "打开提交窗口失败", String(err))
|
||||
toast.error("打开提交窗口失败", { description: String(err) })
|
||||
})
|
||||
}}
|
||||
>
|
||||
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
||||
提交代码...
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() =>
|
||||
runGitTask(
|
||||
"推送代码",
|
||||
() => gitPush(folderPath),
|
||||
(result) => {
|
||||
if (result.upstream_set) {
|
||||
if (result.pushed_commits === 0) {
|
||||
return "已设置远程跟踪分支"
|
||||
}
|
||||
return `已设置远程跟踪分支并推送 ${result.pushed_commits} 个提交`
|
||||
}
|
||||
if (result.pushed_commits === 0) {
|
||||
return "没有可推送的提交"
|
||||
}
|
||||
return `已推送 ${result.pushed_commits} 个提交`
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
推送...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() => {
|
||||
setNewBranchName("")
|
||||
setNewBranchOpen(true)
|
||||
}}
|
||||
>
|
||||
<GitBranchPlus className="h-3.5 w-3.5" />
|
||||
新建分支...
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={handleOpenWorktreeDialog}
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5" />
|
||||
新建工作树...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() =>
|
||||
runGitTask("贮藏更改", () => gitStash(folderPath))
|
||||
}
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
贮藏更改
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() =>
|
||||
runGitTask("取消贮藏", () => gitStashPop(folderPath))
|
||||
}
|
||||
>
|
||||
<ArchiveRestore className="h-3.5 w-3.5" />
|
||||
取消贮藏...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{branchLoading ? (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="max-h-64">
|
||||
<Collapsible open={localOpen} onOpenChange={setLocalOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
本地分支 ({branchList.local.length})
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{branchList.local.length === 0 ? (
|
||||
<DropdownMenuItem disabled>无本地分支</DropdownMenuItem>
|
||||
) : (
|
||||
branchList.local.map((b) => renderBranchItem(b, false))
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible open={remoteOpen} onOpenChange={setRemoteOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
远程分支 ({branchList.remote.length})
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{branchList.remote.length === 0 ? (
|
||||
<DropdownMenuItem disabled>无远程分支</DropdownMenuItem>
|
||||
) : (
|
||||
branchList.remote.map((b) => renderBranchItem(b, true))
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{parentBranch && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-orange-500 dark:text-orange-400 hover:bg-accent hover:text-orange-600 dark:hover:text-orange-300 transition-colors cursor-default select-none"
|
||||
disabled={loading}
|
||||
onClick={handleMergeParent}
|
||||
title={`当前分支从 ${parentBranch} 创建,点击合并 ${parentBranch} 到当前分支`}
|
||||
>
|
||||
<ArrowLeftRight className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate max-w-32">{parentBranch}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={confirmAction !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmAction(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{getConfirmDescription()}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant={
|
||||
confirmAction?.type === "delete" ? "destructive" : "default"
|
||||
}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
确定
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Dialog open={newBranchOpen} onOpenChange={setNewBranchOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建分支</DialogTitle>
|
||||
<DialogDescription>
|
||||
从当前分支 {branch} 创建新分支
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="分支名称"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.nativeEvent.isComposing || e.key === "Process") return
|
||||
if (e.key === "Enter") handleNewBranch()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNewBranchOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!newBranchName.trim() || loading}
|
||||
onClick={handleNewBranch}
|
||||
>
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={worktreeOpen} onOpenChange={setWorktreeOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建工作树</DialogTitle>
|
||||
<DialogDescription>
|
||||
从当前分支 {branch} 创建新的工作树
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wt-branch">分支名称</Label>
|
||||
<Input
|
||||
id="wt-branch"
|
||||
placeholder="分支名称"
|
||||
value={worktreeBranchName}
|
||||
onChange={(e) => handleWorktreeBranchChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.nativeEvent.isComposing || e.key === "Process") return
|
||||
if (e.key === "Enter") handleNewWorktree()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wt-path">工作树路径</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="wt-path"
|
||||
placeholder="工作树路径"
|
||||
value={worktreePath}
|
||||
onChange={(e) => setWorktreePath(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleBrowseWorktreePath}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWorktreeOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!worktreeBranchName.trim() || !worktreePath.trim() || loading
|
||||
}
|
||||
onClick={handleNewWorktree}
|
||||
>
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
"use client"
|
||||
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
||||
import { ChevronDown, Play, Plus, Square } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useTerminalContext } from "@/contexts/terminal-context"
|
||||
import {
|
||||
bootstrapFolderCommandsFromPackageJson,
|
||||
listFolderCommands,
|
||||
terminalKill,
|
||||
terminalList,
|
||||
} from "@/lib/tauri"
|
||||
import type { FolderCommand, TerminalEvent } from "@/lib/types"
|
||||
import { CommandManageDialog } from "./command-manage-dialog"
|
||||
|
||||
function getSelectedCommandId(folderId: number): number | null {
|
||||
try {
|
||||
const v = localStorage.getItem(`lastCmd:${folderId}`)
|
||||
return v ? Number(v) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedCommandId(folderId: number, cmdId: number) {
|
||||
try {
|
||||
localStorage.setItem(`lastCmd:${folderId}`, String(cmdId))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function CommandDropdown() {
|
||||
const { folder } = useFolderContext()
|
||||
const { createTerminalWithCommand } = useTerminalContext()
|
||||
const [commands, setCommands] = useState<FolderCommand[]>([])
|
||||
const [manageOpen, setManageOpen] = useState(false)
|
||||
const [bootstrapping, setBootstrapping] = useState(false)
|
||||
const [selectedCommandId, setSelectedCommandIdState] = useState<
|
||||
number | null
|
||||
>(null)
|
||||
const [runningCommandTerminals, setRunningCommandTerminals] = useState<
|
||||
Record<number, string>
|
||||
>({})
|
||||
const exitUnlistenersRef = useRef<Map<string, UnlistenFn>>(new Map())
|
||||
const runningCommandTerminalsRef = useRef<Record<number, string>>({})
|
||||
|
||||
const folderId = folder?.id ?? 0
|
||||
const folderPath = folder?.path ?? ""
|
||||
|
||||
useEffect(() => {
|
||||
runningCommandTerminalsRef.current = runningCommandTerminals
|
||||
}, [runningCommandTerminals])
|
||||
|
||||
const clearRunningByTerminalId = useCallback((terminalId: string) => {
|
||||
const unlisten = exitUnlistenersRef.current.get(terminalId)
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
exitUnlistenersRef.current.delete(terminalId)
|
||||
}
|
||||
|
||||
setRunningCommandTerminals((prev) => {
|
||||
let changed = false
|
||||
const next = { ...prev }
|
||||
for (const [commandId, mappedTerminalId] of Object.entries(prev)) {
|
||||
if (mappedTerminalId === terminalId) {
|
||||
delete next[Number(commandId)]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearAllRunningStates = useCallback(() => {
|
||||
for (const unlisten of exitUnlistenersRef.current.values()) {
|
||||
unlisten()
|
||||
}
|
||||
exitUnlistenersRef.current.clear()
|
||||
setRunningCommandTerminals({})
|
||||
}, [])
|
||||
|
||||
const selectCommand = useCallback(
|
||||
(commandId: number) => {
|
||||
if (!folderId) return
|
||||
setSelectedCommandId(folderId, commandId)
|
||||
setSelectedCommandIdState(commandId)
|
||||
},
|
||||
[folderId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!folderId) {
|
||||
setSelectedCommandIdState(null)
|
||||
clearAllRunningStates()
|
||||
return
|
||||
}
|
||||
setSelectedCommandIdState(getSelectedCommandId(folderId))
|
||||
clearAllRunningStates()
|
||||
}, [clearAllRunningStates, folderId])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearAllRunningStates()
|
||||
},
|
||||
[clearAllRunningStates]
|
||||
)
|
||||
|
||||
const refreshCommands = useCallback(async () => {
|
||||
if (!folderId) return
|
||||
try {
|
||||
setCommands(await listFolderCommands(folderId))
|
||||
} catch (err) {
|
||||
console.error("Failed to load commands:", err)
|
||||
}
|
||||
}, [folderId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!folderId) return
|
||||
let ignore = false
|
||||
const loadCommands = async () => {
|
||||
try {
|
||||
setBootstrapping(false)
|
||||
const data = await listFolderCommands(folderId)
|
||||
if (ignore) return
|
||||
|
||||
if (data.length > 0 || !folderPath) {
|
||||
setCommands(data)
|
||||
return
|
||||
}
|
||||
|
||||
setBootstrapping(true)
|
||||
const bootstrapped = await bootstrapFolderCommandsFromPackageJson(
|
||||
folderId,
|
||||
folderPath
|
||||
)
|
||||
if (!ignore) setCommands(bootstrapped)
|
||||
} catch (err) {
|
||||
console.error("Failed to load commands:", err)
|
||||
} finally {
|
||||
if (!ignore) setBootstrapping(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadCommands()
|
||||
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [folderId, folderPath])
|
||||
|
||||
const registerExitListener = useCallback(
|
||||
async (terminalId: string) => {
|
||||
if (exitUnlistenersRef.current.has(terminalId)) return
|
||||
try {
|
||||
const unlisten = await listen<TerminalEvent>(
|
||||
`terminal://exit/${terminalId}`,
|
||||
() => {
|
||||
clearRunningByTerminalId(terminalId)
|
||||
}
|
||||
)
|
||||
exitUnlistenersRef.current.set(terminalId, unlisten)
|
||||
} catch (err) {
|
||||
console.error("Failed to subscribe terminal exit event:", err)
|
||||
}
|
||||
},
|
||||
[clearRunningByTerminalId]
|
||||
)
|
||||
|
||||
const runCommand = useCallback(
|
||||
async (cmd: FolderCommand) => {
|
||||
if (!folderPath) return
|
||||
if (runningCommandTerminalsRef.current[cmd.id]) return
|
||||
|
||||
selectCommand(cmd.id)
|
||||
const terminalId = await createTerminalWithCommand(cmd.name, cmd.command)
|
||||
if (!terminalId) return
|
||||
|
||||
setRunningCommandTerminals((prev) => ({ ...prev, [cmd.id]: terminalId }))
|
||||
await registerExitListener(terminalId)
|
||||
},
|
||||
[createTerminalWithCommand, folderPath, registerExitListener, selectCommand]
|
||||
)
|
||||
|
||||
const stopCommand = useCallback(
|
||||
async (cmd: FolderCommand) => {
|
||||
const terminalId = runningCommandTerminalsRef.current[cmd.id]
|
||||
if (!terminalId) return
|
||||
|
||||
clearRunningByTerminalId(terminalId)
|
||||
try {
|
||||
await terminalKill(terminalId)
|
||||
} catch (err) {
|
||||
console.error("Failed to stop command terminal:", err)
|
||||
}
|
||||
},
|
||||
[clearRunningByTerminalId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(runningCommandTerminals).length === 0) return
|
||||
let cancelled = false
|
||||
|
||||
const syncRunningCommandState = async () => {
|
||||
try {
|
||||
const terminals = await terminalList()
|
||||
if (cancelled) return
|
||||
|
||||
const aliveTerminalIds = new Set(terminals.map((item) => item.id))
|
||||
for (const terminalId of Object.values(
|
||||
runningCommandTerminalsRef.current
|
||||
)) {
|
||||
if (!aliveTerminalIds.has(terminalId)) {
|
||||
clearRunningByTerminalId(terminalId)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to sync command terminal state:", err)
|
||||
}
|
||||
}
|
||||
|
||||
syncRunningCommandState()
|
||||
const timer = setInterval(syncRunningCommandState, 1500)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [clearRunningByTerminalId, runningCommandTerminals])
|
||||
|
||||
const activeCmd = useMemo(
|
||||
() =>
|
||||
commands.find((c) => c.id === selectedCommandId) ?? commands[0] ?? null,
|
||||
[commands, selectedCommandId]
|
||||
)
|
||||
const activeTerminalId = activeCmd
|
||||
? runningCommandTerminals[activeCmd.id]
|
||||
: undefined
|
||||
const isActiveCommandRunning = Boolean(activeTerminalId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCmd && selectedCommandId !== null) {
|
||||
setSelectedCommandIdState(null)
|
||||
return
|
||||
}
|
||||
if (!activeCmd || selectedCommandId === activeCmd.id) return
|
||||
selectCommand(activeCmd.id)
|
||||
}, [activeCmd, selectedCommandId, selectCommand])
|
||||
|
||||
const handleRunOrStop = useCallback(() => {
|
||||
if (!activeCmd) return
|
||||
if (isActiveCommandRunning) {
|
||||
void stopCommand(activeCmd)
|
||||
return
|
||||
}
|
||||
void runCommand(activeCmd)
|
||||
}, [activeCmd, isActiveCommandRunning, runCommand, stopCommand])
|
||||
|
||||
const handleSelectCommand = useCallback(
|
||||
(cmd: FolderCommand) => {
|
||||
selectCommand(cmd.id)
|
||||
},
|
||||
[selectCommand]
|
||||
)
|
||||
|
||||
if (!folder) return null
|
||||
|
||||
// No commands → show "Add Command"
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs gap-1 hover:text-foreground/80"
|
||||
onClick={() => setManageOpen(true)}
|
||||
disabled={bootstrapping}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{bootstrapping ? "Loading..." : "Add Command"}
|
||||
</Button>
|
||||
<CommandManageDialog
|
||||
open={manageOpen}
|
||||
onOpenChange={setManageOpen}
|
||||
folderId={folderId}
|
||||
commands={commands}
|
||||
onSaved={refreshCommands}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Has commands → split button: [name ▼] [run/stop]
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-6 hover:text-foreground/80">
|
||||
<span className="max-w-24 truncate">{activeCmd?.name}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-56">
|
||||
{commands.map((cmd) => (
|
||||
<DropdownMenuItem
|
||||
key={cmd.id}
|
||||
onClick={() => handleSelectCommand(cmd)}
|
||||
className={`flex items-center justify-between gap-4 ${
|
||||
cmd.id === activeCmd?.id ? "bg-accent/60" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{cmd.name}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate max-w-32">
|
||||
{cmd.command}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setManageOpen(true)}>
|
||||
Manage Commands...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs gap-1 ${
|
||||
isActiveCommandRunning
|
||||
? "text-destructive hover:text-destructive"
|
||||
: "hover:text-foreground/80"
|
||||
}`}
|
||||
onClick={handleRunOrStop}
|
||||
title={
|
||||
isActiveCommandRunning
|
||||
? `Stop: ${activeCmd?.command}`
|
||||
: `Run: ${activeCmd?.command}`
|
||||
}
|
||||
>
|
||||
{isActiveCommandRunning ? (
|
||||
<Square className="h-3 w-3" />
|
||||
) : (
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CommandManageDialog
|
||||
open={manageOpen}
|
||||
onOpenChange={setManageOpen}
|
||||
folderId={folderId}
|
||||
commands={commands}
|
||||
onSaved={refreshCommands}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import type { FolderCommand } from "@/lib/types"
|
||||
import {
|
||||
createFolderCommand,
|
||||
updateFolderCommand,
|
||||
deleteFolderCommand,
|
||||
} from "@/lib/tauri"
|
||||
|
||||
interface CommandDraft {
|
||||
id: number | null
|
||||
name: string
|
||||
command: string
|
||||
deleted: boolean
|
||||
}
|
||||
|
||||
interface CommandManageDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
folderId: number
|
||||
commands: FolderCommand[]
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
export function CommandManageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
folderId,
|
||||
commands,
|
||||
onSaved,
|
||||
}: CommandManageDialogProps) {
|
||||
const [drafts, setDrafts] = useState<CommandDraft[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDrafts(
|
||||
commands.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
command: c.command,
|
||||
deleted: false,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}, [open, commands])
|
||||
|
||||
const addDraft = () => {
|
||||
setDrafts((prev) => [
|
||||
...prev,
|
||||
{ id: null, name: "", command: "", deleted: false },
|
||||
])
|
||||
}
|
||||
|
||||
const updateDraft = (
|
||||
index: number,
|
||||
field: "name" | "command",
|
||||
value: string
|
||||
) => {
|
||||
setDrafts((prev) =>
|
||||
prev.map((d, i) => (i === index ? { ...d, [field]: value } : d))
|
||||
)
|
||||
}
|
||||
|
||||
const removeDraft = (index: number) => {
|
||||
setDrafts((prev) =>
|
||||
prev.map((d, i) => (i === index ? { ...d, deleted: true } : d))
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
for (const draft of drafts) {
|
||||
if (draft.deleted && draft.id != null) {
|
||||
await deleteFolderCommand(draft.id)
|
||||
} else if (draft.deleted) {
|
||||
continue
|
||||
} else if (draft.id == null && draft.name && draft.command) {
|
||||
await createFolderCommand(folderId, draft.name, draft.command)
|
||||
} else if (draft.id != null) {
|
||||
const orig = commands.find((c) => c.id === draft.id)
|
||||
if (
|
||||
orig &&
|
||||
(orig.name !== draft.name || orig.command !== draft.command)
|
||||
) {
|
||||
await updateFolderCommand(draft.id, draft.name, draft.command)
|
||||
}
|
||||
}
|
||||
}
|
||||
onSaved()
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
console.error("Failed to save commands:", err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleDrafts = drafts.filter((d) => !d.deleted)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Commands</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-72">
|
||||
<div className="space-y-2 pr-2">
|
||||
{visibleDrafts.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No commands yet
|
||||
</p>
|
||||
)}
|
||||
{drafts.map(
|
||||
(draft, index) =>
|
||||
!draft.deleted && (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Name"
|
||||
value={draft.name}
|
||||
onChange={(e) =>
|
||||
updateDraft(index, "name", e.target.value)
|
||||
}
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Command"
|
||||
value={draft.command}
|
||||
onChange={(e) =>
|
||||
updateDraft(index, "command", e.target.value)
|
||||
}
|
||||
className="h-8 text-sm font-mono flex-[2]"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => removeDraft(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||
<Button variant="outline" size="sm" onClick={addDraft}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Check, ChevronDown, ChevronRight, Loader2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
FileTree,
|
||||
FileTreeFile,
|
||||
FileTreeFolder,
|
||||
} from "@/components/ai-elements/file-tree"
|
||||
import {
|
||||
gitAddFiles,
|
||||
gitCommit,
|
||||
gitRollbackFile,
|
||||
gitShowFile,
|
||||
gitStatus,
|
||||
deleteFileTreeEntry,
|
||||
readFilePreview,
|
||||
} from "@/lib/tauri"
|
||||
import type { GitStatusEntry } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { DiffViewer } from "@/components/diff/diff-viewer"
|
||||
import { languageFromPath } from "@/lib/language-detect"
|
||||
|
||||
interface CommitWorkspaceProps {
|
||||
folderPath: string
|
||||
onCommitted?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
interface TreeFileNode {
|
||||
kind: "file"
|
||||
name: string
|
||||
path: string
|
||||
entry: GitStatusEntry
|
||||
}
|
||||
|
||||
interface TreeDirNode {
|
||||
kind: "dir"
|
||||
name: string
|
||||
path: string
|
||||
children: TreeNode[]
|
||||
}
|
||||
|
||||
type TreeNode = TreeFileNode | TreeDirNode
|
||||
|
||||
const UNTRACKED_STATUS = "??"
|
||||
const DEFAULT_LEFT_PANE_WIDTH = 420
|
||||
const MIN_LEFT_PANE_WIDTH = 320
|
||||
const MIN_RIGHT_PANE_WIDTH = 360
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
function toPercent(pixels: number, totalPixels: number): number {
|
||||
if (totalPixels <= 0) return 0
|
||||
return (pixels / totalPixels) * 100
|
||||
}
|
||||
|
||||
function buildFileTree(entries: GitStatusEntry[]): TreeNode[] {
|
||||
type BuildDir = {
|
||||
name: string
|
||||
path: string
|
||||
dirs: Map<string, BuildDir>
|
||||
files: TreeFileNode[]
|
||||
}
|
||||
|
||||
const root: BuildDir = {
|
||||
name: "",
|
||||
path: "",
|
||||
dirs: new Map(),
|
||||
files: [],
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const parts = entry.file.split("/").filter(Boolean)
|
||||
if (parts.length === 0) continue
|
||||
|
||||
let current = root
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const part = parts[i]
|
||||
const isLeaf = i === parts.length - 1
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part
|
||||
|
||||
if (isLeaf) {
|
||||
current.files.push({
|
||||
kind: "file",
|
||||
name: part,
|
||||
path: currentPath,
|
||||
entry,
|
||||
})
|
||||
} else {
|
||||
const found = current.dirs.get(part)
|
||||
if (found) {
|
||||
current = found
|
||||
} else {
|
||||
const next: BuildDir = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
dirs: new Map(),
|
||||
files: [],
|
||||
}
|
||||
current.dirs.set(part, next)
|
||||
current = next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sortNodes(nodes: TreeNode[]) {
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
function toNodes(dir: BuildDir): TreeNode[] {
|
||||
const dirs: TreeNode[] = Array.from(dir.dirs.values()).map((child) => ({
|
||||
kind: "dir",
|
||||
name: child.name,
|
||||
path: child.path,
|
||||
children: toNodes(child),
|
||||
}))
|
||||
|
||||
return sortNodes([...dirs, ...dir.files])
|
||||
}
|
||||
|
||||
return toNodes(root)
|
||||
}
|
||||
|
||||
/** Depth-first traversal to find the first file node (matches visual order). */
|
||||
function findFirstFile(nodes: TreeNode[]): string | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "file") return node.path
|
||||
const found = findFirstFile(node.children)
|
||||
if (found) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function collectDirPaths(entries: GitStatusEntry[]) {
|
||||
const paths = new Set<string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const parts = entry.file.split("/").filter(Boolean)
|
||||
if (parts.length < 2) continue
|
||||
|
||||
let currentPath = ""
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
|
||||
paths.add(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
interface ConfirmState {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
action: (() => void) | null
|
||||
variant: "default" | "destructive"
|
||||
}
|
||||
|
||||
const CONFIRM_INITIAL: ConfirmState = {
|
||||
open: false,
|
||||
title: "",
|
||||
description: "",
|
||||
action: null,
|
||||
variant: "default",
|
||||
}
|
||||
|
||||
export function CommitWorkspace({
|
||||
folderPath,
|
||||
onCommitted,
|
||||
onCancel,
|
||||
}: CommitWorkspaceProps) {
|
||||
const [entries, setEntries] = useState<GitStatusEntry[]>([])
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [diffOriginal, setDiffOriginal] = useState("")
|
||||
const [diffModified, setDiffModified] = useState("")
|
||||
const [diffLanguage, setDiffLanguage] = useState("plaintext")
|
||||
const [diffFile, setDiffFile] = useState<string | null>(null)
|
||||
const messageRef = useRef("")
|
||||
const [hasMessage, setHasMessage] = useState(false)
|
||||
const [messageInputKey, setMessageInputKey] = useState(0)
|
||||
const [loadingStatus, setLoadingStatus] = useState(false)
|
||||
const [loadingDiff, setLoadingDiff] = useState(false)
|
||||
const [committing, setCommitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [untrackedOpen, setUntrackedOpen] = useState(false)
|
||||
const [expandedTrackedDirs, setExpandedTrackedDirs] = useState<Set<string>>(
|
||||
new Set()
|
||||
)
|
||||
const [expandedUntrackedDirs, setExpandedUntrackedDirs] = useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(CONFIRM_INITIAL)
|
||||
|
||||
// Use refs to track mutable values without causing callback recreation
|
||||
const diffFileRef = useRef(diffFile)
|
||||
diffFileRef.current = diffFile
|
||||
const entriesRef = useRef(entries)
|
||||
entriesRef.current = entries
|
||||
|
||||
const folderName = useMemo(() => {
|
||||
const parts = folderPath.replace(/[/\\]+$/, "").split(/[/\\]/)
|
||||
return parts[parts.length - 1] || folderPath
|
||||
}, [folderPath])
|
||||
|
||||
const trackedEntries = useMemo(
|
||||
() => entries.filter((entry) => entry.status !== UNTRACKED_STATUS),
|
||||
[entries]
|
||||
)
|
||||
const untrackedEntries = useMemo(
|
||||
() => entries.filter((entry) => entry.status === UNTRACKED_STATUS),
|
||||
[entries]
|
||||
)
|
||||
const trackedTree = useMemo(
|
||||
() => buildFileTree(trackedEntries),
|
||||
[trackedEntries]
|
||||
)
|
||||
const untrackedTree = useMemo(
|
||||
() => buildFileTree(untrackedEntries),
|
||||
[untrackedEntries]
|
||||
)
|
||||
const filePathSet = useMemo(
|
||||
() => new Set(entries.map((entry) => entry.file)),
|
||||
[entries]
|
||||
)
|
||||
const trackedFiles = useMemo(
|
||||
() => trackedEntries.map((entry) => entry.file),
|
||||
[trackedEntries]
|
||||
)
|
||||
const untrackedFiles = useMemo(
|
||||
() => untrackedEntries.map((entry) => entry.file),
|
||||
[untrackedEntries]
|
||||
)
|
||||
|
||||
// Shared diff loading logic — extracted to avoid duplication
|
||||
const loadDiff = useCallback(
|
||||
async (file: string, allEntries?: GitStatusEntry[]) => {
|
||||
if (!folderPath) return
|
||||
setDiffFile(file)
|
||||
setDiffLanguage(languageFromPath(file))
|
||||
setLoadingDiff(true)
|
||||
setDiffOriginal("")
|
||||
setDiffModified("")
|
||||
|
||||
try {
|
||||
const statusSource = allEntries ?? entriesRef.current
|
||||
const isUntracked =
|
||||
statusSource.find((e) => e.file === file)?.status === UNTRACKED_STATUS
|
||||
|
||||
const [originalContent, modifiedContent] = await Promise.all([
|
||||
isUntracked
|
||||
? Promise.resolve("")
|
||||
: gitShowFile(folderPath, file).catch(() => ""),
|
||||
readFilePreview(folderPath, file)
|
||||
.then((r) => r.content)
|
||||
.catch(() => ""),
|
||||
])
|
||||
|
||||
setDiffOriginal(originalContent)
|
||||
setDiffModified(modifiedContent)
|
||||
} catch {
|
||||
setDiffOriginal("")
|
||||
setDiffModified("")
|
||||
} finally {
|
||||
setLoadingDiff(false)
|
||||
}
|
||||
},
|
||||
[folderPath]
|
||||
)
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
if (!folderPath) return
|
||||
setLoadingStatus(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await gitStatus(folderPath)
|
||||
setEntries(result)
|
||||
const tracked = result.filter(
|
||||
(entry) => entry.status !== UNTRACKED_STATUS
|
||||
)
|
||||
const untracked = result.filter(
|
||||
(entry) => entry.status === UNTRACKED_STATUS
|
||||
)
|
||||
setSelected(new Set(tracked.map((entry) => entry.file)))
|
||||
const trackedDirs = collectDirPaths(tracked)
|
||||
trackedDirs.add(folderName)
|
||||
setExpandedTrackedDirs(trackedDirs)
|
||||
const untrackedDirs = collectDirPaths(untracked)
|
||||
untrackedDirs.add(folderName)
|
||||
setExpandedUntrackedDirs(untrackedDirs)
|
||||
|
||||
// Auto-select the first file in visual tree order for diff preview
|
||||
const firstFile =
|
||||
findFirstFile(buildFileTree(tracked)) ??
|
||||
findFirstFile(buildFileTree(untracked))
|
||||
if (firstFile) {
|
||||
await loadDiff(firstFile, result)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
setEntries([])
|
||||
setExpandedTrackedDirs(new Set())
|
||||
setExpandedUntrackedDirs(new Set())
|
||||
} finally {
|
||||
setLoadingStatus(false)
|
||||
}
|
||||
}, [folderPath, folderName, loadDiff])
|
||||
|
||||
useEffect(() => {
|
||||
if (!folderPath) return
|
||||
setDiffOriginal("")
|
||||
setDiffModified("")
|
||||
setDiffLanguage("plaintext")
|
||||
setDiffFile(null)
|
||||
messageRef.current = ""
|
||||
setHasMessage(false)
|
||||
setMessageInputKey((key) => key + 1)
|
||||
setUntrackedOpen(false)
|
||||
void loadStatus()
|
||||
}, [folderPath, loadStatus])
|
||||
|
||||
const handleViewDiff = useCallback(
|
||||
(file: string) => {
|
||||
if (!folderPath || diffFileRef.current === file) return
|
||||
void loadDiff(file)
|
||||
},
|
||||
[folderPath, loadDiff]
|
||||
)
|
||||
|
||||
const toggleFile = useCallback((file: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(file)) {
|
||||
next.delete(file)
|
||||
} else {
|
||||
next.add(file)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
if (prev.size === entries.length) {
|
||||
return new Set<string>()
|
||||
}
|
||||
return new Set(entries.map((entry) => entry.file))
|
||||
})
|
||||
}, [entries])
|
||||
|
||||
const toggleGroup = useCallback((files: string[]) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
const allInGroupSelected = files.every((file) => next.has(file))
|
||||
if (allInGroupSelected) {
|
||||
files.forEach((file) => next.delete(file))
|
||||
} else {
|
||||
files.forEach((file) => next.add(file))
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSelectPath = useCallback(
|
||||
(path: string) => {
|
||||
if (!filePathSet.has(path)) return
|
||||
handleViewDiff(path)
|
||||
},
|
||||
[filePathSet, handleViewDiff]
|
||||
)
|
||||
|
||||
const handleCommit = useCallback(async () => {
|
||||
const commitMessage = messageRef.current.trim()
|
||||
if (!commitMessage || selected.size === 0 || !folderPath) return
|
||||
setCommitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await gitCommit(
|
||||
folderPath,
|
||||
commitMessage,
|
||||
Array.from(selected)
|
||||
)
|
||||
toast.success("提交代码完成", {
|
||||
description: `已提交 ${result.committed_files} 个文件`,
|
||||
})
|
||||
onCommitted?.()
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setCommitting(false)
|
||||
}
|
||||
}, [folderPath, onCommitted, selected])
|
||||
|
||||
// --- Context menu actions ---
|
||||
|
||||
const handleAddToVcs = useCallback(
|
||||
async (file: string) => {
|
||||
if (!folderPath) return
|
||||
try {
|
||||
await gitAddFiles(folderPath, [file])
|
||||
toast.success("已添加到 VCS", { description: file })
|
||||
void loadStatus()
|
||||
} catch (err) {
|
||||
toast.error("添加到 VCS 失败", { description: String(err) })
|
||||
}
|
||||
},
|
||||
[folderPath, loadStatus]
|
||||
)
|
||||
|
||||
const handleDeleteFile = useCallback(
|
||||
(file: string) => {
|
||||
setConfirm({
|
||||
open: true,
|
||||
title: "确认删除",
|
||||
description: `确定要删除文件「${file}」吗?此操作不可恢复。`,
|
||||
variant: "destructive",
|
||||
action: () => {
|
||||
void (async () => {
|
||||
if (!folderPath) return
|
||||
try {
|
||||
await deleteFileTreeEntry(folderPath, file)
|
||||
toast.success("文件已删除", { description: file })
|
||||
// If deleted file was being viewed, clear the diff
|
||||
if (diffFileRef.current === file) {
|
||||
setDiffFile(null)
|
||||
setDiffOriginal("")
|
||||
setDiffModified("")
|
||||
}
|
||||
setSelected((prev) => {
|
||||
if (!prev.has(file)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(file)
|
||||
return next
|
||||
})
|
||||
void loadStatus()
|
||||
} catch (err) {
|
||||
toast.error("删除失败", { description: String(err) })
|
||||
}
|
||||
})()
|
||||
},
|
||||
})
|
||||
},
|
||||
[folderPath, loadStatus]
|
||||
)
|
||||
|
||||
const handleRollbackFile = useCallback(
|
||||
(file: string) => {
|
||||
setConfirm({
|
||||
open: true,
|
||||
title: "确认回滚",
|
||||
description: `确定要回滚文件「${file}」到 HEAD 版本吗?未保存的修改将丢失。`,
|
||||
variant: "destructive",
|
||||
action: () => {
|
||||
void (async () => {
|
||||
if (!folderPath) return
|
||||
try {
|
||||
await gitRollbackFile(folderPath, file)
|
||||
toast.success("文件已回滚", { description: file })
|
||||
if (diffFileRef.current === file) {
|
||||
setDiffFile(null)
|
||||
setDiffOriginal("")
|
||||
setDiffModified("")
|
||||
}
|
||||
setSelected((prev) => {
|
||||
if (!prev.has(file)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(file)
|
||||
return next
|
||||
})
|
||||
void loadStatus()
|
||||
} catch (err) {
|
||||
toast.error("回滚失败", { description: String(err) })
|
||||
}
|
||||
})()
|
||||
},
|
||||
})
|
||||
},
|
||||
[folderPath, loadStatus]
|
||||
)
|
||||
|
||||
const closeConfirm = useCallback(() => {
|
||||
setConfirm(CONFIRM_INITIAL)
|
||||
}, [])
|
||||
|
||||
const confirmActionRef = useRef(confirm.action)
|
||||
confirmActionRef.current = confirm.action
|
||||
|
||||
const executeConfirmAction = useCallback(() => {
|
||||
confirmActionRef.current?.()
|
||||
setConfirm(CONFIRM_INITIAL)
|
||||
}, [])
|
||||
|
||||
const allSelected = useMemo(
|
||||
() => entries.length > 0 && selected.size === entries.length,
|
||||
[entries.length, selected.size]
|
||||
)
|
||||
const trackedAllSelected = useMemo(
|
||||
() =>
|
||||
trackedFiles.length > 0 &&
|
||||
trackedFiles.every((file) => selected.has(file)),
|
||||
[trackedFiles, selected]
|
||||
)
|
||||
const untrackedAllSelected = useMemo(
|
||||
() =>
|
||||
untrackedFiles.length > 0 &&
|
||||
untrackedFiles.every((file) => selected.has(file)),
|
||||
[untrackedFiles, selected]
|
||||
)
|
||||
|
||||
const handleMessageChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const nextValue = e.target.value
|
||||
messageRef.current = nextValue
|
||||
const nextHasMessage = nextValue.trim().length > 0
|
||||
setHasMessage((prev) => (prev === nextHasMessage ? prev : nextHasMessage))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const updateWidth = (next: number) => {
|
||||
setContainerWidth((prev) => (Math.abs(prev - next) < 1 ? prev : next))
|
||||
}
|
||||
|
||||
updateWidth(container.clientWidth)
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
updateWidth(entries[0]?.contentRect.width ?? container.clientWidth)
|
||||
})
|
||||
|
||||
observer.observe(container)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const safeContainerWidth =
|
||||
containerWidth > 0
|
||||
? containerWidth
|
||||
: DEFAULT_LEFT_PANE_WIDTH + MIN_RIGHT_PANE_WIDTH + 240
|
||||
const leftMinSize = clamp(
|
||||
toPercent(MIN_LEFT_PANE_WIDTH, safeContainerWidth),
|
||||
5,
|
||||
95
|
||||
)
|
||||
const rightMinSize = clamp(
|
||||
toPercent(MIN_RIGHT_PANE_WIDTH, safeContainerWidth),
|
||||
5,
|
||||
95
|
||||
)
|
||||
const leftMaxSize = Math.max(leftMinSize, 100 - rightMinSize)
|
||||
const leftDefaultSize = clamp(
|
||||
toPercent(DEFAULT_LEFT_PANE_WIDTH, safeContainerWidth),
|
||||
leftMinSize,
|
||||
leftMaxSize
|
||||
)
|
||||
|
||||
// --- Render helpers for file tree nodes ---
|
||||
|
||||
const renderTrackedNode = useCallback(
|
||||
function renderNode(node: TreeNode): React.ReactNode {
|
||||
if (node.kind === "dir") {
|
||||
return (
|
||||
<FileTreeFolder
|
||||
key={`tracked:${node.path}`}
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
>
|
||||
{node.children.map(renderNode)}
|
||||
</FileTreeFolder>
|
||||
)
|
||||
}
|
||||
|
||||
const isDeleted = node.entry.status === " D" || node.entry.status === "D"
|
||||
|
||||
return (
|
||||
<ContextMenu key={`tracked:${node.path}`}>
|
||||
<ContextMenuTrigger>
|
||||
<FileTreeFile
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
className="gap-1 px-1.5 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFile(node.path)
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
selected.has(node.path)
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input"
|
||||
)}
|
||||
aria-label={`${selected.has(node.path) ? "取消选择" : "选择"} ${node.path}`}
|
||||
>
|
||||
{selected.has(node.path) && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 truncate text-left hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleViewDiff(node.path)
|
||||
}}
|
||||
title={node.path}
|
||||
>
|
||||
{node.name}
|
||||
</button>
|
||||
<span className="w-6 shrink-0 text-right text-xs font-medium text-muted-foreground">
|
||||
{node.entry.status}
|
||||
</span>
|
||||
</FileTreeFile>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{!isDeleted && (
|
||||
<ContextMenuItem onClick={() => handleRollbackFile(node.path)}>
|
||||
回滚
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteFile(node.path)}
|
||||
>
|
||||
删除
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
},
|
||||
[selected, toggleFile, handleViewDiff, handleRollbackFile, handleDeleteFile]
|
||||
)
|
||||
|
||||
const renderUntrackedNode = useCallback(
|
||||
function renderNode(node: TreeNode): React.ReactNode {
|
||||
if (node.kind === "dir") {
|
||||
return (
|
||||
<FileTreeFolder
|
||||
key={`untracked:${node.path}`}
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
>
|
||||
{node.children.map(renderNode)}
|
||||
</FileTreeFolder>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu key={`untracked:${node.path}`}>
|
||||
<ContextMenuTrigger>
|
||||
<FileTreeFile
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
className="gap-1 px-1.5 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFile(node.path)
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
selected.has(node.path)
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input"
|
||||
)}
|
||||
aria-label={`${selected.has(node.path) ? "取消选择" : "选择"} ${node.path}`}
|
||||
>
|
||||
{selected.has(node.path) && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 truncate text-left hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleViewDiff(node.path)
|
||||
}}
|
||||
title={node.path}
|
||||
>
|
||||
{node.name}
|
||||
</button>
|
||||
</FileTreeFile>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleAddToVcs(node.path)}>
|
||||
添加到 VCS
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteFile(node.path)}
|
||||
>
|
||||
删除
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
},
|
||||
[selected, toggleFile, handleViewDiff, handleAddToVcs, handleDeleteFile]
|
||||
)
|
||||
|
||||
const toggleTrackedGroup = useCallback(
|
||||
() => toggleGroup(trackedFiles),
|
||||
[toggleGroup, trackedFiles]
|
||||
)
|
||||
const toggleUntrackedGroup = useCallback(
|
||||
() => toggleGroup(untrackedFiles),
|
||||
[toggleGroup, untrackedFiles]
|
||||
)
|
||||
const toggleUntrackedOpen = useCallback(
|
||||
() => setUntrackedOpen((open) => !open),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-full min-h-0 overflow-hidden rounded-lg border bg-card"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full min-h-0 min-w-0"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={leftDefaultSize}
|
||||
minSize={leftMinSize}
|
||||
maxSize={leftMaxSize}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{error && (
|
||||
<div className="border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex h-9 items-center gap-2 border-b bg-muted/50 px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
allSelected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input"
|
||||
)}
|
||||
aria-label={allSelected ? "取消选择全部文件" : "选择全部文件"}
|
||||
>
|
||||
{allSelected && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{loadingStatus
|
||||
? "加载中..."
|
||||
: `${selected.size} / ${entries.length} 个文件`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
{entries.length === 0 && !loadingStatus ? (
|
||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
没有改动的文件
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-2">
|
||||
{trackedEntries.length > 0 && (
|
||||
<section className="space-y-1">
|
||||
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTrackedGroup}
|
||||
className={cn(
|
||||
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
trackedAllSelected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input"
|
||||
)}
|
||||
aria-label={
|
||||
trackedAllSelected
|
||||
? "取消选择已跟踪改动"
|
||||
: "选择已跟踪改动"
|
||||
}
|
||||
>
|
||||
{trackedAllSelected && (
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</button>
|
||||
<span>已跟踪改动 ({trackedEntries.length})</span>
|
||||
</div>
|
||||
<FileTree
|
||||
className="rounded-none border-0 bg-transparent font-sans text-sm [&>div]:p-1"
|
||||
expanded={expandedTrackedDirs}
|
||||
onExpandedChange={setExpandedTrackedDirs}
|
||||
selectedPath={diffFile ?? undefined}
|
||||
onSelect={handleSelectPath}
|
||||
>
|
||||
<FileTreeFolder name={folderName} path={folderName}>
|
||||
{trackedTree.map(renderTrackedNode)}
|
||||
</FileTreeFolder>
|
||||
</FileTree>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{untrackedEntries.length > 0 && (
|
||||
<section className="space-y-1">
|
||||
<div className="flex w-full items-center gap-2 px-1 py-0.5 text-[11px] text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleUntrackedGroup}
|
||||
className={cn(
|
||||
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
untrackedAllSelected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input"
|
||||
)}
|
||||
aria-label={
|
||||
untrackedAllSelected
|
||||
? "取消选择未跟踪文件"
|
||||
: "选择未跟踪文件"
|
||||
}
|
||||
>
|
||||
{untrackedAllSelected && (
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={toggleUntrackedOpen}
|
||||
>
|
||||
{untrackedOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span>未跟踪文件 ({untrackedEntries.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
{untrackedOpen && (
|
||||
<FileTree
|
||||
className="rounded-none border-0 bg-transparent font-sans text-sm [&>div]:p-1"
|
||||
expanded={expandedUntrackedDirs}
|
||||
onExpandedChange={setExpandedUntrackedDirs}
|
||||
selectedPath={diffFile ?? undefined}
|
||||
onSelect={handleSelectPath}
|
||||
>
|
||||
<FileTreeFolder name={folderName} path={folderName}>
|
||||
{untrackedTree.map(renderUntrackedNode)}
|
||||
</FileTreeFolder>
|
||||
</FileTree>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-3">
|
||||
<div className="mb-2 text-xs text-muted-foreground">提交消息</div>
|
||||
<Textarea
|
||||
key={messageInputKey}
|
||||
placeholder="输入提交信息..."
|
||||
defaultValue=""
|
||||
onChange={handleMessageChange}
|
||||
className="min-h-[90px] resize-y"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={committing || !hasMessage || selected.size === 0}
|
||||
onClick={handleCommit}
|
||||
>
|
||||
{committing && (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
提交 ({selected.size})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={100 - leftDefaultSize}
|
||||
minSize={rightMinSize}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{!diffFile ? (
|
||||
<>
|
||||
<div className="flex h-9 items-center gap-3 border-b bg-muted/50 px-3 text-xs text-muted-foreground">
|
||||
<span className="font-medium">HEAD</span>
|
||||
<span className="text-muted-foreground/60">↔</span>
|
||||
<span className="font-medium">Working Tree</span>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
点击文件名查看差异
|
||||
</div>
|
||||
</>
|
||||
) : loadingDiff ? (
|
||||
<>
|
||||
<div className="flex h-9 items-center gap-3 border-b bg-muted/50 px-3 text-xs text-muted-foreground">
|
||||
<span className="font-medium">HEAD</span>
|
||||
<span className="text-muted-foreground/60">↔</span>
|
||||
<span className="font-medium">Working Tree</span>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
加载差异...
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<DiffViewer
|
||||
key={diffFile}
|
||||
original={diffOriginal}
|
||||
modified={diffModified}
|
||||
originalLabel="HEAD"
|
||||
modifiedLabel="Working Tree"
|
||||
language={diffLanguage}
|
||||
className="h-full [&>div:first-child]:h-9 [&>div:first-child]:py-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<AlertDialog
|
||||
open={confirm.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeConfirm()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{confirm.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{confirm.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant={
|
||||
confirm.variant === "destructive" ? "destructive" : "default"
|
||||
}
|
||||
onClick={executeConfirmAction}
|
||||
>
|
||||
确认
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronDown, Folder, FolderOpen, GitBranch } from "lucide-react"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
focusFolderWindow,
|
||||
listOpenFolders,
|
||||
loadFolderHistory,
|
||||
openFolderWindow,
|
||||
} from "@/lib/tauri"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { CloneDialog } from "@/components/welcome/clone-dialog"
|
||||
import type { FolderHistoryEntry } from "@/lib/types"
|
||||
|
||||
export function FolderNameDropdown() {
|
||||
const { folder } = useFolderContext()
|
||||
const [openFolders, setOpenFolders] = useState<FolderHistoryEntry[]>([])
|
||||
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
|
||||
const folderPath = folder?.path ?? ""
|
||||
const folderName = folder?.name ?? "文件夹"
|
||||
|
||||
async function handleOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
try {
|
||||
const [openEntries, historyEntries] = await Promise.all([
|
||||
listOpenFolders(),
|
||||
loadFolderHistory(),
|
||||
])
|
||||
setOpenFolders(openEntries)
|
||||
const openPaths = new Set(openEntries.map((e) => e.path))
|
||||
setHistory(historyEntries.filter((e) => !openPaths.has(e.path)))
|
||||
} catch {
|
||||
setOpenFolders([])
|
||||
setHistory([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenFolder() {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
if (selected) {
|
||||
await openFolderWindow(selected)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelect(path: string) {
|
||||
try {
|
||||
await openFolderWindow(path)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
suppressHydrationWarning
|
||||
className="flex items-center gap-1 text-sm tracking-tight truncate hover:text-foreground/80 transition-colors outline-none cursor-default"
|
||||
>
|
||||
{folderName}
|
||||
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-64" align="start">
|
||||
<DropdownMenuItem onSelect={handleOpenFolder}>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
|
||||
Open Folder
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setCloneOpen(true)}>
|
||||
<GitBranch className="h-3.5 w-3.5 shrink-0" />
|
||||
Clone Repository
|
||||
</DropdownMenuItem>
|
||||
{openFolders.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>已打开</DropdownMenuLabel>
|
||||
{openFolders.map((entry) => (
|
||||
<DropdownMenuItem
|
||||
key={entry.path}
|
||||
onSelect={() => focusFolderWindow(entry.id)}
|
||||
>
|
||||
{entry.path === folderPath ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className={`truncate ${entry.path === folderPath ? "font-medium text-foreground" : ""}`}
|
||||
>
|
||||
{entry.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{entry.path}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{history.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>最近打开</DropdownMenuLabel>
|
||||
{history.map((entry) => (
|
||||
<DropdownMenuItem
|
||||
key={entry.path}
|
||||
onSelect={() => handleSelect(entry.path)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{entry.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{entry.path}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import {
|
||||
Columns2,
|
||||
FileCode2,
|
||||
MessageSquare,
|
||||
PanelBottom,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
Search,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/tauri"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useSidebarContext } from "@/contexts/sidebar-context"
|
||||
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
|
||||
import { useTerminalContext } from "@/contexts/terminal-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { useIsMac } from "@/hooks/use-is-mac"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
import {
|
||||
formatShortcutLabel,
|
||||
matchShortcutEvent,
|
||||
} from "@/lib/keyboard-shortcuts"
|
||||
import { AppTitleBar } from "./app-title-bar"
|
||||
import { FolderNameDropdown } from "./folder-name-dropdown"
|
||||
import { BranchDropdown } from "./branch-dropdown"
|
||||
import { CommandDropdown } from "./command-dropdown"
|
||||
import { SearchCommandDialog } from "@/components/conversations/search-command-dialog"
|
||||
|
||||
const MODE_TABS = [
|
||||
{
|
||||
mode: "conversation",
|
||||
title: "会话模式",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
mode: "fusion",
|
||||
title: "融合模式",
|
||||
icon: Columns2,
|
||||
},
|
||||
{
|
||||
mode: "files",
|
||||
title: "文件模式",
|
||||
icon: FileCode2,
|
||||
},
|
||||
] as const
|
||||
|
||||
export function FolderTitleBar() {
|
||||
const { folder } = useFolderContext()
|
||||
const { isOpen, toggle } = useSidebarContext()
|
||||
const { isOpen: auxPanelOpen, toggle: toggleAuxPanel } = useAuxPanelContext()
|
||||
const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext()
|
||||
const { openNewConversationTab } = useTabContext()
|
||||
const { mode, setMode } = useWorkspaceContext()
|
||||
const isMac = useIsMac()
|
||||
const { shortcuts } = useShortcutSettings()
|
||||
const [branch, setBranch] = useState<string | null>(null)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const folderPath = folder?.path ?? ""
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
try {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
if (!selected) return
|
||||
await openFolderWindow(selected)
|
||||
} catch (err) {
|
||||
console.error("[FolderTitleBar] failed to open folder:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
openSettingsWindow().catch((err) => {
|
||||
console.error("[FolderTitleBar] failed to open settings:", err)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!folderPath) return
|
||||
let cancelled = false
|
||||
|
||||
async function doFetch() {
|
||||
if (document.visibilityState !== "visible") return
|
||||
|
||||
try {
|
||||
const b = await getGitBranch(folderPath)
|
||||
if (!cancelled) setBranch(b)
|
||||
} catch {
|
||||
if (!cancelled) setBranch(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "visible") {
|
||||
void doFetch()
|
||||
}
|
||||
}
|
||||
|
||||
void doFetch()
|
||||
intervalRef.current = setInterval(() => {
|
||||
void doFetch()
|
||||
}, 10_000)
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(intervalRef.current)
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
}
|
||||
}, [folderPath])
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (matchShortcutEvent(e, shortcuts.toggle_search)) {
|
||||
e.preventDefault()
|
||||
setSearchOpen((prev) => !prev)
|
||||
return
|
||||
}
|
||||
if (matchShortcutEvent(e, shortcuts.toggle_sidebar)) {
|
||||
e.preventDefault()
|
||||
toggle()
|
||||
return
|
||||
}
|
||||
if (matchShortcutEvent(e, shortcuts.toggle_terminal)) {
|
||||
e.preventDefault()
|
||||
toggleTerminal()
|
||||
return
|
||||
}
|
||||
if (matchShortcutEvent(e, shortcuts.toggle_aux_panel)) {
|
||||
e.preventDefault()
|
||||
toggleAuxPanel()
|
||||
return
|
||||
}
|
||||
if (matchShortcutEvent(e, shortcuts.new_conversation)) {
|
||||
if (!folderPath) return
|
||||
e.preventDefault()
|
||||
openNewConversationTab("codex", folderPath)
|
||||
return
|
||||
}
|
||||
if (matchShortcutEvent(e, shortcuts.open_folder)) {
|
||||
e.preventDefault()
|
||||
void handleOpenFolder()
|
||||
return
|
||||
}
|
||||
if (matchShortcutEvent(e, shortcuts.open_settings)) {
|
||||
e.preventDefault()
|
||||
handleOpenSettings()
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||
}, [
|
||||
folderPath,
|
||||
handleOpenFolder,
|
||||
handleOpenSettings,
|
||||
openNewConversationTab,
|
||||
shortcuts,
|
||||
toggle,
|
||||
toggleAuxPanel,
|
||||
toggleTerminal,
|
||||
])
|
||||
|
||||
const refreshBranch = useCallback(async () => {
|
||||
if (!folderPath) return
|
||||
try {
|
||||
setBranch(await getGitBranch(folderPath))
|
||||
} catch {
|
||||
setBranch(null)
|
||||
}
|
||||
}, [folderPath])
|
||||
const modeIndex = MODE_TABS.findIndex((item) => item.mode === mode)
|
||||
const indicatorLeft = `${2 + modeIndex * 32}px`
|
||||
const handleModeKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLDivElement>, nextMode: typeof mode) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
setMode(nextMode)
|
||||
}
|
||||
},
|
||||
[setMode]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppTitleBar
|
||||
centerInteractive
|
||||
left={
|
||||
<div className="flex items-center gap-4 min-w-0 pl-4">
|
||||
<FolderNameDropdown />
|
||||
<BranchDropdown
|
||||
branch={branch}
|
||||
parentBranch={folder?.parent_branch ?? null}
|
||||
onBranchChange={refreshBranch}
|
||||
/>
|
||||
<div data-tauri-drag-region className="h-8 flex-1" />
|
||||
</div>
|
||||
}
|
||||
center={
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="工作区模式"
|
||||
className="relative flex h-[27px] items-center rounded-full border border-border/80 bg-background/80 p-0.5"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-[2px] top-[2px] w-8 rounded-full bg-accent transition-[left] duration-300 ease-out"
|
||||
style={{ left: indicatorLeft }}
|
||||
/>
|
||||
{MODE_TABS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = mode === item.mode
|
||||
return (
|
||||
<div
|
||||
key={item.mode}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
className={`relative z-10 m-0 flex h-[23px] w-8 cursor-pointer select-none items-center justify-center rounded-full border-0 bg-transparent p-0 align-middle leading-none transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 ${
|
||||
isActive
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground/80"
|
||||
}`}
|
||||
onClick={() => setMode(item.mode)}
|
||||
onKeyDown={(event) => handleModeKeyDown(event, item.mode)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
title={item.title}
|
||||
aria-label={item.title}
|
||||
aria-selected={isActive}
|
||||
>
|
||||
<Icon
|
||||
className="block h-3 w-3 shrink-0"
|
||||
shapeRendering="geometricPrecision"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex items-center gap-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandDropdown />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:text-foreground/80"
|
||||
onClick={toggle}
|
||||
title={`${isOpen ? "Hide Sidebar" : "Show Sidebar"} (${formatShortcutLabel(shortcuts.toggle_sidebar, isMac)})`}
|
||||
>
|
||||
<PanelLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
|
||||
onClick={() => toggleTerminal()}
|
||||
title={`Toggle Terminal (${formatShortcutLabel(shortcuts.toggle_terminal, isMac)})`}
|
||||
>
|
||||
<PanelBottom className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-6 w-6 hover:text-foreground/80 ${auxPanelOpen ? "bg-accent" : ""}`}
|
||||
onClick={toggleAuxPanel}
|
||||
title={`Toggle Auxiliary Panel (${formatShortcutLabel(shortcuts.toggle_aux_panel, isMac)})`}
|
||||
>
|
||||
<PanelRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:text-foreground/80"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
title={`Search (${formatShortcutLabel(shortcuts.toggle_search, isMac)})`}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:text-foreground/80"
|
||||
onClick={handleOpenSettings}
|
||||
title={`Open Settings (${formatShortcutLabel(shortcuts.open_settings, isMac)})`}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<SearchCommandDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useRef } from "react"
|
||||
import { ChevronsDownUp, ChevronsUpDown, Crosshair, Plus } from "lucide-react"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useSidebarContext } from "@/contexts/sidebar-context"
|
||||
import {
|
||||
SidebarConversationList,
|
||||
type SidebarConversationListHandle,
|
||||
} from "@/components/conversations/sidebar-conversation-list"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function Sidebar() {
|
||||
const { folder } = useFolderContext()
|
||||
const { openNewConversationTab } = useTabContext()
|
||||
const { isOpen } = useSidebarContext()
|
||||
const listRef = useRef<SidebarConversationListHandle>(null)
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (!folder) return
|
||||
openNewConversationTab("codex", folder.path)
|
||||
}, [folder, openNewConversationTab])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<aside className="group/sidebar flex h-full min-h-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground select-none">
|
||||
<div className="flex h-10 items-center justify-between border-b border-border px-4">
|
||||
<h2 className="text-xs font-bold">Conversations</h2>
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/sidebar:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground"
|
||||
onClick={() => listRef.current?.scrollToActive()}
|
||||
title="Locate Active Conversation"
|
||||
>
|
||||
<Crosshair className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground"
|
||||
onClick={() => listRef.current?.expandAll()}
|
||||
title="Expand All Groups"
|
||||
>
|
||||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground"
|
||||
onClick={() => listRef.current?.collapseAll()}
|
||||
title="Collapse All Groups"
|
||||
>
|
||||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground"
|
||||
onClick={handleNewConversation}
|
||||
title="New Conversation"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SidebarConversationList ref={listRef} />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client"
|
||||
|
||||
import { CircleAlert, X, Trash2 } from "lucide-react"
|
||||
import {
|
||||
useAlertContext,
|
||||
type AlertLevel,
|
||||
type AlertAction,
|
||||
} from "@/contexts/alert-context"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { useAcpActions } from "@/contexts/acp-connections-context"
|
||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
||||
import { openSettingsWindow } from "@/lib/tauri"
|
||||
import { AGENT_LABELS, type AgentType } from "@/lib/types"
|
||||
|
||||
const KNOWN_AGENT_TYPES = new Set<AgentType>(
|
||||
Object.keys(AGENT_LABELS) as AgentType[]
|
||||
)
|
||||
|
||||
function parseAgentType(value: unknown): AgentType | null {
|
||||
if (typeof value !== "string") return null
|
||||
return KNOWN_AGENT_TYPES.has(value as AgentType) ? (value as AgentType) : null
|
||||
}
|
||||
|
||||
function parseOpenAgentsSettingsPayload(payload: string): {
|
||||
agentType: AgentType | null
|
||||
} {
|
||||
const normalized = payload.trim()
|
||||
if (!normalized || normalized === "agents") {
|
||||
return { agentType: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { agentType: null }
|
||||
}
|
||||
return {
|
||||
agentType: parseAgentType(
|
||||
(parsed as { agentType?: unknown }).agentType ?? null
|
||||
),
|
||||
}
|
||||
} catch {
|
||||
return { agentType: null }
|
||||
}
|
||||
}
|
||||
|
||||
function AlertLevelIcon({ level }: { level: AlertLevel }) {
|
||||
if (level === "error") {
|
||||
return <CircleAlert className="h-3 w-3 shrink-0 text-red-500" />
|
||||
}
|
||||
return <CircleAlert className="h-3 w-3 shrink-0 text-yellow-500" />
|
||||
}
|
||||
|
||||
function AlertActionButton({ action }: { action: AlertAction }) {
|
||||
const { connect } = useAcpActions()
|
||||
|
||||
const handleClick = async () => {
|
||||
switch (action.kind) {
|
||||
case "open_url":
|
||||
await openUrl(action.payload)
|
||||
break
|
||||
case "retry_connection": {
|
||||
try {
|
||||
const data = JSON.parse(action.payload)
|
||||
await connect(
|
||||
data.contextKey,
|
||||
data.agentType,
|
||||
data.workingDir,
|
||||
data.sessionId
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("[AlertAction] retry_connection failed:", e)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "redownload_binary": {
|
||||
try {
|
||||
const data = JSON.parse(action.payload)
|
||||
await connect(
|
||||
data.contextKey,
|
||||
data.agentType,
|
||||
data.workingDir,
|
||||
data.sessionId
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("[AlertAction] redownload_binary failed:", e)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "open_agents_settings":
|
||||
await openSettingsWindow("agents", {
|
||||
agentType: parseOpenAgentsSettingsPayload(action.payload).agentType,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-accent hover:bg-accent/80 text-accent-foreground transition-colors"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBarAlerts() {
|
||||
const { alerts, hasAlerts, dismissAlert, clearAll } = useAlertContext()
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||||
<CircleAlert
|
||||
className={`size-3.5 ${hasAlerts ? "text-red-500" : ""}`}
|
||||
/>
|
||||
{hasAlerts && <span className="text-red-500">{alerts.length}</span>}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="end" className="w-80 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium">告警</span>
|
||||
{hasAlerts && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!hasAlerts ? (
|
||||
<div className="text-xs text-muted-foreground py-2">暂无告警信息</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-start gap-2 text-xs group"
|
||||
>
|
||||
<AlertLevelIcon level={alert.level} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="break-words">{alert.message}</div>
|
||||
{alert.detail && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 break-all whitespace-pre-wrap">
|
||||
{alert.detail}
|
||||
</div>
|
||||
)}
|
||||
{alert.actions && alert.actions.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{alert.actions.map((action, i) => (
|
||||
<AlertActionButton key={i} action={action} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismissAlert(alert.id)}
|
||||
className="shrink-0 opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react"
|
||||
import { Unplug } from "lucide-react"
|
||||
import { useConnectionStore } from "@/contexts/acp-connections-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { AgentIcon } from "@/components/agent-icon"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { AGENT_LABELS } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const STATUS_STYLE: Record<string, { className: string; title: string }> = {
|
||||
connected: { className: "opacity-100", title: "Connected" },
|
||||
connecting: {
|
||||
className: "opacity-100 animate-pulse",
|
||||
title: "Connecting...",
|
||||
},
|
||||
downloading: {
|
||||
className: "opacity-100 animate-pulse",
|
||||
title: "Downloading...",
|
||||
},
|
||||
prompting: {
|
||||
className: "opacity-100 animate-pulse",
|
||||
title: "Responding...",
|
||||
},
|
||||
error: { className: "opacity-50", title: "Connection error" },
|
||||
}
|
||||
|
||||
export function StatusBarConnection() {
|
||||
const store = useConnectionStore()
|
||||
const { tabs, activeTabId } = useTabContext()
|
||||
const { conversations } = useFolderContext()
|
||||
|
||||
// Subscribe to activeKey changes
|
||||
const subscribeActiveKey = useCallback(
|
||||
(cb: () => void) => store.subscribeActiveKey(cb),
|
||||
[store]
|
||||
)
|
||||
const getActiveKey = useCallback(() => store.getActiveKey(), [store])
|
||||
const activeKey = useSyncExternalStore(
|
||||
subscribeActiveKey,
|
||||
getActiveKey,
|
||||
getActiveKey
|
||||
)
|
||||
|
||||
// Subscribe to the active connection's changes
|
||||
const subscribeConn = useCallback(
|
||||
(cb: () => void) => {
|
||||
if (!activeKey) return () => {}
|
||||
return store.subscribeKey(activeKey, cb)
|
||||
},
|
||||
[store, activeKey]
|
||||
)
|
||||
const getConnSnapshot = useCallback(
|
||||
() => (activeKey ? store.getConnection(activeKey) : undefined),
|
||||
[store, activeKey]
|
||||
)
|
||||
const activeConn = useSyncExternalStore(
|
||||
subscribeConn,
|
||||
getConnSnapshot,
|
||||
getConnSnapshot
|
||||
)
|
||||
|
||||
const status = activeConn?.status ?? null
|
||||
const agentType = activeConn?.agentType ?? null
|
||||
|
||||
const model = useMemo(() => {
|
||||
const tab = tabs.find((t) => t.id === activeTabId)
|
||||
if (!tab || tab.kind !== "conversation") return null
|
||||
const conv = conversations.find(
|
||||
(c) => c.id === tab.conversationId && c.agent_type === tab.agentType
|
||||
)
|
||||
return conv?.model ?? null
|
||||
}, [tabs, activeTabId, conversations])
|
||||
|
||||
if (!agentType || !status || status === "disconnected") {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Unplug className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||
{model && <span>{model}</span>}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Disconnected</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const style = STATUS_STYLE[status]
|
||||
if (!style) return null
|
||||
|
||||
const label = AGENT_LABELS[agentType]
|
||||
const tooltipText =
|
||||
status === "error" && activeConn?.error
|
||||
? `${label} - ${activeConn.error}`
|
||||
: `${label} - ${style.title}`
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<AgentIcon
|
||||
agentType={agentType}
|
||||
className={cn("size-3", style.className)}
|
||||
/>
|
||||
{model && <span>{model}</span>}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { MessageSquare, GitBranch } from "lucide-react"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
|
||||
export function StatusBarSessionInfo() {
|
||||
const { tabs, activeTabId } = useTabContext()
|
||||
const { conversations } = useFolderContext()
|
||||
|
||||
const activeTab = useMemo(
|
||||
() => tabs.find((t) => t.id === activeTabId) ?? null,
|
||||
[tabs, activeTabId]
|
||||
)
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!activeTab || activeTab.kind !== "conversation") return null
|
||||
return conversations.find(
|
||||
(c) =>
|
||||
c.id === activeTab.conversationId &&
|
||||
c.agent_type === activeTab.agentType
|
||||
)
|
||||
}, [activeTab, conversations])
|
||||
|
||||
if (!summary) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
{summary.git_branch && (
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{summary.git_branch}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{summary.message_count}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { AGENT_LABELS } from "@/lib/types"
|
||||
import { AgentIcon } from "@/components/agent-icon"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
export function StatusBarStats() {
|
||||
const { stats } = useFolderContext()
|
||||
|
||||
const activeAgents = useMemo(
|
||||
() => stats?.by_agent.filter((a) => a.conversation_count > 0) ?? [],
|
||||
[stats]
|
||||
)
|
||||
|
||||
if (!stats) return null
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="flex items-center gap-1.5 hover:text-foreground transition-colors">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
<span>{stats.total_conversations} conversations</span>
|
||||
<span className="flex items-center gap-1 ml-1">
|
||||
{activeAgents.map((a) => (
|
||||
<AgentIcon
|
||||
key={a.agent_type}
|
||||
agentType={a.agent_type}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" className="w-64 p-3">
|
||||
<div className="text-xs font-medium mb-2">
|
||||
{stats.total_conversations} conversations / {stats.total_messages}{" "}
|
||||
messages
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{activeAgents.map((a) => (
|
||||
<div key={a.agent_type} className="flex items-center gap-2 text-xs">
|
||||
<AgentIcon agentType={a.agent_type} className="w-3.5 h-3.5" />
|
||||
<span className="text-muted-foreground">
|
||||
{AGENT_LABELS[a.agent_type]}
|
||||
</span>
|
||||
<span className="ml-auto text-muted-foreground">
|
||||
{a.conversation_count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { Clock } from "lucide-react"
|
||||
import { useTaskContext } from "@/contexts/task-context"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-spin rounded-full border-[1.5px] border-current border-t-transparent ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBarTasks() {
|
||||
const { tasks } = useTaskContext()
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
const runningTask = tasks.find(
|
||||
(t) => t.status === "running" || t.status === "pending"
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{runningTask && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="truncate max-w-40">
|
||||
{runningTask.label || runningTask.description}
|
||||
</span>
|
||||
<Skeleton className="h-1 w-28 rounded bg-primary/80" />
|
||||
<Spinner className="h-3 w-3 shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||||
{!runningTask && <Clock className="h-3 w-3" />}
|
||||
{tasks.length > 1 && <span>{tasks.length}</span>}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="end" className="w-72 p-3">
|
||||
<div className="text-xs font-medium mb-2">任务</div>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{task.status === "running" ? (
|
||||
<Spinner className="h-3 w-3 text-blue-500" />
|
||||
) : (
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate flex-1">{task.label}</span>
|
||||
</div>
|
||||
{task.status === "running" && task.progress != null && (
|
||||
<div className="h-1 rounded-full bg-muted ml-5">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import { Coins } from "lucide-react"
|
||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
const ICON_RADIUS = 6
|
||||
const ICON_CENTER = 8
|
||||
const ICON_VIEWBOX = 16
|
||||
const ICON_CIRCUMFERENCE = 2 * Math.PI * ICON_RADIUS
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatPercent(percent: number | null): string {
|
||||
if (percent == null) return "--"
|
||||
return `${percent.toFixed(1)}%`
|
||||
}
|
||||
|
||||
export function StatusBarTokens() {
|
||||
const { sessionStats } = useSessionStats()
|
||||
const usage = sessionStats?.total_usage
|
||||
|
||||
const contextUsed = sessionStats?.context_window_used_tokens ?? null
|
||||
const contextMax = sessionStats?.context_window_max_tokens ?? null
|
||||
const contextPercentRaw =
|
||||
sessionStats?.context_window_usage_percent ??
|
||||
(contextUsed != null && contextMax != null && contextMax > 0
|
||||
? (contextUsed / contextMax) * 100
|
||||
: null)
|
||||
const contextPercent =
|
||||
contextPercentRaw == null
|
||||
? null
|
||||
: Math.max(0, Math.min(100, contextPercentRaw))
|
||||
const hasContext = contextPercent != null
|
||||
const hasUsage = usage != null
|
||||
const fallbackTotal = hasUsage
|
||||
? usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
: null
|
||||
const total = sessionStats?.total_tokens ?? fallbackTotal
|
||||
|
||||
const dashOffset = ICON_CIRCUMFERENCE * (1 - (contextPercent ?? 0) / 100)
|
||||
|
||||
const rows: { label: string; value: number }[] = []
|
||||
if (hasUsage) {
|
||||
rows.push(
|
||||
{ label: "Input", value: usage.input_tokens },
|
||||
{ label: "Output", value: usage.output_tokens },
|
||||
{ label: "Cache Read", value: usage.cache_read_input_tokens },
|
||||
{ label: "Cache Write", value: usage.cache_creation_input_tokens }
|
||||
)
|
||||
}
|
||||
if (total != null) {
|
||||
rows.push({ label: "Total", value: total })
|
||||
}
|
||||
|
||||
const hasTokenSection = rows.length > 0
|
||||
|
||||
if (!hasContext && !hasTokenSection) return null
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||||
{hasContext ? (
|
||||
<>
|
||||
<svg
|
||||
aria-label="Context window usage"
|
||||
className="size-3.5"
|
||||
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
||||
>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
r={ICON_RADIUS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
r={ICON_RADIUS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${ICON_CIRCUMFERENCE} ${ICON_CIRCUMFERENCE}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
style={{
|
||||
transformOrigin: "center",
|
||||
transform: "rotate(-90deg)",
|
||||
}}
|
||||
opacity="0.75"
|
||||
/>
|
||||
</svg>
|
||||
<span>{formatPercent(contextPercent)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Coins className="size-3.5" />
|
||||
<span>{formatTokenCount(total ?? 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="end" className="w-56 gap-2 p-3 text-xs">
|
||||
{hasContext ? (
|
||||
<div
|
||||
className={`space-y-1 ${
|
||||
hasUsage ? "mb-0.5 border-b border-border pb-0.5" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-xs font-medium whitespace-nowrap">
|
||||
<span>Context Window</span>
|
||||
<span className="tabular-nums shrink-0">
|
||||
{formatPercent(contextPercent)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-foreground/70"
|
||||
style={{ width: `${contextPercent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs leading-none text-muted-foreground">
|
||||
<span>Used / Max</span>
|
||||
<span className="tabular-nums">
|
||||
{contextUsed == null || contextMax == null
|
||||
? "--"
|
||||
: `${formatNumber(contextUsed)} / ${formatNumber(contextMax)}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasTokenSection ? (
|
||||
<>
|
||||
<div className="mb-0 mt-0.5 text-xs leading-none font-medium">
|
||||
Token Usage
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={`flex items-center justify-between py-0.5 text-xs leading-none ${
|
||||
row.label === "Total"
|
||||
? "mt-0.5 border-t border-border pt-0.5 font-medium"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{row.label}</span>
|
||||
<span className="tabular-nums">
|
||||
{formatNumber(row.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { StatusBarStats } from "@/components/layout/status-bar-stats"
|
||||
import { StatusBarSessionInfo } from "@/components/layout/status-bar-session-info"
|
||||
import { StatusBarTasks } from "@/components/layout/status-bar-tasks"
|
||||
import { StatusBarTokens } from "@/components/layout/status-bar-tokens"
|
||||
import { StatusBarConnection } from "@/components/layout/status-bar-connection"
|
||||
import { StatusBarAlerts } from "@/components/layout/status-bar-alerts"
|
||||
|
||||
export function StatusBar() {
|
||||
return (
|
||||
<div className="h-8 shrink-0 border-t border-border bg-muted px-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center">
|
||||
<StatusBarStats />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusBarTasks />
|
||||
<StatusBarSessionInfo />
|
||||
<StatusBarTokens />
|
||||
<StatusBarConnection />
|
||||
<StatusBarAlerts />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||
import { usePlatform } from "@/hooks/use-platform"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function WindowControls() {
|
||||
const { isWindows } = usePlatform()
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWindows) return
|
||||
|
||||
let disposed = false
|
||||
let unlistenResize: (() => void) | null = null
|
||||
let resizeFrame: number | null = null
|
||||
const appWindow = getCurrentWindow()
|
||||
|
||||
const syncMaximized = async () => {
|
||||
try {
|
||||
const maximized = await appWindow.isMaximized()
|
||||
if (!disposed) {
|
||||
setIsMaximized(maximized)
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
setIsMaximized(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleSync = () => {
|
||||
if (resizeFrame !== null) return
|
||||
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
resizeFrame = null
|
||||
void syncMaximized()
|
||||
})
|
||||
}
|
||||
|
||||
void syncMaximized()
|
||||
|
||||
appWindow
|
||||
.onResized(() => {
|
||||
scheduleSync()
|
||||
})
|
||||
.then((unlisten) => {
|
||||
unlistenResize = unlisten
|
||||
})
|
||||
.catch(() => {
|
||||
unlistenResize = null
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (resizeFrame !== null) {
|
||||
window.cancelAnimationFrame(resizeFrame)
|
||||
}
|
||||
unlistenResize?.()
|
||||
}
|
||||
}, [isWindows])
|
||||
|
||||
if (!isWindows) return null
|
||||
|
||||
const appWindow = getCurrentWindow()
|
||||
|
||||
return (
|
||||
<div className="flex h-8 items-stretch [-webkit-app-region:no-drag]">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
appWindow.minimize().catch((err) => {
|
||||
console.error("[WindowControls] failed to minimize:", err)
|
||||
})
|
||||
}}
|
||||
aria-label="Minimize window"
|
||||
title="Minimize"
|
||||
>
|
||||
<MinimizeIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
appWindow.toggleMaximize().catch((err) => {
|
||||
console.error("[WindowControls] failed to toggle maximize:", err)
|
||||
})
|
||||
}}
|
||||
aria-label={isMaximized ? "Restore window" : "Maximize window"}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{isMaximized ? <RestoreIcon /> : <MaximizeIcon />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonClass,
|
||||
"hover:bg-[#e81123] hover:text-white active:bg-[#c50f1f] active:text-white"
|
||||
)}
|
||||
onClick={() => {
|
||||
appWindow.close().catch((err) => {
|
||||
console.error("[WindowControls] failed to close:", err)
|
||||
})
|
||||
}}
|
||||
aria-label="Close window"
|
||||
title="Close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const buttonClass =
|
||||
"flex h-8 w-[46px] items-center justify-center text-foreground/85 transition-colors duration-75 hover:bg-foreground/10 active:bg-foreground/15"
|
||||
|
||||
function MinimizeIcon() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-px w-[10px] translate-y-[2px] bg-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MaximizeIcon() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-[10px] w-[10px] border border-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RestoreIcon() {
|
||||
return (
|
||||
<span aria-hidden className="relative inline-block h-[10px] w-[10px]">
|
||||
<span className="absolute right-0 top-0 h-[7px] w-[7px] border border-current" />
|
||||
<span className="absolute bottom-0 left-0 h-[7px] w-[7px] border border-current" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<span aria-hidden className="relative inline-block h-[10px] w-[10px]">
|
||||
<span className="absolute left-1/2 top-0 h-[10px] w-px -translate-x-1/2 rotate-45 bg-current" />
|
||||
<span className="absolute left-1/2 top-0 h-[10px] w-px -translate-x-1/2 -rotate-45 bg-current" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,332 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||
import { inferLiveToolName } from "@/lib/tool-call-normalization"
|
||||
import {
|
||||
countUnifiedDiffLineChanges,
|
||||
estimateChangedLineStats,
|
||||
} from "@/lib/line-change-stats"
|
||||
import { FilePenLine, Timer, Wrench } from "lucide-react"
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`
|
||||
return `${(ms / 1_000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
interface LiveTurnStatsProps {
|
||||
message: LiveMessage
|
||||
}
|
||||
|
||||
interface LineChangeStats {
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
interface LiveEditStats extends LineChangeStats {
|
||||
files: number
|
||||
}
|
||||
|
||||
const COMPACT_NUMBER = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
function formatCompactInt(n: number): string {
|
||||
if (n < 1000) return String(n)
|
||||
return COMPACT_NUMBER.format(n).toUpperCase()
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function parseInputObject(
|
||||
input: string | null
|
||||
): Record<string, unknown> | null {
|
||||
if (!input) return null
|
||||
try {
|
||||
return asObject(JSON.parse(input))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function unescapeInlineEscapes(text: string): string {
|
||||
return text
|
||||
.replace(/\\r\\n/g, "\n")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
}
|
||||
|
||||
function looksLikeDiffPayload(input: string): boolean {
|
||||
const normalized = unescapeInlineEscapes(input)
|
||||
return (
|
||||
normalized.includes("*** Begin Patch") ||
|
||||
normalized.includes("*** Update File:") ||
|
||||
/^diff --git /m.test(normalized) ||
|
||||
(/^--- .+/m.test(normalized) && /^\+\+\+ .+/m.test(normalized)) ||
|
||||
/^@@ /m.test(normalized)
|
||||
)
|
||||
}
|
||||
|
||||
function extractPatchText(
|
||||
rawInput: string | null,
|
||||
parsed: Record<string, unknown> | null
|
||||
): string | null {
|
||||
if (!rawInput) return null
|
||||
if (looksLikeDiffPayload(rawInput)) return unescapeInlineEscapes(rawInput)
|
||||
if (!parsed) return null
|
||||
|
||||
const candidates = [
|
||||
parsed.patch,
|
||||
parsed.diff,
|
||||
parsed.unified_diff,
|
||||
parsed.unifiedDiff,
|
||||
parsed.command,
|
||||
parsed.input,
|
||||
parsed.arguments,
|
||||
parsed.payload,
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") continue
|
||||
if (looksLikeDiffPayload(candidate)) return unescapeInlineEscapes(candidate)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function addPathIfValid(paths: Set<string>, value: unknown): void {
|
||||
if (typeof value !== "string") return
|
||||
const path = value.trim()
|
||||
if (!path) return
|
||||
paths.add(path)
|
||||
}
|
||||
|
||||
function collectParsedPaths(
|
||||
parsed: Record<string, unknown> | null
|
||||
): Set<string> {
|
||||
const paths = new Set<string>()
|
||||
if (!parsed) return paths
|
||||
|
||||
addPathIfValid(
|
||||
paths,
|
||||
parsed.file_path ?? parsed.filePath ?? parsed.path ?? parsed.notebook_path
|
||||
)
|
||||
|
||||
const changes = asObject(parsed.changes)
|
||||
if (changes) {
|
||||
for (const path of Object.keys(changes)) {
|
||||
addPathIfValid(paths, path)
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
function parseApplyPatchStats(patch: string): {
|
||||
files: Set<string>
|
||||
additions: number
|
||||
deletions: number
|
||||
} {
|
||||
const files = new Set<string>()
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const line of patch.split("\n")) {
|
||||
if (line.startsWith("*** Add File: ")) {
|
||||
addPathIfValid(files, line.slice(14))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Update File: ")) {
|
||||
addPathIfValid(files, line.slice(17))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Delete File: ")) {
|
||||
addPathIfValid(files, line.slice(17))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("+++ ")) {
|
||||
const normalized = line.slice(4).replace(/^b\//, "").trim()
|
||||
if (normalized && normalized !== "/dev/null") {
|
||||
files.add(normalized)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1
|
||||
if (line.startsWith("-") && !line.startsWith("---")) deletions += 1
|
||||
}
|
||||
|
||||
return { files, additions, deletions }
|
||||
}
|
||||
|
||||
function extractEditStats(parsed: Record<string, unknown>): LineChangeStats {
|
||||
const changes = asObject(parsed.changes)
|
||||
if (changes) {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const change of Object.values(changes)) {
|
||||
const record = asObject(change)
|
||||
if (!record) continue
|
||||
|
||||
const unifiedDiff =
|
||||
(typeof record.unifiedDiff === "string" && record.unifiedDiff) ||
|
||||
(typeof record.unified_diff === "string" && record.unified_diff) ||
|
||||
null
|
||||
|
||||
if (unifiedDiff) {
|
||||
const stats = countUnifiedDiffLineChanges(unifiedDiff)
|
||||
additions += stats.additions
|
||||
deletions += stats.deletions
|
||||
continue
|
||||
}
|
||||
|
||||
const oldString =
|
||||
(typeof record.oldText === "string" && record.oldText) ||
|
||||
(typeof record.old_string === "string" && record.old_string) ||
|
||||
""
|
||||
const newString =
|
||||
(typeof record.newText === "string" && record.newText) ||
|
||||
(typeof record.new_string === "string" && record.new_string) ||
|
||||
""
|
||||
|
||||
const estimated = estimateChangedLineStats(oldString, newString)
|
||||
additions += estimated.additions
|
||||
deletions += estimated.deletions
|
||||
}
|
||||
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
const oldString =
|
||||
(typeof parsed.old_string === "string" && parsed.old_string) ||
|
||||
(typeof parsed.oldText === "string" && parsed.oldText) ||
|
||||
""
|
||||
const newString =
|
||||
(typeof parsed.new_string === "string" && parsed.new_string) ||
|
||||
(typeof parsed.newText === "string" && parsed.newText) ||
|
||||
""
|
||||
|
||||
return estimateChangedLineStats(oldString, newString)
|
||||
}
|
||||
|
||||
function extractWriteStats(parsed: Record<string, unknown>): LineChangeStats {
|
||||
const content =
|
||||
(typeof parsed.content === "string" && parsed.content) ||
|
||||
(typeof parsed.new_source === "string" && parsed.new_source) ||
|
||||
""
|
||||
|
||||
const additions = content.length === 0 ? 0 : content.split("\n").length
|
||||
return { additions, deletions: 0 }
|
||||
}
|
||||
|
||||
function extractLiveEditStats(message: LiveMessage): LiveEditStats {
|
||||
const files = new Set<string>()
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const block of message.content) {
|
||||
if (block.type !== "tool_call") continue
|
||||
const toolName = inferLiveToolName({
|
||||
title: block.info.title,
|
||||
kind: block.info.kind,
|
||||
rawInput: block.info.raw_input,
|
||||
})
|
||||
if (
|
||||
toolName !== "edit" &&
|
||||
toolName !== "write" &&
|
||||
toolName !== "apply_patch"
|
||||
)
|
||||
continue
|
||||
|
||||
const parsed = parseInputObject(block.info.raw_input)
|
||||
for (const path of collectParsedPaths(parsed)) files.add(path)
|
||||
|
||||
if (toolName === "apply_patch") {
|
||||
const patch = extractPatchText(block.info.raw_input, parsed)
|
||||
if (!patch) continue
|
||||
const stats = parseApplyPatchStats(patch)
|
||||
for (const path of stats.files) files.add(path)
|
||||
additions += stats.additions
|
||||
deletions += stats.deletions
|
||||
continue
|
||||
}
|
||||
|
||||
if (!parsed) continue
|
||||
|
||||
const stats =
|
||||
toolName === "edit" ? extractEditStats(parsed) : extractWriteStats(parsed)
|
||||
additions += stats.additions
|
||||
deletions += stats.deletions
|
||||
}
|
||||
|
||||
return { files: files.size, additions, deletions }
|
||||
}
|
||||
|
||||
export function LiveTurnStats({ message }: LiveTurnStatsProps) {
|
||||
const [elapsed, setElapsed] = useState(() => Date.now() - message.startedAt)
|
||||
const editStats = useMemo(() => extractLiveEditStats(message), [message])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setElapsed(Date.now() - message.startedAt)
|
||||
}, 100)
|
||||
return () => clearInterval(timer)
|
||||
}, [message.startedAt])
|
||||
|
||||
// Count tool calls from live content
|
||||
let toolCallCount = 0
|
||||
let isThinking = false
|
||||
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_call") {
|
||||
toolCallCount++
|
||||
} else if (block.type === "thinking") {
|
||||
isThinking = true
|
||||
}
|
||||
}
|
||||
|
||||
// If the last block is thinking, mark as currently thinking
|
||||
const lastBlock = message.content[message.content.length - 1]
|
||||
if (lastBlock?.type === "thinking") {
|
||||
isThinking = true
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-8 shrink-0 items-center justify-center gap-3 px-4 text-xs leading-none text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary animate-pulse shrink-0" />
|
||||
{isThinking && message.content.length <= 1 ? (
|
||||
<span>Thinking...</span>
|
||||
) : (
|
||||
<span>Streaming</span>
|
||||
)}
|
||||
<span className="text-border leading-none">|</span>
|
||||
<span className="inline-flex items-center gap-1 leading-none">
|
||||
<Timer className="h-3 w-3 shrink-0" />
|
||||
{formatElapsed(elapsed)}
|
||||
</span>
|
||||
{editStats.files > 0 && (
|
||||
<>
|
||||
<span className="text-border leading-none">|</span>
|
||||
<span className="inline-flex items-center gap-1 leading-none">
|
||||
<FilePenLine className="h-3 w-3 shrink-0" />
|
||||
{editStats.files}F +{formatCompactInt(editStats.additions)}/-
|
||||
{formatCompactInt(editStats.deletions)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{toolCallCount > 0 && (
|
||||
<>
|
||||
<span className="text-border leading-none">|</span>
|
||||
<span className="inline-flex items-center gap-1 leading-none">
|
||||
<Wrench className="h-3 w-3 shrink-0" />
|
||||
{toolCallCount} tool {toolCallCount === 1 ? "use" : "uses"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { User, Bot, Terminal, Cpu } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ContentPartsRenderer } from "./content-parts-renderer"
|
||||
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: AdaptedMessage
|
||||
}
|
||||
|
||||
function RoleIcon({ role }: { role: string }) {
|
||||
switch (role) {
|
||||
case "user":
|
||||
return <User className="h-4 w-4" />
|
||||
case "assistant":
|
||||
return <Bot className="h-4 w-4" />
|
||||
case "system":
|
||||
return <Cpu className="h-4 w-4" />
|
||||
case "tool":
|
||||
return <Terminal className="h-4 w-4" />
|
||||
default:
|
||||
return <Bot className="h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageBubble = memo(function MessageBubble({
|
||||
message,
|
||||
}: MessageBubbleProps) {
|
||||
const isUser = message.role === "user"
|
||||
const timeAgo = formatDistanceToNow(new Date(message.timestamp), {
|
||||
addSuffix: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-3 px-4 py-3", isUser ? "bg-muted/30" : "")}>
|
||||
<div
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5",
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground"
|
||||
)}
|
||||
>
|
||||
<RoleIcon role={message.role} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium capitalize">{message.role}</span>
|
||||
<span className="text-xs text-muted-foreground">{timeAgo}</span>
|
||||
</div>
|
||||
|
||||
<ContentPartsRenderer parts={message.content} role={message.role} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useEffect, useMemo, useRef } from "react"
|
||||
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
|
||||
import { ContentPartsRenderer } from "./content-parts-renderer"
|
||||
import {
|
||||
adaptMessageTurns,
|
||||
type AdaptedMessage,
|
||||
type AdaptedContentPart,
|
||||
type MessageGroup,
|
||||
type UserResourceDisplay,
|
||||
groupAdaptedMessages,
|
||||
extractUserResourcesFromText,
|
||||
} from "@/lib/adapters/ai-elements-adapter"
|
||||
import { TurnStats } from "./turn-stats"
|
||||
import { LiveTurnStats } from "./live-turn-stats"
|
||||
import { UserResourceLinks } from "./user-resource-links"
|
||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
||||
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||
import {
|
||||
MessageThread,
|
||||
MessageThreadContent,
|
||||
} from "@/components/ai-elements/message-thread"
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import {
|
||||
buildPlanKey,
|
||||
extractLatestPlanEntriesFromMessages,
|
||||
} from "@/lib/agent-plan"
|
||||
|
||||
import type { ConnectionStatus } from "@/lib/types"
|
||||
|
||||
interface MessageListViewProps {
|
||||
conversationId: number
|
||||
connStatus?: ConnectionStatus | null
|
||||
liveMessage?: LiveMessage | null
|
||||
pendingMessages?: AdaptedMessage[]
|
||||
onPendingClear?: () => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
interface ResolvedMessageGroup extends MessageGroup {
|
||||
parts: AdaptedContentPart[]
|
||||
resources: UserResourceDisplay[]
|
||||
}
|
||||
|
||||
function fallbackExtractUserResources(group: MessageGroup): {
|
||||
parts: AdaptedContentPart[]
|
||||
resources: UserResourceDisplay[]
|
||||
} {
|
||||
if (group.role !== "user") {
|
||||
return {
|
||||
parts: group.parts,
|
||||
resources: group.userResources ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
const parsedResources: UserResourceDisplay[] = []
|
||||
const parsedParts: AdaptedContentPart[] = []
|
||||
|
||||
for (const part of group.parts) {
|
||||
if (part.type !== "text") {
|
||||
parsedParts.push(part)
|
||||
continue
|
||||
}
|
||||
const extracted = extractUserResourcesFromText(part.text)
|
||||
if (extracted.resources.length > 0) {
|
||||
parsedResources.push(...extracted.resources)
|
||||
if (extracted.text.length > 0) {
|
||||
parsedParts.push({ type: "text", text: extracted.text })
|
||||
}
|
||||
} else {
|
||||
parsedParts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
const resources = [...(group.userResources ?? []), ...parsedResources]
|
||||
const dedupedResources: UserResourceDisplay[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const resource of resources) {
|
||||
const key = `${resource.name}::${resource.uri}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
dedupedResources.push(resource)
|
||||
}
|
||||
|
||||
if (parsedParts.length === 0 && dedupedResources.length > 0) {
|
||||
parsedParts.push({ type: "text", text: "Attached resources" })
|
||||
}
|
||||
|
||||
return { parts: parsedParts, resources: dedupedResources }
|
||||
}
|
||||
|
||||
function resolveMessageGroup(group: MessageGroup): ResolvedMessageGroup {
|
||||
const resolved = fallbackExtractUserResources(group)
|
||||
return {
|
||||
...group,
|
||||
parts: resolved.parts,
|
||||
resources: resolved.resources,
|
||||
}
|
||||
}
|
||||
|
||||
const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
|
||||
group,
|
||||
}: {
|
||||
group: ResolvedMessageGroup
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
contentVisibility: "auto",
|
||||
containIntrinsicSize: "auto 120px",
|
||||
}}
|
||||
>
|
||||
<Message from={group.role}>
|
||||
<MessageContent>
|
||||
<ContentPartsRenderer parts={group.parts} role={group.role} />
|
||||
</MessageContent>
|
||||
{group.role === "user" && group.resources.length > 0 ? (
|
||||
<UserResourceLinks resources={group.resources} className="self-end" />
|
||||
) : null}
|
||||
</Message>
|
||||
{group.role === "assistant" && (
|
||||
<TurnStats
|
||||
usage={group.usage}
|
||||
duration_ms={group.duration_ms}
|
||||
model={group.model}
|
||||
models={group.models}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const PendingMessageGroup = memo(function PendingMessageGroup({
|
||||
group,
|
||||
}: {
|
||||
group: ResolvedMessageGroup
|
||||
}) {
|
||||
return (
|
||||
<div className="opacity-70">
|
||||
<Message from={group.role}>
|
||||
<MessageContent>
|
||||
<ContentPartsRenderer parts={group.parts} role={group.role} />
|
||||
</MessageContent>
|
||||
{group.role === "user" && group.resources.length > 0 ? (
|
||||
<UserResourceLinks resources={group.resources} className="self-end" />
|
||||
) : null}
|
||||
</Message>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export function MessageListView({
|
||||
conversationId,
|
||||
connStatus,
|
||||
liveMessage,
|
||||
pendingMessages,
|
||||
onPendingClear,
|
||||
isActive = true,
|
||||
}: MessageListViewProps) {
|
||||
const { detail, loading, error, refetch } = useDbMessageDetail(conversationId)
|
||||
const turnCount = detail?.turns.length ?? 0
|
||||
|
||||
// Refetch when agent turn completes (prompting → other status)
|
||||
const prevStatusRef = useRef(connStatus)
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current
|
||||
prevStatusRef.current = connStatus
|
||||
if (prev === "prompting" && connStatus && connStatus !== "prompting") {
|
||||
refetch()
|
||||
}
|
||||
}, [connStatus, refetch])
|
||||
|
||||
// Clear pending when detail gains new turns (new data fetched successfully)
|
||||
const prevTurnCountRef = useRef(turnCount)
|
||||
const prevConvIdRef = useRef(conversationId)
|
||||
useEffect(() => {
|
||||
if (prevConvIdRef.current !== conversationId) {
|
||||
// Conversation switched — reset baseline, don't clear
|
||||
prevConvIdRef.current = conversationId
|
||||
prevTurnCountRef.current = turnCount
|
||||
return
|
||||
}
|
||||
if (turnCount > prevTurnCountRef.current && onPendingClear) {
|
||||
onPendingClear()
|
||||
}
|
||||
prevTurnCountRef.current = turnCount
|
||||
}, [turnCount, onPendingClear, conversationId])
|
||||
|
||||
const { setSessionStats } = useSessionStats()
|
||||
const sessionStats = detail?.session_stats ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setSessionStats(sessionStats)
|
||||
}
|
||||
}, [isActive, sessionStats, setSessionStats])
|
||||
|
||||
// Track whether the initial scroll has happened.
|
||||
// After that, disable resize-triggered scroll so tab switches
|
||||
// don't jump to the bottom.
|
||||
const shouldUseSmoothResize = !(isActive && !loading && detail)
|
||||
|
||||
const messages = useMemo(
|
||||
() => (detail ? adaptMessageTurns(detail.turns) : []),
|
||||
[detail]
|
||||
)
|
||||
|
||||
const groups = useMemo(() => groupAdaptedMessages(messages), [messages])
|
||||
const historicalPlanEntries = useMemo(
|
||||
() => extractLatestPlanEntriesFromMessages(messages),
|
||||
[messages]
|
||||
)
|
||||
const historicalPlanKey = useMemo(
|
||||
() => buildPlanKey(historicalPlanEntries),
|
||||
[historicalPlanEntries]
|
||||
)
|
||||
|
||||
const pendingGroups = useMemo(
|
||||
() =>
|
||||
pendingMessages?.length ? groupAdaptedMessages(pendingMessages) : [],
|
||||
[pendingMessages]
|
||||
)
|
||||
const resolvedGroups = useMemo(
|
||||
() => groups.map(resolveMessageGroup),
|
||||
[groups]
|
||||
)
|
||||
const resolvedPendingGroups = useMemo(
|
||||
() => pendingGroups.map(resolveMessageGroup),
|
||||
[pendingGroups]
|
||||
)
|
||||
|
||||
const showLiveMessage = Boolean(
|
||||
liveMessage &&
|
||||
(connStatus === "prompting" ||
|
||||
(liveMessage.content.length > 0 && resolvedPendingGroups.length > 0))
|
||||
)
|
||||
const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}`
|
||||
|
||||
if (loading && !detail) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-destructive text-sm">Error: {error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!detail) return null
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col">
|
||||
{/* Messages */}
|
||||
<MessageThread
|
||||
className="flex-1 min-h-0"
|
||||
resize={shouldUseSmoothResize ? "smooth" : undefined}
|
||||
>
|
||||
<MessageThreadContent className="p-4 max-w-3xl mx-auto">
|
||||
{resolvedGroups.length === 0 &&
|
||||
resolvedPendingGroups.length === 0 &&
|
||||
!showLiveMessage ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No messages in this conversation.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{resolvedGroups.map((group) => (
|
||||
<HistoricalMessageGroup key={group.id} group={group} />
|
||||
))}
|
||||
{resolvedPendingGroups.map((group) => (
|
||||
<PendingMessageGroup key={group.id} group={group} />
|
||||
))}
|
||||
{resolvedPendingGroups.length > 0 && !showLiveMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_infinite]" />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_0.2s_infinite]" />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_0.4s_infinite]" />
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
{showLiveMessage && liveMessage && (
|
||||
<LiveMessageBlock message={liveMessage} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MessageThreadContent>
|
||||
</MessageThread>
|
||||
{showLiveMessage && liveMessage && (
|
||||
<LiveTurnStats message={liveMessage} />
|
||||
)}
|
||||
<AgentPlanOverlay
|
||||
key={agentPlanOverlayKey}
|
||||
message={liveMessage ?? null}
|
||||
entries={historicalPlanEntries}
|
||||
planKey={historicalPlanKey}
|
||||
defaultExpanded={showLiveMessage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronRight, ChevronDown, Wrench, AlertCircle } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ToolCallBlockProps {
|
||||
type: "tool_use" | "tool_result"
|
||||
toolName?: string
|
||||
content: string | null
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export function ToolCallBlock({
|
||||
type,
|
||||
toolName,
|
||||
content,
|
||||
isError = false,
|
||||
}: ToolCallBlockProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border rounded-md text-xs",
|
||||
isError
|
||||
? "border-destructive/30 bg-destructive/5"
|
||||
: "border-border bg-muted/30"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-left hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{type === "tool_use" ? (
|
||||
<>
|
||||
<Wrench className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{toolName || "Tool"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isError ? (
|
||||
<AlertCircle className="h-3 w-3 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<Wrench className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium">{isError ? "Error" : "Result"}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{expanded && content && (
|
||||
<div className="px-3 pb-2 border-t border-border">
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all mt-2 max-h-64 overflow-auto">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TurnUsage } from "@/lib/types"
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`
|
||||
return `${(ms / 1_000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
interface TurnStatsProps {
|
||||
usage?: TurnUsage | null
|
||||
duration_ms?: number | null
|
||||
model?: string | null
|
||||
models?: string[]
|
||||
}
|
||||
|
||||
export function TurnStats({
|
||||
usage,
|
||||
duration_ms,
|
||||
model,
|
||||
models,
|
||||
}: TurnStatsProps) {
|
||||
if (!usage && !duration_ms) return null
|
||||
|
||||
const displayModels = models?.length ? models : model ? [model] : []
|
||||
|
||||
const parts: string[] = []
|
||||
if (displayModels.length > 0) parts.push(displayModels.join(", "))
|
||||
if (usage) {
|
||||
parts.push(`${formatTokenCount(usage.input_tokens)} input`)
|
||||
parts.push(`${formatTokenCount(usage.output_tokens)} output`)
|
||||
if (usage.cache_read_input_tokens > 0)
|
||||
parts.push(
|
||||
`${formatTokenCount(usage.cache_read_input_tokens)} cache read`
|
||||
)
|
||||
if (usage.cache_creation_input_tokens > 0)
|
||||
parts.push(
|
||||
`${formatTokenCount(usage.cache_creation_input_tokens)} cache write`
|
||||
)
|
||||
}
|
||||
if (duration_ms) parts.push(formatDuration(duration_ms))
|
||||
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{"[ " + parts.join(" · ") + " ]"}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { FileSearch } from "lucide-react"
|
||||
import type { UserResourceDisplay } from "@/lib/adapters/ai-elements-adapter"
|
||||
|
||||
interface UserResourceLinksProps {
|
||||
resources: UserResourceDisplay[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function UserResourceLinks({
|
||||
resources,
|
||||
className,
|
||||
}: UserResourceLinksProps) {
|
||||
if (resources.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{resources.map((resource, index) => (
|
||||
<div
|
||||
key={`${resource.uri}-${index}`}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<FileSearch className="h-3 w-3" />
|
||||
<span className="max-w-56 truncate">{resource.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { Monitor, Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
type ThemeMode = "system" | "light" | "dark"
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { theme, resolvedTheme, setTheme } = useTheme()
|
||||
const resolvedThemeLabel =
|
||||
resolvedTheme === "dark"
|
||||
? "深色"
|
||||
: resolvedTheme === "light"
|
||||
? "浅色"
|
||||
: "--"
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="w-full space-y-4">
|
||||
<section className="rounded-xl border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">主题外观</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
选择浅色、深色或跟随系统主题,设置会自动保存。
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
主题模式
|
||||
</label>
|
||||
<Select
|
||||
value={theme ?? "system"}
|
||||
onValueChange={(value) => setTheme(value as ThemeMode)}
|
||||
>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="请选择主题模式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="system">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
跟随系统
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="light">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
浅色
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
深色
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
className="text-[11px] text-muted-foreground"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
当前生效主题:{resolvedThemeLabel}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, type ComponentType, type ReactNode } from "react"
|
||||
import {
|
||||
Bot,
|
||||
BookOpenText,
|
||||
Keyboard,
|
||||
Palette,
|
||||
PlugZap,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
|
||||
interface SettingsNavItem {
|
||||
href: string
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
{
|
||||
href: "/settings/appearance",
|
||||
label: "Appearance",
|
||||
icon: Palette,
|
||||
},
|
||||
{
|
||||
href: "/settings/agents",
|
||||
label: "Agents",
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
href: "/settings/mcp",
|
||||
label: "MCP",
|
||||
icon: PlugZap,
|
||||
},
|
||||
{
|
||||
href: "/settings/skills",
|
||||
label: "Skills",
|
||||
icon: BookOpenText,
|
||||
},
|
||||
{
|
||||
href: "/settings/shortcuts",
|
||||
label: "Shortcuts",
|
||||
icon: Keyboard,
|
||||
},
|
||||
{
|
||||
href: "/settings/system",
|
||||
label: "System",
|
||||
icon: Settings,
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsShellProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
const noSuffix = path.replace(/\/index\.html$/, "").replace(/\.html$/, "")
|
||||
const noTrailingSlash = noSuffix.replace(/\/+$/, "")
|
||||
return noTrailingSlash || "/"
|
||||
}
|
||||
|
||||
function isWindowsRuntime(): boolean {
|
||||
if (typeof navigator === "undefined") return false
|
||||
const platform = navigator.platform.toLowerCase()
|
||||
const userAgent = navigator.userAgent.toLowerCase()
|
||||
return platform.includes("win") || userAgent.includes("windows")
|
||||
}
|
||||
|
||||
export function SettingsShell({ children }: SettingsShellProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const normalizedPathname = normalizePath(pathname)
|
||||
|
||||
const navigateTo = useCallback(
|
||||
(href: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const target = normalizePath(href)
|
||||
const current = normalizePath(window.location.pathname)
|
||||
if (current === target) return
|
||||
|
||||
if (isWindowsRuntime()) {
|
||||
// WebView2 on Windows: hard navigation is more reliable than client routing.
|
||||
window.location.assign(target)
|
||||
return
|
||||
}
|
||||
|
||||
// macOS/Linux: keep client-side routing for snappier transitions.
|
||||
router.push(target)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTitleBar
|
||||
center={
|
||||
<div className="text-sm font-bold tracking-tight">Settings</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
<aside className="w-56 shrink-0 border-r p-3">
|
||||
<div className="px-1 pb-2 text-[11px] font-medium text-muted-foreground">
|
||||
Preferences
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{SETTINGS_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active =
|
||||
normalizedPathname === item.href ||
|
||||
normalizedPathname.startsWith(`${item.href}/`)
|
||||
return (
|
||||
<Button
|
||||
key={item.href}
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("w-full justify-start")}
|
||||
type="button"
|
||||
onClick={() => navigateTo(item.href)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section className="flex-1 min-w-0 min-h-0 p-4">{children}</section>
|
||||
</div>
|
||||
<AppToaster position="bottom-right" closeButton duration={4000} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Keyboard, RotateCcw } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useIsMac } from "@/hooks/use-is-mac"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
import {
|
||||
DEFAULT_SHORTCUTS,
|
||||
SHORTCUT_DEFINITIONS,
|
||||
type ShortcutActionId,
|
||||
formatShortcutLabel,
|
||||
shortcutFromKeyboardEvent,
|
||||
} from "@/lib/keyboard-shortcuts"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const SHARED_SHORTCUT_PAIRS: Array<[ShortcutActionId, ShortcutActionId]> = [
|
||||
["new_terminal_tab", "new_conversation"],
|
||||
["close_current_terminal_tab", "close_current_tab"],
|
||||
]
|
||||
|
||||
function canShareShortcut(a: ShortcutActionId, b: ShortcutActionId): boolean {
|
||||
return SHARED_SHORTCUT_PAIRS.some(
|
||||
([left, right]) =>
|
||||
(left === a && right === b) || (left === b && right === a)
|
||||
)
|
||||
}
|
||||
|
||||
export function ShortcutSettings() {
|
||||
const { shortcuts, updateShortcut, resetShortcuts } = useShortcutSettings()
|
||||
const isMac = useIsMac()
|
||||
const [recordingAction, setRecordingAction] =
|
||||
useState<ShortcutActionId | null>(null)
|
||||
|
||||
const isDefault = useMemo(
|
||||
() =>
|
||||
SHORTCUT_DEFINITIONS.every(
|
||||
(definition) =>
|
||||
shortcuts[definition.id] === DEFAULT_SHORTCUTS[definition.id]
|
||||
),
|
||||
[shortcuts]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordingAction) return
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.key === "Escape") {
|
||||
setRecordingAction(null)
|
||||
return
|
||||
}
|
||||
|
||||
const shortcut = shortcutFromKeyboardEvent(event)
|
||||
if (!shortcut) return
|
||||
|
||||
const conflict = SHORTCUT_DEFINITIONS.find(
|
||||
(definition) =>
|
||||
definition.id !== recordingAction &&
|
||||
!canShareShortcut(definition.id, recordingAction) &&
|
||||
shortcuts[definition.id] === shortcut
|
||||
)
|
||||
|
||||
if (conflict) {
|
||||
toast.error(`快捷键已被「${conflict.title}」占用`)
|
||||
return
|
||||
}
|
||||
|
||||
if (updateShortcut(recordingAction, shortcut)) {
|
||||
toast.success("快捷键已更新")
|
||||
} else {
|
||||
toast.error("快捷键无效,请重试")
|
||||
}
|
||||
|
||||
setRecordingAction(null)
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown, true)
|
||||
}
|
||||
}, [recordingAction, shortcuts, updateShortcut])
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="w-full space-y-4">
|
||||
<section className="rounded-xl border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">快捷键</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
resetShortcuts()
|
||||
setRecordingAction(null)
|
||||
toast.success("已恢复默认快捷键")
|
||||
}}
|
||||
disabled={isDefault}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
点击右侧按钮后按下组合键即可修改。建议使用 Ctrl/Cmd、Alt、Shift
|
||||
的组合。按 Esc 可取消录制。
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{SHORTCUT_DEFINITIONS.map((definition) => {
|
||||
const isRecording = recordingAction === definition.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={definition.id}
|
||||
className="rounded-lg border px-3 py-2 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">
|
||||
{definition.title}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{definition.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={isRecording ? "default" : "secondary"}
|
||||
size="sm"
|
||||
className="font-mono min-w-36 justify-center"
|
||||
onClick={() => {
|
||||
setRecordingAction((previous) =>
|
||||
previous === definition.id ? null : definition.id
|
||||
)
|
||||
}}
|
||||
>
|
||||
{isRecording
|
||||
? "按下快捷键..."
|
||||
: formatShortcutLabel(shortcuts[definition.id], isMac)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { ArrowUpCircle, Loader2, RefreshCw, Save, Wifi } from "lucide-react"
|
||||
import type { Update } from "@tauri-apps/plugin-updater"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { getSystemProxySettings, updateSystemProxySettings } from "@/lib/tauri"
|
||||
import {
|
||||
checkAppUpdate,
|
||||
closeAppUpdate,
|
||||
getCurrentAppVersion,
|
||||
installAppUpdate,
|
||||
relaunchApp,
|
||||
} from "@/lib/updater"
|
||||
|
||||
const PROXY_EXAMPLE = "http://127.0.0.1:7890"
|
||||
|
||||
export function SystemNetworkSettings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [proxyUrl, setProxyUrl] = useState("")
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [currentVersion, setCurrentVersion] = useState<string>("")
|
||||
const [availableUpdate, setAvailableUpdate] = useState<Update | null>(null)
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false)
|
||||
const [installingUpdate, setInstallingUpdate] = useState(false)
|
||||
const [updateError, setUpdateError] = useState<string | null>(null)
|
||||
const [lastCheckedAt, setLastCheckedAt] = useState<Date | null>(null)
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
|
||||
try {
|
||||
const settings = await getSystemProxySettings()
|
||||
setEnabled(settings.enabled)
|
||||
setProxyUrl(settings.proxy_url ?? "")
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setLoadError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadAppVersion = useCallback(async () => {
|
||||
try {
|
||||
const version = await getCurrentAppVersion()
|
||||
setCurrentVersion(version)
|
||||
} catch (err) {
|
||||
console.error("[Settings] load app version failed:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings().catch((err) => {
|
||||
console.error("[Settings] load system proxy settings failed:", err)
|
||||
})
|
||||
loadAppVersion().catch((err) => {
|
||||
console.error("[Settings] load app version failed:", err)
|
||||
})
|
||||
}, [loadSettings, loadAppVersion])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!availableUpdate) return
|
||||
closeAppUpdate(availableUpdate).catch((err) => {
|
||||
console.error("[Settings] release updater resource failed:", err)
|
||||
})
|
||||
}
|
||||
}, [availableUpdate])
|
||||
|
||||
const saveSettings = useCallback(async () => {
|
||||
if (enabled && !proxyUrl.trim()) {
|
||||
toast.error("启用代理时必须填写代理地址")
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const next = await updateSystemProxySettings({
|
||||
enabled,
|
||||
proxy_url: proxyUrl.trim() || null,
|
||||
})
|
||||
setEnabled(next.enabled)
|
||||
setProxyUrl(next.proxy_url ?? "")
|
||||
toast.success("系统代理设置已保存")
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(`保存失败:${message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [enabled, proxyUrl])
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
setCheckingUpdate(true)
|
||||
setUpdateError(null)
|
||||
|
||||
try {
|
||||
const previousUpdate = availableUpdate
|
||||
const result = await checkAppUpdate()
|
||||
setCurrentVersion(result.currentVersion)
|
||||
setLastCheckedAt(new Date())
|
||||
|
||||
if (result.update) {
|
||||
setAvailableUpdate(result.update)
|
||||
toast.success(`发现新版本 v${result.update.version}`)
|
||||
} else {
|
||||
setAvailableUpdate(null)
|
||||
toast.success("当前已经是最新版本")
|
||||
}
|
||||
|
||||
if (previousUpdate && previousUpdate !== result.update) {
|
||||
await closeAppUpdate(previousUpdate)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setUpdateError(message)
|
||||
toast.error(`检查更新失败:${message}`)
|
||||
} finally {
|
||||
setCheckingUpdate(false)
|
||||
}
|
||||
}, [availableUpdate])
|
||||
|
||||
const installUpdate = useCallback(async () => {
|
||||
if (!availableUpdate) return
|
||||
|
||||
setInstallingUpdate(true)
|
||||
setUpdateError(null)
|
||||
|
||||
try {
|
||||
await installAppUpdate(availableUpdate)
|
||||
toast.success("升级包已安装,正在重启应用")
|
||||
await relaunchApp()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setUpdateError(message)
|
||||
toast.error(`升级失败:${message}`)
|
||||
} finally {
|
||||
setInstallingUpdate(false)
|
||||
}
|
||||
}, [availableUpdate])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="w-full space-y-4">
|
||||
<section className="space-y-1">
|
||||
<h1 className="text-sm font-semibold">系统管理</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
管理网络代理和应用版本升级。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">网络代理</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
开启后,后续网络请求将优先走该代理(包括 ACP 对话、Agent 安装、 Git
|
||||
远程操作等)。
|
||||
</p>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||
加载失败:{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
/>
|
||||
启用系统代理
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
代理地址
|
||||
</label>
|
||||
<Input
|
||||
value={proxyUrl}
|
||||
onChange={(event) => setProxyUrl(event.target.value)}
|
||||
placeholder={PROXY_EXAMPLE}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
支持 http(s)/socks5,示例:{PROXY_EXAMPLE}
|
||||
。仅在启用系统代理时生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={saveSettings} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
保存
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">应用升级</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
点击检查后会从配置的发布源拉取最新版本信息,有新版本时可直接下载并安装。
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">当前版本</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{currentVersion ? `v${currentVersion}` : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">可升级版本</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{availableUpdate ? `v${availableUpdate.version}` : "暂无"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastCheckedAt && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
上次检查:{lastCheckedAt.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||
更新异常:{updateError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={checkForUpdates}
|
||||
disabled={checkingUpdate || installingUpdate}
|
||||
>
|
||||
{checkingUpdate ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
检查中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
检查更新
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{availableUpdate && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={installUpdate}
|
||||
disabled={installingUpdate || checkingUpdate}
|
||||
>
|
||||
{installingUpdate ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
升级中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||
升级到 v{availableUpdate.version}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Reorder } from "motion/react"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||
import { TabItem } from "./tab-item"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function TabBar() {
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
switchTab,
|
||||
closeTab,
|
||||
closeOtherTabs,
|
||||
closeAllTabs,
|
||||
pinTab,
|
||||
reorderTabs,
|
||||
} = useTabContext()
|
||||
const { mode, activePane } = useWorkspaceContext()
|
||||
const { shortcuts } = useShortcutSettings()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (e.deltaY !== 0 && scrollRef.current) {
|
||||
e.preventDefault()
|
||||
scrollRef.current.scrollLeft += e.deltaY
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !scrollRef.current) return
|
||||
const el = scrollRef.current.querySelector(`[data-tab-id="${activeTabId}"]`)
|
||||
el?.scrollIntoView({ block: "nearest", inline: "nearest" })
|
||||
}, [activeTabId])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const shouldHandleShortcut =
|
||||
mode === "conversation" ||
|
||||
(mode === "fusion" && activePane === "conversation")
|
||||
if (!shouldHandleShortcut) return
|
||||
if (!matchShortcutEvent(event, shortcuts.close_current_tab)) return
|
||||
if (!activeTabId) return
|
||||
|
||||
event.preventDefault()
|
||||
closeTab(activeTabId)
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown)
|
||||
}
|
||||
}, [activePane, activeTabId, closeTab, mode, shortcuts.close_current_tab])
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
return (
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
ref={scrollRef}
|
||||
role="tablist"
|
||||
axis="x"
|
||||
values={tabs}
|
||||
onReorder={reorderTabs}
|
||||
onWheel={handleWheel}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={cn(
|
||||
"h-10 pt-1.5 px-1.5 flex items-stretch gap-1.5 border-b border-border",
|
||||
"overflow-x-scroll",
|
||||
isHovered
|
||||
? [
|
||||
"pb-0.5",
|
||||
"[&::-webkit-scrollbar]:h-1",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-border",
|
||||
]
|
||||
: ["pb-1.5", "[&::-webkit-scrollbar]:h-0"]
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
onSwitch={switchTab}
|
||||
onClose={closeTab}
|
||||
onCloseOthers={closeOtherTabs}
|
||||
onCloseAll={closeAllTabs}
|
||||
onPin={pinTab}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useCallback, useRef } from "react"
|
||||
import { Reorder } from "motion/react"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { STATUS_COLORS } from "@/lib/types"
|
||||
import type { ConversationStatus } from "@/lib/types"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import type { TabItem as TabItemData } from "@/contexts/tab-context"
|
||||
|
||||
interface TabItemProps {
|
||||
tab: TabItemData
|
||||
isActive: boolean
|
||||
onSwitch: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onCloseOthers: (tabId: string) => void
|
||||
onCloseAll: () => void
|
||||
onPin: (tabId: string) => void
|
||||
}
|
||||
|
||||
export const TabItem = memo(function TabItem({
|
||||
tab,
|
||||
isActive,
|
||||
onSwitch,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseAll,
|
||||
onPin,
|
||||
}: TabItemProps) {
|
||||
const isDragging = useRef(false)
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const clearResidualStyles = useCallback(() => {
|
||||
const el = itemRef.current
|
||||
if (!el) return
|
||||
el.style.transform = ""
|
||||
el.style.zIndex = ""
|
||||
el.style.position = ""
|
||||
el.style.userSelect = ""
|
||||
}, [])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isDragging.current) return
|
||||
onSwitch(tab.id)
|
||||
}, [onSwitch, tab.id])
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
if (isDragging.current) return
|
||||
if (!tab.isPinned) {
|
||||
onPin(tab.id)
|
||||
}
|
||||
}, [onPin, tab.id, tab.isPinned])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose(tab.id)
|
||||
}, [onClose, tab.id])
|
||||
|
||||
const handleCloseOthers = useCallback(() => {
|
||||
onCloseOthers(tab.id)
|
||||
}, [onCloseOthers, tab.id])
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
ref={itemRef}
|
||||
as="div"
|
||||
value={tab}
|
||||
data-tab-id={tab.id}
|
||||
onDragStart={() => {
|
||||
isDragging.current = true
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setTimeout(() => {
|
||||
isDragging.current = false
|
||||
clearResidualStyles()
|
||||
}, 200)
|
||||
}}
|
||||
onLayoutAnimationComplete={clearResidualStyles}
|
||||
className="shrink-0 rounded-full cursor-grab active:cursor-grabbing active:opacity-90 active:shadow-md active:z-50"
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={cn(
|
||||
"group/tab relative flex items-center h-full gap-1.5 px-3 text-xs rounded-full",
|
||||
"cursor-pointer select-none shrink-0",
|
||||
"hover:bg-primary/8 transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
tab.status
|
||||
? STATUS_COLORS[tab.status as ConversationStatus]
|
||||
: "bg-gray-400 dark:bg-gray-500"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate max-w-[140px]",
|
||||
!tab.isPinned && "[font-style:oblique]"
|
||||
)}
|
||||
title={tab.title}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-full p-0.5 hover:bg-muted",
|
||||
isActive
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/tab:opacity-100"
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleClose()
|
||||
}}
|
||||
aria-label="Close conversation tab"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={handleClose}>关闭</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={handleCloseOthers}>
|
||||
关闭其它
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={onCloseAll}>关闭所有</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</Reorder.Item>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { useTerminalContext } from "@/contexts/terminal-context"
|
||||
import { TerminalTabBar } from "./terminal-tab-bar"
|
||||
import { TerminalView } from "./terminal-view"
|
||||
|
||||
export function TerminalPanel() {
|
||||
const { isOpen, tabs, activeTabId } = useTerminalContext()
|
||||
|
||||
return (
|
||||
<section
|
||||
data-terminal-panel-region="true"
|
||||
className="flex h-full min-h-0 flex-col bg-background"
|
||||
>
|
||||
<TerminalTabBar />
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
{tabs.map((tab) => (
|
||||
<TerminalView
|
||||
key={tab.id}
|
||||
terminalId={tab.id}
|
||||
isActive={tab.id === activeTabId}
|
||||
isVisible={isOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { Minus, Plus, X } from "lucide-react"
|
||||
import { useTerminalContext } from "@/contexts/terminal-context"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
|
||||
export function TerminalTabBar() {
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
switchTerminal,
|
||||
closeTerminal,
|
||||
closeOtherTerminals,
|
||||
closeAllTerminals,
|
||||
renameTerminal,
|
||||
createTerminal,
|
||||
toggle,
|
||||
} = useTerminalContext()
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState("")
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const startRename = (id: string, title: string) => {
|
||||
setEditingId(id)
|
||||
setEditValue(title)
|
||||
setTimeout(() => inputRef.current?.select(), 0)
|
||||
}
|
||||
|
||||
const commitRename = () => {
|
||||
if (editingId && editValue.trim()) {
|
||||
renameTerminal(editingId, editValue.trim())
|
||||
}
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-8 bg-muted/50 border-b gap-0.5 px-1 shrink-0">
|
||||
{tabs.map((tab) => (
|
||||
<ContextMenu key={tab.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={`flex items-center gap-1 h-6 px-2 rounded-sm text-xs cursor-pointer select-none ${
|
||||
tab.id === activeTabId
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
onClick={() => switchTerminal(tab.id)}
|
||||
>
|
||||
{editingId === tab.id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="bg-transparent outline-none border border-primary/50 rounded px-0.5 w-20 text-xs"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename()
|
||||
if (e.key === "Escape") setEditingId(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[120px]">{tab.title}</span>
|
||||
)}
|
||||
<button
|
||||
className="ml-1 rounded-sm hover:bg-muted-foreground/20 p-0.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTerminal(tab.id)
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => startRename(tab.id, tab.title)}>
|
||||
重命名
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => closeTerminal(tab.id)}>
|
||||
关闭
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => closeOtherTerminals(tab.id)}
|
||||
disabled={tabs.length <= 1}
|
||||
>
|
||||
关闭其它
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => closeAllTerminals()}>
|
||||
关闭所有
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => createTerminal()}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 ml-auto"
|
||||
onClick={toggle}
|
||||
title="Hide Terminal (Ctrl+J)"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { terminalWrite, terminalResize } from "@/lib/tauri"
|
||||
import type { TerminalEvent } from "@/lib/types"
|
||||
import type { ITheme } from "@xterm/xterm"
|
||||
|
||||
const DARK_THEME: ITheme = {
|
||||
background: "#1a1a1a",
|
||||
foreground: "#e0e0e0",
|
||||
cursor: "#e0e0e0",
|
||||
cursorAccent: "#1a1a1a",
|
||||
selectionBackground: "#444444",
|
||||
black: "#1a1a1a",
|
||||
red: "#f87171",
|
||||
green: "#4ade80",
|
||||
yellow: "#facc15",
|
||||
blue: "#60a5fa",
|
||||
magenta: "#c084fc",
|
||||
cyan: "#22d3ee",
|
||||
white: "#e0e0e0",
|
||||
brightBlack: "#737373",
|
||||
brightRed: "#fca5a5",
|
||||
brightGreen: "#86efac",
|
||||
brightYellow: "#fde68a",
|
||||
brightBlue: "#93c5fd",
|
||||
brightMagenta: "#d8b4fe",
|
||||
brightCyan: "#67e8f9",
|
||||
brightWhite: "#ffffff",
|
||||
}
|
||||
|
||||
const LIGHT_THEME: ITheme = {
|
||||
background: "#ffffff",
|
||||
foreground: "#1a1a1a",
|
||||
cursor: "#1a1a1a",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "#b4d5fe",
|
||||
black: "#1a1a1a",
|
||||
red: "#dc2626",
|
||||
green: "#16a34a",
|
||||
yellow: "#ca8a04",
|
||||
blue: "#2563eb",
|
||||
magenta: "#9333ea",
|
||||
cyan: "#0891b2",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#a3a3a3",
|
||||
brightRed: "#ef4444",
|
||||
brightGreen: "#22c55e",
|
||||
brightYellow: "#eab308",
|
||||
brightBlue: "#3b82f6",
|
||||
brightMagenta: "#a855f7",
|
||||
brightCyan: "#06b6d4",
|
||||
brightWhite: "#ffffff",
|
||||
}
|
||||
|
||||
function isDarkMode() {
|
||||
return document.documentElement.classList.contains("dark")
|
||||
}
|
||||
|
||||
function resolveBackgroundColor(
|
||||
element: HTMLElement | null | undefined
|
||||
): string | null {
|
||||
let current = element
|
||||
while (current) {
|
||||
const color = getComputedStyle(current).backgroundColor
|
||||
if (color && color !== "transparent" && color !== "rgba(0, 0, 0, 0)") {
|
||||
return color
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getTerminalTheme(container: HTMLDivElement | null): ITheme {
|
||||
const baseTheme = isDarkMode() ? DARK_THEME : LIGHT_THEME
|
||||
const background = resolveBackgroundColor(container)
|
||||
if (!background) return baseTheme
|
||||
|
||||
return {
|
||||
...baseTheme,
|
||||
background,
|
||||
cursorAccent: background,
|
||||
}
|
||||
}
|
||||
|
||||
interface TerminalViewProps {
|
||||
terminalId: string
|
||||
isActive: boolean
|
||||
isVisible: boolean
|
||||
}
|
||||
|
||||
export function TerminalView({
|
||||
terminalId,
|
||||
isActive,
|
||||
isVisible,
|
||||
}: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const fitAddonRef = useRef<{ fit: () => void } | null>(null)
|
||||
const termRef = useRef<{ focus: () => void } | null>(null)
|
||||
const lastResizeRef = useRef<{ cols: number; rows: number } | null>(null)
|
||||
const isActiveRef = useRef(isActive)
|
||||
const isVisibleRef = useRef(isVisible)
|
||||
|
||||
useEffect(() => {
|
||||
isActiveRef.current = isActive
|
||||
isVisibleRef.current = isVisible
|
||||
}, [isActive, isVisible])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let cleanup: (() => void) | undefined
|
||||
|
||||
async function init() {
|
||||
const { Terminal } = await import("@xterm/xterm")
|
||||
const { FitAddon } = await import("@xterm/addon-fit")
|
||||
const { WebLinksAddon } = await import("@xterm/addon-web-links")
|
||||
|
||||
if (cancelled || !containerRef.current) return
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
||||
theme: getTerminalTheme(containerRef.current),
|
||||
allowProposedApi: true,
|
||||
})
|
||||
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.open(containerRef.current)
|
||||
|
||||
fitAddonRef.current = fitAddon
|
||||
termRef.current = term
|
||||
|
||||
// Watch <html> class changes for theme switching
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
term.options.theme = getTerminalTheme(containerRef.current)
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
})
|
||||
|
||||
// Send input to PTY
|
||||
const onDataDisposable = term.onData((data: string) => {
|
||||
// Some apps toggle focus reporting; don't leak focus in/out sequences
|
||||
// into the shell prompt when tabs are switched.
|
||||
if (data === "\x1b[I" || data === "\x1b[O") return
|
||||
terminalWrite(terminalId, data).catch(() => {})
|
||||
})
|
||||
|
||||
// Debounced resize — avoid flooding IPC during drag
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const onResizeDisposable = term.onResize(
|
||||
({ cols, rows }: { cols: number; rows: number }) => {
|
||||
const last = lastResizeRef.current
|
||||
if (last && last.cols === cols && last.rows === rows) return
|
||||
lastResizeRef.current = { cols, rows }
|
||||
if (resizeTimer) clearTimeout(resizeTimer)
|
||||
resizeTimer = setTimeout(() => {
|
||||
terminalResize(terminalId, cols, rows).catch(() => {})
|
||||
}, 50)
|
||||
}
|
||||
)
|
||||
|
||||
// Set up event listeners BEFORE fit so initial output is captured
|
||||
const unlisten = await listen<TerminalEvent>(
|
||||
`terminal://output/${terminalId}`,
|
||||
(event) => {
|
||||
term.write(event.payload.data)
|
||||
}
|
||||
)
|
||||
|
||||
const unlistenExit = await listen<TerminalEvent>(
|
||||
`terminal://exit/${terminalId}`,
|
||||
() => {
|
||||
term.write("\r\n\x1b[90m[Process exited]\x1b[0m\r\n")
|
||||
}
|
||||
)
|
||||
|
||||
if (cancelled) {
|
||||
themeObserver.disconnect()
|
||||
onDataDisposable.dispose()
|
||||
onResizeDisposable.dispose()
|
||||
unlisten()
|
||||
unlistenExit()
|
||||
term.dispose()
|
||||
return
|
||||
}
|
||||
|
||||
const fitIfReady = () => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
if (!isActiveRef.current || !isVisibleRef.current) return
|
||||
if (el.clientWidth <= 0 || el.clientHeight <= 0) return
|
||||
fitAddon.fit()
|
||||
}
|
||||
|
||||
// Only fit when terminal is actually visible/active.
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) fitIfReady()
|
||||
})
|
||||
|
||||
// Debounced fit on container resize while active
|
||||
let fitTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (fitTimer) clearTimeout(fitTimer)
|
||||
fitTimer = setTimeout(() => {
|
||||
fitIfReady()
|
||||
}, 30)
|
||||
})
|
||||
resizeObserver.observe(containerRef.current)
|
||||
|
||||
cleanup = () => {
|
||||
if (resizeTimer) clearTimeout(resizeTimer)
|
||||
if (fitTimer) clearTimeout(fitTimer)
|
||||
themeObserver.disconnect()
|
||||
onDataDisposable.dispose()
|
||||
onResizeDisposable.dispose()
|
||||
unlisten()
|
||||
unlistenExit()
|
||||
resizeObserver.disconnect()
|
||||
term.dispose()
|
||||
fitAddonRef.current = null
|
||||
termRef.current = null
|
||||
lastResizeRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
cleanup?.()
|
||||
}
|
||||
}, [terminalId])
|
||||
|
||||
// Refit and focus when becoming active or panel becomes visible
|
||||
useEffect(() => {
|
||||
if (isActive && isVisible) {
|
||||
requestAnimationFrame(() => {
|
||||
const el = containerRef.current
|
||||
if (el && el.clientWidth > 0 && el.clientHeight > 0) {
|
||||
fitAddonRef.current?.fit()
|
||||
}
|
||||
termRef.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [isActive, isVisible])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 h-full w-full p-2"
|
||||
style={{
|
||||
visibility: isActive ? "visible" : "hidden",
|
||||
pointerEvents: isActive ? "auto" : "none",
|
||||
}}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import type { ThemeProviderProps } from "next-themes"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/5 gap-6 rounded-4xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-full sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster, type ToasterProps } from "sonner"
|
||||
|
||||
export function AppToaster(props: ToasterProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const theme = props.theme ?? (resolvedTheme === "dark" ? "dark" : "light")
|
||||
|
||||
return <Toaster {...props} theme={theme} />
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"rounded-full aspect-square size-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/30",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,83 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl flex w-fit items-stretch *:focus-visible:z-10 *:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl! [&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl! flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted gap-2 rounded-4xl border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"ring-foreground/10 bg-card text-card-foreground gap-6 overflow-hidden rounded-2xl py-6 text-sm ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"gap-2 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("text-base font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4 flex items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean
|
||||
showClear?: boolean
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
asChild
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxTrigger />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 *:data-[slot=input-group]:bg-input/30 overflow-hidden rounded-2xl shadow-2xl ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 p-1 data-empty:p-0 overflow-y-auto overscroll-contain",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("text-muted-foreground px-3.5 py-2.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-4xl border bg-clip-padding px-2.5 py-1.5 text-sm transition-colors focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"bg-muted-foreground/10 text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-4xl px-2 text-xs font-medium whitespace-nowrap has-data-[slot=combobox-chip-remove]:pr-0 has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null)
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { VisuallyHidden } from "radix-ui"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & { title?: string }) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 rounded-2xl max-w-lg">
|
||||
<VisuallyHidden.Root>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</VisuallyHidden.Root>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandSeparator,
|
||||
CommandItem,
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 bg-popover text-popover-foreground min-w-36 rounded-2xl p-1 shadow-2xl ring-1 duration-100 z-50 overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive gap-2.5 rounded-xl px-3 py-2 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-2.5 rounded-xl px-3 py-2 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="ml-auto size-4"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 bg-popover text-popover-foreground min-w-36 rounded-2xl p-1 shadow-2xl ring-1 duration-100 z-50 overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSeparator,
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/5 gap-6 rounded-4xl p-6 ring-1 duration-100 fixed top-1/2 left-1/2 z-50 grid w-full max-w-md -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("grid gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 bg-popover text-popover-foreground min-w-48 rounded-2xl p-1 shadow-2xl ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl px-3 py-2 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-muted-foreground px-3 py-2.5 text-xs data-inset:pl-9.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-xl px-3 py-2 text-sm data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 bg-popover text-popover-foreground min-w-36 rounded-2xl p-1 shadow-2xl ring-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"data-[invalid=true]:text-destructive gap-3 group/field flex w-full",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:*:data-[slot=field-label]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"gap-1 group/field-content flex flex-1 flex-col leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-xl has-[>[data-slot=field]]:border *:data-[slot=field]:p-4 group/field-label peer/field-label flex w-fit leading-snug",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-destructive text-sm font-normal", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"border-input bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 h-9 rounded-4xl border transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-2xl has-data-[align=block-start]:rounded-2xl has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[textarea]:rounded-xl has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground **:data-[slot=kbd]:bg-muted-foreground/10 h-auto gap-2 py-2 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-4xl **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"pl-3 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem] order-first",
|
||||
"inline-end":
|
||||
"pr-3 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem] order-last",
|
||||
"block-start":
|
||||
"px-3 pt-3 group-has-[>input]/input-group:pt-3 [.border-b]:pb-3 order-first w-full justify-start",
|
||||
"block-end":
|
||||
"px-3 pb-3 group-has-[>input]/input-group:pb-3 [.border-t]:pt-3 order-last w-full justify-start",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"gap-2 rounded-4xl text-sm shadow-none flex items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs": "size-6 p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1 resize-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-4xl border px-3 py-1 text-base transition-colors file:h-7 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 flex flex-col gap-4 rounded-2xl p-4 text-sm shadow-2xl ring-1 duration-100 z-50 w-72 origin-(--radix-popover-content-transform-origin) outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("text-base font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
void withHandle
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative z-20 flex w-px items-center justify-center overflow-visible [--resize-handle-thickness:1px] data-[resize-handle-state=hover]:[--resize-handle-thickness:5px] data-[resize-handle-state=drag]:[--resize-handle-thickness:5px] after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 before:pointer-events-none before:absolute before:inset-y-0 before:left-1/2 before:h-full before:w-[var(--resize-handle-thickness)] before:-translate-x-1/2 before:bg-border before:transition-[width,height,background-color] before:duration-150 before:ease-out data-[resize-handle-state=hover]:before:bg-foreground/40 data-[resize-handle-state=drag]:before:bg-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:inset-x-0 data-[panel-group-direction=vertical]:after:top-1/2 data-[panel-group-direction=vertical]:after:h-3 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:before:inset-x-0 data-[panel-group-direction=vertical]:before:inset-y-auto data-[panel-group-direction=vertical]:before:top-1/2 data-[panel-group-direction=vertical]:before:h-[var(--resize-handle-thickness)] data-[panel-group-direction=vertical]:before:w-full data-[panel-group-direction=vertical]:before:-translate-y-1/2 data-[panel-group-direction=vertical]:before:translate-x-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="rounded-full bg-border relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-placeholder:text-muted-foreground bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-4xl border px-3 py-2 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 min-w-36 rounded-2xl shadow-2xl ring-1 duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn(
|
||||
"bg-border/50 -mx-1 my-1 h-px pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-muted rounded-xl animate-pulse", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block size-4 rounded-full bg-background shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"gap-2 group/tabs flex data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-4xl p-[3px] group-data-horizontal/tabs:h-9 group-data-vertical/tabs:rounded-2xl data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn(
|
||||
"text-sm flex-1 outline-none data-[state=inactive]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-xl border px-3 py-3 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-2xl px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-4xl bg-foreground text-background z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="size-2.5 rotate-45 rounded-[2px] data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import { cloneRepository, openFolderWindow } from "@/lib/tauri"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FolderOpen, Loader2 } from "lucide-react"
|
||||
|
||||
interface CloneDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
|
||||
const [url, setUrl] = useState("")
|
||||
const [targetDir, setTargetDir] = useState("")
|
||||
const [cloning, setCloning] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
if (selected) {
|
||||
setTargetDir(selected)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClone = async () => {
|
||||
if (!url || !targetDir) return
|
||||
|
||||
// Derive repo name from URL
|
||||
const repoName =
|
||||
url
|
||||
.replace(/\.git$/, "")
|
||||
.split("/")
|
||||
.pop() ?? "repo"
|
||||
const fullPath = `${targetDir}/${repoName}`
|
||||
|
||||
setCloning(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await cloneRepository(url, fullPath)
|
||||
await openFolderWindow(fullPath)
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setCloning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setUrl("")
|
||||
setTargetDir("")
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v)
|
||||
if (!v) resetForm()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clone Repository</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repo-url">Repository URL</Label>
|
||||
<Input
|
||||
id="repo-url"
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={cloning}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target-dir">Directory</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="target-dir"
|
||||
placeholder="Select target directory..."
|
||||
value={targetDir}
|
||||
onChange={(e) => setTargetDir(e.target.value)}
|
||||
disabled={cloning}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleBrowse}
|
||||
disabled={cloning}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={cloning}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClone}
|
||||
disabled={!url || !targetDir || cloning}
|
||||
>
|
||||
{cloning && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Clone
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user