Files
clash-verge-rev/src/pages/profiles.tsx
T
Tunglies 6e08a77a84 refactor(backend): specta ipc bindings (#8195)
* refactor(ipc): generate bindings with Specta

* fix(profile): project IPC inputs to consumed fields

* fix(events): remove unused refresh payloads

* fix(webdav): return only consumed backup metadata

* fix(network): trim unused interface metadata

* fix(sysinfo): return only displayed system fields

* chore(knip): check generated runtime exports

* fix(proxy): confirm runtime chain updates before UI changes

* fix(clipboard): propagate proxy environment copy failures

* refactor(dns): remove ignored runtime patch

* refactor(core): trim unused upgrade response fields

* refactor(ipc): avoid redundant response copies

* refactor(proxy): clarify automatic proxy query naming

* chore(tests): remove added IPC regression tests

* refactor(ipc): enforce typed command boundaries

* chore(contract): check generated bindings before push

* refactor(ipc): remove unused proxy liveness payloads
2026-10-12 06:16:05 +00:00

988 lines
29 KiB
TypeScript

import {
DragDropProvider,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable } from '@dnd-kit/react/sortable'
import {
CheckBoxOutlineBlankRounded,
CheckBoxRounded,
ClearRounded,
ContentPasteRounded,
DeleteRounded,
IndeterminateCheckBoxRounded,
LocalFireDepartmentRounded,
RefreshRounded,
TextSnippetOutlined,
} from '@mui/icons-material'
import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { readText } from '@tauri-apps/plugin-clipboard-manager'
import { readTextFile } from '@tauri-apps/plugin-fs'
import { useLockFn } from 'ahooks'
import { throttle } from 'lodash-es'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router'
import { closeAllConnections } from 'tauri-plugin-mihomo-api'
import {
BasePage,
BaseStyledTextField,
type DialogRef,
} from '@/components/base'
import { ProfileItem } from '@/components/profile/profile-item'
import { ProfileMore } from '@/components/profile/profile-more'
import {
ProfileViewer,
type ProfileViewerRef,
} from '@/components/profile/profile-viewer'
import { ConfigViewer } from '@/components/setting/mods/config-viewer'
import { fetchProfilesIntoCache, useProfiles } from '@/hooks/use-profiles'
import {
createProfile,
deleteProfile,
enhanceProfiles,
getRuntimeLogs,
importProfile,
reorderProfile,
updateProfile,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { errorDetail, showNotice } from '@/services/notice-service'
import { revalidateQuery, useQuery } from '@/services/query-client'
import { useThemeMode } from '@/services/states'
import {
useProfileLoadingCache,
useProfileUpdates,
useSetProfileLoading,
} from '@/store/app-store-context'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
// 与 src-tauri/src/main.rs 的 worker_limit 上限(8)保持一致,避免前后端更新风暴不对齐
const PROFILE_UPDATE_WORKER_LIMIT = 8
const PROFILE_SWITCH_LOADING_DELAY = 400
const profilePointerSensor = PointerSensor.configure({
activationConstraints: () => undefined,
})
interface ProfileSwitchRequest {
profile: string
notifySuccess: boolean
force: boolean
}
const debugProfileSwitch = (action: string, profile: string, extra?: any) => {
const timestamp = new Date().toISOString().substring(11, 23)
debugLog(`[Profile-Debug][${timestamp}] ${action}: ${profile}`, extra || '')
}
const ProfilePage = () => {
const { t } = useTranslation()
const location = useLocation()
const [url, setUrl] = useState('')
const [disabled, setDisabled] = useState(false)
const [profileDndRevision, setProfileDndRevision] = useState(0)
const [activatings, setActivatings] = useState<string[]>([])
const [switchTarget, setSwitchTarget] = useState<string | null>(null)
const [visibleSwitchingProfile, setVisibleSwitchingProfile] = useState<
string | null
>(null)
const [loading, setLoading] = useState(false)
const [batchMode, setBatchMode] = useState(false)
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(
() => new Set(),
)
// Profile 切换在前端串行执行;队列中只保留用户最后一次选择。
const latestSwitchTargetRef = useRef<string | null>(null)
const queuedSwitchRef = useRef<ProfileSwitchRequest | null>(null)
const switchRunnerRef = useRef<Promise<void> | null>(null)
const switchLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const currentProfileRef = useRef<string | undefined>(undefined)
const profilePageMountedRef = useRef(true)
const { current } = location.state || {}
const {
profiles = {},
patchProfiles,
mutateProfiles,
error,
isStale,
} = useProfiles()
useEffect(() => {
currentProfileRef.current = profiles.current!
}, [profiles])
useEffect(() => {
const unlisten = getCurrentWindow().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop') return
const paths = event.payload.paths
for (const file of paths) {
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) {
showNotice.error('profiles.page.feedback.errors.onlyYaml')
continue
}
const item = {
type: 'local',
name: file.split(/\/|\\/).pop() ?? 'New Profile',
desc: '',
url: '',
option: {
with_proxy: false,
self_proxy: false,
},
} as IProfileItem
const data = await readTextFile(file)
await mutate(() => createProfile(item, data), {
id: 'create-profile',
errorNotice: false,
revalidate: [['getProfiles']],
})
}
await mutate(() => enhanceProfiles(), {
id: 'enhance-profiles',
errorNotice: false,
})
})
return () => {
void unlisten.then((cleanup) => cleanup())
}
}, [])
const onEmergencyRefresh = useLockFn(async () => {
debugLog('[紧急刷新] 开始强制刷新所有数据')
try {
await Promise.all([revalidateQuery(['getRuntimeLogs']), mutateProfiles()])
await new Promise((resolve) => setTimeout(resolve, 500))
await onEnhance(false)
showNotice.success(
'profiles.page.feedback.notices.forceRefreshCompleted',
2000,
)
} catch (error) {
console.error('[紧急刷新] 失败:', error)
showNotice.error(
'profiles.page.feedback.notices.emergencyRefreshFailed',
{ message: errorDetail(error) },
4000,
)
}
})
const { data: chainLogs = {}, refetch: refetchLogs } = useQuery({
queryKey: ['getRuntimeLogs'],
queryFn: getRuntimeLogs,
})
const refetchLogsRef = useRef(refetchLogs)
refetchLogsRef.current = refetchLogs
const mutateLogs = useCallback(() => refetchLogsRef.current(), [])
const viewerRef = useRef<ProfileViewerRef>(null)
const configRef = useRef<DialogRef>(null)
const profileItems = useMemo(() => {
const items = profiles.items || []
const type1 = ['local', 'remote']
return items.filter((i) => i?.type && type1.includes(i.type))
}, [profiles])
const currentActivatings = () => {
return [...new Set([profiles.current ?? ''])].filter(Boolean)
}
const onImport = async () => {
if (!url) return
if (!isValidUrl(url)) {
showNotice.error('profiles.page.feedback.errors.invalidUrl')
return
}
setLoading(true)
const prevCurrent = profiles.current
const handleImportSuccess = async (noticeKey: string) => {
showNotice.success(noticeKey)
setUrl('')
await performRobustRefresh(prevCurrent)
}
try {
await mutate(() => importProfile(url), {
id: 'import-profile',
errorNotice: false,
})
await handleImportSuccess('shared.feedback.notifications.importSuccess')
} catch (initialErr) {
console.warn('[订阅导入] 首次导入失败:', initialErr)
const initialDetail = errorDetail(initialErr)
if (initialDetail.toLowerCase().includes('legacy tls')) {
showNotice.error(initialErr)
return
}
showNotice.info('profiles.page.feedback.notifications.importRetry')
try {
await mutate(
() =>
importProfile(url, {
with_proxy: false,
self_proxy: true,
}),
{
id: 'import-profile',
errorNotice: false,
},
)
await handleImportSuccess(
'shared.feedback.notifications.importWithClashProxy',
)
} catch (retryErr) {
showNotice.error(
'profiles.page.feedback.notifications.importFail',
retryErr,
)
}
} finally {
setDisabled(false)
setLoading(false)
}
}
// `useProfiles` already retries three times; add only one business-level retry.
const performRobustRefresh = async (prevCurrent?: string | null) => {
let retryCount = 0
const maxRetries = 1
const baseDelay = 200
while (retryCount < maxRetries) {
try {
debugLog(`[导入刷新] 第${retryCount + 1}次尝试刷新配置数据`)
const fresh = await fetchProfilesIntoCache()
await new Promise((resolve) =>
setTimeout(resolve, baseDelay * (retryCount + 1)),
)
if (fresh?.current !== prevCurrent) {
await onEnhance(false)
}
return
} catch (error) {
console.error(`[导入刷新] 第${retryCount + 1}次刷新失败:`, error)
retryCount++
await new Promise((resolve) =>
setTimeout(resolve, baseDelay * retryCount),
)
}
}
console.warn(`[导入刷新] 常规刷新失败,尝试清除缓存重新获取`)
try {
const fresh = await fetchProfilesIntoCache()
if (fresh?.current !== prevCurrent) {
await onEnhance(false)
}
showNotice.error(
'profiles.page.feedback.notifications.importNeedsRefresh',
3000,
)
} catch (finalError) {
console.error(`[导入刷新] 最终刷新尝试失败:`, finalError)
showNotice.error(
'profiles.page.feedback.notifications.importSuccess',
5000,
)
}
}
const onDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: newIndex, initialIndex: oldIndex } = source.sortable
if (
oldIndex < 0 ||
newIndex < 0 ||
oldIndex >= profileItems.length ||
newIndex >= profileItems.length ||
oldIndex === newIndex
) {
return
}
const activeId = profileItems[oldIndex]?.uid
const overId = profileItems[newIndex]?.uid
if (activeId == null || overId == null || activeId === overId) return
try {
await mutate(() => reorderProfile(activeId, overId), {
id: 'reorder-profile',
errorNotice: false,
})
mutateProfiles()
} catch (error) {
setProfileDndRevision((revision) => revision + 1)
showNotice.error(error)
}
}
const executeProfileSwitch = useCallback(
async ({ profile, notifySuccess, force }: ProfileSwitchRequest) => {
if (!force && currentProfileRef.current === profile) {
debugProfileSwitch('ALREADY_CURRENT_IGNORED', profile)
return
}
debugProfileSwitch('SWITCH_START', profile)
try {
const outcome = await patchProfiles({ current: profile })
if (outcome.status === 'busy') {
debugProfileSwitch('SWITCH_BUSY', profile)
showNotice.info(
'profiles.page.feedback.notifications.switchBusy',
2000,
)
return
}
if (outcome.status === 'valid') {
currentProfileRef.current = profile
void mutateLogs().catch(() => {})
void mutate(() => closeAllConnections(), {
id: 'close-all-connections',
errorNotice: false,
}).catch(() => {})
if (
notifySuccess &&
latestSwitchTargetRef.current === profile &&
queuedSwitchRef.current === null
) {
showNotice.success(
'profiles.page.feedback.notifications.profileSwitched',
1000,
)
}
debugProfileSwitch('SWITCH_SUCCESS', profile)
} else {
debugProfileSwitch('SWITCH_REJECTED', profile, outcome)
}
} catch (err: any) {
console.error(`[Profile] 切换失败:`, err)
showNotice.error(err, 4000)
} finally {
debugProfileSwitch('SWITCH_END', profile)
}
},
[mutateLogs, patchProfiles],
)
const runProfileSwitchQueue = useCallback(async () => {
while (profilePageMountedRef.current && queuedSwitchRef.current) {
const request = queuedSwitchRef.current
queuedSwitchRef.current = null
await executeProfileSwitch(request)
}
}, [executeProfileSwitch])
const activateProfile = useCallback(
(profile: string, notifySuccess: boolean, force = false) => {
if (!profilePageMountedRef.current) return Promise.resolve()
if (
!force &&
currentProfileRef.current === profile &&
switchRunnerRef.current === null
) {
debugProfileSwitch('ALREADY_CURRENT_IGNORED', profile)
return Promise.resolve()
}
if (
latestSwitchTargetRef.current === profile &&
switchRunnerRef.current
) {
debugProfileSwitch('DUPLICATE_SWITCH_IGNORED', profile)
return switchRunnerRef.current
}
latestSwitchTargetRef.current = profile
queuedSwitchRef.current = { profile, notifySuccess, force }
setSwitchTarget(profile)
setVisibleSwitchingProfile(null)
if (switchLoadingTimerRef.current) {
window.clearTimeout(switchLoadingTimerRef.current)
}
switchLoadingTimerRef.current = window.setTimeout(() => {
if (
profilePageMountedRef.current &&
latestSwitchTargetRef.current === profile
) {
setVisibleSwitchingProfile(profile)
}
}, PROFILE_SWITCH_LOADING_DELAY)
if (switchRunnerRef.current) {
debugProfileSwitch('SWITCH_QUEUED', profile)
return switchRunnerRef.current
}
const runner = runProfileSwitchQueue().finally(() => {
if (switchRunnerRef.current === runner) {
switchRunnerRef.current = null
latestSwitchTargetRef.current = null
if (switchLoadingTimerRef.current) {
window.clearTimeout(switchLoadingTimerRef.current)
switchLoadingTimerRef.current = null
}
if (profilePageMountedRef.current) {
setSwitchTarget(null)
setVisibleSwitchingProfile(null)
}
}
})
switchRunnerRef.current = runner
return runner
},
[runProfileSwitchQueue],
)
const onSelect = async (profile: string, force: boolean) => {
await activateProfile(profile, true, force)
}
useEffect(() => {
let cancelled = false
void (async () => {
if (current) {
await mutateProfiles()
if (cancelled) return
await activateProfile(current, false)
}
})()
return () => {
cancelled = true
}
}, [current, activateProfile, mutateProfiles])
const onEnhance = useLockFn(async (notifySuccess: boolean) => {
if (switchRunnerRef.current) {
debugLog(
`[Profile] 有profile正在切换中(${latestSwitchTargetRef.current}),跳过enhance操作`,
)
return
}
const currentProfiles = currentActivatings()
setActivatings((prev) => [...new Set([...prev, ...currentProfiles])])
try {
const result = await mutate(() => enhanceProfiles(), {
id: 'enhance-profiles',
errorNotice: false,
})
if (!(result.ok && result.value === true)) return
mutateLogs()
if (notifySuccess) {
showNotice.success(
'profiles.page.feedback.notifications.profileReactivated',
1000,
)
}
} catch (err: any) {
showNotice.error(err, 3000)
} finally {
setActivatings([])
}
})
const onDelete = useLockFn(async (uid: string) => {
const current = profiles.current === uid
try {
setActivatings([...(current ? currentActivatings() : []), uid])
await mutate(() => deleteProfile(uid), {
id: 'delete-profile',
errorNotice: false,
})
mutateProfiles()
mutateLogs()
if (current) {
await onEnhance(false)
}
} catch (err: any) {
showNotice.error(err)
} finally {
setActivatings([])
}
})
const loadingCache = useProfileLoadingCache()
const { updateRevisions: completedUpdateRevisions, timerRevisions } =
useProfileUpdates()
const setLoadingProfiles = useSetProfileLoading()
const runProfileUpdates = useCallback(
async (uids: string[]) => {
if (uids.length === 0) return
const throttleMutate = throttle(mutateProfiles, 2000, {
trailing: true,
})
let cursor = 0
const updateOne = async (uid: string) => {
try {
// Per-uid id: the worker pool must not dedupe distinct subscriptions.
await mutate(() => updateProfile(uid), {
id: `update-profile:${uid}`,
errorNotice: false,
})
throttleMutate()
} catch (err: any) {
console.error(`更新订阅 ${uid} 失败:`, err)
}
}
const worker = async () => {
while (cursor < uids.length) {
const uid = uids[cursor++]
await updateOne(uid)
}
}
try {
const active = Math.min(PROFILE_UPDATE_WORKER_LIMIT, uids.length)
await Promise.allSettled(Array.from({ length: active }, worker))
} finally {
setLoadingProfiles(uids, false)
void mutateProfiles()
}
},
[mutateProfiles, setLoadingProfiles],
)
const onUpdateAll = useLockFn(async () => {
const items = profileItems.filter((e) => e.type === 'remote')
const target = items
.map((item) => item.uid!)
.filter((uid) => !loadingCache.has(uid))
setLoadingProfiles(target, true)
await runProfileUpdates(target)
})
const onCopyLink = async () => {
const text = await readText()
if (text) setUrl(text)
}
const toggleBatchMode = () => {
setBatchMode(!batchMode)
if (!batchMode) {
setSelectedProfiles(new Set())
}
}
const toggleProfileSelection = (uid: string) => {
setSelectedProfiles((prev) => {
const newSet = new Set(prev)
if (newSet.has(uid)) {
newSet.delete(uid)
} else {
newSet.add(uid)
}
return newSet
})
}
const selectAllProfiles = () => {
setSelectedProfiles(new Set(profileItems.map((item) => item.uid!)))
}
const clearAllSelections = () => {
setSelectedProfiles(new Set())
}
const isAllSelected = () => {
return (
profileItems.length > 0 && profileItems.length === selectedProfiles.size
)
}
const getSelectionState = () => {
if (selectedProfiles.size === 0) {
return 'none'
} else if (selectedProfiles.size === profileItems.length) {
return 'all'
} else {
return 'partial'
}
}
const deleteSelectedProfiles = useLockFn(async () => {
if (selectedProfiles.size === 0) return
try {
const currentActivating =
profiles.current && selectedProfiles.has(profiles.current)
? [profiles.current]
: []
setActivatings((prev) => [...new Set([...prev, ...currentActivating])])
for (const uid of selectedProfiles) {
await mutate(() => deleteProfile(uid), {
id: 'delete-profile',
errorNotice: false,
})
}
await mutateProfiles()
await mutateLogs()
if (currentActivating.length > 0) {
await onEnhance(false)
}
setSelectedProfiles(new Set())
setBatchMode(false)
showNotice.success('profiles.page.feedback.notifications.batchDeleted')
} catch (err: any) {
showNotice.error(err)
} finally {
setActivatings([])
}
})
const mode = useThemeMode()
const isLight = mode === 'light'
const dividercolor = isLight
? 'rgba(0, 0, 0, 0.06)'
: 'rgba(255, 255, 255, 0.06)'
// 卸载后不再执行尚未发送的切换意图。
useEffect(() => {
profilePageMountedRef.current = true
return () => {
profilePageMountedRef.current = false
queuedSwitchRef.current = null
latestSwitchTargetRef.current = null
if (switchLoadingTimerRef.current) {
window.clearTimeout(switchLoadingTimerRef.current)
switchLoadingTimerRef.current = null
}
}
}, [])
return (
<BasePage
full
title={t('profiles.page.title')}
contentStyle={{ height: '100%' }}
header={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{!batchMode ? (
<>
{/* Batch mode toggle button */}
<IconButton
size="small"
color="inherit"
title={t('profiles.page.batch.title')}
onClick={toggleBatchMode}
>
<CheckBoxOutlineBlankRounded />
</IconButton>
<IconButton
size="small"
color="inherit"
title={t('profiles.page.actions.updateAll')}
onClick={onUpdateAll}
>
<RefreshRounded />
</IconButton>
<IconButton
size="small"
color="inherit"
title={t('profiles.page.actions.viewRuntimeConfig')}
onClick={() => configRef.current?.open()}
>
<TextSnippetOutlined />
</IconButton>
<IconButton
size="small"
color="primary"
title={t('profiles.page.actions.reactivate')}
onClick={() => onEnhance(true)}
>
<LocalFireDepartmentRounded />
</IconButton>
{/* 故障检测和紧急恢复按钮 */}
{(error || isStale) && (
<IconButton
size="small"
color="warning"
title={t(
'profiles.page.feedback.tooltips.forceRefreshStaleData',
)}
onClick={onEmergencyRefresh}
sx={{
animation: 'pulse 2s infinite',
'@keyframes pulse': {
'0%': { opacity: 1 },
'50%': { opacity: 0.5 },
'100%': { opacity: 1 },
},
}}
>
<ClearRounded />
</IconButton>
)}
</>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton
size="small"
color="inherit"
title={
isAllSelected()
? t('profiles.page.batch.actions.deselectAll')
: t('profiles.page.batch.actions.selectAll')
}
onClick={
isAllSelected() ? clearAllSelections : selectAllProfiles
}
>
{getSelectionState() === 'all' ? (
<CheckBoxRounded />
) : getSelectionState() === 'partial' ? (
<IndeterminateCheckBoxRounded />
) : (
<CheckBoxOutlineBlankRounded />
)}
</IconButton>
<IconButton
size="small"
color="error"
title={t('profiles.page.batch.actions.delete')}
onClick={deleteSelectedProfiles}
disabled={selectedProfiles.size === 0}
>
<DeleteRounded />
</IconButton>
<Button size="small" variant="outlined" onClick={toggleBatchMode}>
{t('profiles.page.batch.actions.done')}
</Button>
<Box
sx={{ flex: 1, textAlign: 'right', color: 'text.secondary' }}
>
{t('profiles.page.batch.summary.selected')}{' '}
{selectedProfiles.size} {t('profiles.page.batch.summary.items')}
</Box>
</Box>
)}
</Box>
}
>
<Stack
direction="row"
spacing={1}
sx={{
pt: 1,
mb: 0.5,
mx: '10px',
height: '36px',
display: 'flex',
alignItems: 'center',
}}
>
<BaseStyledTextField
value={url}
variant="outlined"
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
return
}
if (!url || disabled || loading) {
return
}
event.preventDefault()
void onImport()
}}
placeholder={t('profiles.page.importForm.placeholder')}
slotProps={{
input: {
sx: { pr: 1 },
endAdornment: !url ? (
<IconButton
size="small"
sx={{ p: 0.5 }}
title={t('profiles.page.importForm.actions.paste')}
onClick={onCopyLink}
>
<ContentPasteRounded fontSize="inherit" />
</IconButton>
) : (
<IconButton
size="small"
sx={{ p: 0.5 }}
title={t('shared.actions.clear')}
onClick={() => setUrl('')}
>
<ClearRounded fontSize="inherit" />
</IconButton>
),
},
}}
/>
<Button
disabled={!url || disabled}
loading={loading}
variant="contained"
size="small"
sx={{ borderRadius: '6px' }}
onClick={onImport}
>
{t('profiles.page.actions.import')}
</Button>
<Button
variant="contained"
size="small"
sx={{ borderRadius: '6px' }}
onClick={() => viewerRef.current?.create()}
>
{t('shared.actions.new')}
</Button>
</Stack>
<Box
sx={{
pl: '10px',
pr: '10px',
height: 'calc(100% - 48px)',
overflowY: 'auto',
}}
>
<DragDropProvider
key={profileDndRevision}
sensors={[profilePointerSensor, KeyboardSensor]}
onDragEnd={onDragEnd}
>
<Box
sx={{
mb: 1.5,
display: 'grid',
overflow: 'hidden',
// Up to 4 columns until a column would pass 480px, then more; 24px is the 3 gaps from `gap: 1`.
gridTemplateColumns:
'repeat(auto-fill, minmax(clamp(260px, calc((100% - 24px) / 4), 480px), 1fr))',
gap: 1,
px: 0.5,
}}
>
{profileItems.map((item, index) => (
<ProfileItem
key={item.uid}
id={item.uid!}
index={index}
selected={(switchTarget ?? profiles.current) === item.uid}
activating={
activatings.includes(item.uid!) ||
visibleSwitchingProfile === item.uid
}
itemData={item}
timerUpdateRevision={timerRevisions.get(item.uid!) ?? 0}
completedUpdateRevision={
completedUpdateRevisions.get(item.uid!) ?? 0
}
mutateProfiles={mutateProfiles}
onSelect={(f) => onSelect(item.uid!, f)}
onEdit={() => viewerRef.current?.edit(item)}
onSave={async (prev, curr) => {
if (prev !== curr && profiles.current === item.uid) {
await onEnhance(false)
}
}}
onDelete={() => {
if (batchMode) {
toggleProfileSelection(item.uid!)
} else {
onDelete(item.uid!)
}
}}
batchMode={batchMode}
isSelected={selectedProfiles.has(item.uid!)}
onSelectionChange={() => toggleProfileSelection(item.uid!)}
/>
))}
</Box>
</DragDropProvider>
<Divider
variant="middle"
flexItem
sx={{ width: `calc(100% - 32px)`, borderColor: dividercolor }}
></Divider>
<Box sx={{ mt: 1.5, mb: '10px' }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
<Grid size={{ xs: 12, sm: 6, md: 6, lg: 6 }}>
<ProfileMore
id="Merge"
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance(false)
}
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 6, lg: 6 }}>
<ProfileMore
id="Script"
logInfo={chainLogs['Script']}
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance(false)
}
}}
/>
</Grid>
</Grid>
</Box>
</Box>
<ProfileViewer
ref={viewerRef}
onChange={async (isActivating) => {
mutateProfiles()
if (isActivating) {
await onEnhance(false)
}
}}
/>
<ConfigViewer ref={configRef} />
</BasePage>
)
}
export default ProfilePage