refactor: replace @mui/x-data-grid with @tanstack/react-table (#5544)

This commit is contained in:
Sline 2025-11-20 17:41:34 +08:00 committed by GitHub
parent 7e373ff39c
commit bf06cbc87d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 477 additions and 435 deletions

View File

@ -44,7 +44,8 @@
"@mui/icons-material": "^7.3.5", "@mui/icons-material": "^7.3.5",
"@mui/lab": "7.0.0-beta.17", "@mui/lab": "7.0.0-beta.17",
"@mui/material": "^7.3.5", "@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/api": "2.9.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-dialog": "^2.4.2",

80
pnpm-lock.yaml generated
View File

@ -38,9 +38,12 @@ importers:
'@mui/material': '@mui/material':
specifier: ^7.3.5 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) 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': '@tanstack/react-table':
specifier: ^8.18.0 specifier: ^8.21.3
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) 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': '@tauri-apps/api':
specifier: 2.9.0 specifier: 2.9.0
version: 2.9.0 version: 2.9.0
@ -1245,22 +1248,6 @@ packages:
'@types/react': '@types/react':
optional: true 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': '@mui/x-internals@8.18.0':
resolution: {integrity: sha512-iM2SJALLo4kNqxTel8lfjIymYV9MgTa6021/rAlfdh/vwPMglaKyXQHrxkkWs2Eu/JFKkCKr5Fd34Gsdp63wIg==} resolution: {integrity: sha512-iM2SJALLo4kNqxTel8lfjIymYV9MgTa6021/rAlfdh/vwPMglaKyXQHrxkkWs2Eu/JFKkCKr5Fd34Gsdp63wIg==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@ -1687,6 +1674,26 @@ packages:
'@swc/types@0.1.25': '@swc/types@0.1.25':
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} 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': '@tauri-apps/api@2.9.0':
resolution: {integrity: sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==} resolution: {integrity: sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==}
@ -5436,25 +5443,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.6 '@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)': '@mui/x-internals@8.18.0(@types/react@19.2.6)(react@19.2.0)':
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
@ -5814,6 +5802,22 @@ snapshots:
dependencies: dependencies:
'@swc/counter': 0.1.3 '@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/api@2.9.0': {}
'@tauri-apps/cli-darwin-arm64@2.9.4': '@tauri-apps/cli-darwin-arm64@2.9.4':

View File

