diff --git a/package.json b/package.json index 4297af535..357c83a6b 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "@mui/icons-material": "^7.3.5", "@mui/lab": "7.0.0-beta.17", "@mui/material": "^7.3.5", - "@mui/x-data-grid": "^8.18.0", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "2.9.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6a51ac91..ced86b22a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,12 @@ importers: '@mui/material': specifier: ^7.3.5 version: 7.3.5(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@mui/x-data-grid': - specifier: ^8.18.0 - version: 8.18.0(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0))(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/react-virtual': + specifier: ^3.13.12 + version: 3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tauri-apps/api': specifier: 2.9.0 version: 2.9.0 @@ -1245,22 +1248,6 @@ packages: '@types/react': optional: true - '@mui/x-data-grid@8.18.0': - resolution: {integrity: sha512-g8y5EI3TNqrimHpH/Hv6u6i04cbvsqh39Tg4bZEhGq+SDxWp42iABlUvB7p+gtXfyd+IbmpfzUQ1hOCsHlTMZw==} - engines: {node: '>=14.0.0'} - peerDependencies: - '@emotion/react': ^11.9.0 - '@emotion/styled': ^11.8.1 - '@mui/material': ^5.15.14 || ^6.0.0 || ^7.0.0 - '@mui/system': ^5.15.14 || ^6.0.0 || ^7.0.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/react': - optional: true - '@emotion/styled': - optional: true - '@mui/x-internals@8.18.0': resolution: {integrity: sha512-iM2SJALLo4kNqxTel8lfjIymYV9MgTa6021/rAlfdh/vwPMglaKyXQHrxkkWs2Eu/JFKkCKr5Fd34Gsdp63wIg==} engines: {node: '>=14.0.0'} @@ -1687,6 +1674,26 @@ packages: '@swc/types@0.1.25': resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@tauri-apps/api@2.9.0': resolution: {integrity: sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==} @@ -5436,25 +5443,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.6 - '@mui/x-data-grid@8.18.0(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0))(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': - dependencies: - '@babel/runtime': 7.28.4 - '@mui/material': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@mui/system': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0) - '@mui/utils': 7.3.5(@types/react@19.2.6)(react@19.2.0) - '@mui/x-internals': 8.18.0(@types/react@19.2.6)(react@19.2.0) - '@mui/x-virtualizer': 0.2.8(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - clsx: 2.1.1 - prop-types: 15.8.1 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - use-sync-external-store: 1.6.0(react@19.2.0) - optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.6)(react@19.2.0) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.6)(react@19.2.0))(@types/react@19.2.6)(react@19.2.0) - transitivePeerDependencies: - - '@types/react' - '@mui/x-internals@8.18.0(@types/react@19.2.6)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 @@ -5814,6 +5802,22 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@tanstack/react-table@8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@tanstack/react-virtual@3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/virtual-core': 3.13.12 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@tanstack/table-core@8.21.3': {} + + '@tanstack/virtual-core@3.13.12': {} + '@tauri-apps/api@2.9.0': {} '@tauri-apps/cli-darwin-arm64@2.9.4': diff --git a/src/components/connection/connection-table.tsx b/src/components/connection/connection-table.tsx index c43e6e0e5..774eded22 100644 --- a/src/components/connection/connection-table.tsx +++ b/src/components/connection/connection-table.tsx @@ -1,26 +1,27 @@ -import { Box } from "@mui/material"; +import { ViewColumnRounded } from "@mui/icons-material"; +import { Box, IconButton, Tooltip } from "@mui/material"; import { - DataGrid, - GridColDef, - GridColumnOrderChangeParams, - GridColumnResizeParams, - GridColumnVisibilityModel, - useGridApiRef, - GridColumnMenuItemProps, - GridColumnMenuHideItem, - useGridRootProps, -} from "@mui/x-data-grid"; + ColumnDef, + ColumnSizingState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getSortedRowModel, + Updater, + useReactTable, +} from "@tanstack/react-table"; +import { useVirtualizer } from "@tanstack/react-virtual"; import dayjs from "dayjs"; import { useLocalStorage } from "foxact/use-local-storage"; import { useCallback, useEffect, - useLayoutEffect, useMemo, - createContext, - use, + useRef, + useState, + type ReactNode, } from "react"; -import type { MouseEvent } from "react"; import { useTranslation } from "react-i18next"; import parseTraffic from "@/utils/parse-traffic"; @@ -28,7 +29,7 @@ import { truncateStr } from "@/utils/truncate-str"; import { ConnectionColumnManager } from "./connection-column-manager"; -const ColumnManagerContext = createContext<() => void>(() => {}); +const ROW_HEIGHT = 40; /** * Reconcile stored column order with base columns to handle added/removed fields @@ -59,131 +60,6 @@ export const ConnectionTable = (props: Props) => { onCloseColumnManager, } = props; const { t } = useTranslation(); - const apiRef = useGridApiRef(); - useLayoutEffect(() => { - const PATCH_FLAG_KEY = "__clashPatchedPublishEvent" as const; - const ORIGINAL_KEY = "__clashOriginalPublishEvent" as const; - let isUnmounted = false; - let retryHandle: ReturnType | null = null; - let cleanupOriginal: (() => void) | null = null; - - const scheduleRetry = () => { - if (isUnmounted || retryHandle !== null) return; - retryHandle = setTimeout(() => { - retryHandle = null; - ensurePatched(); - }, 16); - }; - - // Safari occasionally emits grid events without an event object, - // and MUI expects `defaultMuiPrevented` to exist. Normalize here to avoid crashes. - const createFallbackEvent = () => { - const fallback = { - defaultMuiPrevented: false, - preventDefault() { - fallback.defaultMuiPrevented = true; - }, - }; - return fallback; - }; - - const ensureMuiEvent = ( - value: unknown, - ): { - defaultMuiPrevented: boolean; - preventDefault: () => void; - [key: string]: unknown; - } => { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return createFallbackEvent(); - } - - const eventObject = value as { - defaultMuiPrevented?: unknown; - preventDefault?: () => void; - [key: string]: unknown; - }; - - if (typeof eventObject.defaultMuiPrevented !== "boolean") { - eventObject.defaultMuiPrevented = false; - } - - if (typeof eventObject.preventDefault !== "function") { - eventObject.preventDefault = () => { - eventObject.defaultMuiPrevented = true; - }; - } - - return eventObject as { - defaultMuiPrevented: boolean; - preventDefault: () => void; - [key: string]: unknown; - }; - }; - - const ensurePatched = () => { - if (isUnmounted) return; - const api = apiRef.current; - - if (!api?.publishEvent) { - scheduleRetry(); - return; - } - - const metadataApi = api as unknown as typeof api & - Record; - if (metadataApi[PATCH_FLAG_KEY] === true) return; - - const originalPublishEvent = api.publishEvent; - - // Use Proxy to create a more resilient wrapper that always normalizes events - const patchedPublishEvent = new Proxy(originalPublishEvent, { - apply(target, thisArg, rawArgs: unknown[]) { - rawArgs[2] = ensureMuiEvent(rawArgs[2]); - - return Reflect.apply( - target as (...args: unknown[]) => unknown, - thisArg, - rawArgs, - ); - }, - }) as typeof originalPublishEvent; - - api.publishEvent = patchedPublishEvent; - metadataApi[PATCH_FLAG_KEY] = true; - metadataApi[ORIGINAL_KEY] = originalPublishEvent; - - cleanupOriginal = () => { - const storedOriginal = metadataApi[ORIGINAL_KEY] as - | typeof originalPublishEvent - | undefined; - - api.publishEvent = ( - typeof storedOriginal === "function" - ? storedOriginal - : originalPublishEvent - ) as typeof originalPublishEvent; - - delete metadataApi[PATCH_FLAG_KEY]; - delete metadataApi[ORIGINAL_KEY]; - }; - }; - - ensurePatched(); - - return () => { - isUnmounted = true; - if (retryHandle !== null) { - clearTimeout(retryHandle); - retryHandle = null; - } - if (cleanupOriginal) { - cleanupOriginal(); - cleanupOriginal = null; - } - }; - }, [apiRef]); - const [columnWidths, setColumnWidths] = useLocalStorage< Record >( @@ -229,95 +105,132 @@ export const ConnectionTable = (props: Props) => { }, ); - const baseColumns = useMemo(() => { + const createConnectionRow = (each: IConnectionsItem) => { + const { metadata, rulePayload } = each; + const chains = [...each.chains].reverse().join(" / "); + const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule; + const Destination = metadata.destinationIP + ? `${metadata.destinationIP}:${metadata.destinationPort}` + : `${metadata.remoteDestination}:${metadata.destinationPort}`; + return { + id: each.id, + host: metadata.host + ? `${metadata.host}:${metadata.destinationPort}` + : `${metadata.remoteDestination}:${metadata.destinationPort}`, + download: each.download, + upload: each.upload, + dlSpeed: each.curDownload, + ulSpeed: each.curUpload, + chains, + rule, + process: truncateStr(metadata.process || metadata.processPath), + time: each.start, + source: `${metadata.sourceIP}:${metadata.sourcePort}`, + remoteDestination: Destination, + type: `${metadata.type}(${metadata.network})`, + connectionData: each, + }; + }; + + type ConnectionRow = ReturnType; + + type ColumnField = Exclude; + + interface BaseColumn { + field: ColumnField; + headerName: string; + width?: number; + minWidth?: number; + align?: "left" | "right"; + cell?: (row: ConnectionRow) => ReactNode; + } + + const baseColumns = useMemo(() => { return [ { field: "host", headerName: t("connections.components.fields.host"), - width: columnWidths["host"] || 220, - minWidth: 180, + width: 180, + minWidth: 140, }, { field: "download", headerName: t("shared.labels.downloaded"), - width: columnWidths["download"] || 88, + width: 76, + minWidth: 60, align: "right", - headerAlign: "right", - valueFormatter: (value: number) => parseTraffic(value).join(" "), + cell: (row) => parseTraffic(row.download).join(" "), }, { field: "upload", headerName: t("shared.labels.uploaded"), - width: columnWidths["upload"] || 88, + width: 76, + minWidth: 60, align: "right", - headerAlign: "right", - valueFormatter: (value: number) => parseTraffic(value).join(" "), + cell: (row) => parseTraffic(row.upload).join(" "), }, { field: "dlSpeed", headerName: t("connections.components.fields.dlSpeed"), - width: columnWidths["dlSpeed"] || 88, + width: 76, + minWidth: 60, align: "right", - headerAlign: "right", - valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s", + cell: (row) => `${parseTraffic(row.dlSpeed).join(" ")}/s`, }, { field: "ulSpeed", headerName: t("connections.components.fields.ulSpeed"), - width: columnWidths["ulSpeed"] || 88, + width: 76, + minWidth: 60, align: "right", - headerAlign: "right", - valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s", + cell: (row) => `${parseTraffic(row.ulSpeed).join(" ")}/s`, }, { field: "chains", headerName: t("connections.components.fields.chains"), - width: columnWidths["chains"] || 340, - minWidth: 180, + width: 280, + minWidth: 160, }, { field: "rule", headerName: t("connections.components.fields.rule"), - width: columnWidths["rule"] || 280, - minWidth: 180, + width: 220, + minWidth: 160, }, { field: "process", headerName: t("connections.components.fields.process"), - width: columnWidths["process"] || 220, - minWidth: 180, + width: 180, + minWidth: 140, }, { field: "time", headerName: t("connections.components.fields.time"), - width: columnWidths["time"] || 120, - minWidth: 100, + width: 100, + minWidth: 80, align: "right", - headerAlign: "right", - sortComparator: (v1: string, v2: string) => - new Date(v2).getTime() - new Date(v1).getTime(), - valueFormatter: (value: number) => dayjs(value).fromNow(), + cell: (row) => dayjs(row.time).fromNow(), }, { field: "source", headerName: t("connections.components.fields.source"), - width: columnWidths["source"] || 200, - minWidth: 130, + width: 160, + minWidth: 120, }, { field: "remoteDestination", headerName: t("connections.components.fields.destination"), - width: columnWidths["remoteDestination"] || 200, - minWidth: 130, + width: 160, + minWidth: 120, }, { field: "type", headerName: t("connections.components.fields.type"), - width: columnWidths["type"] || 160, - minWidth: 100, + width: 120, + minWidth: 80, }, ]; - }, [columnWidths, t]); + }, [t]); useEffect(() => { setColumnOrder((prevValue) => { @@ -334,7 +247,7 @@ export const ConnectionTable = (props: Props) => { }); }, [baseColumns, setColumnOrder]); - const columns = useMemo(() => { + const columns = useMemo(() => { const order = Array.isArray(columnOrder) ? columnOrder : []; const orderMap = new Map(order.map((field, index) => [field, index])); @@ -362,42 +275,6 @@ export const ConnectionTable = (props: Props) => { }, 0); }, [columns, columnVisibilityModel]); - const handleColumnResize = (params: GridColumnResizeParams) => { - const { colDef, width } = params; - setColumnWidths((prev) => ({ - ...prev, - [colDef.field]: width, - })); - }; - - const handleColumnVisibilityChange = useCallback( - (model: GridColumnVisibilityModel) => { - const hiddenFields = new Set(); - Object.entries(model).forEach(([field, value]) => { - if (value === false) { - hiddenFields.add(field); - } - }); - - const nextVisibleCount = columns.reduce((count, column) => { - return hiddenFields.has(column.field) ? count : count + 1; - }, 0); - - if (nextVisibleCount === 0) { - return; - } - - setColumnVisibilityModel(() => { - const sanitized: Partial> = {}; - hiddenFields.forEach((field) => { - sanitized[field] = false; - }); - return sanitized; - }); - }, - [columns, setColumnVisibilityModel], - ); - const handleToggleColumn = useCallback( (field: string, visible: boolean) => { if (!visible && visibleColumnsCount <= 1) { @@ -417,30 +294,6 @@ export const ConnectionTable = (props: Props) => { [setColumnVisibilityModel, visibleColumnsCount], ); - const handleColumnOrderChange = useCallback( - (params: GridColumnOrderChangeParams) => { - setColumnOrder((prevValue) => { - const baseFields = baseColumns.map((col) => col.field); - const currentOrder = Array.isArray(prevValue) - ? [...prevValue] - : [...baseFields]; - const field = params.column.field; - const currentIndex = currentOrder.indexOf(field); - if (currentIndex === -1) return currentOrder; - - currentOrder.splice(currentIndex, 1); - const targetIndex = Math.min( - Math.max(params.targetIndex, 0), - currentOrder.length, - ); - currentOrder.splice(targetIndex, 0, field); - - return currentOrder; - }); - }, - [baseColumns, setColumnOrder], - ); - const handleManagerOrderChange = useCallback( (order: string[]) => { setColumnOrder(() => { @@ -456,16 +309,54 @@ export const ConnectionTable = (props: Props) => { setColumnOrder(baseColumns.map((col) => col.field)); }, [baseColumns, setColumnOrder, setColumnVisibilityModel]); - const gridVisibilityModel = useMemo(() => { - const result: GridColumnVisibilityModel = {}; - if (!columnVisibilityModel) return result; - Object.entries(columnVisibilityModel).forEach(([field, value]) => { - if (typeof value === "boolean") { - result[field] = value; - } + const handleColumnVisibilityChange = useCallback( + (update: Updater) => { + setColumnVisibilityModel((prev) => { + const current = prev ?? {}; + const baseState: VisibilityState = {}; + columns.forEach((column) => { + baseState[column.field] = (current[column.field] ?? true) !== false; + }); + + const mergedState = + typeof update === "function" + ? update(baseState) + : { ...baseState, ...update }; + + const hiddenFields = columns + .filter((column) => mergedState[column.field] === false) + .map((column) => column.field); + + if (columns.length - hiddenFields.length === 0) { + return current; + } + + const sanitized: Partial> = {}; + hiddenFields.forEach((field) => { + sanitized[field] = false; + }); + return sanitized; + }); + }, + [columns, setColumnVisibilityModel], + ); + + const columnVisibilityState = useMemo(() => { + const result: VisibilityState = {}; + if (!columnVisibilityModel) { + columns.forEach((column) => { + result[column.field] = true; + }); + return result; + } + + columns.forEach((column) => { + result[column.field] = + (columnVisibilityModel?.[column.field] ?? true) !== false; }); + return result; - }, [columnVisibilityModel]); + }, [columnVisibilityModel, columns]); const columnOptions = useMemo(() => { return columns.map((column) => ({ @@ -475,72 +366,293 @@ export const ConnectionTable = (props: Props) => { })); }, [columns, columnVisibilityModel]); - const connRows = useMemo(() => { - return connections.map((each) => { - const { metadata, rulePayload } = each; - const chains = [...each.chains].reverse().join(" / "); - const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule; - const Destination = metadata.destinationIP - ? `${metadata.destinationIP}:${metadata.destinationPort}` - : `${metadata.remoteDestination}:${metadata.destinationPort}`; - return { - id: each.id, - host: metadata.host - ? `${metadata.host}:${metadata.destinationPort}` - : `${metadata.remoteDestination}:${metadata.destinationPort}`, - download: each.download, - upload: each.upload, - dlSpeed: each.curDownload, - ulSpeed: each.curUpload, - chains, - rule, - process: truncateStr(metadata.process || metadata.processPath), - time: each.start, - source: `${metadata.sourceIP}:${metadata.sourcePort}`, - remoteDestination: Destination, - type: `${metadata.type}(${metadata.network})`, - connectionData: each, - }; - }); - }, [connections]); + const connRows = useMemo( + () => connections.map((each) => createConnectionRow(each)), + [connections], + ); + + const columnDefs = useMemo[]>(() => { + return columns.map((column) => ({ + id: column.field, + accessorKey: column.field, + header: column.headerName, + size: column.width, + minSize: column.minWidth ?? 80, + enableResizing: true, + meta: { + align: column.align ?? "left", + field: column.field, + }, + cell: column.cell + ? ({ row }) => column.cell?.(row.original) + : (info) => info.getValue(), + })); + }, [columns]); + + const [sorting, setSorting] = useState([]); + + const handleColumnSizingChange = useCallback( + (updater: Updater) => { + setColumnWidths((prev) => { + const prevState = prev ?? {}; + const nextState = + typeof updater === "function" ? updater(prevState) : updater; + const sanitized: Record = {}; + Object.entries(nextState).forEach(([key, size]) => { + if (typeof size === "number" && Number.isFinite(size)) { + sanitized[key] = size; + } + }); + return sanitized; + }); + }, + [setColumnWidths], + ); + + const table = useReactTable({ + data: connRows, + columns: columnDefs, + state: { + columnVisibility: columnVisibilityState, + columnSizing: columnWidths, + sorting, + }, + columnResizeMode: "onChange", + enableSortingRemoval: true, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + onSortingChange: setSorting, + onColumnSizingChange: handleColumnSizingChange, + onColumnVisibilityChange: handleColumnVisibilityChange, + }); + + const rows = table.getRowModel().rows; + const tableContainerRef = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 8, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); + const tableWidth = table.getTotalSize(); return ( - + <> theme.typography.fontFamily, }} > - onShowDetail(e.row.connectionData)} - density="compact" + + + theme.palette.mode === "dark" + ? theme.palette.background.default + : theme.palette.background.paper, + "&:hover": { + backgroundColor: (theme) => theme.palette.action.hover, + }, + }} + > + + + + + > + + + {table.getHeaderGroups().map((headerGroup) => ( + + `1px solid ${theme.palette.divider}`, + backgroundColor: (theme) => theme.palette.background.paper, + }} + > + {headerGroup.headers.map((header) => { + if (header.isPlaceholder) { + return null; + } + const meta = header.column.columnDef.meta as { + align?: "left" | "right"; + field: string; + }; + return ( + + theme.palette.action.hover, + }, + }} + > + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {{ + asc: "▲", + desc: "▼", + }[header.column.getIsSorted() as string] ?? null} + + {header.column.getCanResize() && ( + + theme.palette.action.active, + }, + }} + /> + )} + + ); + })} + + ))} + + + {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) return null; + + return ( + onShowDetail(row.original.connectionData)} + sx={{ + display: "flex", + position: "absolute", + left: 0, + right: 0, + height: virtualRow.size, + transform: `translateY(${virtualRow.start}px)`, + borderBottom: (theme) => + `1px solid ${theme.palette.divider}`, + cursor: "pointer", + "&:hover": { + backgroundColor: (theme) => theme.palette.action.hover, + }, + }} + > + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta as { + align?: "left" | "right"; + }; + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + + ); + })} + + + { onOrderChange={handleManagerOrderChange} onReset={handleResetColumns} /> - - ); -}; - -type ConnectionColumnMenuManageItemProps = GridColumnMenuItemProps & { - onOpenColumnManager: () => void; -}; - -const ConnectionColumnMenuManageItem = ( - props: ConnectionColumnMenuManageItemProps, -) => { - const { onClick, onOpenColumnManager } = props; - const rootProps = useGridRootProps(); - const { t } = useTranslation(); - const handleClick = useCallback( - (event: MouseEvent) => { - onClick(event); - onOpenColumnManager(); - }, - [onClick, onOpenColumnManager], - ); - - if (rootProps.disableColumnSelector) { - return null; - } - - const MenuItem = rootProps.slots.baseMenuItem; - const Icon = rootProps.slots.columnMenuManageColumnsIcon; - - return ( - }> - {t("connections.components.columnManager.title")} - - ); -}; - -const ConnectionColumnMenuColumnsItem = (props: GridColumnMenuItemProps) => { - const onOpenColumnManager = use(ColumnManagerContext); - - return ( - <> - - ); }; diff --git a/src/components/layout/use-custom-theme.ts b/src/components/layout/use-custom-theme.ts index 3e8e7c916..d65fbce00 100644 --- a/src/components/layout/use-custom-theme.ts +++ b/src/components/layout/use-custom-theme.ts @@ -1,18 +1,10 @@ import { alpha, createTheme, Theme as MuiTheme, Shadows } from "@mui/material"; -import { - arSD as arXDataGrid, - enUS as enXDataGrid, - faIR as faXDataGrid, - ruRU as ruXDataGrid, - zhCN as zhXDataGrid, -} from "@mui/x-data-grid/locales"; import { getCurrentWebviewWindow, WebviewWindow, } from "@tauri-apps/api/webviewWindow"; import { Theme as TauriOsTheme } from "@tauri-apps/api/window"; import { useEffect, useMemo } from "react"; -import { useTranslation } from "react-i18next"; import { useVerge } from "@/hooks/use-verge"; import { defaultDarkTheme, defaultTheme } from "@/pages/_theme"; @@ -73,24 +65,12 @@ ${css} return scopedBlock; }; -const languagePackMap: Record = { - zh: { ...zhXDataGrid }, - fa: { ...faXDataGrid }, - ru: { ...ruXDataGrid }, - ar: { ...arXDataGrid }, - en: { ...enXDataGrid }, -}; - -const getLanguagePackMap = (key: string) => - languagePackMap[key] || languagePackMap.en; - /** * custom theme */ export const useCustomTheme = () => { const appWindow: WebviewWindow = useMemo(() => getCurrentWebviewWindow(), []); const { verge } = useVerge(); - const { i18n } = useTranslation(); const { theme_mode, theme_setting } = verge ?? {}; const mode = useThemeMode(); const setMode = useSetThemeMode(); @@ -237,37 +217,34 @@ export const useCustomTheme = () => { let muiTheme: MuiTheme; try { - muiTheme = createTheme( - { - breakpoints: { - values: { xs: 0, sm: 650, md: 900, lg: 1200, xl: 1536 }, + muiTheme = createTheme({ + breakpoints: { + values: { xs: 0, sm: 650, md: 900, lg: 1200, xl: 1536 }, + }, + palette: { + mode, + primary: { main: setting.primary_color || dt.primary_color }, + secondary: { main: setting.secondary_color || dt.secondary_color }, + info: { main: setting.info_color || dt.info_color }, + error: { main: setting.error_color || dt.error_color }, + warning: { main: setting.warning_color || dt.warning_color }, + success: { main: setting.success_color || dt.success_color }, + text: { + primary: setting.primary_text || dt.primary_text, + secondary: setting.secondary_text || dt.secondary_text, }, - palette: { - mode, - primary: { main: setting.primary_color || dt.primary_color }, - secondary: { main: setting.secondary_color || dt.secondary_color }, - info: { main: setting.info_color || dt.info_color }, - error: { main: setting.error_color || dt.error_color }, - warning: { main: setting.warning_color || dt.warning_color }, - success: { main: setting.success_color || dt.success_color }, - text: { - primary: setting.primary_text || dt.primary_text, - secondary: setting.secondary_text || dt.secondary_text, - }, - background: { - paper: dt.background_color, - default: dt.background_color, - }, - }, - shadows: Array(25).fill("none") as Shadows, - typography: { - fontFamily: setting.font_family - ? `${setting.font_family}, ${dt.font_family}` - : dt.font_family, + background: { + paper: dt.background_color, + default: dt.background_color, }, }, - getLanguagePackMap(i18n.language), - ); + shadows: Array(25).fill("none") as Shadows, + typography: { + fontFamily: setting.font_family + ? `${setting.font_family}, ${dt.font_family}` + : dt.font_family, + }, + }); } catch (e) { console.error("Error creating MUI theme, falling back to defaults:", e); muiTheme = createTheme({ @@ -419,13 +396,7 @@ export const useCustomTheme = () => { }, 0); return muiTheme; - }, [ - mode, - theme_setting, - i18n.language, - userBackgroundImage, - hasUserBackground, - ]); + }, [mode, theme_setting, userBackgroundImage, hasUserBackground]); return { theme }; };