add: 移除除了 desktop 以外的所有版本
This commit is contained in:
@@ -1,140 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
const getCurrentWindow = async () => {
|
||||
const m = await import("@tauri-apps/api/window")
|
||||
return m.getCurrentWindow()
|
||||
}
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { CommitWorkspace } from "@/components/layout/commit-dialog"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { getFolder } from "@/lib/api"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import type { FolderDetail } from "@/lib/types"
|
||||
import { GitCredentialProvider } from "@/contexts/git-credential-context"
|
||||
|
||||
const TOAST_DURATION_MS = 6000
|
||||
|
||||
interface FolderLoadState {
|
||||
loadedId: number | null
|
||||
folder: FolderDetail | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function CommitPageInner() {
|
||||
const t = useTranslations("CommitPage")
|
||||
const searchParams = useSearchParams()
|
||||
const [state, setState] = useState<FolderLoadState>({
|
||||
loadedId: null,
|
||||
folder: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const folderId = Number(searchParams.get("folderId") ?? "0")
|
||||
const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0
|
||||
const hasValidFolderId = normalizedFolderId > 0
|
||||
const loading = hasValidFolderId && state.loadedId !== normalizedFolderId
|
||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
||||
|
||||
const closeWindow = useCallback(async () => {
|
||||
try {
|
||||
const win = await getCurrentWindow()
|
||||
await win.close()
|
||||
} catch (err) {
|
||||
console.error("[CommitPage] failed to close window:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasValidFolderId) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
getFolder(normalizedFolderId)
|
||||
.then((detail) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
loadedId: normalizedFolderId,
|
||||
folder: detail,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
loadedId: normalizedFolderId,
|
||||
folder: null,
|
||||
error: toErrorMessage(err),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hasValidFolderId, normalizedFolderId])
|
||||
|
||||
const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title")
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${pageTitle} - codeg`
|
||||
}, [pageTitle])
|
||||
|
||||
return (
|
||||
<GitCredentialProvider>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTitleBar
|
||||
center={
|
||||
<div className="text-sm font-semibold tracking-tight">
|
||||
{t("title")}
|
||||
{hasValidFolderId && folder ? ` · ${folder.name}` : ""}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="flex-1 min-h-0 p-3">
|
||||
{!hasValidFolderId ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{t("invalidFolderId")}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("loadingRepo")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : folder ? (
|
||||
<CommitWorkspace
|
||||
folderPath={folder.path}
|
||||
folderId={folder.id}
|
||||
onCommitted={closeWindow}
|
||||
onCancel={closeWindow}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<AppToaster
|
||||
position="bottom-right"
|
||||
duration={TOAST_DURATION_MS}
|
||||
closeButton
|
||||
/>
|
||||
</div>
|
||||
</GitCredentialProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CommitPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<CommitPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { isDesktop } from "@/lib/platform"
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [token, setToken] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Login - codeg"
|
||||
}, [])
|
||||
|
||||
// Desktop users skip login entirely
|
||||
if (isDesktop()) {
|
||||
router.replace("/workspace")
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Validate token by calling a lightweight API endpoint
|
||||
const res = await fetch("/api/health", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: "{}",
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
localStorage.setItem("codeg_token", token)
|
||||
router.replace("/workspace")
|
||||
} else if (res.status === 401) {
|
||||
setError("Token 无效,请检查后重试")
|
||||
} else {
|
||||
setError(`连接失败 (HTTP ${res.status})`)
|
||||
}
|
||||
} catch {
|
||||
setError("无法连接到服务器")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="w-full max-w-sm space-y-6 px-4">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Codeg</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
输入访问 Token 以连接到桌面端
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="Access Token"
|
||||
autoFocus
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!token || loading}
|
||||
className="inline-flex h-10 w-full items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loading ? "连接中..." : "连接"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Token 可在桌面端 设置 → Web 服务 中获取
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
const getCurrentWindow = async () => {
|
||||
const m = await import("@tauri-apps/api/window")
|
||||
return m.getCurrentWindow()
|
||||
}
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { MergeWorkspace } from "@/components/merge/merge-workspace"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { getFolder } from "@/lib/api"
|
||||
import type { FolderDetail } from "@/lib/types"
|
||||
|
||||
const TOAST_DURATION_MS = 6000
|
||||
|
||||
interface FolderLoadState {
|
||||
loadedId: number | null
|
||||
folder: FolderDetail | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function MergePageInner() {
|
||||
const t = useTranslations("MergePage")
|
||||
const searchParams = useSearchParams()
|
||||
const [state, setState] = useState<FolderLoadState>({
|
||||
loadedId: null,
|
||||
folder: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const folderId = Number(searchParams.get("folderId") ?? "0")
|
||||
const operation = searchParams.get("operation") ?? "merge"
|
||||
const upstreamCommit = searchParams.get("upstreamCommit") ?? undefined
|
||||
const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0
|
||||
const hasValidFolderId = normalizedFolderId > 0
|
||||
const loading = hasValidFolderId && state.loadedId !== normalizedFolderId
|
||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
||||
|
||||
const closeWindow = useCallback(async () => {
|
||||
try {
|
||||
const win = await getCurrentWindow()
|
||||
await win.close()
|
||||
} catch (err) {
|
||||
console.error("[MergePage] failed to close window:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasValidFolderId) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
getFolder(normalizedFolderId)
|
||||
.then((detail) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
loadedId: normalizedFolderId,
|
||||
folder: detail,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
loadedId: normalizedFolderId,
|
||||
folder: null,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hasValidFolderId, normalizedFolderId])
|
||||
|
||||
const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title")
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${pageTitle} - codeg`
|
||||
}, [pageTitle])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTitleBar
|
||||
center={
|
||||
<div className="text-sm font-semibold tracking-tight">
|
||||
{t("title")}
|
||||
{hasValidFolderId && folder ? ` · ${folder.name}` : ""}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="flex-1 min-h-0 p-3">
|
||||
{!hasValidFolderId ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{t("invalidFolderId")}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("loadingRepo")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : folder ? (
|
||||
<MergeWorkspace
|
||||
folderId={normalizedFolderId}
|
||||
folderPath={folder.path}
|
||||
operation={operation}
|
||||
upstreamCommit={upstreamCommit}
|
||||
onCompleted={closeWindow}
|
||||
onAborted={closeWindow}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<AppToaster
|
||||
position="bottom-right"
|
||||
duration={TOAST_DURATION_MS}
|
||||
closeButton
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MergePage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<MergePageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { ProjectBootWorkspace } from "@/components/project-boot/project-boot-workspace"
|
||||
|
||||
function ProjectBootPageInner() {
|
||||
const t = useTranslations("ProjectBoot")
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t("title")} - codeg`
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTitleBar
|
||||
center={
|
||||
<div className="text-sm font-semibold tracking-tight">
|
||||
{t("title")}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="min-h-0 flex-1">
|
||||
<ProjectBootWorkspace />
|
||||
</main>
|
||||
|
||||
<AppToaster position="bottom-right" duration={6000} closeButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProjectBootPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ProjectBootPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { GitCredentialProvider } from "@/contexts/git-credential-context"
|
||||
|
||||
export default function PushLayout({ children }: { children: ReactNode }) {
|
||||
return <GitCredentialProvider>{children}</GitCredentialProvider>
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
const getCurrentWindow = async () => {
|
||||
const m = await import("@tauri-apps/api/window")
|
||||
return m.getCurrentWindow()
|
||||
}
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { PushWorkspace } from "@/components/layout/push-workspace"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { getFolder } from "@/lib/api"
|
||||
import type { FolderDetail } from "@/lib/types"
|
||||
|
||||
const TOAST_DURATION_MS = 6000
|
||||
|
||||
interface FolderLoadState {
|
||||
loadedId: number | null
|
||||
folder: FolderDetail | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function PushPageInner() {
|
||||
const t = useTranslations("Folder.pushWindow")
|
||||
const searchParams = useSearchParams()
|
||||
const [state, setState] = useState<FolderLoadState>({
|
||||
loadedId: null,
|
||||
folder: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const closeWindow = useCallback(async () => {
|
||||
try {
|
||||
const win = await getCurrentWindow()
|
||||
await win.close()
|
||||
} catch (err) {
|
||||
console.error("[PushPage] failed to close window:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const folderId = Number(searchParams.get("folderId") ?? "0")
|
||||
const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0
|
||||
const hasValidFolderId = normalizedFolderId > 0
|
||||
const loading = hasValidFolderId && state.loadedId !== normalizedFolderId
|
||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasValidFolderId) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
getFolder(normalizedFolderId)
|
||||
.then((detail) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
loadedId: normalizedFolderId,
|
||||
folder: detail,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
loadedId: normalizedFolderId,
|
||||
folder: null,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hasValidFolderId, normalizedFolderId])
|
||||
|
||||
const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title")
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${pageTitle} - codeg`
|
||||
}, [pageTitle])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTitleBar
|
||||
center={
|
||||
<div className="text-sm font-semibold tracking-tight">
|
||||
{t("title")}
|
||||
{hasValidFolderId && folder ? ` · ${folder.name}` : ""}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="min-h-0 flex-1">
|
||||
{!hasValidFolderId ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
Invalid folder ID
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : folder ? (
|
||||
<PushWorkspace
|
||||
folderPath={folder.path}
|
||||
folderName={folder.name}
|
||||
folderId={folder.id}
|
||||
onPushed={closeWindow}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<AppToaster
|
||||
position="bottom-right"
|
||||
duration={TOAST_DURATION_MS}
|
||||
closeButton
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PushPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<PushPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ChatChannelSettings } from "@/components/settings/chat-channel-settings"
|
||||
|
||||
export default function SettingsChatChannelsPage() {
|
||||
return <ChatChannelSettings />
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { StashWorkspace } from "@/components/layout/unstash-dialog"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { getFolder } from "@/lib/api"
|
||||
import type { FolderDetail } from "@/lib/types"
|
||||
|
||||
const TOAST_DURATION_MS = 6000
|
||||
|
||||
interface FolderLoadState {
|
||||
loadedId: number | null
|
||||
folder: FolderDetail | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function StashPageInner() {
|
||||
const t = useTranslations("Folder.branchDropdown.unstashDialog")
|
||||
const searchParams = useSearchParams()
|
||||
const [state, setState] = useState<FolderLoadState>({
|
||||
loadedId: null,
|
||||
folder: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const folderId = Number(searchParams.get("folderId") ?? "0")
|
||||
const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0
|
||||
const hasValidFolderId = normalizedFolderId > 0
|
||||
const loading = hasValidFolderId && state.loadedId !== normalizedFolderId
|
||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasValidFolderId) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
getFolder(normalizedFolderId)
|
||||
.then((detail) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
loadedId: normalizedFolderId,
|
||||
folder: detail,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
loadedId: normalizedFolderId,
|
||||
folder: null,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hasValidFolderId, normalizedFolderId])
|
||||
|
||||
const pageTitle = folder ? `${t("title")} · ${folder.name}` : t("title")
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${pageTitle} - codeg`
|
||||
}, [pageTitle])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTitleBar
|
||||
center={
|
||||
<div className="text-sm font-semibold tracking-tight">
|
||||
{t("title")}
|
||||
{hasValidFolderId && folder ? ` · ${folder.name}` : ""}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="min-h-0 flex-1">
|
||||
{!hasValidFolderId ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
Invalid folder ID
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : folder ? (
|
||||
<StashWorkspace folderPath={folder.path} />
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<AppToaster
|
||||
position="bottom-right"
|
||||
duration={TOAST_DURATION_MS}
|
||||
closeButton
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StashPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<StashPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
Loader2,
|
||||
Palette,
|
||||
Plus,
|
||||
Rocket,
|
||||
XCircle,
|
||||
} from "lucide-react"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
@@ -34,7 +33,6 @@ import { useTaskContext } from "@/contexts/task-context"
|
||||
import { useZoomLevel } from "@/hooks/use-appearance"
|
||||
import {
|
||||
importLocalConversations,
|
||||
openProjectBootWindow,
|
||||
updateConversationTitle,
|
||||
updateConversationStatus,
|
||||
updateFolderColor,
|
||||
@@ -964,14 +962,6 @@ export function SidebarConversationList({
|
||||
[openFolder]
|
||||
)
|
||||
|
||||
const handleProjectBoot = useCallback(() => {
|
||||
openProjectBootWindow().catch((err) => {
|
||||
console.error(
|
||||
"[SidebarConversationList] failed to open project boot:",
|
||||
err
|
||||
)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const showEmptyWorkspaceActions =
|
||||
folders.length === 0 && conversations.length === 0
|
||||
@@ -1016,15 +1006,6 @@ export function SidebarConversationList({
|
||||
<GitBranch className="h-3.5 w-3.5 mr-1.5" />
|
||||
{tFolderDropdown("cloneRepository")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full max-w-[14rem] justify-start"
|
||||
onClick={handleProjectBoot}
|
||||
>
|
||||
<Rocket className="h-3.5 w-3.5 mr-1.5" />
|
||||
{tFolderDropdown("projectBoot")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ContextMenu>
|
||||
@@ -1122,10 +1103,6 @@ export function SidebarConversationList({
|
||||
<GitBranch className="h-4 w-4" />
|
||||
{tFolderDropdown("cloneRepository")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={handleProjectBoot}>
|
||||
<Rocket className="h-4 w-4" />
|
||||
{tFolderDropdown("projectBoot")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
gitStatus,
|
||||
readFileForEdit,
|
||||
readFilePreview,
|
||||
openCommitWindow,
|
||||
renameFileTreeEntry,
|
||||
saveFileCopy,
|
||||
} from "@/lib/api"
|
||||
@@ -444,7 +443,6 @@ interface RenderNodeProps {
|
||||
onOpenFilePreview: (path: string) => void
|
||||
onOpenFileDiff: (path: string) => void
|
||||
onOpenDirDiff: (path: string) => void
|
||||
onOpenCommitWindow: () => void
|
||||
onRequestCompareWithBranch: (target: FileActionTarget) => void
|
||||
onRequestRollback: (target: FileActionTarget) => void
|
||||
onOpenDirInTerminal: (dirPath: string, fileName: string) => Promise<void>
|
||||
@@ -470,7 +468,6 @@ function RenderNode({
|
||||
onOpenFilePreview,
|
||||
onOpenFileDiff,
|
||||
onOpenDirDiff,
|
||||
onOpenCommitWindow,
|
||||
onRequestCompareWithBranch,
|
||||
onRequestRollback,
|
||||
onOpenDirInTerminal,
|
||||
@@ -563,12 +560,6 @@ function RenderNode({
|
||||
{t("git")}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onOpenCommitWindow()}
|
||||
disabled={isGitMenuDisabled}
|
||||
>
|
||||
{t("actions.commitCode")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onRequestAddToVcs(node)}
|
||||
disabled={
|
||||
@@ -690,7 +681,6 @@ function RenderNode({
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onOpenFileDiff={onOpenFileDiff}
|
||||
onOpenDirDiff={onOpenDirDiff}
|
||||
onOpenCommitWindow={onOpenCommitWindow}
|
||||
onRequestCompareWithBranch={onRequestCompareWithBranch}
|
||||
onRequestRollback={onRequestRollback}
|
||||
onOpenDirInTerminal={onOpenDirInTerminal}
|
||||
@@ -729,12 +719,6 @@ function RenderNode({
|
||||
{t("git")}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onOpenCommitWindow()}
|
||||
disabled={isGitMenuDisabled}
|
||||
>
|
||||
{t("actions.commitCode")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onRequestAddToVcs(node)}
|
||||
disabled={isGitMenuDisabled}
|
||||
@@ -1342,16 +1326,6 @@ export function FileTreeTab() {
|
||||
[createTerminalInDirectory, t]
|
||||
)
|
||||
|
||||
const handleOpenCommitWindow = useCallback(() => {
|
||||
if (!folder) return
|
||||
openCommitWindow(folder.id).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
toast.error(t("toasts.openCommitWindowFailed"), {
|
||||
description: message,
|
||||
})
|
||||
})
|
||||
}, [folder, t])
|
||||
|
||||
const handleRequestCreate = useCallback(
|
||||
(parentPath: string, kind: "file" | "dir") => {
|
||||
setCreateParentPath(parentPath)
|
||||
@@ -2182,7 +2156,6 @@ export function FileTreeTab() {
|
||||
mode: "overview",
|
||||
})
|
||||
}}
|
||||
onOpenCommitWindow={handleOpenCommitWindow}
|
||||
onRequestCompareWithBranch={
|
||||
handleRequestCompareWithBranch
|
||||
}
|
||||
@@ -2218,12 +2191,6 @@ export function FileTreeTab() {
|
||||
{t("git")}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => handleOpenCommitWindow()}
|
||||
disabled={!gitEnabled}
|
||||
>
|
||||
{t("actions.commitCode")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => void handleAddToVcs(rootTarget)}
|
||||
disabled={!gitEnabled}
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
gitAddFiles,
|
||||
gitRollbackFile,
|
||||
gitStatus,
|
||||
openCommitWindow,
|
||||
} from "@/lib/api"
|
||||
import { joinFsPath } from "@/lib/path-utils"
|
||||
import { emitAttachFileToSession } from "@/lib/session-attachment-events"
|
||||
@@ -561,15 +560,6 @@ export function GitChangesTab() {
|
||||
setExpandedUntrackedPaths(new Set())
|
||||
}, [allUntrackedDirectoryPaths, untrackedCanExpand])
|
||||
|
||||
const handleOpenCommitWindow = useCallback(() => {
|
||||
if (!folder) return
|
||||
openCommitWindow(folder.id).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
toast.error(t("toasts.openCommitWindowFailed"), {
|
||||
description: message,
|
||||
})
|
||||
})
|
||||
}, [folder, t])
|
||||
const handleAttachToSession = useCallback(
|
||||
(relativePath: string) => {
|
||||
if (!activeSessionTabId || !folder?.path) return
|
||||
@@ -845,13 +835,6 @@ export function GitChangesTab() {
|
||||
</FileTreeFolder>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
handleOpenCommitWindow()
|
||||
}}
|
||||
>
|
||||
{t("actions.commitCode")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
void openWorkingTreeDiff(node.path, { mode: "overview" })
|
||||
@@ -928,13 +911,6 @@ export function GitChangesTab() {
|
||||
</FileTreeFile>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
handleOpenCommitWindow()
|
||||
}}
|
||||
>
|
||||
{t("actions.commitCode")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={!canOpenCurrentFile}
|
||||
onSelect={() => {
|
||||
@@ -983,7 +959,6 @@ export function GitChangesTab() {
|
||||
[
|
||||
canAttachToSession,
|
||||
handleAttachToSession,
|
||||
handleOpenCommitWindow,
|
||||
handleRequestDelete,
|
||||
handleRequestRollback,
|
||||
openFilePreview,
|
||||
@@ -1017,13 +992,6 @@ export function GitChangesTab() {
|
||||
</FileTreeFolder>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
handleOpenCommitWindow()
|
||||
}}
|
||||
>
|
||||
{t("actions.commitCode")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
void openWorkingTreeDiff(node.path, { mode: "overview" })
|
||||
@@ -1096,13 +1064,6 @@ export function GitChangesTab() {
|
||||
</FileTreeFile>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
handleOpenCommitWindow()
|
||||
}}
|
||||
>
|
||||
{t("actions.commitCode")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
void openFilePreview(file.path)
|
||||
@@ -1155,7 +1116,6 @@ export function GitChangesTab() {
|
||||
[
|
||||
canAttachToSession,
|
||||
handleAttachToSession,
|
||||
handleOpenCommitWindow,
|
||||
handleAddToVcs,
|
||||
handleRequestDelete,
|
||||
handleRequestRollback,
|
||||
@@ -1265,13 +1225,6 @@ export function GitChangesTab() {
|
||||
</FileTreeFolder>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
handleOpenCommitWindow()
|
||||
}}
|
||||
>
|
||||
{t("actions.commitCode")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
void openWorkingTreeDiff(".", {
|
||||
@@ -1374,13 +1327,6 @@ export function GitChangesTab() {
|
||||
</FileTreeFolder>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
handleOpenCommitWindow()
|
||||
}}
|
||||
>
|
||||
{t("actions.commitCode")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
void openWorkingTreeDiff(".", {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
GitCompare,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Commit,
|
||||
@@ -88,7 +87,6 @@ import {
|
||||
gitLog,
|
||||
gitNewBranch,
|
||||
gitReset,
|
||||
openPushWindow,
|
||||
} from "@/lib/api"
|
||||
import type {
|
||||
GitBranchList,
|
||||
@@ -1385,20 +1383,6 @@ export function GitLogTab() {
|
||||
<RefreshCw className="size-3.5" />
|
||||
{tCommon("refresh")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
if (!folder) return
|
||||
openPushWindow(folder.id).catch((err) => {
|
||||
const msg = toErrorMessage(err)
|
||||
toast.error(t("toasts.openPushWindowFailed"), {
|
||||
description: msg,
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
{tCommon("push")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
@@ -1415,20 +1399,6 @@ export function GitLogTab() {
|
||||
<RefreshCw className="size-3.5" />
|
||||
{tCommon("refresh")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => {
|
||||
if (!folder) return
|
||||
openPushWindow(folder.id).catch((err) => {
|
||||
const msg = toErrorMessage(err)
|
||||
toast.error(t("toasts.openPushWindowFailed"), {
|
||||
description: msg,
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
{tCommon("push")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useState, useRef, useCallback, useMemo, useEffect } from "react"
|
||||
import {
|
||||
ArchiveRestore,
|
||||
Archive,
|
||||
ArrowDownToLine,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -11,7 +9,6 @@ import {
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
GitBranchPlus,
|
||||
GitCommitHorizontal,
|
||||
GitFork,
|
||||
GitMerge,
|
||||
GitPullRequestArrow,
|
||||
@@ -19,7 +16,6 @@ import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
@@ -73,16 +69,11 @@ import {
|
||||
gitRebase,
|
||||
gitDeleteBranch,
|
||||
gitDeleteRemoteBranch,
|
||||
openCommitWindow,
|
||||
openPushWindow,
|
||||
openStashWindow,
|
||||
} from "@/lib/api"
|
||||
import { openFileDialog, subscribe } from "@/lib/platform"
|
||||
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
|
||||
import { ConflictDialog } from "@/components/layout/conflict-dialog"
|
||||
import { StashDialog } from "@/components/layout/stash-dialog"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import type { GitBranchList, GitConflictInfo } from "@/lib/types"
|
||||
import type { GitBranchList } from "@/lib/types"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useTaskContext } from "@/contexts/task-context"
|
||||
@@ -146,8 +137,6 @@ export function BranchDropdown() {
|
||||
const [worktreeBranchName, setWorktreeBranchName] = useState("")
|
||||
const [worktreePath, setWorktreePath] = useState("")
|
||||
const [manageRemotesOpen, setManageRemotesOpen] = useState(false)
|
||||
const [stashDialogOpen, setStashDialogOpen] = useState(false)
|
||||
const [conflictInfo, setConflictInfo] = useState<GitConflictInfo | null>(null)
|
||||
const taskSeq = useRef(0)
|
||||
|
||||
const worktreeBranchSet = useMemo(
|
||||
@@ -361,7 +350,6 @@ export function BranchDropdown() {
|
||||
() => gitMerge(folderPath, branchName),
|
||||
(result) => {
|
||||
if (result.conflict?.has_conflicts) {
|
||||
setConflictInfo(result.conflict)
|
||||
return false
|
||||
}
|
||||
if (result.merged_commits === 0) {
|
||||
@@ -377,7 +365,6 @@ export function BranchDropdown() {
|
||||
() => gitRebase(folderPath, branchName),
|
||||
(result) => {
|
||||
if (result.conflict?.has_conflicts) {
|
||||
setConflictInfo(result.conflict)
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
@@ -618,7 +605,6 @@ export function BranchDropdown() {
|
||||
}),
|
||||
(result) => {
|
||||
if (result.conflict?.has_conflicts) {
|
||||
setConflictInfo(result.conflict)
|
||||
return false
|
||||
}
|
||||
if (result.updated_files === 0) {
|
||||
@@ -649,41 +635,6 @@ export function BranchDropdown() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() => {
|
||||
if (!folderId) return
|
||||
setDropdownOpen(false)
|
||||
openCommitWindow(folderId).catch((err) => {
|
||||
const title = t("toasts.openCommitWindowFailed")
|
||||
const msg = toErrorMessage(err)
|
||||
pushAlert("error", title, msg)
|
||||
toast.error(title, { description: msg })
|
||||
})
|
||||
}}
|
||||
>
|
||||
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
||||
{t("openCommitWindow")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() => {
|
||||
if (!folderId) return
|
||||
setDropdownOpen(false)
|
||||
openPushWindow(folderId).catch((err) => {
|
||||
const title = t("toasts.openPushWindowFailed")
|
||||
const msg = toErrorMessage(err)
|
||||
pushAlert("error", title, msg)
|
||||
toast.error(title, { description: msg })
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("pushCode")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
@@ -704,32 +655,6 @@ export function BranchDropdown() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() => {
|
||||
setDropdownOpen(false)
|
||||
setStashDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
{t("stashChanges")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() => {
|
||||
if (!folderId) return
|
||||
openStashWindow(folderId).catch((err) => {
|
||||
const msg = toErrorMessage(err)
|
||||
pushAlert("error", t("stashPop"), msg)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ArchiveRestore className="h-3.5 w-3.5" />
|
||||
{t("stashPop")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
@@ -937,21 +862,6 @@ export function BranchDropdown() {
|
||||
folderPath={folderPath}
|
||||
onSaved={() => loadAllBranches()}
|
||||
/>
|
||||
|
||||
<ConflictDialog
|
||||
conflictInfo={conflictInfo}
|
||||
folderId={folderId}
|
||||
folderPath={folderPath}
|
||||
onClose={() => setConflictInfo(null)}
|
||||
onResolved={refresh}
|
||||
/>
|
||||
|
||||
<StashDialog
|
||||
open={stashDialogOpen}
|
||||
folderPath={folderPath}
|
||||
onClose={() => setStashDialogOpen(false)}
|
||||
onStashed={refresh}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,258 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { subscribe } from "@/lib/platform"
|
||||
import { AlertTriangle, Check, FileWarning, Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
gitListConflicts,
|
||||
gitAbortOperation,
|
||||
gitContinueOperation,
|
||||
openMergeWindow,
|
||||
} from "@/lib/api"
|
||||
import type { GitConflictInfo } from "@/lib/types"
|
||||
|
||||
interface ConflictDialogProps {
|
||||
conflictInfo: GitConflictInfo | null
|
||||
folderId: number
|
||||
folderPath: string
|
||||
onClose: () => void
|
||||
onResolved: () => void
|
||||
}
|
||||
|
||||
export function ConflictDialog({
|
||||
conflictInfo,
|
||||
folderId,
|
||||
folderPath,
|
||||
onClose,
|
||||
onResolved,
|
||||
}: ConflictDialogProps) {
|
||||
const t = useTranslations("Folder.branchDropdown.conflict")
|
||||
const [conflictedFiles, setConflictedFiles] = useState<string[]>([])
|
||||
const [resolvedFiles, setResolvedFiles] = useState<Set<string>>(new Set())
|
||||
const [aborting, setAborting] = useState(false)
|
||||
const [completing, setCompleting] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
const open = conflictInfo !== null
|
||||
const operation = conflictInfo?.operation ?? "merge"
|
||||
|
||||
// Initialize conflict files from conflictInfo
|
||||
useEffect(() => {
|
||||
if (conflictInfo) {
|
||||
setConflictedFiles(conflictInfo.conflicted_files)
|
||||
setResolvedFiles(new Set())
|
||||
setDone(false)
|
||||
}
|
||||
}, [conflictInfo])
|
||||
|
||||
// Refresh conflict list to detect resolved files
|
||||
const refreshConflicts = useCallback(async () => {
|
||||
if (!folderPath || !open) return
|
||||
try {
|
||||
const remaining = await gitListConflicts(folderPath)
|
||||
const nowResolved = new Set(
|
||||
conflictedFiles.filter((f) => !remaining.includes(f))
|
||||
)
|
||||
setResolvedFiles(nowResolved)
|
||||
} catch {
|
||||
// ignore refresh errors
|
||||
}
|
||||
}, [folderPath, open, conflictedFiles])
|
||||
|
||||
// Listen for merge events from the merge window
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
let unlistenResolved: (() => void) | null = null
|
||||
let unlistenCompleted: (() => void) | null = null
|
||||
let unlistenAborted: (() => void) | null = null
|
||||
|
||||
subscribe<{ folder_id: number; file: string }>(
|
||||
"folder://merge-conflict-resolved",
|
||||
(payload) => {
|
||||
if (payload.folder_id !== folderId) return
|
||||
setResolvedFiles((prev) => new Set([...prev, payload.file]))
|
||||
}
|
||||
)
|
||||
.then((fn) => {
|
||||
unlistenResolved = fn
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
subscribe<{ folder_id: number }>("folder://merge-completed", (payload) => {
|
||||
if (payload.folder_id !== folderId) return
|
||||
setDone(true)
|
||||
onResolved()
|
||||
onClose()
|
||||
})
|
||||
.then((fn) => {
|
||||
unlistenCompleted = fn
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
// Merge was aborted (user clicked abort in merge window, or window closed)
|
||||
// Reset resolved state since abort reverts all changes
|
||||
subscribe<{ folder_id: number }>("folder://merge-aborted", (payload) => {
|
||||
if (payload.folder_id !== folderId) return
|
||||
setDone(true)
|
||||
setResolvedFiles(new Set())
|
||||
onClose()
|
||||
})
|
||||
.then((fn) => {
|
||||
unlistenAborted = fn
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return () => {
|
||||
unlistenResolved?.()
|
||||
unlistenCompleted?.()
|
||||
unlistenAborted?.()
|
||||
}
|
||||
}, [open, folderId, onResolved, onClose])
|
||||
|
||||
// Periodically refresh conflict status (skip for pull — merge is aborted
|
||||
// until the merge tool re-starts it, so git index has no conflicts yet)
|
||||
useEffect(() => {
|
||||
if (!open || operation === "pull") return
|
||||
const interval = setInterval(refreshConflicts, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [open, operation, refreshConflicts])
|
||||
|
||||
const allResolved =
|
||||
conflictedFiles.length > 0 &&
|
||||
conflictedFiles.every((f) => resolvedFiles.has(f))
|
||||
|
||||
async function handleOpenMergeTool() {
|
||||
try {
|
||||
await openMergeWindow(folderId, operation, conflictInfo?.upstream_commit)
|
||||
} catch (err) {
|
||||
toast.error(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAbort() {
|
||||
// For pull operations, the merge was already aborted during conflict
|
||||
// detection, so there's nothing to abort — just close the dialog.
|
||||
if (operation === "pull") {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
setAborting(true)
|
||||
try {
|
||||
await gitAbortOperation(folderPath, operation)
|
||||
toast.success(t("abortSuccess"))
|
||||
onClose()
|
||||
onResolved()
|
||||
} catch (err) {
|
||||
toast.error(String(err))
|
||||
} finally {
|
||||
setAborting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
if (done) return
|
||||
setCompleting(true)
|
||||
try {
|
||||
await gitContinueOperation(folderPath, operation)
|
||||
toast.success(t("completeSuccess"))
|
||||
onResolved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(String(err))
|
||||
} finally {
|
||||
setCompleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
{t("title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-60">
|
||||
<div className="space-y-1 pr-3">
|
||||
{conflictedFiles.map((file) => {
|
||||
const isResolved = resolvedFiles.has(file)
|
||||
return (
|
||||
<div
|
||||
key={file}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
||||
>
|
||||
{isResolved ? (
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<FileWarning className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
isResolved
|
||||
? "text-muted-foreground line-through"
|
||||
: "text-foreground"
|
||||
}
|
||||
>
|
||||
{file}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="flex-row justify-between sm:justify-between">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleAbort}
|
||||
disabled={aborting || completing}
|
||||
>
|
||||
{aborting && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("abort")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleOpenMergeTool}
|
||||
disabled={aborting || completing}
|
||||
>
|
||||
{t("openMergeTool")}
|
||||
</Button>
|
||||
{allResolved && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleComplete}
|
||||
disabled={completing || aborting || done}
|
||||
>
|
||||
{completing && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("completeMerge")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { FolderOpen, FolderPlus, GitBranch, Rocket } from "lucide-react"
|
||||
import { FolderOpen, FolderPlus, GitBranch } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { openProjectBootWindow } from "@/lib/api"
|
||||
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { CloneDialog } from "@/components/layout/clone-dialog"
|
||||
@@ -29,7 +28,9 @@ export function NewFolderDropdown() {
|
||||
multiple: false,
|
||||
})
|
||||
if (selected) {
|
||||
await openFolder(Array.isArray(selected) ? selected[0] : selected)
|
||||
openFolder(selected as string).catch((err) => {
|
||||
console.error("[NewFolderDropdown] failed to open folder:", err)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setBrowserOpen(true)
|
||||
@@ -40,13 +41,8 @@ export function NewFolderDropdown() {
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:text-foreground/80"
|
||||
title={t("openFolder")}
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-56" align="start">
|
||||
@@ -58,10 +54,6 @@ export function NewFolderDropdown() {
|
||||
<GitBranch className="h-3.5 w-3.5 shrink-0" />
|
||||
{t("cloneRepository")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => openProjectBootWindow()}>
|
||||
<Rocket className="h-3.5 w-3.5 shrink-0" />
|
||||
{t("projectBoot")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||
@@ -76,4 +68,4 @@ export function NewFolderDropdown() {
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,724 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
CloudOff,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import {
|
||||
FileTree,
|
||||
FileTreeFile,
|
||||
FileTreeFolder,
|
||||
} from "@/components/ai-elements/file-tree"
|
||||
import {
|
||||
Commit,
|
||||
CommitContent,
|
||||
CommitFileAdditions,
|
||||
CommitFileChanges,
|
||||
CommitFileDeletions,
|
||||
CommitFileIcon,
|
||||
CommitFileInfo,
|
||||
CommitFilePath,
|
||||
CommitFiles,
|
||||
CommitFileStatus,
|
||||
CommitHash,
|
||||
CommitHeader,
|
||||
CommitInfo,
|
||||
CommitMessage,
|
||||
CommitMetadata,
|
||||
CommitTimestamp,
|
||||
} from "@/components/ai-elements/commit"
|
||||
import { DiffViewer } from "@/components/diff/diff-viewer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { gitLog, gitPush, gitPushInfo, gitShowFile } from "@/lib/api"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import { languageFromPath } from "@/lib/language-detect"
|
||||
import type { GitLogEntry, GitLogFileChange, GitPushInfo } from "@/lib/types"
|
||||
import {
|
||||
useGitCredential,
|
||||
type GitRemoteHint,
|
||||
} from "@/contexts/git-credential-context"
|
||||
|
||||
// --- File tree types & builder (same as aux-panel-git-log-tab) ---
|
||||
|
||||
type CommitFileTreeDirNode = {
|
||||
kind: "dir"
|
||||
name: string
|
||||
path: string
|
||||
children: CommitFileTreeNode[]
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
type CommitFileTreeFileNode = {
|
||||
kind: "file"
|
||||
name: string
|
||||
path: string
|
||||
change: GitLogFileChange
|
||||
}
|
||||
|
||||
type CommitFileTreeNode = CommitFileTreeDirNode | CommitFileTreeFileNode
|
||||
|
||||
interface MutableCommitFileTreeDirNode {
|
||||
kind: "dir"
|
||||
name: string
|
||||
path: string
|
||||
children: Map<string, MutableCommitFileTreeDirNode | CommitFileTreeFileNode>
|
||||
}
|
||||
|
||||
function normalizePathSegments(path: string): string[] {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
|
||||
if (!normalized) return []
|
||||
return normalized.split("/").filter(Boolean)
|
||||
}
|
||||
|
||||
function toSortedTreeNodes(
|
||||
dir: MutableCommitFileTreeDirNode
|
||||
): CommitFileTreeNode[] {
|
||||
return Array.from(dir.children.values())
|
||||
.map<CommitFileTreeNode>((node) => {
|
||||
if (node.kind === "file") return node
|
||||
return {
|
||||
kind: "dir" as const,
|
||||
fileCount: 0,
|
||||
name: node.name,
|
||||
path: node.path,
|
||||
children: toSortedTreeNodes(node),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
||||
})
|
||||
}
|
||||
|
||||
function compressAndAnnotateDir(
|
||||
node: CommitFileTreeDirNode
|
||||
): CommitFileTreeDirNode {
|
||||
let compressedChildren: CommitFileTreeNode[] = node.children.map((child) => {
|
||||
if (child.kind === "file") return child
|
||||
return compressAndAnnotateDir(child)
|
||||
})
|
||||
|
||||
let fileCount = compressedChildren.reduce((count, child) => {
|
||||
if (child.kind === "file") return count + 1
|
||||
return count + child.fileCount
|
||||
}, 0)
|
||||
|
||||
let nextNode: CommitFileTreeDirNode = {
|
||||
...node,
|
||||
children: compressedChildren,
|
||||
fileCount,
|
||||
}
|
||||
|
||||
while (
|
||||
nextNode.children.length === 1 &&
|
||||
nextNode.children[0].kind === "dir"
|
||||
) {
|
||||
const onlyChild = nextNode.children[0]
|
||||
nextNode = {
|
||||
kind: "dir",
|
||||
name: `${nextNode.name}/${onlyChild.name}`,
|
||||
path: onlyChild.path,
|
||||
children: onlyChild.children,
|
||||
fileCount: onlyChild.fileCount,
|
||||
}
|
||||
}
|
||||
|
||||
compressedChildren = nextNode.children
|
||||
fileCount = compressedChildren.reduce((count, child) => {
|
||||
if (child.kind === "file") return count + 1
|
||||
return count + child.fileCount
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
...nextNode,
|
||||
children: compressedChildren,
|
||||
fileCount,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCommitFileTree(files: GitLogFileChange[]): CommitFileTreeNode[] {
|
||||
const root: MutableCommitFileTreeDirNode = {
|
||||
kind: "dir",
|
||||
name: "",
|
||||
path: "",
|
||||
children: new Map(),
|
||||
}
|
||||
|
||||
for (const change of files) {
|
||||
const segments = normalizePathSegments(change.path)
|
||||
if (segments.length === 0) continue
|
||||
|
||||
let current = root
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
const nodePath = segments.slice(0, index + 1).join("/")
|
||||
const isLeaf = index === segments.length - 1
|
||||
|
||||
if (isLeaf) {
|
||||
current.children.set(`file:${nodePath}`, {
|
||||
kind: "file",
|
||||
name: segment,
|
||||
path: nodePath,
|
||||
change,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const dirKey = `dir:${nodePath}`
|
||||
const existing = current.children.get(dirKey)
|
||||
if (existing && existing.kind === "dir") {
|
||||
current = existing
|
||||
continue
|
||||
}
|
||||
|
||||
const nextDir: MutableCommitFileTreeDirNode = {
|
||||
kind: "dir",
|
||||
name: segment,
|
||||
path: nodePath,
|
||||
children: new Map(),
|
||||
}
|
||||
current.children.set(dirKey, nextDir)
|
||||
current = nextDir
|
||||
}
|
||||
}
|
||||
|
||||
const sortedNodes = toSortedTreeNodes(root)
|
||||
return sortedNodes.map((node) => {
|
||||
if (node.kind === "file") return node
|
||||
return compressAndAnnotateDir(node)
|
||||
})
|
||||
}
|
||||
|
||||
function collectExpandedDirectoryPaths(
|
||||
nodes: CommitFileTreeNode[],
|
||||
expanded = new Set<string>()
|
||||
): Set<string> {
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== "dir") continue
|
||||
expanded.add(node.path)
|
||||
collectExpandedDirectoryPaths(node.children, expanded)
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
function mapFileStatus(
|
||||
status: string
|
||||
): "added" | "modified" | "deleted" | "renamed" {
|
||||
switch (status.toUpperCase().charAt(0)) {
|
||||
case "A":
|
||||
return "added"
|
||||
case "D":
|
||||
return "deleted"
|
||||
case "R":
|
||||
return "renamed"
|
||||
default:
|
||||
return "modified"
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(
|
||||
dateStr: string,
|
||||
t: (
|
||||
key:
|
||||
| "time.monthsAgo"
|
||||
| "time.daysAgo"
|
||||
| "time.hoursAgo"
|
||||
| "time.minsAgo"
|
||||
| "time.justNow",
|
||||
values?: { count: number }
|
||||
) => string
|
||||
): string {
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return dateStr
|
||||
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60_000)
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
const diffDay = Math.floor(diffHour / 24)
|
||||
|
||||
if (diffDay > 30) {
|
||||
const diffMonth = Math.floor(diffDay / 30)
|
||||
return t("time.monthsAgo", { count: diffMonth })
|
||||
}
|
||||
if (diffDay > 0) return t("time.daysAgo", { count: diffDay })
|
||||
if (diffHour > 0) return t("time.hoursAgo", { count: diffHour })
|
||||
if (diffMin > 0) return t("time.minsAgo", { count: diffMin })
|
||||
return t("time.justNow", { count: 0 })
|
||||
}
|
||||
|
||||
function parseDate(dateStr: string): Date | null {
|
||||
const date = new Date(dateStr)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
interface PushWorkspaceProps {
|
||||
folderPath: string
|
||||
folderName: string
|
||||
folderId?: number | null
|
||||
onPushed?: () => void
|
||||
}
|
||||
|
||||
export function PushWorkspace({
|
||||
folderPath,
|
||||
folderName,
|
||||
folderId,
|
||||
onPushed,
|
||||
}: PushWorkspaceProps) {
|
||||
const t = useTranslations("Folder.pushWindow")
|
||||
const tLog = useTranslations("Folder.gitLogTab")
|
||||
const { withCredentialRetry } = useGitCredential()
|
||||
|
||||
const [pushInfoData, setPushInfoData] = useState<GitPushInfo | null>(null)
|
||||
const [selectedRemote, setSelectedRemote] = useState<string | null>(null)
|
||||
const [commits, setCommits] = useState<GitLogEntry[]>([])
|
||||
const [hasUpstream, setHasUpstream] = useState(true)
|
||||
const [listLoading, setListLoading] = useState(false)
|
||||
const [openByCommit, setOpenByCommit] = useState<Record<string, boolean>>({})
|
||||
const [pushing, setPushing] = useState(false)
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null)
|
||||
const [originalContent, setOriginalContent] = useState("")
|
||||
const [modifiedContent, setModifiedContent] = useState("")
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
|
||||
const unpushedCommits = useMemo(
|
||||
() => commits.filter((c) => c.pushed === false),
|
||||
[commits]
|
||||
)
|
||||
|
||||
// Load push info (branch, remotes, tracking remote)
|
||||
useEffect(() => {
|
||||
gitPushInfo(folderPath)
|
||||
.then((info) => {
|
||||
setPushInfoData(info)
|
||||
// Default to tracking remote or first remote
|
||||
const defaultRemote =
|
||||
info.tracking_remote ??
|
||||
(info.remotes.length > 0 ? info.remotes[0].name : null)
|
||||
setSelectedRemote(defaultRemote)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(toErrorMessage(err))
|
||||
})
|
||||
}, [folderPath])
|
||||
|
||||
// Deduplicate remotes (git remote -v returns fetch + push entries)
|
||||
const uniqueRemotes = useMemo(() => {
|
||||
if (!pushInfoData) return []
|
||||
const seen = new Set<string>()
|
||||
return pushInfoData.remotes.filter((r) => {
|
||||
if (seen.has(r.name)) return false
|
||||
seen.add(r.name)
|
||||
return true
|
||||
})
|
||||
}, [pushInfoData])
|
||||
|
||||
const loadCommits = useCallback(
|
||||
async (remote?: string) => {
|
||||
setListLoading(true)
|
||||
try {
|
||||
const result = await gitLog(
|
||||
folderPath,
|
||||
100,
|
||||
undefined,
|
||||
remote ?? undefined
|
||||
)
|
||||
setCommits(result.entries)
|
||||
setHasUpstream(result.has_upstream)
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setListLoading(false)
|
||||
}
|
||||
},
|
||||
[folderPath]
|
||||
)
|
||||
|
||||
// Reload commits when selected remote changes
|
||||
useEffect(() => {
|
||||
if (selectedRemote !== null) {
|
||||
loadCommits(selectedRemote)
|
||||
}
|
||||
}, [selectedRemote, loadCommits])
|
||||
|
||||
async function handleSelectFile(commitHash: string, file: string) {
|
||||
setSelectedFile(file)
|
||||
setSelectedCommit(commitHash)
|
||||
setDiffLoading(true)
|
||||
try {
|
||||
const [orig, mod] = await Promise.all([
|
||||
gitShowFile(folderPath, file, `${commitHash}~1`).catch(() => ""),
|
||||
gitShowFile(folderPath, file, commitHash).catch(() => ""),
|
||||
])
|
||||
setOriginalContent(orig)
|
||||
setModifiedContent(mod)
|
||||
} catch {
|
||||
setOriginalContent("")
|
||||
setModifiedContent("")
|
||||
} finally {
|
||||
setDiffLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePush() {
|
||||
setPushing(true)
|
||||
try {
|
||||
// Resolve the selected remote's URL for credential matching
|
||||
const remoteUrl = pushInfoData?.remotes.find(
|
||||
(r) => r.name === selectedRemote
|
||||
)?.url
|
||||
const hint: GitRemoteHint = remoteUrl ? { remoteUrl } : { folderPath }
|
||||
await withCredentialRetry(
|
||||
(creds) => gitPush(folderPath, selectedRemote, creds, folderId),
|
||||
hint
|
||||
)
|
||||
onPushed?.()
|
||||
} catch (err) {
|
||||
toast.error(t("toasts.pushFailed"), {
|
||||
description: toErrorMessage(err),
|
||||
})
|
||||
} finally {
|
||||
setPushing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Push target header: branch → remote/branch */}
|
||||
{pushInfoData && (
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{pushInfoData.branch}
|
||||
</span>
|
||||
{uniqueRemotes.length > 0 && (
|
||||
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{uniqueRemotes.length === 0 ? null : uniqueRemotes.length <= 1 ? (
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{selectedRemote ?? "origin"}/{pushInfoData.branch}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Select
|
||||
value={selectedRemote ?? ""}
|
||||
onValueChange={setSelectedRemote}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-auto gap-1 border-none bg-transparent px-1.5 text-sm shadow-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uniqueRemotes.map((r) => (
|
||||
<SelectItem key={r.name} value={r.name}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
/{pushInfoData.branch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
||||
{/* Left panel: commit list */}
|
||||
<ResizablePanel defaultSize={35} minSize={25}>
|
||||
<div className="flex h-full flex-col">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{listLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : uniqueRemotes.length === 0 ? (
|
||||
<div className="flex items-center justify-center px-4 py-12 text-center text-sm text-muted-foreground whitespace-pre-line">
|
||||
{t("noRemoteConfigured")}
|
||||
</div>
|
||||
) : unpushedCommits.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{!hasUpstream
|
||||
? t("newBranchNoPushedCommits")
|
||||
: t("noUnpushedCommits")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{unpushedCommits.map((entry) => {
|
||||
const commitKey = entry.full_hash
|
||||
const commitDate = parseDate(entry.date)
|
||||
const isOpen = !!openByCommit[commitKey]
|
||||
|
||||
return (
|
||||
<Commit
|
||||
key={commitKey}
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOpenByCommit((prev) => ({
|
||||
...prev,
|
||||
[commitKey]: open,
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<CommitHeader>
|
||||
<CommitInfo className="min-w-0">
|
||||
<CommitMessage className="line-clamp-1 leading-snug">
|
||||
{entry.message}
|
||||
</CommitMessage>
|
||||
<CommitMetadata className="mt-1 min-w-0 flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-flex shrink-0"
|
||||
title={t("unpushed")}
|
||||
>
|
||||
<CloudOff
|
||||
className="text-amber-500"
|
||||
size={12}
|
||||
/>
|
||||
</span>
|
||||
<span className="truncate">{entry.author}</span>
|
||||
<CommitTimestamp
|
||||
className="shrink-0"
|
||||
date={commitDate ?? new Date()}
|
||||
>
|
||||
{formatRelativeTime(entry.date, tLog)}
|
||||
</CommitTimestamp>
|
||||
<CommitHash className="text-primary/70">
|
||||
{entry.hash}
|
||||
</CommitHash>
|
||||
</CommitMetadata>
|
||||
</CommitInfo>
|
||||
</CommitHeader>
|
||||
<CommitContent>
|
||||
{entry.files.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tLog("noFileChangeDetails")}
|
||||
</p>
|
||||
) : (
|
||||
<PushCommitFilesTree
|
||||
commitHash={entry.full_hash}
|
||||
files={entry.files}
|
||||
folderName={folderName}
|
||||
onSelectFile={(file) =>
|
||||
handleSelectFile(entry.full_hash, file)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CommitContent>
|
||||
</Commit>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Push button */}
|
||||
<div className="border-t p-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={
|
||||
pushing ||
|
||||
uniqueRemotes.length === 0 ||
|
||||
(hasUpstream && unpushedCommits.length === 0)
|
||||
}
|
||||
onClick={handlePush}
|
||||
>
|
||||
{pushing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("push")}
|
||||
{unpushedCommits.length > 0 && ` (${unpushedCommits.length})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* Right panel: diff viewer */}
|
||||
<ResizablePanel defaultSize={65} minSize={40}>
|
||||
{diffLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : selectedFile && selectedCommit ? (
|
||||
<DiffViewer
|
||||
original={originalContent}
|
||||
modified={modifiedContent}
|
||||
originalLabel={`${selectedCommit.slice(0, 7)}~ (${t("before")})`}
|
||||
modifiedLabel={`${selectedCommit.slice(0, 7)} (${t("after")})`}
|
||||
language={languageFromPath(selectedFile)}
|
||||
className="h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("selectFileToViewDiff")}
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Commit Files Tree for Push Window ---
|
||||
|
||||
function PushCommitFilesTree({
|
||||
commitHash,
|
||||
files,
|
||||
folderName,
|
||||
onSelectFile,
|
||||
}: {
|
||||
commitHash: string
|
||||
files: GitLogFileChange[]
|
||||
folderName: string
|
||||
onSelectFile: (file: string) => void
|
||||
}) {
|
||||
const tLog = useTranslations("Folder.gitLogTab")
|
||||
const rootPath = "__push_file_tree_root__"
|
||||
const treeNodes = useMemo(() => buildCommitFileTree(files), [files])
|
||||
const allDirectoryPaths = useMemo(() => {
|
||||
const paths = collectExpandedDirectoryPaths(treeNodes)
|
||||
paths.add(rootPath)
|
||||
return paths
|
||||
}, [treeNodes])
|
||||
const [expandedPaths, setExpandedPaths] =
|
||||
useState<Set<string>>(allDirectoryPaths)
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedPaths(allDirectoryPaths)
|
||||
}, [allDirectoryPaths])
|
||||
|
||||
const canExpandAll = useMemo(() => {
|
||||
if (allDirectoryPaths.size === 0) return false
|
||||
for (const path of allDirectoryPaths) {
|
||||
if (!expandedPaths.has(path)) return true
|
||||
}
|
||||
return false
|
||||
}, [allDirectoryPaths, expandedPaths])
|
||||
|
||||
const canCollapseAll = expandedPaths.size > 0
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
if (canExpandAll) {
|
||||
setExpandedPaths(new Set(allDirectoryPaths))
|
||||
return
|
||||
}
|
||||
setExpandedPaths(new Set())
|
||||
}, [allDirectoryPaths, canExpandAll])
|
||||
|
||||
const renderNode = (node: CommitFileTreeNode): ReactElement => {
|
||||
if (node.kind === "dir") {
|
||||
return (
|
||||
<FileTreeFolder
|
||||
key={node.path}
|
||||
path={node.path}
|
||||
name={node.name}
|
||||
suffix={`(${node.fileCount})`}
|
||||
suffixClassName="text-muted-foreground/45"
|
||||
title={node.path}
|
||||
>
|
||||
{node.children.map(renderNode)}
|
||||
</FileTreeFolder>
|
||||
)
|
||||
}
|
||||
|
||||
const file = node.change
|
||||
return (
|
||||
<FileTreeFile
|
||||
key={`${commitHash}:${file.path}`}
|
||||
className="w-full min-w-0 cursor-pointer"
|
||||
name={node.name}
|
||||
onClick={() => onSelectFile(file.path)}
|
||||
path={node.path}
|
||||
title={file.path}
|
||||
>
|
||||
<>
|
||||
<span className="size-4 shrink-0" />
|
||||
<CommitFileInfo className="flex-1 min-w-0 gap-1.5">
|
||||
<CommitFileStatus status={mapFileStatus(file.status)}>
|
||||
{file.status}
|
||||
</CommitFileStatus>
|
||||
<CommitFileIcon />
|
||||
<CommitFilePath title={file.path}>{node.name}</CommitFilePath>
|
||||
</CommitFileInfo>
|
||||
<CommitFileChanges>
|
||||
<CommitFileAdditions count={file.additions} />
|
||||
<CommitFileDeletions count={file.deletions} />
|
||||
</CommitFileChanges>
|
||||
</>
|
||||
</FileTreeFile>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{tLog("filesTitle")}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
onClick={toggleExpanded}
|
||||
disabled={!canExpandAll && !canCollapseAll}
|
||||
title={
|
||||
canExpandAll ? tLog("expandAllFiles") : tLog("collapseAllFiles")
|
||||
}
|
||||
>
|
||||
{canExpandAll ? (
|
||||
<ChevronsUpDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronsDownUp className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CommitFiles>
|
||||
<FileTree
|
||||
className="max-h-[32rem] overflow-auto rounded-md border-border/60 bg-transparent text-xs [&>div]:p-1"
|
||||
expanded={expandedPaths}
|
||||
onExpandedChange={setExpandedPaths}
|
||||
>
|
||||
<FileTreeFolder
|
||||
path={rootPath}
|
||||
name={folderName}
|
||||
suffix={`(${files.length})`}
|
||||
suffixClassName="text-muted-foreground/45"
|
||||
title={folderName}
|
||||
>
|
||||
{treeNodes.map(renderNode)}
|
||||
</FileTreeFolder>
|
||||
</FileTree>
|
||||
</CommitFiles>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { gitStashPush } from "@/lib/api"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
|
||||
interface StashDialogProps {
|
||||
open: boolean
|
||||
folderPath: string
|
||||
onClose: () => void
|
||||
onStashed: () => void
|
||||
}
|
||||
|
||||
export function StashDialog({
|
||||
open,
|
||||
folderPath,
|
||||
onClose,
|
||||
onStashed,
|
||||
}: StashDialogProps) {
|
||||
const t = useTranslations("Folder.branchDropdown.stashDialog")
|
||||
const [message, setMessage] = useState("")
|
||||
const [keepIndex, setKeepIndex] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function handleClose() {
|
||||
if (loading) return
|
||||
setMessage("")
|
||||
setKeepIndex(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
async function handleStash() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await gitStashPush(folderPath, message.trim() || undefined, keepIndex)
|
||||
toast.success(t("success"))
|
||||
setMessage("")
|
||||
setKeepIndex(false)
|
||||
onStashed()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(t("error"), { description: toErrorMessage(err) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stash-message">{t("messageLabel")}</Label>
|
||||
<Input
|
||||
id="stash-message"
|
||||
placeholder={t("messagePlaceholder")}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !loading) {
|
||||
handleStash()
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="keep-index"
|
||||
checked={keepIndex}
|
||||
onCheckedChange={setKeepIndex}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="keep-index">{t("keepIndex")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleStash} disabled={loading}>
|
||||
{loading && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
{t("stash")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Archive, ArchiveRestore, ChevronRight, Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import {
|
||||
FileTree,
|
||||
FileTreeFile,
|
||||
FileTreeFolder,
|
||||
} from "@/components/ai-elements/file-tree"
|
||||
import { DiffViewer } from "@/components/diff/diff-viewer"
|
||||
import {
|
||||
gitStashList,
|
||||
gitStashShow,
|
||||
gitStashApply,
|
||||
gitStashDrop,
|
||||
gitShowFile,
|
||||
} from "@/lib/api"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import { languageFromPath } from "@/lib/language-detect"
|
||||
import type { GitStashEntry, GitStatusEntry } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// --- File tree types & builder (same pattern as commit-dialog) ---
|
||||
|
||||
interface TreeFileNode {
|
||||
kind: "file"
|
||||
name: string
|
||||
path: string
|
||||
entry: GitStatusEntry
|
||||
}
|
||||
|
||||
interface TreeDirNode {
|
||||
kind: "dir"
|
||||
name: string
|
||||
path: string
|
||||
children: TreeNode[]
|
||||
}
|
||||
|
||||
type TreeNode = TreeFileNode | TreeDirNode
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 p = ""
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
p = p ? `${p}/${parts[i]}` : parts[i]
|
||||
paths.add(p)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function statusColor(status: string) {
|
||||
switch (status.charAt(0).toUpperCase()) {
|
||||
case "A":
|
||||
return "text-green-500"
|
||||
case "D":
|
||||
return "text-red-500"
|
||||
case "M":
|
||||
return "text-blue-500"
|
||||
default:
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
interface StashWorkspaceProps {
|
||||
folderPath: string
|
||||
}
|
||||
|
||||
export function StashWorkspace({ folderPath }: StashWorkspaceProps) {
|
||||
const t = useTranslations("Folder.branchDropdown.unstashDialog")
|
||||
|
||||
const [stashes, setStashes] = useState<GitStashEntry[]>([])
|
||||
const [expandedStash, setExpandedStash] = useState<string | null>(null)
|
||||
const [stashFiles, setStashFiles] = useState<
|
||||
Record<string, GitStatusEntry[]>
|
||||
>({})
|
||||
const [filesLoading, setFilesLoading] = useState<string | null>(null)
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [selectedStashRef, setSelectedStashRef] = useState<string | null>(null)
|
||||
const [originalContent, setOriginalContent] = useState("")
|
||||
const [modifiedContent, setModifiedContent] = useState("")
|
||||
|
||||
const [listLoading, setListLoading] = useState(false)
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
|
||||
const loadStashes = useCallback(async () => {
|
||||
setListLoading(true)
|
||||
try {
|
||||
const list = await gitStashList(folderPath)
|
||||
setStashes(list)
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setListLoading(false)
|
||||
}
|
||||
}, [folderPath])
|
||||
|
||||
useEffect(() => {
|
||||
loadStashes()
|
||||
}, [loadStashes])
|
||||
|
||||
async function handleToggleStash(stashRef: string) {
|
||||
if (expandedStash === stashRef) {
|
||||
setExpandedStash(null)
|
||||
return
|
||||
}
|
||||
setExpandedStash(stashRef)
|
||||
|
||||
if (!stashFiles[stashRef]) {
|
||||
setFilesLoading(stashRef)
|
||||
try {
|
||||
const fileList = await gitStashShow(folderPath, stashRef)
|
||||
setStashFiles((prev) => ({ ...prev, [stashRef]: fileList }))
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setFilesLoading(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectFile(stashRef: string, file: string) {
|
||||
setSelectedFile(file)
|
||||
setSelectedStashRef(stashRef)
|
||||
setDiffLoading(true)
|
||||
try {
|
||||
const [orig, mod] = await Promise.all([
|
||||
gitShowFile(folderPath, file, stashRef + "^").catch(() => ""),
|
||||
gitShowFile(folderPath, file, stashRef).catch(() => ""),
|
||||
])
|
||||
setOriginalContent(orig)
|
||||
setModifiedContent(mod)
|
||||
} catch {
|
||||
setOriginalContent("")
|
||||
setModifiedContent("")
|
||||
} finally {
|
||||
setDiffLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApply(stashRef: string) {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await gitStashApply(folderPath, stashRef)
|
||||
toast.success(t("applySuccess"))
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(stashRef: string) {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await gitStashDrop(folderPath, stashRef)
|
||||
toast.success(t("dropSuccess"))
|
||||
if (expandedStash === stashRef) {
|
||||
setExpandedStash(null)
|
||||
}
|
||||
if (selectedStashRef === stashRef) {
|
||||
setSelectedFile(null)
|
||||
setSelectedStashRef(null)
|
||||
setOriginalContent("")
|
||||
setModifiedContent("")
|
||||
}
|
||||
setStashFiles((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[stashRef]
|
||||
return next
|
||||
})
|
||||
await loadStashes()
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Render file tree nodes
|
||||
function renderNode(node: TreeNode, stashRef: string): React.ReactNode {
|
||||
if (node.kind === "dir") {
|
||||
return (
|
||||
<FileTreeFolder key={node.path} name={node.name} path={node.path}>
|
||||
{node.children.map((child) => renderNode(child, stashRef))}
|
||||
</FileTreeFolder>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger>
|
||||
<FileTreeFile
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
className="gap-1 px-1.5 py-1"
|
||||
>
|
||||
<span className="flex-1 truncate text-left" title={node.path}>
|
||||
{node.name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-5 shrink-0 text-right text-xs font-bold",
|
||||
statusColor(node.entry.status)
|
||||
)}
|
||||
>
|
||||
{node.entry.status.charAt(0)}
|
||||
</span>
|
||||
</FileTreeFile>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleSelectFile(stashRef, node.path)}
|
||||
>
|
||||
{t("viewDiff")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
{/* Left panel: stash cards */}
|
||||
<ResizablePanel defaultSize={35} minSize={25}>
|
||||
<ScrollArea className="h-full">
|
||||
{listLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : stashes.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t("noStashes")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{stashes.map((stash) => (
|
||||
<StashCard
|
||||
key={stash.ref_name}
|
||||
stash={stash}
|
||||
isExpanded={expandedStash === stash.ref_name}
|
||||
isLoadingFiles={filesLoading === stash.ref_name}
|
||||
actionLoading={actionLoading}
|
||||
files={stashFiles[stash.ref_name]}
|
||||
selectedFile={
|
||||
selectedStashRef === stash.ref_name ? selectedFile : null
|
||||
}
|
||||
onToggle={() => handleToggleStash(stash.ref_name)}
|
||||
onApply={() => handleApply(stash.ref_name)}
|
||||
onDrop={() => handleDrop(stash.ref_name)}
|
||||
onSelectFile={(file) =>
|
||||
handleSelectFile(stash.ref_name, file)
|
||||
}
|
||||
renderNode={(node) => renderNode(node, stash.ref_name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* Right panel: diff viewer */}
|
||||
<ResizablePanel defaultSize={65} minSize={40}>
|
||||
{diffLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : selectedFile && selectedStashRef ? (
|
||||
<DiffViewer
|
||||
original={originalContent}
|
||||
modified={modifiedContent}
|
||||
originalLabel={`${selectedStashRef}^ (${t("original")})`}
|
||||
modifiedLabel={`${selectedStashRef} (${t("modified")})`}
|
||||
language={languageFromPath(selectedFile)}
|
||||
className="h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("selectFile")}
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Stash Card Component ---
|
||||
|
||||
interface StashCardProps {
|
||||
stash: GitStashEntry
|
||||
isExpanded: boolean
|
||||
isLoadingFiles: boolean
|
||||
actionLoading: boolean
|
||||
files?: GitStatusEntry[]
|
||||
selectedFile: string | null
|
||||
onToggle: () => void
|
||||
onApply: () => void
|
||||
onDrop: () => void
|
||||
onSelectFile: (file: string) => void
|
||||
renderNode: (node: TreeNode) => React.ReactNode
|
||||
}
|
||||
|
||||
function StashCard({
|
||||
stash,
|
||||
isExpanded,
|
||||
isLoadingFiles,
|
||||
actionLoading,
|
||||
files,
|
||||
selectedFile,
|
||||
onToggle,
|
||||
onApply,
|
||||
onDrop,
|
||||
onSelectFile,
|
||||
renderNode,
|
||||
}: StashCardProps) {
|
||||
const t = useTranslations("Folder.branchDropdown.unstashDialog")
|
||||
const [confirmApplyOpen, setConfirmApplyOpen] = useState(false)
|
||||
const tree = useMemo(() => (files ? buildFileTree(files) : []), [files])
|
||||
|
||||
const defaultExpanded = useMemo(
|
||||
() => (files ? collectDirPaths(files) : new Set<string>()),
|
||||
[files]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<Collapsible open={isExpanded} onOpenChange={onToggle}>
|
||||
<div className="group rounded-lg border bg-card">
|
||||
<div className="relative flex items-center">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-t-lg px-3 py-2 text-left text-sm transition-colors group-hover:bg-muted/50"
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<Archive className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{stash.ref_name}
|
||||
</span>
|
||||
<span className="truncate font-medium">
|
||||
{stash.message}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-[10px] text-muted-foreground/70">
|
||||
<span>{stash.branch}</span>
|
||||
<span>{stash.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100"
|
||||
title={t("apply") as string}
|
||||
onClick={() => setConfirmApplyOpen(true)}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<ArchiveRestore className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="border-t">
|
||||
{/* File tree */}
|
||||
{isLoadingFiles ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : tree.length > 0 ? (
|
||||
<div className="px-2 pb-2">
|
||||
<FileTree
|
||||
defaultExpanded={defaultExpanded}
|
||||
selectedPath={selectedFile ?? undefined}
|
||||
onSelect={onSelectFile}
|
||||
className="border-0 bg-transparent"
|
||||
>
|
||||
{tree.map(renderNode)}
|
||||
</FileTree>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => setConfirmApplyOpen(true)}>
|
||||
{t("apply")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" onClick={onDrop}>
|
||||
{t("drop")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<AlertDialog open={confirmApplyOpen} onOpenChange={setConfirmApplyOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("apply")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("confirmApply", { ref: stash.ref_name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setConfirmApplyOpen(false)
|
||||
onApply()
|
||||
}}
|
||||
>
|
||||
{t("apply")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
export interface ConflictRegion {
|
||||
/** Line number (1-based) of <<<<<<< marker */
|
||||
startLine: number
|
||||
/** Line number (1-based) of ======= marker */
|
||||
separatorLine: number
|
||||
/** Line number (1-based) of >>>>>>> marker */
|
||||
endLine: number
|
||||
/** Content from the ours (local/HEAD) side */
|
||||
oursContent: string
|
||||
/** Content from the theirs (remote/incoming) side */
|
||||
theirsContent: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse git conflict markers from file content.
|
||||
* Returns an array of conflict regions sorted by line number.
|
||||
*/
|
||||
export function parseConflictMarkers(content: string): ConflictRegion[] {
|
||||
const lines = content.split("\n")
|
||||
const regions: ConflictRegion[] = []
|
||||
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
if (lines[i].startsWith("<<<<<<<")) {
|
||||
const startLine = i + 1 // 1-based
|
||||
let separatorLine = -1
|
||||
let endLine = -1
|
||||
const oursLines: string[] = []
|
||||
const theirsLines: string[] = []
|
||||
let inOurs = true
|
||||
|
||||
let j = i + 1
|
||||
while (j < lines.length) {
|
||||
if (lines[j].startsWith("=======") && separatorLine === -1) {
|
||||
separatorLine = j + 1
|
||||
inOurs = false
|
||||
} else if (lines[j].startsWith(">>>>>>>")) {
|
||||
endLine = j + 1
|
||||
break
|
||||
} else if (inOurs) {
|
||||
oursLines.push(lines[j])
|
||||
} else {
|
||||
theirsLines.push(lines[j])
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
if (separatorLine !== -1 && endLine !== -1) {
|
||||
regions.push({
|
||||
startLine,
|
||||
separatorLine,
|
||||
endLine,
|
||||
oursContent: oursLines.join("\n"),
|
||||
theirsContent: theirsLines.join("\n"),
|
||||
})
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return regions
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single conflict region by replacing the conflict block
|
||||
* with the chosen content.
|
||||
*/
|
||||
export function resolveConflict(
|
||||
content: string,
|
||||
region: ConflictRegion,
|
||||
choice: "ours" | "theirs" | "both"
|
||||
): string {
|
||||
const lines = content.split("\n")
|
||||
const startIdx = region.startLine - 1
|
||||
const endIdx = region.endLine - 1
|
||||
|
||||
let replacement: string
|
||||
switch (choice) {
|
||||
case "ours":
|
||||
replacement = region.oursContent
|
||||
break
|
||||
case "theirs":
|
||||
replacement = region.theirsContent
|
||||
break
|
||||
case "both":
|
||||
replacement = region.oursContent + "\n" + region.theirsContent
|
||||
break
|
||||
}
|
||||
|
||||
const replacementLines = replacement === "" ? [] : replacement.split("\n")
|
||||
lines.splice(startIdx, endIdx - startIdx + 1, ...replacementLines)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content still has unresolved conflict markers.
|
||||
*/
|
||||
export function hasConflictMarkers(content: string): boolean {
|
||||
return content.includes("<<<<<<<") && content.includes(">>>>>>>")
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
/**
|
||||
* Line-level diff engine for three-way merge.
|
||||
*
|
||||
* Computes diffs between base↔ours and base↔theirs, then aligns
|
||||
* them into MergeHunks classified as left-only, right-only, or conflict.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DiffHunk {
|
||||
/** Start index in the "old" (base) array, 0-based */
|
||||
baseStart: number
|
||||
/** Number of lines removed from base (0 = pure insertion) */
|
||||
baseCount: number
|
||||
/** Replacement lines from the "new" side */
|
||||
newLines: string[]
|
||||
}
|
||||
|
||||
export type HunkStatus = "pending" | "applied" | "ignored"
|
||||
|
||||
export interface MergeHunk {
|
||||
id: string
|
||||
/** Start index in base lines, 0-based */
|
||||
baseStart: number
|
||||
/** Number of base lines covered */
|
||||
baseCount: number
|
||||
/** Diff hunk from ours (left) side, null if unchanged */
|
||||
leftHunk: DiffHunk | null
|
||||
/** Diff hunk from theirs (right) side, null if unchanged */
|
||||
rightHunk: DiffHunk | null
|
||||
type: "left-only" | "right-only" | "conflict"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LCS-based line diff
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the Longest Common Subsequence table for two string arrays.
|
||||
* Returns a 2D array where dp[i][j] = LCS length for a[0..i-1], b[0..j-1].
|
||||
*/
|
||||
function lcsTable(a: string[], b: string[]): number[][] {
|
||||
const m = a.length
|
||||
const n = b.length
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
new Array<number>(n + 1).fill(0)
|
||||
)
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return dp
|
||||
}
|
||||
|
||||
/**
|
||||
* Backtrack the LCS table to produce edit operations.
|
||||
* Returns an array of { type, aIdx, bIdx } entries.
|
||||
*/
|
||||
interface EditOp {
|
||||
type: "equal" | "delete" | "insert"
|
||||
aIdx: number // index in a (-1 for insert)
|
||||
bIdx: number // index in b (-1 for delete)
|
||||
}
|
||||
|
||||
function backtrackLCS(a: string[], b: string[], dp: number[][]): EditOp[] {
|
||||
const ops: EditOp[] = []
|
||||
let i = a.length
|
||||
let j = b.length
|
||||
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
||||
ops.push({ type: "equal", aIdx: i - 1, bIdx: j - 1 })
|
||||
i--
|
||||
j--
|
||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||
ops.push({ type: "insert", aIdx: -1, bIdx: j - 1 })
|
||||
j--
|
||||
} else {
|
||||
ops.push({ type: "delete", aIdx: i - 1, bIdx: -1 })
|
||||
i--
|
||||
}
|
||||
}
|
||||
|
||||
return ops.reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute line-level diff hunks between old (a) and new (b) arrays.
|
||||
*/
|
||||
export function computeLineDiff(a: string[], b: string[]): DiffHunk[] {
|
||||
const dp = lcsTable(a, b)
|
||||
const ops = backtrackLCS(a, b, dp)
|
||||
|
||||
const hunks: DiffHunk[] = []
|
||||
let idx = 0
|
||||
|
||||
while (idx < ops.length) {
|
||||
const op = ops[idx]
|
||||
|
||||
if (op.type === "equal") {
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
|
||||
// Start of a change region
|
||||
let baseStart = op.type === "delete" ? op.aIdx : -1
|
||||
let baseCount = 0
|
||||
const newLines: string[] = []
|
||||
|
||||
while (idx < ops.length && ops[idx].type !== "equal") {
|
||||
const cur = ops[idx]
|
||||
if (cur.type === "delete") {
|
||||
if (baseStart === -1) baseStart = cur.aIdx
|
||||
baseCount++
|
||||
} else {
|
||||
// insert
|
||||
if (baseStart === -1) {
|
||||
// Pure insertion — position it at the next base line
|
||||
// Find the previous equal op's aIdx + 1, or 0
|
||||
baseStart = findInsertionPoint(ops, idx)
|
||||
}
|
||||
newLines.push(b[cur.bIdx])
|
||||
}
|
||||
idx++
|
||||
}
|
||||
|
||||
hunks.push({ baseStart, baseCount, newLines })
|
||||
}
|
||||
|
||||
return hunks
|
||||
}
|
||||
|
||||
/**
|
||||
* For a pure insertion (no deletes in this hunk), determine
|
||||
* where in the base array to anchor it.
|
||||
*/
|
||||
function findInsertionPoint(ops: EditOp[], currentIdx: number): number {
|
||||
// Walk backwards to find the last "equal" or "delete" op
|
||||
for (let k = currentIdx - 1; k >= 0; k--) {
|
||||
if (ops[k].type === "equal" || ops[k].type === "delete") {
|
||||
return ops[k].aIdx + 1
|
||||
}
|
||||
}
|
||||
// If nothing found, insert at start
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Three-way merge hunk computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RangedHunk {
|
||||
baseStart: number
|
||||
baseEnd: number // exclusive
|
||||
hunk: DiffHunk
|
||||
side: "left" | "right"
|
||||
}
|
||||
|
||||
/**
|
||||
* Given diff hunks from base→ours and base→theirs, produce
|
||||
* a list of MergeHunks sorted by base position.
|
||||
*/
|
||||
export function computeMergeHunks(
|
||||
base: string,
|
||||
ours: string,
|
||||
theirs: string
|
||||
): MergeHunk[] {
|
||||
const baseLines = base.split("\n")
|
||||
const oursLines = ours.split("\n")
|
||||
const theirsLines = theirs.split("\n")
|
||||
|
||||
const leftDiffs = computeLineDiff(baseLines, oursLines)
|
||||
const rightDiffs = computeLineDiff(baseLines, theirsLines)
|
||||
|
||||
// Convert to ranged hunks for overlap detection
|
||||
const ranged: RangedHunk[] = []
|
||||
|
||||
for (const h of leftDiffs) {
|
||||
ranged.push({
|
||||
baseStart: h.baseStart,
|
||||
baseEnd: h.baseStart + Math.max(h.baseCount, 1), // at least 1 for insertions
|
||||
hunk: h,
|
||||
side: "left",
|
||||
})
|
||||
}
|
||||
for (const h of rightDiffs) {
|
||||
ranged.push({
|
||||
baseStart: h.baseStart,
|
||||
baseEnd: h.baseStart + Math.max(h.baseCount, 1),
|
||||
hunk: h,
|
||||
side: "right",
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by baseStart, then by side (left first)
|
||||
ranged.sort(
|
||||
(a, b) => a.baseStart - b.baseStart || (a.side === "left" ? -1 : 1)
|
||||
)
|
||||
|
||||
// Merge overlapping hunks from different sides into conflicts
|
||||
const mergeHunks: MergeHunk[] = []
|
||||
const used = new Set<number>()
|
||||
|
||||
for (let i = 0; i < ranged.length; i++) {
|
||||
if (used.has(i)) continue
|
||||
|
||||
const r = ranged[i]
|
||||
|
||||
// Check for overlapping hunk from the other side
|
||||
let paired: RangedHunk | null = null
|
||||
let pairedIdx = -1
|
||||
|
||||
for (let j = i + 1; j < ranged.length; j++) {
|
||||
if (used.has(j)) continue
|
||||
const s = ranged[j]
|
||||
if (s.side === r.side) continue
|
||||
// Check overlap: ranges [r.baseStart, r.baseEnd) and [s.baseStart, s.baseEnd)
|
||||
if (s.baseStart < r.baseEnd && r.baseStart < s.baseEnd) {
|
||||
paired = s
|
||||
pairedIdx = j
|
||||
break
|
||||
}
|
||||
// If s starts beyond r, no more overlaps possible
|
||||
if (s.baseStart >= r.baseEnd) break
|
||||
}
|
||||
|
||||
if (paired && pairedIdx >= 0) {
|
||||
used.add(pairedIdx)
|
||||
|
||||
// Check if both sides made identical changes — treat as non-conflict
|
||||
const leftH = r.side === "left" ? r.hunk : paired.hunk
|
||||
const rightH = r.side === "right" ? r.hunk : paired.hunk
|
||||
|
||||
const identical =
|
||||
leftH.baseStart === rightH.baseStart &&
|
||||
leftH.baseCount === rightH.baseCount &&
|
||||
leftH.newLines.length === rightH.newLines.length &&
|
||||
leftH.newLines.every((line, k) => line === rightH.newLines[k])
|
||||
|
||||
if (identical) {
|
||||
// Both sides made the same change — treat as left-only (auto-applicable)
|
||||
const bStart = Math.min(r.baseStart, paired.baseStart)
|
||||
const bEnd = Math.max(r.baseEnd, paired.baseEnd)
|
||||
mergeHunks.push({
|
||||
id: `hunk-${mergeHunks.length}`,
|
||||
baseStart: bStart,
|
||||
baseCount: bEnd - bStart,
|
||||
leftHunk: leftH,
|
||||
rightHunk: null,
|
||||
type: "left-only",
|
||||
})
|
||||
} else {
|
||||
// Conflict
|
||||
const bStart = Math.min(r.baseStart, paired.baseStart)
|
||||
const bEnd = Math.max(r.baseEnd, paired.baseEnd)
|
||||
mergeHunks.push({
|
||||
id: `hunk-${mergeHunks.length}`,
|
||||
baseStart: bStart,
|
||||
baseCount: bEnd - bStart,
|
||||
leftHunk: r.side === "left" ? r.hunk : paired.hunk,
|
||||
rightHunk: r.side === "right" ? r.hunk : paired.hunk,
|
||||
type: "conflict",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Single-side change
|
||||
mergeHunks.push({
|
||||
id: `hunk-${mergeHunks.length}`,
|
||||
baseStart: r.baseStart,
|
||||
baseCount: r.hunk.baseCount,
|
||||
leftHunk: r.side === "left" ? r.hunk : null,
|
||||
rightHunk: r.side === "right" ? r.hunk : null,
|
||||
type: r.side === "left" ? "left-only" : "right-only",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by baseStart
|
||||
mergeHunks.sort((a, b) => a.baseStart - b.baseStart)
|
||||
|
||||
return mergeHunks
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AppliedHunkInfo {
|
||||
id: string
|
||||
side: "left" | "right"
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the result content by starting from base and applying
|
||||
* hunks that have been accepted.
|
||||
*
|
||||
* @param base Original base content
|
||||
* @param hunks All merge hunks
|
||||
* @param applied Map of hunk id → which side was applied
|
||||
*/
|
||||
export function buildResult(
|
||||
base: string,
|
||||
hunks: MergeHunk[],
|
||||
applied: Map<string, "left" | "right">
|
||||
): string {
|
||||
const baseLines = base.split("\n")
|
||||
const result: string[] = []
|
||||
let baseIdx = 0
|
||||
|
||||
// Process hunks in order of baseStart
|
||||
const sorted = [...hunks].sort((a, b) => a.baseStart - b.baseStart)
|
||||
|
||||
for (const hunk of sorted) {
|
||||
// Copy unchanged base lines before this hunk
|
||||
while (baseIdx < hunk.baseStart) {
|
||||
result.push(baseLines[baseIdx])
|
||||
baseIdx++
|
||||
}
|
||||
|
||||
const appliedSide = applied.get(hunk.id)
|
||||
|
||||
if (appliedSide) {
|
||||
// Apply the chosen side's content
|
||||
const diffHunk = appliedSide === "left" ? hunk.leftHunk : hunk.rightHunk
|
||||
if (diffHunk) {
|
||||
result.push(...diffHunk.newLines)
|
||||
}
|
||||
// Skip over the base lines that were replaced
|
||||
baseIdx = hunk.baseStart + hunk.baseCount
|
||||
} else {
|
||||
// Not applied — keep base content
|
||||
for (let i = 0; i < hunk.baseCount; i++) {
|
||||
if (baseIdx < baseLines.length) {
|
||||
result.push(baseLines[baseIdx])
|
||||
baseIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy remaining base lines
|
||||
while (baseIdx < baseLines.length) {
|
||||
result.push(baseLines[baseIdx])
|
||||
baseIdx++
|
||||
}
|
||||
|
||||
return result.join("\n")
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
async function emitEvent(event: string, payload?: unknown) {
|
||||
try {
|
||||
const { emit } = await import("@tauri-apps/api/event")
|
||||
await emit(event, payload)
|
||||
} catch {
|
||||
/* not in Tauri */
|
||||
}
|
||||
}
|
||||
import { Check, FileWarning, Loader2, X, CheckCheck } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
gitListConflicts,
|
||||
gitConflictFileVersions,
|
||||
gitResolveConflict,
|
||||
gitAbortOperation,
|
||||
gitContinueOperation,
|
||||
gitStartPullMerge,
|
||||
} from "@/lib/api"
|
||||
import { languageFromPath } from "@/lib/language-detect"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import type { GitConflictFileVersions } from "@/lib/types"
|
||||
import { ThreePaneMergeEditor } from "./three-pane-merge-editor"
|
||||
|
||||
interface MergeWorkspaceProps {
|
||||
folderId: number
|
||||
folderPath: string
|
||||
operation: string
|
||||
upstreamCommit?: string
|
||||
onCompleted: () => void
|
||||
onAborted: () => void
|
||||
}
|
||||
|
||||
export function MergeWorkspace({
|
||||
folderId,
|
||||
folderPath,
|
||||
operation,
|
||||
upstreamCommit,
|
||||
onCompleted,
|
||||
onAborted,
|
||||
}: MergeWorkspaceProps) {
|
||||
const t = useTranslations("MergePage")
|
||||
const [files, setFiles] = useState<string[]>([])
|
||||
const [resolvedFiles, setResolvedFiles] = useState<Set<string>>(new Set())
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [versions, setVersions] = useState<GitConflictFileVersions | null>(null)
|
||||
const [loadingVersions, setLoadingVersions] = useState(false)
|
||||
const [resolving, setResolving] = useState(false)
|
||||
const [aborting, setAborting] = useState(false)
|
||||
const [completing, setCompleting] = useState(false)
|
||||
const currentContentRef = useRef<string>("")
|
||||
const [hasUnresolvedConflicts, setHasUnresolvedConflicts] = useState(true)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
|
||||
// Load conflict files on mount
|
||||
useEffect(() => {
|
||||
loadConflicts()
|
||||
}, [folderPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function loadConflicts() {
|
||||
try {
|
||||
// For pull operations, the merge was aborted during detection to keep
|
||||
// working tree clean. Re-start the merge to create conflict state.
|
||||
if (operation === "pull") {
|
||||
setPreparing(true)
|
||||
try {
|
||||
await gitStartPullMerge(folderPath, upstreamCommit)
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
}
|
||||
const conflictFiles = await gitListConflicts(folderPath)
|
||||
setFiles(conflictFiles)
|
||||
if (conflictFiles.length > 0 && !selectedFile) {
|
||||
selectFile(conflictFiles[0])
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(file: string) {
|
||||
setSelectedFile(file)
|
||||
setLoadingVersions(true)
|
||||
try {
|
||||
const v = await gitConflictFileVersions(folderPath, file)
|
||||
setVersions(v)
|
||||
currentContentRef.current = v.base
|
||||
setHasUnresolvedConflicts(true)
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
setVersions(null)
|
||||
} finally {
|
||||
setLoadingVersions(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentChange = useCallback((content: string) => {
|
||||
currentContentRef.current = content
|
||||
}, [])
|
||||
|
||||
const handleConflictStatusChange = useCallback((hasUnresolved: boolean) => {
|
||||
setHasUnresolvedConflicts(hasUnresolved)
|
||||
}, [])
|
||||
|
||||
async function handleResolve() {
|
||||
if (!selectedFile) return
|
||||
|
||||
const content = currentContentRef.current
|
||||
if (hasUnresolvedConflicts) {
|
||||
toast.warning(t("unresolvedConflicts"))
|
||||
return
|
||||
}
|
||||
|
||||
setResolving(true)
|
||||
try {
|
||||
await gitResolveConflict(folderPath, selectedFile, content)
|
||||
setResolvedFiles((prev) => new Set([...prev, selectedFile]))
|
||||
|
||||
// Notify parent window
|
||||
await emitEvent("folder://merge-conflict-resolved", {
|
||||
folder_id: folderId,
|
||||
file: selectedFile,
|
||||
})
|
||||
|
||||
// Auto-select next unresolved file
|
||||
const nextUnresolved = files.find(
|
||||
(f) => f !== selectedFile && !resolvedFiles.has(f)
|
||||
)
|
||||
if (nextUnresolved) {
|
||||
selectFile(nextUnresolved)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setResolving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAbort() {
|
||||
setAborting(true)
|
||||
try {
|
||||
await gitAbortOperation(folderPath, operation)
|
||||
toast.success(t("abortSuccess"))
|
||||
await emitEvent("folder://merge-aborted", { folder_id: folderId })
|
||||
onAborted()
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setAborting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
setCompleting(true)
|
||||
try {
|
||||
await gitContinueOperation(folderPath, operation)
|
||||
toast.success(t("allResolved"))
|
||||
await emitEvent("folder://merge-completed", { folder_id: folderId })
|
||||
onCompleted()
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setCompleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const allResolved =
|
||||
files.length > 0 && files.every((f) => resolvedFiles.has(f))
|
||||
|
||||
const language = selectedFile ? languageFromPath(selectedFile) : "plaintext"
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="flex-1 min-h-0 rounded-lg border"
|
||||
>
|
||||
{/* Left sidebar: conflict file list */}
|
||||
<ResizablePanel defaultSize={18} minSize={12} maxSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{t("conflictFiles")} ({files.length})
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-1">
|
||||
{files.map((file) => {
|
||||
const isResolved = resolvedFiles.has(file)
|
||||
const isSelected = file === selectedFile
|
||||
return (
|
||||
<button
|
||||
key={file}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={() => !isResolved && selectFile(file)}
|
||||
disabled={isResolved}
|
||||
>
|
||||
{isResolved ? (
|
||||
<Check className="h-3 w-3 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<FileWarning className="h-3 w-3 shrink-0 text-amber-500" />
|
||||
)}
|
||||
<span
|
||||
className={`truncate ${isResolved ? "text-muted-foreground line-through" : ""}`}
|
||||
>
|
||||
{file}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{files.length === 0 && (
|
||||
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
{t("noConflicts")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* Main area: three-pane merge editor */}
|
||||
<ResizablePanel defaultSize={82}>
|
||||
{preparing ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("preparingMerge")}
|
||||
</div>
|
||||
) : loadingVersions ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("loadingFile")}
|
||||
</div>
|
||||
) : versions && selectedFile ? (
|
||||
<ThreePaneMergeEditor
|
||||
key={selectedFile}
|
||||
base={versions.base}
|
||||
ours={versions.ours}
|
||||
theirs={versions.theirs}
|
||||
merged={versions.merged}
|
||||
language={language}
|
||||
onContentChange={handleContentChange}
|
||||
onConflictStatusChange={handleConflictStatusChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("selectFile")}
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleAbort}
|
||||
disabled={aborting || completing || resolving}
|
||||
>
|
||||
{aborting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
<X className="mr-1 h-3.5 w-3.5" />
|
||||
{t("abortMerge")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleResolve}
|
||||
disabled={
|
||||
!selectedFile ||
|
||||
resolving ||
|
||||
aborting ||
|
||||
completing ||
|
||||
(selectedFile !== null && resolvedFiles.has(selectedFile))
|
||||
}
|
||||
>
|
||||
{resolving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
<Check className="mr-1 h-3.5 w-3.5" />
|
||||
{t("markResolved")}
|
||||
</Button>
|
||||
{allResolved && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleComplete}
|
||||
disabled={completing || aborting}
|
||||
>
|
||||
{completing && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
<CheckCheck className="mr-1 h-3.5 w-3.5" />
|
||||
{t("completeMerge")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,769 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import dynamic from "next/dynamic"
|
||||
import type { OnMount } from "@monaco-editor/react"
|
||||
import type { editor as MonacoEditorNs, IRange } from "monaco-editor"
|
||||
import { ArrowLeft, ArrowRight, CheckCheck } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
||||
import { useZoomLevel } from "@/hooks/use-appearance"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const EDITOR_BASE_FONT_SIZE = 13
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import {
|
||||
computeLineDiff,
|
||||
computeMergeHunks,
|
||||
buildResult,
|
||||
type DiffHunk,
|
||||
type MergeHunk,
|
||||
} from "./merge-diff"
|
||||
import { useSyncScroll } from "./use-sync-scroll"
|
||||
|
||||
import "@/lib/monaco-local"
|
||||
|
||||
const MonacoEditor = dynamic(
|
||||
async () => {
|
||||
const mod = await import("@monaco-editor/react")
|
||||
return { default: mod.default }
|
||||
},
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
interface ThreePaneMergeEditorProps {
|
||||
base: string
|
||||
ours: string
|
||||
theirs: string
|
||||
merged: string
|
||||
language?: string
|
||||
className?: string
|
||||
onContentChange?: (content: string) => void
|
||||
onConflictStatusChange?: (hasUnresolved: boolean) => void
|
||||
}
|
||||
|
||||
export function ThreePaneMergeEditor({
|
||||
base,
|
||||
ours,
|
||||
theirs,
|
||||
language = "plaintext",
|
||||
className,
|
||||
onContentChange,
|
||||
onConflictStatusChange,
|
||||
}: ThreePaneMergeEditorProps) {
|
||||
const t = useTranslations("MergePage")
|
||||
const editorTheme = useMonacoThemeSync()
|
||||
const { zoomLevel } = useZoomLevel()
|
||||
const { registerEditor } = useSyncScroll()
|
||||
|
||||
const leftEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
|
||||
null
|
||||
)
|
||||
const centerEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
|
||||
null
|
||||
)
|
||||
const rightEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
|
||||
null
|
||||
)
|
||||
|
||||
// Decorations collections
|
||||
const leftDecorationsRef =
|
||||
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null)
|
||||
const centerDecorationsRef =
|
||||
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null)
|
||||
const rightDecorationsRef =
|
||||
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null)
|
||||
|
||||
// Scroll tick counter — incremented on every scroll to trigger gutter re-render
|
||||
const [scrollTick, setScrollTick] = useState(0)
|
||||
|
||||
// Merge state
|
||||
const mergeHunks = useMemo(
|
||||
() => computeMergeHunks(base, ours, theirs),
|
||||
[base, ours, theirs]
|
||||
)
|
||||
|
||||
// Track which hunks have been applied and which side was chosen
|
||||
const [appliedHunks, setAppliedHunks] = useState<
|
||||
Map<string, "left" | "right">
|
||||
>(new Map())
|
||||
|
||||
// Track ignored hunks
|
||||
const [ignoredHunks, setIgnoredHunks] = useState<Set<string>>(new Set())
|
||||
|
||||
const onContentChangeRef = useRef(onContentChange)
|
||||
const onConflictStatusChangeRef = useRef(onConflictStatusChange)
|
||||
|
||||
useEffect(() => {
|
||||
onContentChangeRef.current = onContentChange
|
||||
}, [onContentChange])
|
||||
|
||||
useEffect(() => {
|
||||
onConflictStatusChangeRef.current = onConflictStatusChange
|
||||
}, [onConflictStatusChange])
|
||||
|
||||
// Compute diffs for left/right pane decorations
|
||||
const baseLines = useMemo(() => base.split("\n"), [base])
|
||||
const leftDiffs = useMemo(
|
||||
() => computeLineDiff(baseLines, ours.split("\n")),
|
||||
[baseLines, ours]
|
||||
)
|
||||
const rightDiffs = useMemo(
|
||||
() => computeLineDiff(baseLines, theirs.split("\n")),
|
||||
[baseLines, theirs]
|
||||
)
|
||||
|
||||
// Build the result content from base + applied hunks
|
||||
const resultContent = useMemo(
|
||||
() => buildResult(base, mergeHunks, appliedHunks),
|
||||
[base, mergeHunks, appliedHunks]
|
||||
)
|
||||
|
||||
// Notify parent of content changes
|
||||
useEffect(() => {
|
||||
onContentChangeRef.current?.(resultContent)
|
||||
}, [resultContent])
|
||||
|
||||
// Notify parent of conflict status
|
||||
useEffect(() => {
|
||||
const hasUnresolved = mergeHunks.some(
|
||||
(h) =>
|
||||
h.type === "conflict" &&
|
||||
!appliedHunks.has(h.id) &&
|
||||
!ignoredHunks.has(h.id)
|
||||
)
|
||||
onConflictStatusChangeRef.current?.(hasUnresolved)
|
||||
}, [mergeHunks, appliedHunks, ignoredHunks])
|
||||
|
||||
// Apply hunk handler
|
||||
const applyHunk = useCallback((id: string, side: "left" | "right") => {
|
||||
setAppliedHunks((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(id, side)
|
||||
return next
|
||||
})
|
||||
setIgnoredHunks((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Sync center editor content when result changes
|
||||
useEffect(() => {
|
||||
const editor = centerEditorRef.current
|
||||
if (!editor) return
|
||||
const currentValue = editor.getValue()
|
||||
if (currentValue !== resultContent) {
|
||||
const pos = editor.getPosition()
|
||||
editor.setValue(resultContent)
|
||||
if (pos) editor.setPosition(pos)
|
||||
}
|
||||
}, [resultContent])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decorations for left (ours) pane
|
||||
// ---------------------------------------------------------------------------
|
||||
const applyLeftDecorations = useCallback(
|
||||
(editor: MonacoEditorNs.IStandaloneCodeEditor) => {
|
||||
const decorations: MonacoEditorNs.IModelDeltaDecoration[] = []
|
||||
const oursLines = ours.split("\n")
|
||||
|
||||
for (const hunk of leftDiffs) {
|
||||
const range = hunkToEditorRange(hunk, leftDiffs, oursLines.length)
|
||||
if (!range) continue
|
||||
|
||||
const cssClass =
|
||||
hunk.baseCount === 0
|
||||
? "merge-hunk-added-bg"
|
||||
: hunk.newLines.length === 0
|
||||
? "merge-hunk-removed-bg"
|
||||
: "merge-hunk-modified-bg"
|
||||
|
||||
decorations.push({
|
||||
range,
|
||||
options: { isWholeLine: true, className: cssClass },
|
||||
})
|
||||
}
|
||||
|
||||
if (leftDecorationsRef.current) {
|
||||
leftDecorationsRef.current.set(decorations)
|
||||
} else {
|
||||
leftDecorationsRef.current =
|
||||
editor.createDecorationsCollection(decorations)
|
||||
}
|
||||
},
|
||||
[leftDiffs, ours]
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decorations for right (theirs) pane
|
||||
// ---------------------------------------------------------------------------
|
||||
const applyRightDecorations = useCallback(
|
||||
(editor: MonacoEditorNs.IStandaloneCodeEditor) => {
|
||||
const decorations: MonacoEditorNs.IModelDeltaDecoration[] = []
|
||||
const theirsLines = theirs.split("\n")
|
||||
|
||||
for (const hunk of rightDiffs) {
|
||||
const range = hunkToEditorRange(hunk, rightDiffs, theirsLines.length)
|
||||
if (!range) continue
|
||||
|
||||
const cssClass =
|
||||
hunk.baseCount === 0
|
||||
? "merge-hunk-added-bg"
|
||||
: hunk.newLines.length === 0
|
||||
? "merge-hunk-removed-bg"
|
||||
: "merge-hunk-modified-bg"
|
||||
|
||||
decorations.push({
|
||||
range,
|
||||
options: { isWholeLine: true, className: cssClass },
|
||||
})
|
||||
}
|
||||
|
||||
if (rightDecorationsRef.current) {
|
||||
rightDecorationsRef.current.set(decorations)
|
||||
} else {
|
||||
rightDecorationsRef.current =
|
||||
editor.createDecorationsCollection(decorations)
|
||||
}
|
||||
},
|
||||
[rightDiffs, theirs]
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decorations for center (result) pane
|
||||
// ---------------------------------------------------------------------------
|
||||
const applyCenterDecorations = useCallback(
|
||||
(editor: MonacoEditorNs.IStandaloneCodeEditor) => {
|
||||
const decorations: MonacoEditorNs.IModelDeltaDecoration[] = []
|
||||
const currentLines = resultContent.split("\n")
|
||||
|
||||
let resultOffset = 0
|
||||
const sortedHunks = [...mergeHunks].sort(
|
||||
(a, b) => a.baseStart - b.baseStart
|
||||
)
|
||||
let lastBaseEnd = 0
|
||||
|
||||
for (const hunk of sortedHunks) {
|
||||
resultOffset += hunk.baseStart - lastBaseEnd
|
||||
|
||||
const isApplied = appliedHunks.has(hunk.id)
|
||||
const isIgnored = ignoredHunks.has(hunk.id)
|
||||
|
||||
let lineCount: number
|
||||
if (isApplied) {
|
||||
const side = appliedHunks.get(hunk.id)!
|
||||
const diffHunk = side === "left" ? hunk.leftHunk : hunk.rightHunk
|
||||
lineCount = diffHunk ? diffHunk.newLines.length : 0
|
||||
} else {
|
||||
lineCount = hunk.baseCount
|
||||
}
|
||||
|
||||
if (lineCount > 0) {
|
||||
const startLine = resultOffset + 1
|
||||
const endLine = resultOffset + lineCount
|
||||
|
||||
let cssClass: string
|
||||
if (isApplied) {
|
||||
cssClass = "merge-hunk-applied-bg"
|
||||
} else if (isIgnored) {
|
||||
cssClass = ""
|
||||
} else if (hunk.type === "conflict") {
|
||||
cssClass = "merge-hunk-conflict-bg"
|
||||
} else {
|
||||
cssClass = "merge-hunk-pending-bg"
|
||||
}
|
||||
|
||||
if (cssClass) {
|
||||
decorations.push({
|
||||
range: {
|
||||
startLineNumber: startLine,
|
||||
startColumn: 1,
|
||||
endLineNumber: Math.min(endLine, currentLines.length),
|
||||
endColumn: 1,
|
||||
},
|
||||
options: { isWholeLine: true, className: cssClass },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
resultOffset += lineCount
|
||||
lastBaseEnd = hunk.baseStart + hunk.baseCount
|
||||
}
|
||||
|
||||
if (centerDecorationsRef.current) {
|
||||
centerDecorationsRef.current.set(decorations)
|
||||
} else {
|
||||
centerDecorationsRef.current =
|
||||
editor.createDecorationsCollection(decorations)
|
||||
}
|
||||
},
|
||||
[mergeHunks, appliedHunks, ignoredHunks, resultContent]
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply decorations when state changes
|
||||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (leftEditorRef.current) {
|
||||
applyLeftDecorations(leftEditorRef.current)
|
||||
}
|
||||
}, [applyLeftDecorations])
|
||||
|
||||
useEffect(() => {
|
||||
if (centerEditorRef.current) {
|
||||
applyCenterDecorations(centerEditorRef.current)
|
||||
}
|
||||
}, [applyCenterDecorations])
|
||||
|
||||
useEffect(() => {
|
||||
if (rightEditorRef.current) {
|
||||
applyRightDecorations(rightEditorRef.current)
|
||||
}
|
||||
}, [applyRightDecorations])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor mount handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleLeftMount: OnMount = useCallback(
|
||||
(editor) => {
|
||||
leftEditorRef.current = editor
|
||||
registerEditor(editor, 0)
|
||||
applyLeftDecorations(editor)
|
||||
|
||||
// Also listen to left editor scroll to update gutter
|
||||
editor.onDidScrollChange(() => {
|
||||
setScrollTick((n) => n + 1)
|
||||
})
|
||||
|
||||
// Trigger initial gutter render after editor is ready
|
||||
requestAnimationFrame(() => {
|
||||
setScrollTick((n) => n + 1)
|
||||
})
|
||||
},
|
||||
[registerEditor, applyLeftDecorations]
|
||||
)
|
||||
|
||||
const handleCenterMount: OnMount = useCallback(
|
||||
(editor) => {
|
||||
centerEditorRef.current = editor
|
||||
registerEditor(editor, 1)
|
||||
applyCenterDecorations(editor)
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
const value = editor.getValue()
|
||||
onContentChangeRef.current?.(value)
|
||||
})
|
||||
},
|
||||
[registerEditor, applyCenterDecorations]
|
||||
)
|
||||
|
||||
const handleRightMount: OnMount = useCallback(
|
||||
(editor) => {
|
||||
rightEditorRef.current = editor
|
||||
registerEditor(editor, 2)
|
||||
applyRightDecorations(editor)
|
||||
|
||||
// Also listen to right editor scroll to update gutter
|
||||
editor.onDidScrollChange(() => {
|
||||
setScrollTick((n) => n + 1)
|
||||
})
|
||||
|
||||
// Trigger initial gutter render after editor is ready
|
||||
requestAnimationFrame(() => {
|
||||
setScrollTick((n) => n + 1)
|
||||
})
|
||||
},
|
||||
[registerEditor, applyRightDecorations]
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compute gutter arrow items (line numbers only, positions computed at render)
|
||||
// ---------------------------------------------------------------------------
|
||||
const leftGutterItems = useMemo(() => {
|
||||
const oursLines = ours.split("\n")
|
||||
const items: Array<{
|
||||
hunk: MergeHunk
|
||||
lineNumber: number
|
||||
}> = []
|
||||
|
||||
for (const hunk of mergeHunks) {
|
||||
if (!hunk.leftHunk) continue
|
||||
if (appliedHunks.has(hunk.id) || ignoredHunks.has(hunk.id)) continue
|
||||
|
||||
const range = hunkToEditorRange(
|
||||
hunk.leftHunk,
|
||||
leftDiffs,
|
||||
oursLines.length
|
||||
)
|
||||
if (!range) continue
|
||||
|
||||
items.push({ hunk, lineNumber: range.startLineNumber })
|
||||
}
|
||||
return items
|
||||
}, [mergeHunks, appliedHunks, ignoredHunks, leftDiffs, ours])
|
||||
|
||||
const rightGutterItems = useMemo(() => {
|
||||
const theirsLines = theirs.split("\n")
|
||||
const items: Array<{
|
||||
hunk: MergeHunk
|
||||
lineNumber: number
|
||||
}> = []
|
||||
|
||||
for (const hunk of mergeHunks) {
|
||||
if (!hunk.rightHunk) continue
|
||||
if (appliedHunks.has(hunk.id) || ignoredHunks.has(hunk.id)) continue
|
||||
|
||||
const range = hunkToEditorRange(
|
||||
hunk.rightHunk,
|
||||
rightDiffs,
|
||||
theirsLines.length
|
||||
)
|
||||
if (!range) continue
|
||||
|
||||
items.push({ hunk, lineNumber: range.startLineNumber })
|
||||
}
|
||||
return items
|
||||
}, [mergeHunks, appliedHunks, ignoredHunks, rightDiffs, theirs])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toolbar actions
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleApplyAllNonConflicting = useCallback(() => {
|
||||
setAppliedHunks((prev) => {
|
||||
const next = new Map(prev)
|
||||
for (const hunk of mergeHunks) {
|
||||
if (hunk.type === "left-only" && hunk.leftHunk && !next.has(hunk.id)) {
|
||||
next.set(hunk.id, "left")
|
||||
} else if (
|
||||
hunk.type === "right-only" &&
|
||||
hunk.rightHunk &&
|
||||
!next.has(hunk.id)
|
||||
) {
|
||||
next.set(hunk.id, "right")
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [mergeHunks])
|
||||
|
||||
const handleApplyLeftNonConflicting = useCallback(() => {
|
||||
setAppliedHunks((prev) => {
|
||||
const next = new Map(prev)
|
||||
for (const hunk of mergeHunks) {
|
||||
if (hunk.type === "left-only" && hunk.leftHunk && !next.has(hunk.id)) {
|
||||
next.set(hunk.id, "left")
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [mergeHunks])
|
||||
|
||||
const handleApplyRightNonConflicting = useCallback(() => {
|
||||
setAppliedHunks((prev) => {
|
||||
const next = new Map(prev)
|
||||
for (const hunk of mergeHunks) {
|
||||
if (
|
||||
hunk.type === "right-only" &&
|
||||
hunk.rightHunk &&
|
||||
!next.has(hunk.id)
|
||||
) {
|
||||
next.set(hunk.id, "right")
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [mergeHunks])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
const unresolvedConflicts = mergeHunks.filter(
|
||||
(h) =>
|
||||
h.type === "conflict" &&
|
||||
!appliedHunks.has(h.id) &&
|
||||
!ignoredHunks.has(h.id)
|
||||
).length
|
||||
const pendingNonConflicts = mergeHunks.filter(
|
||||
(h) =>
|
||||
h.type !== "conflict" &&
|
||||
!appliedHunks.has(h.id) &&
|
||||
!ignoredHunks.has(h.id)
|
||||
).length
|
||||
const totalChanges = mergeHunks.length
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor options
|
||||
// ---------------------------------------------------------------------------
|
||||
const editorOptions =
|
||||
useMemo<MonacoEditorNs.IStandaloneEditorConstructionOptions>(
|
||||
() => ({
|
||||
fontSize: (EDITOR_BASE_FONT_SIZE * zoomLevel) / 100,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
lineNumbers: "on",
|
||||
glyphMargin: true,
|
||||
folding: false,
|
||||
wordWrap: "off",
|
||||
overviewRulerLanes: 0,
|
||||
}),
|
||||
[zoomLevel]
|
||||
)
|
||||
|
||||
const readonlyOptions = useMemo(
|
||||
() => ({
|
||||
...editorOptions,
|
||||
readOnly: true,
|
||||
domReadOnly: true,
|
||||
}),
|
||||
[editorOptions]
|
||||
)
|
||||
|
||||
const loadingEl = (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||
Loading editor...
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center border-b bg-muted/50 px-3 py-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t("localVersion")}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-foreground">
|
||||
{t("result")}
|
||||
{unresolvedConflicts > 0 && (
|
||||
<span className="text-red-500">
|
||||
({unresolvedConflicts}{" "}
|
||||
{unresolvedConflicts === 1 ? "conflict" : "conflicts"})
|
||||
</span>
|
||||
)}
|
||||
{pendingNonConflicts > 0 && (
|
||||
<span className="text-amber-500">
|
||||
({pendingNonConflicts} pending)
|
||||
</span>
|
||||
)}
|
||||
{totalChanges > 0 &&
|
||||
unresolvedConflicts === 0 &&
|
||||
pendingNonConflicts === 0 && (
|
||||
<span className="text-green-500">
|
||||
<CheckCheck className="inline h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{pendingNonConflicts > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[10px]"
|
||||
onClick={handleApplyLeftNonConflicting}
|
||||
>
|
||||
<ArrowRight className="mr-0.5 h-2.5 w-2.5" />
|
||||
{t("applyLeftNonConflicting")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[10px]"
|
||||
onClick={handleApplyAllNonConflicting}
|
||||
>
|
||||
{t("applyAllNonConflicting")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[10px]"
|
||||
onClick={handleApplyRightNonConflicting}
|
||||
>
|
||||
{t("applyRightNonConflicting")}
|
||||
<ArrowLeft className="ml-0.5 h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t("remoteVersion")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Three-panel layout: [left editor + gutter] | center editor | [gutter + right editor] */}
|
||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
||||
{/* Left: Ours (local) + arrow gutter */}
|
||||
<ResizablePanel defaultSize={34} minSize={15}>
|
||||
<div className="flex h-full">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MonacoEditor
|
||||
value={ours}
|
||||
language={language}
|
||||
theme={editorTheme}
|
||||
beforeMount={defineMonacoThemes}
|
||||
onMount={handleLeftMount}
|
||||
loading={loadingEl}
|
||||
options={readonlyOptions}
|
||||
/>
|
||||
</div>
|
||||
<ArrowGutter
|
||||
items={leftGutterItems}
|
||||
direction="right"
|
||||
editorRef={leftEditorRef}
|
||||
scrollTick={scrollTick}
|
||||
onApply={(id) => applyHunk(id, "left")}
|
||||
title={t("acceptLocal")}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* Center: Result (editable) */}
|
||||
<ResizablePanel defaultSize={32} minSize={15}>
|
||||
<MonacoEditor
|
||||
defaultValue={base}
|
||||
language={language}
|
||||
theme={editorTheme}
|
||||
beforeMount={defineMonacoThemes}
|
||||
onMount={handleCenterMount}
|
||||
loading={loadingEl}
|
||||
options={editorOptions}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* Right: arrow gutter + Theirs (remote) */}
|
||||
<ResizablePanel defaultSize={34} minSize={15}>
|
||||
<div className="flex h-full">
|
||||
<ArrowGutter
|
||||
items={rightGutterItems}
|
||||
direction="left"
|
||||
editorRef={rightEditorRef}
|
||||
scrollTick={scrollTick}
|
||||
onApply={(id) => applyHunk(id, "right")}
|
||||
title={t("acceptRemote")}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<MonacoEditor
|
||||
value={theirs}
|
||||
language={language}
|
||||
theme={editorTheme}
|
||||
beforeMount={defineMonacoThemes}
|
||||
onMount={handleRightMount}
|
||||
loading={loadingEl}
|
||||
options={readonlyOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arrow Gutter Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ArrowGutterProps {
|
||||
items: Array<{ hunk: MergeHunk; lineNumber: number }>
|
||||
direction: "left" | "right"
|
||||
editorRef: React.RefObject<MonacoEditorNs.IStandaloneCodeEditor | null>
|
||||
scrollTick: number // triggers re-render on scroll
|
||||
onApply: (hunkId: string) => void
|
||||
title: string
|
||||
}
|
||||
|
||||
function ArrowGutter({
|
||||
items,
|
||||
direction,
|
||||
editorRef,
|
||||
scrollTick,
|
||||
onApply,
|
||||
title,
|
||||
}: ArrowGutterProps) {
|
||||
const editor = editorRef.current
|
||||
|
||||
const positioned = useMemo(() => {
|
||||
if (!editor) return []
|
||||
return items
|
||||
.map(({ hunk, lineNumber }) => {
|
||||
const pos = editor.getScrolledVisiblePosition({
|
||||
lineNumber,
|
||||
column: 1,
|
||||
})
|
||||
return pos ? { hunk, top: pos.top } : null
|
||||
})
|
||||
.filter((item): item is { hunk: MergeHunk; top: number } => item !== null)
|
||||
// scrollTick is included to recompute on scroll
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor, items, scrollTick])
|
||||
|
||||
return (
|
||||
<div className="merge-gutter-column">
|
||||
{positioned.map(({ hunk, top }) => (
|
||||
<button
|
||||
key={hunk.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"merge-gutter-arrow-btn",
|
||||
hunk.type === "conflict"
|
||||
? "merge-gutter-arrow-conflict"
|
||||
: "merge-gutter-arrow-accept"
|
||||
)}
|
||||
style={{ top: `${top}px` }}
|
||||
onClick={() => onApply(hunk.id)}
|
||||
title={title}
|
||||
>
|
||||
{direction === "right" ? "\u00BB" : "\u00AB"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a DiffHunk to an editor range in the modified file,
|
||||
* accounting for offset from previous hunks.
|
||||
*/
|
||||
function hunkToEditorRange(
|
||||
hunk: DiffHunk,
|
||||
allHunks: DiffHunk[],
|
||||
totalLines: number
|
||||
): IRange | null {
|
||||
let offset = 0
|
||||
for (const h of allHunks) {
|
||||
if (h.baseStart >= hunk.baseStart) break
|
||||
offset += h.newLines.length - h.baseCount
|
||||
}
|
||||
|
||||
if (hunk.newLines.length > 0) {
|
||||
const start = hunk.baseStart + offset + 1
|
||||
const end = start + hunk.newLines.length - 1
|
||||
return {
|
||||
startLineNumber: start,
|
||||
startColumn: 1,
|
||||
endLineNumber: Math.min(end, totalLines),
|
||||
endColumn: 1,
|
||||
}
|
||||
} else if (hunk.baseCount > 0) {
|
||||
const line = Math.min(hunk.baseStart + offset + 1, totalLines)
|
||||
return {
|
||||
startLineNumber: line,
|
||||
startColumn: 1,
|
||||
endLineNumber: line,
|
||||
endColumn: 1,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useCallback, useRef } from "react"
|
||||
import type { editor as MonacoEditorNs } from "monaco-editor"
|
||||
|
||||
type EditorInstance = MonacoEditorNs.IStandaloneCodeEditor
|
||||
|
||||
/**
|
||||
* Hook to synchronize scrolling between multiple Monaco editors.
|
||||
* Uses a flag to prevent infinite scroll loops.
|
||||
*/
|
||||
export function useSyncScroll() {
|
||||
const isSyncing = useRef(false)
|
||||
const editorsRef = useRef<EditorInstance[]>([])
|
||||
|
||||
const registerEditor = useCallback(
|
||||
(editor: EditorInstance, index: number) => {
|
||||
editorsRef.current[index] = editor
|
||||
|
||||
editor.onDidScrollChange(() => {
|
||||
if (isSyncing.current) return
|
||||
isSyncing.current = true
|
||||
|
||||
const scrollTop = editor.getScrollTop()
|
||||
const scrollLeft = editor.getScrollLeft()
|
||||
|
||||
for (let i = 0; i < editorsRef.current.length; i++) {
|
||||
if (i !== index && editorsRef.current[i]) {
|
||||
editorsRef.current[i].setScrollPosition({
|
||||
scrollTop,
|
||||
scrollLeft,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Use rAF to release the sync flag after all scroll events settle
|
||||
requestAnimationFrame(() => {
|
||||
isSyncing.current = false
|
||||
})
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return { registerEditor }
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { ShadcnLauncher } from "./shadcn/shadcn-launcher"
|
||||
|
||||
export function ProjectBootWorkspace() {
|
||||
const t = useTranslations("ProjectBoot")
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="shadcn" className="flex h-full flex-col gap-0">
|
||||
<div className="shrink-0 border-b px-4 py-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="shadcn">{t("tabs.shadcn")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="shadcn" className="min-h-0 flex-1">
|
||||
<ShadcnLauncher />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
// ── Preset encoding/decoding (matches shadcn v2 format) ─────────────
|
||||
|
||||
const PRESET_STYLES = ["nova", "vega", "maia", "lyra", "mira"] as const
|
||||
|
||||
const PRESET_BASE_COLORS = [
|
||||
"neutral",
|
||||
"stone",
|
||||
"zinc",
|
||||
"gray",
|
||||
"mauve",
|
||||
"olive",
|
||||
"mist",
|
||||
"taupe",
|
||||
] as const
|
||||
|
||||
const PRESET_THEMES = [
|
||||
"neutral",
|
||||
"stone",
|
||||
"zinc",
|
||||
"gray",
|
||||
"amber",
|
||||
"blue",
|
||||
"cyan",
|
||||
"emerald",
|
||||
"fuchsia",
|
||||
"green",
|
||||
"indigo",
|
||||
"lime",
|
||||
"orange",
|
||||
"pink",
|
||||
"purple",
|
||||
"red",
|
||||
"rose",
|
||||
"sky",
|
||||
"teal",
|
||||
"violet",
|
||||
"yellow",
|
||||
"mauve",
|
||||
"olive",
|
||||
"mist",
|
||||
"taupe",
|
||||
] as const
|
||||
|
||||
const PRESET_ICON_LIBRARIES = [
|
||||
"lucide",
|
||||
"hugeicons",
|
||||
"tabler",
|
||||
"phosphor",
|
||||
"remixicon",
|
||||
] as const
|
||||
|
||||
const PRESET_FONTS = [
|
||||
"inter",
|
||||
"noto-sans",
|
||||
"nunito-sans",
|
||||
"figtree",
|
||||
"roboto",
|
||||
"raleway",
|
||||
"dm-sans",
|
||||
"public-sans",
|
||||
"outfit",
|
||||
"jetbrains-mono",
|
||||
"geist",
|
||||
"geist-mono",
|
||||
"lora",
|
||||
"merriweather",
|
||||
"playfair-display",
|
||||
"noto-serif",
|
||||
"roboto-slab",
|
||||
"oxanium",
|
||||
"manrope",
|
||||
"space-grotesk",
|
||||
"montserrat",
|
||||
"ibm-plex-sans",
|
||||
"source-sans-3",
|
||||
"instrument-sans",
|
||||
] as const
|
||||
|
||||
const PRESET_FONT_HEADINGS = ["inherit", ...PRESET_FONTS] as const
|
||||
|
||||
const PRESET_RADII = ["default", "none", "small", "medium", "large"] as const
|
||||
|
||||
const PRESET_MENU_ACCENTS = ["subtle", "bold"] as const
|
||||
|
||||
const PRESET_MENU_COLORS = [
|
||||
"default",
|
||||
"inverted",
|
||||
"default-translucent",
|
||||
"inverted-translucent",
|
||||
] as const
|
||||
|
||||
/** V2 field layout for bit-packing (order must match shadcn exactly) */
|
||||
const PRESET_FIELDS_V2 = [
|
||||
{ key: "menuColor", values: PRESET_MENU_COLORS, bits: 3 },
|
||||
{ key: "menuAccent", values: PRESET_MENU_ACCENTS, bits: 3 },
|
||||
{ key: "radius", values: PRESET_RADII, bits: 4 },
|
||||
{ key: "font", values: PRESET_FONTS, bits: 6 },
|
||||
{ key: "iconLibrary", values: PRESET_ICON_LIBRARIES, bits: 6 },
|
||||
{ key: "theme", values: PRESET_THEMES, bits: 6 },
|
||||
{ key: "baseColor", values: PRESET_BASE_COLORS, bits: 6 },
|
||||
{ key: "style", values: PRESET_STYLES, bits: 6 },
|
||||
{ key: "chartColor", values: PRESET_THEMES, bits: 6 },
|
||||
{ key: "fontHeading", values: PRESET_FONT_HEADINGS, bits: 5 },
|
||||
] as const
|
||||
|
||||
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
function toBase62(num: number): string {
|
||||
if (num === 0) return "0"
|
||||
let result = ""
|
||||
let n = num
|
||||
while (n > 0) {
|
||||
result = BASE62[n % 62] + result
|
||||
n = Math.floor(n / 62)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Encode a preset config into a compact base62 code (v2 format). */
|
||||
export function encodePreset(config: PresetCodeConfig): string {
|
||||
const defaults: Record<string, string> = {
|
||||
menuColor: "default",
|
||||
menuAccent: "subtle",
|
||||
radius: "default",
|
||||
font: "inter",
|
||||
iconLibrary: "lucide",
|
||||
theme: "neutral",
|
||||
baseColor: "neutral",
|
||||
style: "nova",
|
||||
chartColor: config.theme ?? "neutral",
|
||||
fontHeading: "inherit",
|
||||
}
|
||||
const merged: Record<string, string> = { ...defaults }
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
if (v) merged[k] = v
|
||||
}
|
||||
let bits = 0
|
||||
let offset = 0
|
||||
for (const field of PRESET_FIELDS_V2) {
|
||||
const idx = (field.values as readonly string[]).indexOf(
|
||||
merged[field.key] ?? ""
|
||||
)
|
||||
bits += (idx === -1 ? 0 : idx) * 2 ** offset
|
||||
offset += field.bits
|
||||
}
|
||||
return "b" + toBase62(bits)
|
||||
}
|
||||
|
||||
// ── Config types ────────────────────────────────────────────────────
|
||||
|
||||
/** Fields that are encoded into the preset code (sent to CLI & preview). */
|
||||
export interface PresetCodeConfig {
|
||||
style: string
|
||||
baseColor: string
|
||||
theme: string
|
||||
chartColor: string
|
||||
iconLibrary: string
|
||||
font: string
|
||||
fontHeading: string
|
||||
radius: string
|
||||
menuAccent: string
|
||||
menuColor: string
|
||||
}
|
||||
|
||||
/** Full UI config (preset fields + non-preset fields like base/template). */
|
||||
export interface ShadcnPresetConfig extends PresetCodeConfig {
|
||||
base: string
|
||||
template: string
|
||||
}
|
||||
|
||||
export const DEFAULT_PRESET_CONFIG: ShadcnPresetConfig = {
|
||||
base: "radix",
|
||||
style: "nova",
|
||||
baseColor: "neutral",
|
||||
theme: "orange",
|
||||
chartColor: "orange",
|
||||
iconLibrary: "lucide",
|
||||
font: "inter",
|
||||
fontHeading: "inherit",
|
||||
radius: "default",
|
||||
menuAccent: "subtle",
|
||||
menuColor: "default",
|
||||
template: "start",
|
||||
}
|
||||
|
||||
// ── UI option arrays ────────────────────────────────────────────────
|
||||
|
||||
export const BASE_OPTIONS = [
|
||||
{ value: "radix", label: "Radix" },
|
||||
{ value: "base", label: "Base" },
|
||||
]
|
||||
|
||||
export const STYLE_OPTIONS = PRESET_STYLES.map((v) => ({
|
||||
value: v,
|
||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
||||
}))
|
||||
|
||||
export const BASE_COLOR_OPTIONS = PRESET_BASE_COLORS.map((v) => ({
|
||||
value: v,
|
||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
||||
}))
|
||||
|
||||
export const THEME_OPTIONS = PRESET_THEMES.map((v) => ({
|
||||
value: v,
|
||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
||||
}))
|
||||
|
||||
export const ICON_LIBRARY_OPTIONS = PRESET_ICON_LIBRARIES.map((v) => ({
|
||||
value: v,
|
||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
||||
}))
|
||||
|
||||
export const FONT_OPTIONS = PRESET_FONTS.map((v) => ({
|
||||
value: v,
|
||||
label: v
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" "),
|
||||
}))
|
||||
|
||||
export const FONT_HEADING_OPTIONS = PRESET_FONT_HEADINGS.map((v) => ({
|
||||
value: v,
|
||||
label:
|
||||
v === "inherit"
|
||||
? "Inherit"
|
||||
: v
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" "),
|
||||
}))
|
||||
|
||||
export const MENU_ACCENT_OPTIONS = PRESET_MENU_ACCENTS.map((v) => ({
|
||||
value: v,
|
||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
||||
}))
|
||||
|
||||
export const MENU_COLOR_OPTIONS = PRESET_MENU_COLORS.map((v) => ({
|
||||
value: v,
|
||||
label: v
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" "),
|
||||
}))
|
||||
|
||||
export const RADIUS_OPTIONS = PRESET_RADII.map((v) => ({
|
||||
value: v,
|
||||
label: v.charAt(0).toUpperCase() + v.slice(1),
|
||||
}))
|
||||
|
||||
export const TEMPLATE_OPTIONS = [{ value: "start", label: "Start" }]
|
||||
|
||||
export const FRAMEWORK_OPTIONS = [
|
||||
{ value: "next", label: "Next.js" },
|
||||
{ value: "vite", label: "Vite" },
|
||||
{ value: "start", label: "TanStack Start" },
|
||||
{ value: "react-router", label: "React Router" },
|
||||
{ value: "laravel", label: "Laravel" },
|
||||
{ value: "astro", label: "Astro" },
|
||||
]
|
||||
|
||||
export const PACKAGE_MANAGER_OPTIONS = [
|
||||
{ value: "pnpm", label: "pnpm" },
|
||||
{ value: "npm", label: "npm" },
|
||||
{ value: "yarn", label: "yarn" },
|
||||
{ value: "bun", label: "bun" },
|
||||
]
|
||||
|
||||
// ── URL builders ────────────────────────────────────────────────────
|
||||
|
||||
/** Build the preview iframe URL using a preset code. */
|
||||
export function buildPreviewUrl(_base: string, presetCode: string): string {
|
||||
return `https://ui.shadcn.com/preview/radix/preview-02?preset=${presetCode}`
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
ChevronsUpDown,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldLabel,
|
||||
FieldTitle,
|
||||
} from "@/components/ui/field"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||
import {
|
||||
createShadcnProject,
|
||||
openFolder,
|
||||
detectPackageManager,
|
||||
} from "@/lib/api"
|
||||
import { extractAppCommandError, toErrorMessage } from "@/lib/app-error"
|
||||
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
||||
import {
|
||||
BASE_OPTIONS,
|
||||
FRAMEWORK_OPTIONS,
|
||||
PACKAGE_MANAGER_OPTIONS,
|
||||
} from "./constants"
|
||||
|
||||
interface CreateProjectDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
presetCode: string
|
||||
}
|
||||
|
||||
export function CreateProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
presetCode,
|
||||
}: CreateProjectDialogProps) {
|
||||
const t = useTranslations("ProjectBoot")
|
||||
const [projectName, setProjectName] = useState("my-app")
|
||||
const [framework, setFramework] = useState("next")
|
||||
const [packageManager, setPackageManager] = useState("pnpm")
|
||||
const [saveDirectory, setSaveDirectory] = useState("")
|
||||
const [base, setBase] = useState("radix")
|
||||
const [rtl, setRtl] = useState(false)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [browserOpen, setBrowserOpen] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [pmVersion, setPmVersion] = useState<string | null>(null)
|
||||
const [pmInstalled, setPmInstalled] = useState<boolean | null>(null)
|
||||
const [pmChecking, setPmChecking] = useState(false)
|
||||
|
||||
const checkPackageManager = useCallback(async (name: string) => {
|
||||
setPmChecking(true)
|
||||
setPmInstalled(null)
|
||||
setPmVersion(null)
|
||||
try {
|
||||
const info = await detectPackageManager(name)
|
||||
setPmInstalled(info.installed)
|
||||
setPmVersion(info.version ?? null)
|
||||
} catch {
|
||||
setPmInstalled(false)
|
||||
setPmVersion(null)
|
||||
} finally {
|
||||
setPmChecking(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
checkPackageManager(packageManager)
|
||||
}
|
||||
}, [open, packageManager, checkPackageManager])
|
||||
|
||||
const handleBrowse = async () => {
|
||||
if (isDesktop()) {
|
||||
const result = await openFileDialog({ directory: true, multiple: false })
|
||||
if (!result) return
|
||||
const selected = Array.isArray(result) ? result[0] : result
|
||||
setSaveDirectory(selected)
|
||||
} else {
|
||||
setBrowserOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError(null)
|
||||
setCreating(true)
|
||||
try {
|
||||
const projectPath = await createShadcnProject({
|
||||
projectName,
|
||||
template: framework,
|
||||
presetCode,
|
||||
packageManager,
|
||||
targetDir: saveDirectory,
|
||||
})
|
||||
toast.success(t("toasts.createSuccess"))
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
await openFolder(projectPath)
|
||||
} catch (err) {
|
||||
const appErr = extractAppCommandError(err)
|
||||
const message =
|
||||
appErr?.code === "already_exists"
|
||||
? t("errors.directoryExists")
|
||||
: appErr?.code === "external_command_failed"
|
||||
? t("errors.commandFailed")
|
||||
: toErrorMessage(err)
|
||||
setError(message)
|
||||
toast.error(t("toasts.createFailed"), { description: message })
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setProjectName("my-app")
|
||||
setFramework("next")
|
||||
setPackageManager("pnpm")
|
||||
setSaveDirectory("")
|
||||
setBase("radix")
|
||||
setRtl(false)
|
||||
setAdvancedOpen(false)
|
||||
setError(null)
|
||||
setPmVersion(null)
|
||||
setPmInstalled(null)
|
||||
}
|
||||
|
||||
const canCreate =
|
||||
projectName.trim().length > 0 &&
|
||||
saveDirectory.trim().length > 0 &&
|
||||
pmInstalled === true
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v)
|
||||
if (!v) resetForm()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("createDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("createDialog.projectName")}</Label>
|
||||
<Input
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder={t("createDialog.projectNamePlaceholder")}
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("createDialog.saveDirectory")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={saveDirectory}
|
||||
onChange={(e) => setSaveDirectory(e.target.value)}
|
||||
placeholder={t("createDialog.saveDirectoryPlaceholder")}
|
||||
disabled={creating}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBrowse}
|
||||
disabled={creating}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{saveDirectory && projectName.trim() && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("createDialog.projectPath", {
|
||||
path: `${saveDirectory}/${projectName.trim()}`,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("createDialog.packageManager")}</Label>
|
||||
<Tabs
|
||||
value={packageManager}
|
||||
onValueChange={setPackageManager}
|
||||
className="gap-0"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
|
||||
<TabsTrigger
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="flex-1"
|
||||
disabled={creating}
|
||||
>
|
||||
{opt.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
|
||||
<TabsContent key={opt.value} value={opt.value}>
|
||||
<div className="flex h-8 items-center gap-1.5 text-sm">
|
||||
{pmChecking ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("createDialog.pmChecking")}
|
||||
</span>
|
||||
</>
|
||||
) : pmInstalled ? (
|
||||
<>
|
||||
<CircleCheck className="size-3.5 text-emerald-500" />
|
||||
<span className="text-muted-foreground">
|
||||
{opt.label} v{pmVersion}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CircleX className="size-3.5 text-destructive" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("createDialog.pmNotInstalled")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 px-0 text-xs text-muted-foreground"
|
||||
disabled={creating}
|
||||
>
|
||||
<ChevronsUpDown className="size-3.5" />
|
||||
{t("createDialog.advancedOptions")}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("createDialog.frameworkTemplate")}</Label>
|
||||
<RadioGroup
|
||||
value={framework}
|
||||
onValueChange={setFramework}
|
||||
disabled={creating}
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
{FRAMEWORK_OPTIONS.map((opt) => (
|
||||
<FieldLabel key={opt.value} htmlFor={`fw-${opt.value}`}>
|
||||
<Field orientation="horizontal">
|
||||
<FieldContent>
|
||||
<FieldTitle>{opt.label}</FieldTitle>
|
||||
</FieldContent>
|
||||
<RadioGroupItem
|
||||
value={opt.value}
|
||||
id={`fw-${opt.value}`}
|
||||
/>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("createDialog.base")}</Label>
|
||||
<RadioGroup
|
||||
value={base}
|
||||
onValueChange={setBase}
|
||||
disabled={creating}
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
{BASE_OPTIONS.map((opt) => (
|
||||
<FieldLabel key={opt.value} htmlFor={`base-${opt.value}`}>
|
||||
<Field orientation="horizontal">
|
||||
<FieldContent>
|
||||
<FieldTitle>{opt.label}</FieldTitle>
|
||||
</FieldContent>
|
||||
<RadioGroupItem
|
||||
value={opt.value}
|
||||
id={`base-${opt.value}`}
|
||||
/>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-lg border p-3">
|
||||
<Switch
|
||||
checked={rtl}
|
||||
onCheckedChange={setRtl}
|
||||
disabled={creating}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
{t("createDialog.enableRtl")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("createDialog.enableRtlDescription")}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={creating}
|
||||
>
|
||||
{t("createDialog.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!canCreate || creating}>
|
||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{creating ? t("createDialog.creating") : t("createDialog.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DirectoryBrowserDialog
|
||||
open={browserOpen}
|
||||
onOpenChange={setBrowserOpen}
|
||||
onSelect={(path) => setSaveDirectory(path)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
STYLE_OPTIONS,
|
||||
BASE_COLOR_OPTIONS,
|
||||
THEME_OPTIONS,
|
||||
ICON_LIBRARY_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
FONT_HEADING_OPTIONS,
|
||||
MENU_ACCENT_OPTIONS,
|
||||
MENU_COLOR_OPTIONS,
|
||||
RADIUS_OPTIONS,
|
||||
TEMPLATE_OPTIONS,
|
||||
type ShadcnPresetConfig,
|
||||
} from "./constants"
|
||||
import { CreateProjectDialog } from "./create-project-dialog"
|
||||
|
||||
interface ShadcnConfigPanelProps {
|
||||
config: ShadcnPresetConfig
|
||||
onConfigChange: (key: keyof ShadcnPresetConfig, value: string) => void
|
||||
presetCode: string
|
||||
}
|
||||
|
||||
function ConfigField({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: { value: string; label: string }[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h4 className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShadcnConfigPanel({
|
||||
config,
|
||||
onConfigChange,
|
||||
presetCode,
|
||||
}: ShadcnConfigPanelProps) {
|
||||
const t = useTranslations("ProjectBoot")
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const field = (
|
||||
key: keyof ShadcnPresetConfig,
|
||||
i18nKey: string,
|
||||
options: { value: string; label: string }[]
|
||||
) => (
|
||||
<ConfigField
|
||||
label={t(i18nKey as Parameters<typeof t>[0])}
|
||||
value={config[key]}
|
||||
options={options}
|
||||
onChange={(v) => onConfigChange(key, v)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<ScrollArea className="min-h-0 flex-1 px-4 py-3">
|
||||
<div className="space-y-4">
|
||||
{/* Style & Template */}
|
||||
<div className="space-y-2">
|
||||
<SectionHeader>{t("config.sectionStyle")}</SectionHeader>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
||||
{field("style", "config.style", STYLE_OPTIONS)}
|
||||
{field("template", "config.template", TEMPLATE_OPTIONS)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Colors */}
|
||||
<div className="space-y-2">
|
||||
<SectionHeader>{t("config.sectionColors")}</SectionHeader>
|
||||
<div className="space-y-2">
|
||||
{field("baseColor", "config.baseColor", BASE_COLOR_OPTIONS)}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
||||
{field("theme", "config.theme", THEME_OPTIONS)}
|
||||
{field("chartColor", "config.chartColor", THEME_OPTIONS)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Typography */}
|
||||
<div className="space-y-2">
|
||||
<SectionHeader>{t("config.sectionTypography")}</SectionHeader>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
||||
{field("font", "config.font", FONT_OPTIONS)}
|
||||
{field("fontHeading", "config.fontHeading", FONT_HEADING_OPTIONS)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Interface */}
|
||||
<div className="space-y-2">
|
||||
<SectionHeader>{t("config.sectionInterface")}</SectionHeader>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
||||
{field("iconLibrary", "config.iconLibrary", ICON_LIBRARY_OPTIONS)}
|
||||
{field("radius", "config.radius", RADIUS_OPTIONS)}
|
||||
{field("menuAccent", "config.menuAccent", MENU_ACCENT_OPTIONS)}
|
||||
{field("menuColor", "config.menuColor", MENU_COLOR_OPTIONS)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="shrink-0 border-t px-4 py-3">
|
||||
<Button className="w-full" onClick={() => setCreateOpen(true)}>
|
||||
{t("config.createProject")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CreateProjectDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
presetCode={presetCode}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState, useCallback, useRef, useEffect } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ShadcnConfigPanel } from "./shadcn-config-panel"
|
||||
import { ShadcnPreview } from "./shadcn-preview"
|
||||
import {
|
||||
DEFAULT_PRESET_CONFIG,
|
||||
encodePreset,
|
||||
buildPreviewUrl,
|
||||
type ShadcnPresetConfig,
|
||||
} from "./constants"
|
||||
|
||||
const MIN_WIDTH = 260
|
||||
const MAX_WIDTH = 420
|
||||
const DEFAULT_WIDTH = 320
|
||||
|
||||
export function ShadcnLauncher() {
|
||||
const [config, setConfig] = useState<ShadcnPresetConfig>(
|
||||
DEFAULT_PRESET_CONFIG
|
||||
)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_WIDTH)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const startXRef = useRef(0)
|
||||
const startWidthRef = useRef(0)
|
||||
|
||||
const presetCode = useMemo(() => encodePreset(config), [config])
|
||||
const previewUrl = useMemo(
|
||||
() => buildPreviewUrl(config.base, presetCode),
|
||||
[config.base, presetCode]
|
||||
)
|
||||
|
||||
const updateConfig = (key: keyof ShadcnPresetConfig, value: string) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
startXRef.current = e.clientX
|
||||
startWidthRef.current = sidebarWidth
|
||||
},
|
||||
[sidebarWidth]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const newWidth = Math.min(
|
||||
MAX_WIDTH,
|
||||
Math.max(
|
||||
MIN_WIDTH,
|
||||
startWidthRef.current + (e.clientX - startXRef.current)
|
||||
)
|
||||
)
|
||||
setSidebarWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div style={{ width: sidebarWidth }} className="shrink-0">
|
||||
<ShadcnConfigPanel
|
||||
config={config}
|
||||
onConfigChange={updateConfig}
|
||||
presetCode={presetCode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-20 flex w-px cursor-col-resize items-center justify-center",
|
||||
"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,background-color] before:duration-150 before:ease-out",
|
||||
"after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2",
|
||||
isDragging
|
||||
? "[--resize-handle-thickness:5px] before:bg-foreground/60"
|
||||
: "[--resize-handle-thickness:1px] hover:[--resize-handle-thickness:5px] hover:before:bg-foreground/40"
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn("min-w-0 flex-1", isDragging && "pointer-events-none")}
|
||||
>
|
||||
<ShadcnPreview previewUrl={previewUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
interface ShadcnPreviewProps {
|
||||
previewUrl: string
|
||||
}
|
||||
|
||||
export function ShadcnPreview({ previewUrl }: ShadcnPreviewProps) {
|
||||
const t = useTranslations("ProjectBoot")
|
||||
const [debouncedUrl, setDebouncedUrl] = useState(previewUrl)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDebouncedUrl(previewUrl)
|
||||
setLoading(true)
|
||||
}, 500)
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [previewUrl])
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{t("preview.loading")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
key={debouncedUrl}
|
||||
src={debouncedUrl}
|
||||
className="h-full w-full border-0"
|
||||
onLoad={() => setLoading(false)}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { createChatChannel, saveChatChannelToken } from "@/lib/api"
|
||||
import type { ChannelType } from "@/lib/types"
|
||||
|
||||
interface AddChatChannelDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onChannelAdded: () => void
|
||||
}
|
||||
|
||||
export function AddChatChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onChannelAdded,
|
||||
}: AddChatChannelDialogProps) {
|
||||
const t = useTranslations("ChatChannelSettings")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [channelType, setChannelType] = useState<ChannelType>("telegram")
|
||||
const [token, setToken] = useState("")
|
||||
const [chatId, setChatId] = useState("")
|
||||
const [appId, setAppId] = useState("")
|
||||
const [baseUrl, setBaseUrl] = useState("https://ilinkai.weixin.qq.com")
|
||||
const [dailyReportEnabled, setDailyReportEnabled] = useState(false)
|
||||
const [dailyReportTime, setDailyReportTime] = useState("18:00")
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setName("")
|
||||
setChannelType("telegram")
|
||||
setToken("")
|
||||
setChatId("")
|
||||
setAppId("")
|
||||
setBaseUrl("https://ilinkai.weixin.qq.com")
|
||||
setDailyReportEnabled(false)
|
||||
setDailyReportTime("18:00")
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen) resetForm()
|
||||
onOpenChange(nextOpen)
|
||||
},
|
||||
[onOpenChange, resetForm]
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!name.trim()) {
|
||||
setError(t("nameRequired"))
|
||||
return
|
||||
}
|
||||
if (channelType !== "weixin" && !token.trim()) {
|
||||
setError(t("tokenRequired"))
|
||||
return
|
||||
}
|
||||
if (channelType !== "weixin" && !chatId.trim()) {
|
||||
setError(t("chatIdRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const configJson =
|
||||
channelType === "weixin"
|
||||
? JSON.stringify({ base_url: baseUrl })
|
||||
: channelType === "lark"
|
||||
? JSON.stringify({ app_id: appId, chat_id: chatId })
|
||||
: JSON.stringify({ chat_id: chatId })
|
||||
|
||||
const channel = await createChatChannel({
|
||||
name: name.trim(),
|
||||
channelType,
|
||||
configJson,
|
||||
enabled: true,
|
||||
dailyReportEnabled,
|
||||
dailyReportTime: dailyReportEnabled ? dailyReportTime : null,
|
||||
})
|
||||
|
||||
if (channelType !== "weixin" && token.trim()) {
|
||||
await saveChatChannelToken(channel.id, token.trim())
|
||||
}
|
||||
|
||||
handleOpenChange(false)
|
||||
onChannelAdded()
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [
|
||||
name,
|
||||
token,
|
||||
chatId,
|
||||
channelType,
|
||||
appId,
|
||||
baseUrl,
|
||||
dailyReportEnabled,
|
||||
dailyReportTime,
|
||||
handleOpenChange,
|
||||
onChannelAdded,
|
||||
t,
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("addChannel")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">{t("channelName")}</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("channelNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">{t("channelType")}</label>
|
||||
<Select
|
||||
value={channelType}
|
||||
onValueChange={(v) => setChannelType(v as ChannelType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="telegram">Telegram</SelectItem>
|
||||
<SelectItem value="lark">{t("lark")}</SelectItem>
|
||||
<SelectItem value="weixin">{t("weixin")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{channelType === "lark" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">App ID</label>
|
||||
<Input
|
||||
value={appId}
|
||||
onChange={(e) => setAppId(e.target.value)}
|
||||
placeholder="cli_xxxxx"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channelType !== "weixin" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">
|
||||
{channelType === "telegram" ? "Bot Token" : "App Secret"}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder={
|
||||
channelType === "telegram" ? "123456:ABC-DEF..." : "xxxxx"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channelType !== "weixin" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">Chat ID</label>
|
||||
<Input
|
||||
value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)}
|
||||
placeholder={
|
||||
channelType === "telegram" ? "-100123456789" : "oc_xxxxx"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channelType === "weixin" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("weixinScanDescription")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium">{t("dailyReport")}</label>
|
||||
<Switch
|
||||
checked={dailyReportEnabled}
|
||||
onCheckedChange={setDailyReportEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dailyReportEnabled && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">
|
||||
{t("dailyReportTime")}
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
value={dailyReportTime}
|
||||
onChange={(e) => setDailyReportTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />}
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Loader2, Save } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { getChatCommandPrefix, setChatCommandPrefix } from "@/lib/api"
|
||||
|
||||
const BUILT_IN_COMMANDS = [
|
||||
{ name: "folder [n|path]", descKey: "folderDesc" },
|
||||
{ name: "agent [n|name]", descKey: "agentDesc" },
|
||||
{ name: "task <description>", descKey: "taskDesc" },
|
||||
{ name: "sessions", descKey: "sessionsDesc" },
|
||||
{ name: "resume [id]", descKey: "resumeDesc" },
|
||||
{ name: "cancel", descKey: "cancelDesc" },
|
||||
{ name: "approve [always]", descKey: "approveDesc" },
|
||||
{ name: "deny", descKey: "denyDesc" },
|
||||
{ name: "search <keyword>", descKey: "searchDesc" },
|
||||
{ name: "today", descKey: "todayDesc" },
|
||||
{ name: "status", descKey: "statusDesc" },
|
||||
{ name: "help", descKey: "helpDesc" },
|
||||
] as const
|
||||
|
||||
export function ChannelCommandsTab() {
|
||||
const t = useTranslations("ChatChannelSettings.commands")
|
||||
const [prefix, setPrefix] = useState("/")
|
||||
const [inputPrefix, setInputPrefix] = useState("/")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getChatCommandPrefix()
|
||||
.then((p) => {
|
||||
setPrefix(p)
|
||||
setInputPrefix(p)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleSavePrefix = useCallback(async () => {
|
||||
const trimmed = inputPrefix.trim()
|
||||
if (
|
||||
trimmed.length === 0 ||
|
||||
trimmed.length > 3 ||
|
||||
/[a-zA-Z0-9]/.test(trimmed)
|
||||
) {
|
||||
toast.error(t("prefixInvalid"))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await setChatCommandPrefix(trimmed)
|
||||
setPrefix(trimmed)
|
||||
toast.success(t("prefixSaved"))
|
||||
} catch {
|
||||
toast.error(t("prefixSaveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [inputPrefix, t])
|
||||
|
||||
const dirty = inputPrefix !== prefix
|
||||
|
||||
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="space-y-6">
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t("prefixLabel")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prefixDescription")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={inputPrefix}
|
||||
onChange={(e) => setInputPrefix(e.target.value)}
|
||||
className="w-20 text-center font-mono"
|
||||
maxLength={3}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!dirty || saving}
|
||||
onClick={handleSavePrefix}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t("title")}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t("description")}</p>
|
||||
<div className="space-y-1">
|
||||
{BUILT_IN_COMMANDS.map((cmd) => (
|
||||
<div
|
||||
key={cmd.name}
|
||||
className="flex items-center justify-between rounded-lg border bg-card px-4 py-3"
|
||||
>
|
||||
<code className="text-sm font-mono">
|
||||
{prefix}
|
||||
{cmd.name}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(cmd.descKey)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { getChatEventFilter, setChatEventFilter } from "@/lib/api"
|
||||
|
||||
const ALL_EVENT_TYPES = [
|
||||
{
|
||||
id: "turn_complete",
|
||||
labelKey: "turnComplete",
|
||||
descKey: "turnCompleteDesc",
|
||||
},
|
||||
{ id: "error", labelKey: "error", descKey: "errorDesc" },
|
||||
] as const
|
||||
|
||||
const ALL_IDS = ALL_EVENT_TYPES.map((e) => e.id)
|
||||
|
||||
function parseFilter(arr: string[] | null): Set<string> {
|
||||
if (!arr) return new Set(ALL_IDS)
|
||||
return new Set(arr)
|
||||
}
|
||||
|
||||
export function ChannelEventsTab() {
|
||||
const t = useTranslations("ChatChannelSettings.events")
|
||||
const [enabledEvents, setEnabledEvents] = useState<Set<string>>(
|
||||
new Set(ALL_IDS)
|
||||
)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getChatEventFilter()
|
||||
.then((arr) => setEnabledEvents(parseFilter(arr)))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (eventId: string, checked: boolean) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const next = new Set(enabledEvents)
|
||||
if (checked) {
|
||||
next.add(eventId)
|
||||
} else {
|
||||
next.delete(eventId)
|
||||
}
|
||||
const isAll = next.size === ALL_EVENT_TYPES.length
|
||||
await setChatEventFilter(isAll ? null : [...next])
|
||||
setEnabledEvents(next)
|
||||
toast.success(t("saved"))
|
||||
} catch {
|
||||
toast.error(t("saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[enabledEvents, t]
|
||||
)
|
||||
|
||||
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="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">{t("description")}</p>
|
||||
|
||||
<section className="space-y-1">
|
||||
{ALL_EVENT_TYPES.map((evt) => (
|
||||
<div
|
||||
key={evt.id}
|
||||
className="flex items-center justify-between rounded-lg border bg-card px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t(evt.labelKey)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t(evt.descKey)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabledEvents.has(evt.id)}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) => handleToggle(evt.id, checked)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Square,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import {
|
||||
listChatChannels,
|
||||
deleteChatChannel,
|
||||
connectChatChannel,
|
||||
disconnectChatChannel,
|
||||
testChatChannel,
|
||||
updateChatChannel,
|
||||
getChatChannelStatus,
|
||||
} from "@/lib/api"
|
||||
import { subscribe } from "@/lib/platform"
|
||||
import type {
|
||||
ChatChannelInfo,
|
||||
ChannelStatusInfo,
|
||||
ChannelType,
|
||||
} from "@/lib/types"
|
||||
import { AddChatChannelDialog } from "./add-chat-channel-dialog"
|
||||
import { EditChatChannelDialog } from "./edit-chat-channel-dialog"
|
||||
import { WeixinQrcodeDialog } from "./weixin-qrcode-dialog"
|
||||
|
||||
export function ChannelListTab() {
|
||||
const t = useTranslations("ChatChannelSettings")
|
||||
const [channels, setChannels] = useState<ChatChannelInfo[]>([])
|
||||
const [statuses, setStatuses] = useState<ChannelStatusInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<ChatChannelInfo | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<ChatChannelInfo | null>(null)
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null)
|
||||
const [qrcodeChannelId, setQrcodeChannelId] = useState<number | null>(null)
|
||||
|
||||
const loadChannels = useCallback(async () => {
|
||||
try {
|
||||
const [chs, sts] = await Promise.all([
|
||||
listChatChannels(),
|
||||
getChatChannelStatus().catch(() => []),
|
||||
])
|
||||
setChannels(chs)
|
||||
setStatuses(sts)
|
||||
} catch {
|
||||
toast.error(t("loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
loadChannels().catch(console.error)
|
||||
}, [loadChannels])
|
||||
|
||||
// Subscribe to real-time status change events from backend
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let unsub: (() => void) | undefined
|
||||
subscribe<{
|
||||
channel_id: number
|
||||
status: ChannelStatusInfo["status"]
|
||||
}>("chat-channel://status", (payload) => {
|
||||
setStatuses((prev) => {
|
||||
const idx = prev.findIndex((s) => s.channel_id === payload.channel_id)
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev]
|
||||
updated[idx] = { ...updated[idx], status: payload.status }
|
||||
return updated
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}).then((fn) => {
|
||||
if (cancelled) fn()
|
||||
else unsub = fn
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsub?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (ch: ChatChannelInfo, connected: boolean) => {
|
||||
try {
|
||||
const disabling = ch.enabled
|
||||
if (disabling && connected) {
|
||||
await disconnectChatChannel(ch.id)
|
||||
}
|
||||
await updateChatChannel({ id: ch.id, enabled: !ch.enabled })
|
||||
await loadChannels()
|
||||
} catch {
|
||||
toast.error(t("saveFailed"))
|
||||
}
|
||||
},
|
||||
[loadChannels, t]
|
||||
)
|
||||
|
||||
const handleConnect = useCallback(
|
||||
async (id: number, channelType?: ChannelType) => {
|
||||
setActionLoading(id)
|
||||
try {
|
||||
await connectChatChannel(id)
|
||||
toast.success(t("connectSuccess"))
|
||||
await loadChannels()
|
||||
} catch (err: unknown) {
|
||||
if (channelType === "weixin") {
|
||||
// No token or token expired — show QR code dialog
|
||||
setQrcodeChannelId(id)
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
toast.error(t("connectFailed") + ": " + msg)
|
||||
}
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
},
|
||||
[loadChannels, t]
|
||||
)
|
||||
|
||||
const handleWeixinAuthSuccess = useCallback(
|
||||
async (channelId: number) => {
|
||||
setQrcodeChannelId(null)
|
||||
setActionLoading(channelId)
|
||||
try {
|
||||
await connectChatChannel(channelId)
|
||||
toast.success(t("connectSuccess"))
|
||||
await loadChannels()
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
toast.error(t("connectFailed") + ": " + msg)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
},
|
||||
[loadChannels, t]
|
||||
)
|
||||
|
||||
const handleDisconnect = useCallback(
|
||||
async (id: number) => {
|
||||
setActionLoading(id)
|
||||
try {
|
||||
await disconnectChatChannel(id)
|
||||
toast.success(t("disconnectSuccess"))
|
||||
await loadChannels()
|
||||
} catch {
|
||||
toast.error(t("disconnectFailed"))
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
},
|
||||
[loadChannels, t]
|
||||
)
|
||||
|
||||
const handleTest = useCallback(
|
||||
async (id: number) => {
|
||||
setActionLoading(id)
|
||||
try {
|
||||
await testChatChannel(id)
|
||||
toast.success(t("testSuccess"))
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
toast.error(t("testFailed") + ": " + msg)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return
|
||||
try {
|
||||
await deleteChatChannel(deleteTarget.id)
|
||||
toast.success(t("deleteSuccess"))
|
||||
setDeleteTarget(null)
|
||||
await loadChannels()
|
||||
} catch {
|
||||
toast.error(t("deleteFailed"))
|
||||
}
|
||||
}, [deleteTarget, loadChannels, t])
|
||||
|
||||
const getChannelStatus = (id: number) =>
|
||||
statuses.find((s) => s.channel_id === id)?.status ?? "disconnected"
|
||||
|
||||
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" />
|
||||
{t("loading")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{t("channelListTitle")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("channelListDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
{t("addChannel")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{channels.length === 0 ? (
|
||||
<section className="rounded-xl border bg-card p-8 text-center">
|
||||
<MessageCircle className="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">{t("noChannels")}</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="space-y-2">
|
||||
{channels.map((ch) => {
|
||||
const status = getChannelStatus(ch.id)
|
||||
const isConnected = status === "connected"
|
||||
const isLoading = actionLoading === ch.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="rounded-xl border bg-card p-4 flex items-center gap-4"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{ch.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs inline-flex items-center gap-1"
|
||||
>
|
||||
{ch.channel_type}
|
||||
{ch.channel_type === "weixin" && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="inline-flex cursor-help rounded-sm text-yellow-600 outline-none focus-visible:ring-1 focus-visible:ring-ring dark:text-yellow-500"
|
||||
aria-label={t("weixinReconnectNotice")}
|
||||
>
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t("weixinReconnectNotice")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Badge>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
isConnected
|
||||
? "bg-green-500"
|
||||
: status === "connecting"
|
||||
? "bg-yellow-500 animate-pulse"
|
||||
: status === "error"
|
||||
? "bg-red-500"
|
||||
: "bg-gray-400"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{ch.daily_report_enabled && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("dailyReport")}: {ch.daily_report_time || "18:00"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={ch.enabled}
|
||||
onCheckedChange={() => handleToggleEnabled(ch, isConnected)}
|
||||
/>
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
title={t("disconnect")}
|
||||
disabled={isLoading}
|
||||
onClick={() => handleDisconnect(ch.id)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t("connect")}
|
||||
disabled={isLoading || !ch.enabled}
|
||||
onClick={() => handleConnect(ch.id, ch.channel_type)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t("test")}
|
||||
disabled={isLoading}
|
||||
onClick={() => handleTest(ch.id)}
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t("editChannel")}
|
||||
disabled={isConnected || isLoading}
|
||||
onClick={() => setEditTarget(ch)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t("delete")}
|
||||
onClick={() => setDeleteTarget(ch)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<AddChatChannelDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
onChannelAdded={loadChannels}
|
||||
/>
|
||||
|
||||
{editTarget && (
|
||||
<EditChatChannelDialog
|
||||
open={!!editTarget}
|
||||
channel={editTarget}
|
||||
onOpenChange={(open) => !open && setEditTarget(null)}
|
||||
onChannelUpdated={loadChannels}
|
||||
/>
|
||||
)}
|
||||
|
||||
{qrcodeChannelId !== null && (
|
||||
<WeixinQrcodeDialog
|
||||
open
|
||||
channelId={qrcodeChannelId}
|
||||
onOpenChange={(open) => !open && setQrcodeChannelId(null)}
|
||||
onAuthSuccess={handleWeixinAuthSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteConfirmMessage")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { getChatMessageLanguage, setChatMessageLanguage } from "@/lib/api"
|
||||
|
||||
const SUPPORTED_LANGUAGES = [
|
||||
"en",
|
||||
"zh-cn",
|
||||
"zh-tw",
|
||||
"ja",
|
||||
"ko",
|
||||
"es",
|
||||
"de",
|
||||
"fr",
|
||||
"pt",
|
||||
"ar",
|
||||
] as const
|
||||
|
||||
export function ChannelOtherTab() {
|
||||
const t = useTranslations("ChatChannelSettings.language")
|
||||
const [language, setLanguage] = useState("en")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getChatMessageLanguage()
|
||||
.then((lang) => setLanguage(lang))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
async (value: string) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await setChatMessageLanguage(value)
|
||||
setLanguage(value)
|
||||
toast.success(t("saved"))
|
||||
} catch {
|
||||
toast.error(t("saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
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="space-y-6">
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t("title")}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t("description")}</p>
|
||||
<Select
|
||||
value={language}
|
||||
onValueChange={handleLanguageChange}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang} value={lang}>
|
||||
{t(lang)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ChannelListTab } from "./channel-list-tab"
|
||||
import { ChannelCommandsTab } from "./channel-commands-tab"
|
||||
import { ChannelEventsTab } from "./channel-events-tab"
|
||||
import { ChannelOtherTab } from "./channel-other-tab"
|
||||
|
||||
export function ChatChannelSettings() {
|
||||
const t = useTranslations("ChatChannelSettings")
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<Tabs defaultValue="channels" className="w-full space-y-4 p-3 md:p-4">
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold">{t("sectionTitle")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sectionDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<TabsList>
|
||||
<TabsTrigger value="channels">{t("tabs.channels")}</TabsTrigger>
|
||||
<TabsTrigger value="commands">{t("tabs.commands")}</TabsTrigger>
|
||||
<TabsTrigger value="events">{t("tabs.events")}</TabsTrigger>
|
||||
<TabsTrigger value="other">{t("tabs.other")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</section>
|
||||
|
||||
<TabsContent value="channels" className="mt-0">
|
||||
<ChannelListTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="commands" className="mt-0">
|
||||
<ChannelCommandsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="events" className="mt-0">
|
||||
<ChannelEventsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="other" className="mt-0">
|
||||
<ChannelOtherTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
updateChatChannel,
|
||||
saveChatChannelToken,
|
||||
getChatChannelHasToken,
|
||||
} from "@/lib/api"
|
||||
import type { ChatChannelInfo } from "@/lib/types"
|
||||
|
||||
interface EditChatChannelDialogProps {
|
||||
open: boolean
|
||||
channel: ChatChannelInfo
|
||||
onOpenChange: (open: boolean) => void
|
||||
onChannelUpdated: () => void
|
||||
}
|
||||
|
||||
export function EditChatChannelDialog({
|
||||
open,
|
||||
channel,
|
||||
onOpenChange,
|
||||
onChannelUpdated,
|
||||
}: EditChatChannelDialogProps) {
|
||||
const t = useTranslations("ChatChannelSettings")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const config = JSON.parse(channel.config_json || "{}")
|
||||
const [name, setName] = useState(channel.name)
|
||||
const [token, setToken] = useState("")
|
||||
const [chatId, setChatId] = useState(config.chat_id ?? "")
|
||||
const [appId, setAppId] = useState(config.app_id ?? "")
|
||||
const [baseUrl] = useState(config.base_url ?? "")
|
||||
const [dailyReportEnabled, setDailyReportEnabled] = useState(
|
||||
channel.daily_report_enabled
|
||||
)
|
||||
const [dailyReportTime, setDailyReportTime] = useState(
|
||||
channel.daily_report_time || "18:00"
|
||||
)
|
||||
const [hasToken, setHasToken] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
getChatChannelHasToken(channel.id)
|
||||
.then(setHasToken)
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [open, channel.id])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!name.trim()) {
|
||||
setError(t("nameRequired"))
|
||||
return
|
||||
}
|
||||
if (channel.channel_type !== "weixin" && !chatId.trim()) {
|
||||
setError(t("chatIdRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const configJson =
|
||||
channel.channel_type === "weixin"
|
||||
? JSON.stringify({ base_url: baseUrl })
|
||||
: channel.channel_type === "lark"
|
||||
? JSON.stringify({ app_id: appId, chat_id: chatId })
|
||||
: JSON.stringify({ chat_id: chatId })
|
||||
|
||||
await updateChatChannel({
|
||||
id: channel.id,
|
||||
name: name.trim(),
|
||||
configJson,
|
||||
dailyReportEnabled,
|
||||
dailyReportTime: dailyReportEnabled ? dailyReportTime : null,
|
||||
})
|
||||
|
||||
if (token.trim()) {
|
||||
await saveChatChannelToken(channel.id, token.trim())
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
onChannelUpdated()
|
||||
toast.success(t("editSuccess"))
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [
|
||||
name,
|
||||
token,
|
||||
chatId,
|
||||
channel,
|
||||
appId,
|
||||
baseUrl,
|
||||
dailyReportEnabled,
|
||||
dailyReportTime,
|
||||
onOpenChange,
|
||||
onChannelUpdated,
|
||||
t,
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("editChannel")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">{t("channelName")}</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("channelNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{channel.channel_type === "lark" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">App ID</label>
|
||||
<Input
|
||||
value={appId}
|
||||
onChange={(e) => setAppId(e.target.value)}
|
||||
placeholder="cli_xxxxx"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channel.channel_type !== "weixin" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">
|
||||
{channel.channel_type === "telegram"
|
||||
? "Bot Token"
|
||||
: "App Secret"}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder={
|
||||
hasToken ? t("tokenPlaceholderKeep") : t("tokenRequired")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channel.channel_type !== "weixin" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">Chat ID</label>
|
||||
<Input
|
||||
value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)}
|
||||
placeholder={
|
||||
channel.channel_type === "telegram"
|
||||
? "-100123456789"
|
||||
: "oc_xxxxx"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channel.channel_type === "weixin" && baseUrl && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">Base URL</label>
|
||||
<Input value={baseUrl} disabled />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium">{t("dailyReport")}</label>
|
||||
<Switch
|
||||
checked={dailyReportEnabled}
|
||||
onCheckedChange={setDailyReportEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dailyReportEnabled && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">
|
||||
{t("dailyReportTime")}
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
value={dailyReportTime}
|
||||
onChange={(e) => setDailyReportTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />}
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -11,11 +11,9 @@ import {
|
||||
Bot,
|
||||
BookOpenText,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Keyboard,
|
||||
Menu,
|
||||
MessageSquareText,
|
||||
SendHorizontal,
|
||||
Palette,
|
||||
PlugZap,
|
||||
Server,
|
||||
@@ -28,7 +26,6 @@ import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { detectEnvironment } from "@/lib/transport/detect"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||
@@ -45,9 +42,7 @@ interface SettingsNavItem {
|
||||
| "quick_messages"
|
||||
| "shortcuts"
|
||||
| "version_control"
|
||||
| "chat_channels"
|
||||
| "system"
|
||||
| "web_service"
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
@@ -97,16 +92,6 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
labelKey: "version_control",
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
href: "/settings/chat-channels",
|
||||
labelKey: "chat_channels",
|
||||
icon: SendHorizontal,
|
||||
},
|
||||
{
|
||||
href: "/settings/web-service",
|
||||
labelKey: "web_service",
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
href: "/settings/system",
|
||||
labelKey: "system",
|
||||
@@ -165,18 +150,13 @@ export function SettingsShell({ children }: SettingsShellProps) {
|
||||
[router, setNavOpen]
|
||||
)
|
||||
|
||||
const filteredNavItems = SETTINGS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
!(item.labelKey === "web_service" && detectEnvironment() === "web")
|
||||
)
|
||||
|
||||
const navContent = (
|
||||
<>
|
||||
<div className="px-1 pb-2 text-[11px] font-medium text-muted-foreground">
|
||||
{t("preferences")}
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{filteredNavItems.map((item) => {
|
||||
{SETTINGS_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const translationKey = `nav.${item.labelKey}` as const
|
||||
const active =
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { AlertCircle, Loader2, RefreshCw } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { weixinGetQrcode, weixinCheckQrcode } from "@/lib/api"
|
||||
|
||||
/** Client-side QR code expiry (5 minutes). */
|
||||
const QR_EXPIRY_MS = 5 * 60 * 1000
|
||||
/** Show a warning after this many consecutive polling failures. */
|
||||
const POLL_ERROR_WARN_THRESHOLD = 3
|
||||
|
||||
interface WeixinQrcodeDialogProps {
|
||||
open: boolean
|
||||
channelId: number
|
||||
onOpenChange: (open: boolean) => void
|
||||
onAuthSuccess: (channelId: number) => void
|
||||
}
|
||||
|
||||
function WeixinQrcodeContent({
|
||||
channelId,
|
||||
onAuthSuccess,
|
||||
onClose,
|
||||
}: {
|
||||
channelId: number
|
||||
onAuthSuccess: (channelId: number) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const t = useTranslations("ChatChannelSettings")
|
||||
const [qrcodeImg, setQrcodeImg] = useState<string | null>(null)
|
||||
const [qrcodeId, setQrcodeId] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<"loading" | "waiting" | "expired">(
|
||||
"loading"
|
||||
)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pollErrors, setPollErrors] = useState(0)
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const expiryRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Stabilise callbacks via ref so the polling effect doesn't re-trigger
|
||||
const onAuthSuccessRef = useRef(onAuthSuccess)
|
||||
const onCloseRef = useRef(onClose)
|
||||
useEffect(() => {
|
||||
onAuthSuccessRef.current = onAuthSuccess
|
||||
onCloseRef.current = onClose
|
||||
})
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current)
|
||||
pollingRef.current = null
|
||||
}
|
||||
if (expiryRef.current) {
|
||||
clearTimeout(expiryRef.current)
|
||||
expiryRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchQrcode = useCallback(async () => {
|
||||
setStatus("loading")
|
||||
setError(null)
|
||||
setQrcodeImg(null)
|
||||
setQrcodeId(null)
|
||||
setPollErrors(0)
|
||||
stopPolling()
|
||||
|
||||
try {
|
||||
const result = await weixinGetQrcode()
|
||||
setQrcodeId(result.qrcode_id)
|
||||
|
||||
if (result.qrcode_img_content) {
|
||||
const raw = result.qrcode_img_content
|
||||
const imgSrc = raw.startsWith("data:")
|
||||
? raw
|
||||
: `data:image/png;base64,${raw}`
|
||||
setQrcodeImg(imgSrc)
|
||||
}
|
||||
|
||||
setStatus("waiting")
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
setError(msg)
|
||||
setStatus("expired")
|
||||
}
|
||||
}, [stopPolling])
|
||||
|
||||
// Fetch QR code on mount + cleanup on unmount
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount
|
||||
fetchQrcode()
|
||||
return () => stopPolling()
|
||||
}, [fetchQrcode, stopPolling])
|
||||
|
||||
// Start polling + expiry timer when we have a qrcodeId
|
||||
useEffect(() => {
|
||||
if (!qrcodeId || status !== "waiting") return
|
||||
|
||||
// Client-side expiry guard
|
||||
expiryRef.current = setTimeout(() => {
|
||||
stopPolling()
|
||||
setStatus("expired")
|
||||
}, QR_EXPIRY_MS)
|
||||
|
||||
pollingRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await weixinCheckQrcode(channelId, qrcodeId)
|
||||
setPollErrors(0)
|
||||
if (result.status === "confirmed") {
|
||||
stopPolling()
|
||||
onAuthSuccessRef.current(channelId)
|
||||
onCloseRef.current()
|
||||
} else if (result.status === "expired") {
|
||||
stopPolling()
|
||||
setStatus("expired")
|
||||
}
|
||||
} catch {
|
||||
setPollErrors((n) => n + 1)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => stopPolling()
|
||||
}, [qrcodeId, status, channelId, stopPolling])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t("weixinScanDescription")}
|
||||
</p>
|
||||
|
||||
{status === "loading" && (
|
||||
<div className="flex h-48 w-48 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "waiting" && qrcodeImg && (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={qrcodeImg}
|
||||
alt="WeChat QR Code"
|
||||
className="h-48 w-48 rounded-md"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{t("weixinWaitingScan")}
|
||||
</p>
|
||||
{pollErrors >= POLL_ERROR_WARN_THRESHOLD && (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-yellow-500/30 bg-yellow-500/5 px-3 py-1.5 text-xs text-yellow-500">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
{t("weixinPollError")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "expired" && (
|
||||
<>
|
||||
<div className="flex h-48 w-48 items-center justify-center rounded-md bg-muted">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("weixinQrcodeExpired")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchQrcode}>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||
{t("weixinRefreshQrcode")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WeixinQrcodeDialog({
|
||||
open,
|
||||
channelId,
|
||||
onOpenChange,
|
||||
onAuthSuccess,
|
||||
}: WeixinQrcodeDialogProps) {
|
||||
const t = useTranslations("ChatChannelSettings")
|
||||
const handleClose = useCallback(() => onOpenChange(false), [onOpenChange])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("weixinScanTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{open && (
|
||||
<WeixinQrcodeContent
|
||||
channelId={channelId}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
export type TransportEnvironment = "tauri" | "web"
|
||||
export type TransportEnvironment = "tauri"
|
||||
|
||||
export function detectEnvironment(): TransportEnvironment {
|
||||
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
|
||||
return "tauri"
|
||||
}
|
||||
return "web"
|
||||
}
|
||||
return "tauri"
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { detectEnvironment } from "./detect"
|
||||
import type { Transport } from "./types"
|
||||
|
||||
export type { Transport, UnsubscribeFn } from "./types"
|
||||
@@ -7,26 +6,15 @@ let _transport: Transport | null = null
|
||||
|
||||
export function getTransport(): Transport {
|
||||
if (!_transport) {
|
||||
const env = detectEnvironment()
|
||||
if (env === "tauri") {
|
||||
// Use dynamic require to avoid bundling tauri deps in web mode.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { TauriTransport } = require("./tauri-transport") as {
|
||||
TauriTransport: new () => Transport
|
||||
}
|
||||
_transport = new TauriTransport()
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { WebTransport } = require("./web-transport") as {
|
||||
WebTransport: new (baseUrl: string) => Transport
|
||||
}
|
||||
const baseUrl = window.location.origin
|
||||
_transport = new WebTransport(baseUrl)
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { TauriTransport } = require("./tauri-transport") as {
|
||||
TauriTransport: new () => Transport
|
||||
}
|
||||
_transport = new TauriTransport()
|
||||
}
|
||||
return _transport
|
||||
}
|
||||
|
||||
export function isDesktop(): boolean {
|
||||
return getTransport().isDesktop()
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { Transport, UnsubscribeFn } from "./types"
|
||||
|
||||
interface WebEvent {
|
||||
channel: string
|
||||
payload: unknown
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("codeg_token") ?? ""
|
||||
}
|
||||
|
||||
export class WebTransport implements Transport {
|
||||
private ws: WebSocket | null = null
|
||||
private handlers = new Map<string, Set<(payload: unknown) => void>>()
|
||||
private baseUrl: string
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private wsFailCount = 0
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
async call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${this.baseUrl}/api/${command}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(args ?? {}),
|
||||
})
|
||||
if (res.status === 401) {
|
||||
WebTransport.redirectToLogin()
|
||||
throw new Error("Unauthorized")
|
||||
}
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({
|
||||
code: "network_error",
|
||||
message: `HTTP ${res.status}`,
|
||||
}))
|
||||
throw error
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async subscribe<T>(
|
||||
event: string,
|
||||
handler: (payload: T) => void
|
||||
): Promise<UnsubscribeFn> {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set())
|
||||
}
|
||||
const wrappedHandler = handler as (payload: unknown) => void
|
||||
this.handlers.get(event)!.add(wrappedHandler)
|
||||
|
||||
// If WS is not connected but we now have a token, connect
|
||||
if (!this.ws && getToken()) {
|
||||
this.connectWs()
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.handlers.get(event)?.delete(wrappedHandler)
|
||||
}
|
||||
}
|
||||
|
||||
isDesktop(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
private static redirectToLogin() {
|
||||
if (window.location.pathname.startsWith("/login")) return
|
||||
localStorage.removeItem("codeg_token")
|
||||
window.location.href = "/login"
|
||||
}
|
||||
|
||||
private connectWs() {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
|
||||
const wsUrl =
|
||||
this.baseUrl.replace(/^http/, "ws") +
|
||||
`/ws/events?token=${encodeURIComponent(token)}`
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.wsFailCount = 0
|
||||
}
|
||||
|
||||
this.ws.onmessage = (msg) => {
|
||||
try {
|
||||
const event = JSON.parse(msg.data) as WebEvent
|
||||
const handlers = this.handlers.get(event.channel)
|
||||
if (handlers) {
|
||||
for (const h of handlers) {
|
||||
h(event.payload)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.ws = null
|
||||
this.wsFailCount++
|
||||
if (this.wsFailCount >= 3) {
|
||||
WebTransport.redirectToLogin()
|
||||
return
|
||||
}
|
||||
this.reconnectTimer = setTimeout(() => this.connectWs(), 3000)
|
||||
}
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.ws?.close()
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
}
|
||||
this.ws?.close()
|
||||
this.ws = null
|
||||
this.handlers.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,90 @@
|
||||
import { computeLineDiff, type DiffHunk } from "@/components/merge/merge-diff"
|
||||
export interface DiffHunk {
|
||||
baseStart: number
|
||||
baseCount: number
|
||||
newLines: string[]
|
||||
}
|
||||
|
||||
function lcsTable(a: string[], b: string[]): number[][] {
|
||||
const m = a.length
|
||||
const n = b.length
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
new Array<number>(n + 1).fill(0)
|
||||
)
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return dp
|
||||
}
|
||||
|
||||
interface EditOp {
|
||||
type: "equal" | "delete" | "insert"
|
||||
aIdx: number
|
||||
bIdx: number
|
||||
}
|
||||
|
||||
function backtrackLCS(a: string[], b: string[], dp: number[][]): EditOp[] {
|
||||
const ops: EditOp[] = []
|
||||
let i = a.length
|
||||
let j = b.length
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
||||
ops.push({ type: "equal", aIdx: i - 1, bIdx: j - 1 })
|
||||
i--
|
||||
j--
|
||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||
ops.push({ type: "insert", aIdx: -1, bIdx: j - 1 })
|
||||
j--
|
||||
} else {
|
||||
ops.push({ type: "delete", aIdx: i - 1, bIdx: -1 })
|
||||
i--
|
||||
}
|
||||
}
|
||||
return ops.reverse()
|
||||
}
|
||||
|
||||
function computeLineDiff(a: string[], b: string[]): DiffHunk[] {
|
||||
const dp = lcsTable(a, b)
|
||||
const ops = backtrackLCS(a, b, dp)
|
||||
const hunks: DiffHunk[] = []
|
||||
let idx = 0
|
||||
while (idx < ops.length) {
|
||||
const op = ops[idx]
|
||||
if (op.type === "equal") {
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
let baseStart = op.type === "delete" ? op.aIdx : -1
|
||||
let baseCount = 0
|
||||
const newLines: string[] = []
|
||||
while (idx < ops.length && ops[idx].type !== "equal") {
|
||||
const cur = ops[idx]
|
||||
if (cur.type === "delete") {
|
||||
if (baseStart === -1) baseStart = cur.aIdx
|
||||
baseCount++
|
||||
} else {
|
||||
if (baseStart === -1) {
|
||||
baseStart = 0
|
||||
for (let k = idx - 1; k >= 0; k--) {
|
||||
if (ops[k].type === "equal" || ops[k].type === "delete") {
|
||||
baseStart = ops[k].aIdx + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
newLines.push(b[cur.bIdx])
|
||||
}
|
||||
idx++
|
||||
}
|
||||
hunks.push({ baseStart, baseCount, newLines })
|
||||
}
|
||||
return hunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum product of line counts before falling back to naive diff.
|
||||
|
||||
Reference in New Issue
Block a user