@ -1,26 +1,27 @@
import { Box } from "@mui/material"; import { ViewColumnRounded } from "@mui/icons-material";
import { Box, IconButton, Tooltip } from "@mui/material";
import { import {
DataGrid, ColumnDef,
GridColDef, ColumnSizingState,
GridColumnOrderChangeParams, SortingState,
GridColumnResizeParams, VisibilityState,
GridColumnVisibilityModel, flexRender,
useGridApiRef, getCoreRowModel,
GridColumnMenuItemProps, getSortedRowModel,
GridColumnMenuHideItem, Updater,
useGridRootProps, useReactTable,
} from "@mui/x-data-grid"; } from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useLocalStorage } from "foxact/use-local-storage"; import { useLocalStorage } from "foxact/use-local-storage";
import { import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
createContext, useRef,
use, useState,
type ReactNode,
} from "react"; } from "react";
import type { MouseEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
@ -28,7 +29,7 @@ import { truncateStr } from "@/utils/truncate-str";
import { ConnectionColumnManager } from "./connection-column-manager"; 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 * Reconcile stored column order with base columns to handle added/removed fields
@ -59,131 +60,6 @@ export const ConnectionTable = (props: Props) => {
onCloseColumnManager, onCloseColumnManager,
} = props; } = props;
const { t } = useTranslation(); 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<typeof setTimeout> | 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<string, unknown>;
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< const [columnWidths, setColumnWidths] = useLocalStorage<
Record<string, number> Record<string, number>
>( >(
@ -229,95 +105,132 @@ export const ConnectionTable = (props: Props) => {
}, },
); );
const baseColumns = useMemo<GridColDef[]>(() => { 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<typeof createConnectionRow>;
type ColumnField = Exclude<keyof ConnectionRow, "connectionData">;
interface BaseColumn {
field: ColumnField;
headerName: string;
width?: number;
minWidth?: number;
align?: "left" | "right";
cell?: (row: ConnectionRow) => ReactNode;
}
const baseColumns = useMemo<BaseColumn[]>(() => {
return [ return [
{ {
field: "host", field: "host",
headerName: t("connections.components.fields.host"), headerName: t("connections.components.fields.host"),
width: columnWidths["host"] || 220, width: 180,
minWidth: 180, minWidth: 140,
}, },
{ {
field: "download", field: "download",
headerName: t("shared.labels.downloaded"), headerName: t("shared.labels.downloaded"),
width: columnWidths["download"] || 88, width: 76,
minWidth: 60,
align: "right", align: "right",
headerAlign: "right", cell: (row) => parseTraffic(row.download).join(" "),
valueFormatter: (value: number) => parseTraffic(value).join(" "),
}, },
{ {
field: "upload", field: "upload",
headerName: t("shared.labels.uploaded"), headerName: t("shared.labels.uploaded"),
width: columnWidths["upload"] || 88, width: 76,
minWidth: 60,
align: "right", align: "right",
headerAlign: "right", cell: (row) => parseTraffic(row.upload).join(" "),
valueFormatter: (value: number) => parseTraffic(value).join(" "),
}, },
{ {
field: "dlSpeed", field: "dlSpeed",
headerName: t("connections.components.fields.dlSpeed"), headerName: t("connections.components.fields.dlSpeed"),
width: columnWidths["dlSpeed"] || 88, width: 76,
minWidth: 60,
align: "right", align: "right",
headerAlign: "right", cell: (row) => `${parseTraffic(row.dlSpeed).join(" ")}/s`,
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
}, },
{ {
field: "ulSpeed", field: "ulSpeed",
headerName: t("connections.components.fields.ulSpeed"), headerName: t("connections.components.fields.ulSpeed"),
width: columnWidths["ulSpeed"] || 88, width: 76,
minWidth: 60,
align: "right", align: "right",
headerAlign: "right", cell: (row) => `${parseTraffic(row.ulSpeed).join(" ")}/s`,
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
}, },
{ {
field: "chains", field: "chains",
headerName: t("connections.components.fields.chains"), headerName: t("connections.components.fields.chains"),
width: columnWidths["chains"] || 340, width: 280,
minWidth: 180, minWidth: 160,
}, },
{ {
field: "rule", field: "rule",
headerName: t("connections.components.fields.rule"), headerName: t("connections.components.fields.rule"),
width: columnWidths["rule"] || 280, width: 220,
minWidth: 180, minWidth: 160,
}, },
{ {
field: "process", field: "process",
headerName: t("connections.components.fields.process"), headerName: t("connections.components.fields.process"),
width: columnWidths["process"] || 220, width: 180,
minWidth: 180, minWidth: 140,
}, },
{ {
field: "time", field: "time",
headerName: t("connections.components.fields.time"), headerName: t("connections.components.fields.time"),
width: columnWidths["time"] || 120, width: 100,
minWidth: 100, minWidth: 80,
align: "right", align: "right",
headerAlign: "right", cell: (row) => dayjs(row.time).fromNow(),
sortComparator: (v1: string, v2: string) =>
new Date(v2).getTime() - new Date(v1).getTime(),
valueFormatter: (value: number) => dayjs(value).fromNow(),
}, },
{ {
field: "source", field: "source",
headerName: t("connections.components.fields.source"), headerName: t("connections.components.fields.source"),
width: columnWidths["source"] || 200, width: 160,
minWidth: 130, minWidth: 120,
}, },
{ {
field: "remoteDestination", field: "remoteDestination",
headerName: t("connections.components.fields.destination"), headerName: t("connections.components.fields.destination"),
width: columnWidths["remoteDestination"] || 200, width: 160,
minWidth: 130, minWidth: 120,
}, },
{ {
field: "type", field: "type",
headerName: t("connections.components.fields.type"), headerName: t("connections.components.fields.type"),
width: columnWidths["type"] || 160, width: 120,
minWidth: 100, minWidth: 80,
}, },
]; ];
}, [columnWidths, t]); }, [t]);
useEffect(() => { useEffect(() => {
setColumnOrder((prevValue) => { setColumnOrder((prevValue) => {
@ -334,7 +247,7 @@ export const ConnectionTable = (props: Props) => {
}); });
}, [baseColumns, setColumnOrder]); }, [baseColumns, setColumnOrder]);
const columns = useMemo<GridColDef[]>(() => { const columns = useMemo<BaseColumn[]>(() => {
const order = Array.isArray(columnOrder) ? columnOrder : []; const order = Array.isArray(columnOrder) ? columnOrder : [];
const orderMap = new Map(order.map((field, index) => [field, index])); const orderMap = new Map(order.map((field, index) => [field, index]));
@ -362,42 +275,6 @@ export const ConnectionTable = (props: Props) => {
}, 0); }, 0);
}, [columns, columnVisibilityModel]); }, [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<string>();
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<Record<string, boolean>> = {};
hiddenFields.forEach((field) => {
sanitized[field] = false;
});
return sanitized;
});
},
[columns, setColumnVisibilityModel],
);
const handleToggleColumn = useCallback( const handleToggleColumn = useCallback(
(field: string, visible: boolean) => { (field: string, visible: boolean) => {
if (!visible && visibleColumnsCount <= 1) { if (!visible && visibleColumnsCount <= 1) {
@ -417,30 +294,6 @@ export const ConnectionTable = (props: Props) => {
[setColumnVisibilityModel, visibleColumnsCount], [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( const handleManagerOrderChange = useCallback(
(order: string[]) => { (order: string[]) => {
setColumnOrder(() => { setColumnOrder(() => {
@ -456,16 +309,54 @@ export const ConnectionTable = (props: Props) => {
setColumnOrder(baseColumns.map((col) => col.field)); setColumnOrder(baseColumns.map((col) => col.field));
}, [baseColumns, setColumnOrder, setColumnVisibilityModel]); }, [baseColumns, setColumnOrder, setColumnVisibilityModel]);
const gridVisibilityModel = useMemo(() => { const handleColumnVisibilityChange = useCallback(
const result: GridColumnVisibilityModel = {}; (update: Updater<VisibilityState>) => {
if (!columnVisibilityModel) return result; setColumnVisibilityModel((prev) => {
Object.entries(columnVisibilityModel).forEach(([field, value]) => { const current = prev ?? {};
if (typeof value === "boolean") { const baseState: VisibilityState = {};
result[field] = value; 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<Record<string, boolean>> = {};
hiddenFields.forEach((field) => {
sanitized[field] = false;
});
return sanitized;
});
},
[columns, setColumnVisibilityModel],
);
const columnVisibilityState = useMemo<VisibilityState>(() => {
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; return result;
}, [columnVisibilityModel]); }, [columnVisibilityModel, columns]);
const columnOptions = useMemo(() => { const columnOptions = useMemo(() => {
return columns.map((column) => ({ return columns.map((column) => ({
@ -475,72 +366,293 @@ export const ConnectionTable = (props: Props) => {
})); }));
}, [columns, columnVisibilityModel]); }, [columns, columnVisibilityModel]);
const connRows = useMemo(() => { const connRows = useMemo<ConnectionRow[]>(
return connections.map((each) => { () => connections.map((each) => createConnectionRow(each)),
const { metadata, rulePayload } = each; [connections],
const chains = [...each.chains].reverse().join(" / "); );
const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
const Destination = metadata.destinationIP const columnDefs = useMemo<ColumnDef<ConnectionRow>[]>(() => {
? `${metadata.destinationIP}:${metadata.destinationPort}` return columns.map((column) => ({
: `${metadata.remoteDestination}:${metadata.destinationPort}`; id: column.field,
return { accessorKey: column.field,
id: each.id, header: column.headerName,
host: metadata.host size: column.width,
? `${metadata.host}:${metadata.destinationPort}` minSize: column.minWidth ?? 80,
: `${metadata.remoteDestination}:${metadata.destinationPort}`, enableResizing: true,
download: each.download, meta: {
upload: each.upload, align: column.align ?? "left",
dlSpeed: each.curDownload, field: column.field,
ulSpeed: each.curUpload, },
chains, cell: column.cell
rule, ? ({ row }) => column.cell?.(row.original)
process: truncateStr(metadata.process || metadata.processPath), : (info) => info.getValue(),
time: each.start, }));
source: `${metadata.sourceIP}:${metadata.sourcePort}`, }, [columns]);
remoteDestination: Destination,
type: `${metadata.type}(${metadata.network})`, const [sorting, setSorting] = useState<SortingState>([]);
connectionData: each,
}; const handleColumnSizingChange = useCallback(
}); (updater: Updater<ColumnSizingState>) => {
}, [connections]); setColumnWidths((prev) => {
const prevState = prev ?? {};
const nextState =
typeof updater === "function" ? updater(prevState) : updater;
const sanitized: Record<string, number> = {};
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<HTMLDivElement | null>(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 ( return (
<ColumnManagerContext value={onOpenColumnManager}> <>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
position: "relative",
fontFamily: (theme) => theme.typography.fontFamily,
}} }}
> >
<DataGrid <Tooltip title={t("connections.components.columnManager.title")}>
apiRef={apiRef} <IconButton
rows={connRows} size="small"
columns={columns} onClick={onOpenColumnManager}
onRowClick={(e) => onShowDetail(e.row.connectionData)} sx={{
density="compact" position: "absolute",
top: 4,
right: 4,
zIndex: 3,
backgroundColor: (theme) =>
theme.palette.mode === "dark"
? theme.palette.background.default
: theme.palette.background.paper,
"&:hover": {
backgroundColor: (theme) => theme.palette.action.hover,
},
}}
>
<ViewColumnRounded fontSize="small" />
</IconButton>
</Tooltip>
<Box
ref={tableContainerRef}
sx={{ sx={{
flex: 1, flex: 1,
border: "none",
minHeight: 0, minHeight: 0,
"div:focus": { outline: "none !important" }, overflow: "auto",
"& .MuiDataGrid-columnHeader": { borderRadius: 1,
userSelect: "none", border: "none",
"&::-webkit-scrollbar": {
height: 8,
}, },
}} }}
columnVisibilityModel={gridVisibilityModel} >
onColumnVisibilityModelChange={handleColumnVisibilityChange} <Box
onColumnResize={handleColumnResize} sx={{
onColumnOrderChange={handleColumnOrderChange} minWidth: "100%",
slotProps={{ width: tableWidth,
columnMenu: { }}
slots: { >
columnMenuColumnsItem: ConnectionColumnMenuColumnsItem, <Box
}, sx={{
}, position: "sticky",
}} top: 0,
/> zIndex: 2,
}}
>
{table.getHeaderGroups().map((headerGroup) => (
<Box
key={headerGroup.id}
sx={{
display: "flex",
borderBottom: (theme) =>
`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 (
<Box
key={header.id}
sx={{
flex: `0 0 ${header.getSize()}px`,
minWidth: header.column.columnDef.minSize || 80,
maxWidth: header.column.columnDef.maxSize,
display: "flex",
alignItems: "center",
position: "relative",
px: 1,
py: 1,
fontSize: 13,
fontWeight: 600,
color: "text.secondary",
userSelect: "none",
justifyContent:
meta?.align === "right" ? "flex-end" : "flex-start",
gap: 0.25,
"&:hover": {
backgroundColor: (theme) =>
theme.palette.action.hover,
},
}}
>
<Box
component="span"
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
cursor: header.column.getCanSort()
? "pointer"
: "default",
}}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{{
asc: "▲",
desc: "▼",
}[header.column.getIsSorted() as string] ?? null}
</Box>
{header.column.getCanResize() && (
<Box
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
sx={{
cursor: "col-resize",
position: "absolute",
right: 0,
top: 0,
width: 4,
height: "100%",
transform: "translateX(50%)",
"&:hover": {
backgroundColor: (theme) =>
theme.palette.action.active,
},
}}
/>
)}
</Box>
);
})}
</Box>
))}
</Box>
<Box
sx={{
position: "relative",
height: totalSize,
}}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<Box
key={row.id}
onClick={() => 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 (
<Box
key={cell.id}
sx={{
flex: `0 0 ${cell.column.getSize()}px`,
minWidth: cell.column.columnDef.minSize || 80,
maxWidth: cell.column.columnDef.maxSize,
px: 1,
fontSize: 13,
display: "flex",
alignItems: "center",
justifyContent:
meta?.align === "right"
? "flex-end"
: "flex-start",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Box>
);
})}
</Box>
);
})}
</Box>
</Box>
</Box>
</Box> </Box>
<ConnectionColumnManager <ConnectionColumnManager
open={columnManagerOpen} open={columnManagerOpen}
@ -550,52 +662,6 @@ export const ConnectionTable = (props: Props) => {
onOrderChange={handleManagerOrderChange} onOrderChange={handleManagerOrderChange}
onReset={handleResetColumns} onReset={handleResetColumns}
/> />
</ColumnManagerContext>
);
};
type ConnectionColumnMenuManageItemProps = GridColumnMenuItemProps & {
onOpenColumnManager: () => void;
};
const ConnectionColumnMenuManageItem = (
props: ConnectionColumnMenuManageItemProps,
) => {
const { onClick, onOpenColumnManager } = props;
const rootProps = useGridRootProps();
const { t } = useTranslation();
const handleClick = useCallback(
(event: MouseEvent<HTMLElement>) => {
onClick(event);
onOpenColumnManager();
},
[onClick, onOpenColumnManager],
);
if (rootProps.disableColumnSelector) {
return null;
}
const MenuItem = rootProps.slots.baseMenuItem;
const Icon = rootProps.slots.columnMenuManageColumnsIcon;
return (
<MenuItem onClick={handleClick} iconStart={<Icon fontSize="small" />}>
{t("connections.components.columnManager.title")}
</MenuItem>
);
};
const ConnectionColumnMenuColumnsItem = (props: GridColumnMenuItemProps) => {
const onOpenColumnManager = use(ColumnManagerContext);
return (
<>
<GridColumnMenuHideItem {...props} />
<ConnectionColumnMenuManageItem
{...props}
onOpenColumnManager={onOpenColumnManager}
/>
</> </>
); );
}; };

View File

@ -1,18 +1,10 @@
import { alpha, createTheme, Theme as MuiTheme, Shadows } from "@mui/material"; 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 { import {
getCurrentWebviewWindow, getCurrentWebviewWindow,
WebviewWindow, WebviewWindow,
} from "@tauri-apps/api/webviewWindow"; } from "@tauri-apps/api/webviewWindow";
import { Theme as TauriOsTheme } from "@tauri-apps/api/window"; import { Theme as TauriOsTheme } from "@tauri-apps/api/window";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { defaultDarkTheme, defaultTheme } from "@/pages/_theme"; import { defaultDarkTheme, defaultTheme } from "@/pages/_theme";
@ -73,24 +65,12 @@ ${css}
return scopedBlock; return scopedBlock;
}; };
const languagePackMap: Record<string, any> = {
zh: { ...zhXDataGrid },
fa: { ...faXDataGrid },
ru: { ...ruXDataGrid },
ar: { ...arXDataGrid },
en: { ...enXDataGrid },
};
const getLanguagePackMap = (key: string) =>
languagePackMap[key] || languagePackMap.en;
/** /**
* custom theme * custom theme
*/ */
export const useCustomTheme = () => { export const useCustomTheme = () => {
const appWindow: WebviewWindow = useMemo(() => getCurrentWebviewWindow(), []); const appWindow: WebviewWindow = useMemo(() => getCurrentWebviewWindow(), []);
const { verge } = useVerge(); const { verge } = useVerge();
const { i18n } = useTranslation();
const { theme_mode, theme_setting } = verge ?? {}; const { theme_mode, theme_setting } = verge ?? {};
const mode = useThemeMode(); const mode = useThemeMode();
const setMode = useSetThemeMode(); const setMode = useSetThemeMode();
@ -237,37 +217,34 @@ export const useCustomTheme = () => {
let muiTheme: MuiTheme; let muiTheme: MuiTheme;
try { try {
muiTheme = createTheme( muiTheme = createTheme({
{ breakpoints: {
breakpoints: { values: { xs: 0, sm: 650, md: 900, lg: 1200, xl: 1536 },
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: { background: {
mode, paper: dt.background_color,
primary: { main: setting.primary_color || dt.primary_color }, default: dt.background_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,
}, },
}, },
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) { } catch (e) {
console.error("Error creating MUI theme, falling back to defaults:", e); console.error("Error creating MUI theme, falling back to defaults:", e);
muiTheme = createTheme({ muiTheme = createTheme({
@ -419,13 +396,7 @@ export const useCustomTheme = () => {
}, 0); }, 0);
return muiTheme; return muiTheme;
}, [ }, [mode, theme_setting, userBackgroundImage, hasUserBackground]);
mode,
theme_setting,
i18n.language,
userBackgroundImage,
hasUserBackground,
]);
return { theme }; return { theme };
}; };