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/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",

80
pnpm-lock.yaml generated
View File

@ -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':

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 {
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<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<
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 [
{
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<GridColDef[]>(() => {
const columns = useMemo<BaseColumn[]>(() => {
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<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(
(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<VisibilityState>) => {
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<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;
}, [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<ConnectionRow[]>(
() => connections.map((each) => createConnectionRow(each)),
[connections],
);
const columnDefs = useMemo<ColumnDef<ConnectionRow>[]>(() => {
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<SortingState>([]);
const handleColumnSizingChange = useCallback(
(updater: Updater<ColumnSizingState>) => {
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 (
<ColumnManagerContext value={onOpenColumnManager}>
<>
<Box
sx={{
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
position: "relative",
fontFamily: (theme) => theme.typography.fontFamily,
}}
>
<DataGrid
apiRef={apiRef}
rows={connRows}
columns={columns}
onRowClick={(e) => onShowDetail(e.row.connectionData)}
density="compact"
<Tooltip title={t("connections.components.columnManager.title")}>
<IconButton
size="small"
onClick={onOpenColumnManager}
sx={{
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={{
flex: 1,
border: "none",
minHeight: 0,
"div:focus": { outline: "none !important" },
"& .MuiDataGrid-columnHeader": {
userSelect: "none",
overflow: "auto",
borderRadius: 1,
border: "none",
"&::-webkit-scrollbar": {
height: 8,
},
}}
columnVisibilityModel={gridVisibilityModel}
onColumnVisibilityModelChange={handleColumnVisibilityChange}
onColumnResize={handleColumnResize}
onColumnOrderChange={handleColumnOrderChange}
slotProps={{
columnMenu: {
slots: {
columnMenuColumnsItem: ConnectionColumnMenuColumnsItem,
},
},
}}
/>
>
<Box
sx={{
minWidth: "100%",
width: tableWidth,
}}
>
<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>
<ConnectionColumnManager
open={columnManagerOpen}
@ -550,52 +662,6 @@ export const ConnectionTable = (props: Props) => {
onOrderChange={handleManagerOrderChange}
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 {
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<string, any> = {
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 };
};