This commit is contained in:
2026-02-25 19:28:04 +08:00
parent 3ef9c47508
commit bbb762db97
9 changed files with 1 additions and 1 deletions

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
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 { Textarea } from '@/components/ui/textarea'
import { useRepoStore } from '../store'
import { useShallow } from 'zustand/shallow'
interface CreateRepoDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
interface FormData {
path: string
license: string
description: string
visibility: string
}
export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps) {
const { createRepo, refresh } = useRepoStore(useShallow((state) => ({
createRepo: state.createRepo,
refresh: state.refresh,
})))
const { register, handleSubmit, reset } = useForm<FormData>()
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
if (open) {
// 重置表单
reset({
path: '',
license: '',
description: '',
visibility: 'public'
})
}
}, [open, reset])
const onSubmit = async (data: FormData) => {
setIsSubmitting(true)
try {
const submitData = {
...data,
}
await createRepo(submitData)
onOpenChange(false)
refresh()
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-150">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="path"> *</Label>
<Input
id="path"
placeholder="例如: username/repository"
{...register('path', { required: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
placeholder="简短描述你的仓库..."
rows={3}
{...register('description')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="visibility"></Label>
<Input
id="visibility"
placeholder="public 或 private"
{...register('visibility')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="topics"></Label>
<Input
id="license"
placeholder="例如: MIT, Apache-2.0"
{...register('license')}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? '创建中...' : '创建仓库'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
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 { Textarea } from '@/components/ui/textarea'
import { TagsInput } from '@/components/tags-input'
import { useRepoStore } from '../store'
import { useShallow } from 'zustand/shallow'
interface EditRepoDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
repo: {
id: string
path: string
description: string
site: string
topics: string
license: string
} | null
}
interface FormData {
description: string
site: string
topics: string
license: string
}
export function EditRepoDialog({ open, onOpenChange, repo }: EditRepoDialogProps) {
const { updateRepoInfo, getList } = useRepoStore(useShallow((state) => ({
updateRepoInfo: state.updateRepoInfo,
getList: state.getList,
})))
const { register, handleSubmit, reset, setValue } = useForm<FormData>()
const [tags, setTags] = useState<string[]>([])
useEffect(() => {
if (repo) {
const topicsArray = repo.topics ? repo.topics.split(',').map(t => t.trim()).filter(Boolean) : []
setTags(topicsArray)
reset({
description: repo.description || '',
site: repo.site || '',
topics: repo.topics || '',
license: repo.license || ''
})
}
}, [repo, reset])
const onSubmit = async (data: FormData) => {
if (!repo) return
await updateRepoInfo({
path: repo.path,
description: data.description?.trim() || '',
site: data.site?.trim() || '',
topics: tags.join(','),
license: data.license?.trim() || '',
})
await getList(true)
onOpenChange(false)
}
if (!repo) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl!">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>{repo.path}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
{...register('description')}
placeholder="输入仓库描述"
className="w-full min-h-[100px]"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="site"></Label>
<Input
id="site"
{...register('site')}
placeholder="https://example.com"
type="url"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="topics"></Label>
<TagsInput
value={tags}
onChange={setTags}
placeholder="输入标签后按 Enter 或逗号添加"
/>
<p className="text-xs text-gray-500"> Enter × </p>
</div>
<div className="space-y-2">
<Label htmlFor="license"></Label>
<Input
id="license"
{...register('license')}
placeholder="MIT, Apache-2.0, GPL-3.0 等"
className="w-full"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button>
<Button type="submit">
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,115 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useRepoStore } from '../store'
import { useState, useEffect } from 'react'
import { useShallow } from 'zustand/shallow'
import { get, set } from 'idb-keyval'
import { gitea } from '@/agents/app';
import { toast } from 'sonner'
const SYNC_REPO_STORAGE_KEY = 'sync-repo-mapping'
export function SyncRepoDialog() {
const { syncDialogOpen, setSyncDialogOpen, selectedSyncRepo, buildSync } = useRepoStore(useShallow((state) => ({
syncDialogOpen: state.syncDialogOpen,
setSyncDialogOpen: state.setSyncDialogOpen,
selectedSyncRepo: state.selectedSyncRepo,
buildSync: state.buildSync,
})))
const [toRepo, setToRepo] = useState('')
useEffect(() => {
const loadSavedMapping = async () => {
if (syncDialogOpen && selectedSyncRepo) {
const currentPath = selectedSyncRepo.path || ''
// 从 idb-keyval 获取存储的映射
const mapping = await get<Record<string, string>>(SYNC_REPO_STORAGE_KEY)
// 如果有存储的值,使用存储的值,否则使用当前仓库路径
setToRepo(mapping?.[currentPath] || currentPath)
}
}
loadSavedMapping()
}, [syncDialogOpen, selectedSyncRepo])
const handleSync = async () => {
if (!selectedSyncRepo || !toRepo.trim()) {
return
}
// 保存映射到 idb-keyval
const currentPath = selectedSyncRepo.path || ''
const mapping = await get<Record<string, string>>(SYNC_REPO_STORAGE_KEY) || {}
mapping[currentPath] = toRepo
await set(SYNC_REPO_STORAGE_KEY, mapping)
await buildSync(selectedSyncRepo, { toRepo })
setSyncDialogOpen(false)
}
const onCreateRepo = async () => {
if (!toRepo.trim()) {
return
}
try {
const res = await gitea.repo.createRepo({ name: toRepo })
if (res.code !== 200 && res.code !== 409) {
// 409 表示仓库已存在,可以继续同步
throw new Error(`${res.message}`)
}
if (res.code === 200) {
toast.success('仓库创建成功,正在同步...')
} else {
toast.warning('仓库已存在,正在同步...')
}
handleSync()
} catch (error) {
console.error('创建仓库失败:', error)
}
}
return (
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
<DialogContent className="sm:max-w-125">
<DialogHeader>
<DialogTitle> Gitea</DialogTitle>
<DialogDescription>
<span className="font-semibold text-neutral-900">{selectedSyncRepo?.path}</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="toRepo"></Label>
<Input
id="toRepo"
placeholder="例如: kevisual/my-repo"
value={toRepo}
onChange={(e) => setToRepo(e.target.value)}
/>
<p className="text-xs text-neutral-500">
格式: owner/repo-name
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setSyncDialogOpen(false)}
>
</Button>
<Button onClick={onCreateRepo} disabled={!toRepo.trim()}>
</Button>
<Button
onClick={handleSync}
disabled={!toRepo.trim()}
>
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,285 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useRepoStore } from '../store'
import type { WorkspaceOpen } from '../store'
import {
Code2,
Terminal,
Sparkles,
MousePointer2,
Box,
Lock,
Radio,
Bot,
Zap,
Copy,
Check,
Square
} from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/shallow'
type LinkItemKey = keyof WorkspaceOpen;
interface LinkItem {
key: LinkItemKey
label: string
icon: React.ReactNode
order?: number
getUrl: (data: Partial<WorkspaceOpen>) => string | undefined
}
const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode; url?: string }) => {
const [isHovered, setIsHovered] = useState(false)
const [isCopied, setIsCopied] = useState(false)
const handleClick = () => {
if (url?.startsWith?.('ssh') || url?.startsWith?.('cnb')) {
copy()
return;
}
if (url) {
window.open(url, '_blank')
}
}
const copy = async () => {
try {
await navigator.clipboard.writeText(url!)
setIsCopied(true)
toast.success('已复制到剪贴板')
setTimeout(() => setIsCopied(false), 2000)
} catch (error) {
toast.error('复制失败')
}
}
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation()
if (!url) return
copy()
}
return (
<button
onClick={handleClick}
disabled={!url}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="relative flex items-center gap-3 p-3 rounded-lg border border-neutral-200 hover:border-neutral-900 hover:bg-neutral-50 transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-neutral-200 disabled:hover:bg-transparent group"
>
<div className="w-8 h-8 flex items-center justify-center text-neutral-700">
{icon}
</div>
<span className="text-sm font-medium text-neutral-900 flex-1 text-left truncate">{label}</span>
{url && isHovered && (
<div
onClick={handleCopy}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleCopy(e as any)
}
}}
className="w-6 h-6 flex items-center justify-center text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 rounded transition-colors cursor-pointer"
>
{isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</div>
)}
</button>
)
}
// Dev tab 内容
const DevTabContent = ({ linkItems, workspaceLink, stopWorkspace }: {
linkItems: LinkItem[]
workspaceLink: Partial<WorkspaceOpen>
stopWorkspace: () => void
}) => {
return (
<>
<button
onClick={() => stopWorkspace()}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium transition-colors"
>
<Square className="w-4 h-4" />
</button>
<div className="grid grid-cols-2 gap-3 mt-2">
{linkItems.map((item) => (
<LinkItem
key={item.key}
label={item.label}
icon={item.icon}
url={item.getUrl(workspaceLink)}
/>
))}
</div>
</>
)
}
// Work tab 内容(暂留,需要根据 business_id 做事情)
const WorkTabContent = () => {
const store = useRepoStore(useShallow((state) => ({ selectWorkspace: state.selectWorkspace })))
const businessId = store.selectWorkspace?.business_id;
const appList = [
{
title: 'Kevisual Assistant Client', key: 'Assistant Client', port: 51515, end: '/root/cli-center/'
},
{
title: 'OpenCode', key: 'OpenCode', port: 100, end: ''
},
{
title: 'OpenClaw', key: 'OpenClaw', port: 80, end: '/openclaw'
},
{
title: 'OpenWebUI', key: 'OpenWebUI', port: 200, end: ''
},
]
const links = appList.map(app => {
const url = `https://${businessId}-${app.port}.cnb.run${app.end}`
return {
label: app.title,
icon: <Terminal className="w-5 h-5" />,
url
}
})
return (
<div className="flex flex-col items-center justify-center py-2 text-neutral-400">
<div className="grid grid-cols-1 gap-3 w-full max-w-sm">
{links.map(link => (
<LinkItem key={link.label} label={link.label} icon={link.icon} url={link.url} />
))}
</div>
</div>
)
}
export function WorkspaceDetailDialog() {
const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink, stopWorkspace, workspaceTab, setWorkspaceTab, selectWorkspace } = useRepoStore(useShallow((state) => ({
showWorkspaceDialog: state.showWorkspaceDialog,
setShowWorkspaceDialog: state.setShowWorkspaceDialog,
workspaceLink: state.workspaceLink,
stopWorkspace: state.stopWorkspace,
workspaceTab: state.workspaceTab,
setWorkspaceTab: state.setWorkspaceTab,
selectWorkspace: state.selectWorkspace,
})))
const linkItems: LinkItem[] = [
{
key: 'webide' as LinkItemKey,
label: 'Web IDE',
icon: <Code2 className="w-5 h-5" />,
order: 1,
getUrl: (data) => data.webide
},
{
key: 'vscode' as LinkItemKey,
label: 'VS Code',
icon: <Code2 className="w-5 h-5" />,
order: 2,
getUrl: (data) => data.vscode
},
{
key: 'vscode-insiders' as LinkItemKey,
label: 'VS Code Insiders',
icon: <Sparkles className="w-5 h-5" />,
order: 5,
getUrl: (data) => data['vscode-insiders']
},
{
key: 'cursor' as LinkItemKey,
label: 'Cursor',
icon: <MousePointer2 className="w-5 h-5" />,
order: 6,
getUrl: (data) => data.cursor
},
{
key: 'jetbrains' as LinkItemKey,
label: 'JetBrains IDEs',
icon: <Box className="w-5 h-5" />,
order: 7,
getUrl: (data) => Object.values(data.jetbrains || {}).find(Boolean)
},
{
key: 'ssh' as LinkItemKey,
label: 'SSH',
icon: <Lock className="w-5 h-5" />,
order: 4,
getUrl: (data) => data.ssh
},
{
key: 'remoteSsh' as LinkItemKey,
label: 'Remote SSH',
icon: <Radio className="w-5 h-5" />,
order: 8,
getUrl: (data) => data.remoteSsh
},
{
key: 'codebuddy' as LinkItemKey,
label: 'CodeBuddy',
icon: <Bot className="w-5 h-5" />,
order: 9,
getUrl: (data) => data.codebuddy
},
{
key: 'codebuddycn' as LinkItemKey,
label: 'CodeBuddy CN',
icon: <Zap className="w-5 h-5" />,
order: 3,
getUrl: (data) => data.codebuddycn
},
].sort((a, b) => (a.order || 0) - (b.order || 0))
console.log('workspaceLink', selectWorkspace)
return (
<Dialog open={showWorkspaceDialog} onOpenChange={setShowWorkspaceDialog}>
<DialogContent className="max-w-md! bg-white">
<DialogHeader>
<DialogTitle className="text-neutral-900"></DialogTitle>
</DialogHeader>
{/* Tab 导航 */}
<div className="flex border-b border-neutral-200">
<button
onClick={() => setWorkspaceTab('dev')}
className={`cursor-pointer flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${workspaceTab === 'dev'
? 'text-neutral-900'
: 'text-neutral-500 hover:text-neutral-700'
}`}
>
Dev
{workspaceTab === 'dev' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
)}
</button>
<button
onClick={() => setWorkspaceTab('work')}
className={`cursor-pointer flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${workspaceTab === 'work'
? 'text-neutral-900'
: 'text-neutral-500 hover:text-neutral-700'
}`}
>
Work
{workspaceTab === 'work' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
)}
</button>
</div>
{/* Tab 内容 */}
<div className="py-2">
{workspaceTab === 'dev' ? (
<DevTabContent linkItems={linkItems} workspaceLink={workspaceLink} stopWorkspace={stopWorkspace} />
) : (
<WorkTabContent />
)}
</div>
</DialogContent>
</Dialog>
)
}