@kylincloud/flamegraph 0.35.21 → 0.35.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,13 @@
1
1
  // src/ProfilerTable.tsx
2
- import React, { useRef, RefObject } from 'react';
2
+ import React, { useRef, RefObject, useCallback, useState } from 'react';
3
3
  import type Color from 'color';
4
4
  import cl from 'classnames';
5
5
  import type { Maybe } from 'true-myth';
6
- import { doubleFF, singleFF, Flamebearer } from './models';
6
+ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
7
+ import { faCrosshairs } from '@fortawesome/free-solid-svg-icons/faCrosshairs';
8
+ import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy';
9
+ import { faHighlighter } from '@fortawesome/free-solid-svg-icons/faHighlighter';
10
+ import { doubleFF, singleFF, Flamebearer, createFF } from './models';
7
11
  // until ui is moved to its own package this should do it
8
12
  // eslint-disable-next-line import/no-extraneous-dependencies
9
13
  import TableUI, {
@@ -11,6 +15,7 @@ import TableUI, {
11
15
  BodyRow,
12
16
  TableBodyType,
13
17
  } from './shims/Table';
18
+ import { ControlledMenu, MenuItem, useMenuState } from './shims/Menu';
14
19
  import TableTooltip from './Tooltip/TableTooltip';
15
20
  import { getFormatter, ratioToPercent, diffPercent } from './format/format';
16
21
  import {
@@ -20,10 +25,14 @@ import {
20
25
  import { fitIntoTableCell, FitModes } from './fitMode/fitMode';
21
26
  import { isMatch } from './search';
22
27
  import type { FlamegraphPalette } from './FlameGraph/FlameGraphComponent/colorPalette';
28
+ import { SandwichIcon } from './Icons';
23
29
  import {
24
30
  useFlamegraphI18n,
25
31
  type FlamegraphMessages,
26
32
  } from './i18n';
33
+ import type { ViewTypes } from './FlameGraph/FlameGraphComponent/viewTypes';
34
+
35
+ import styles from './ProfilerTable.module.scss';
27
36
 
28
37
  const zero = (v?: number) => v || 0;
29
38
 
@@ -138,6 +147,42 @@ function generateTable(
138
147
  return Array.from(hash.values());
139
148
  }
140
149
 
150
+ /**
151
+ * 根据函数名在 flamebearer 中查找 total 值最大的节点位置
152
+ * 返回 { i, j } 坐标,用于聚焦
153
+ */
154
+ function findNodeByName(
155
+ flamebearer: Flamebearer,
156
+ targetName: string
157
+ ): { i: number; j: number } | null {
158
+ if (!flamebearer || !flamebearer.levels) {
159
+ return null;
160
+ }
161
+
162
+ const { names, levels, format } = flamebearer;
163
+ const ff = createFF(format);
164
+
165
+ let bestMatch: { i: number; j: number; total: number } | null = null;
166
+
167
+ for (let i = 0; i < levels.length; i++) {
168
+ const level = levels[i];
169
+ for (let j = 0; j < level.length; j += ff.jStep) {
170
+ const nameIndex = ff.getBarName(level, j);
171
+ const name = names[nameIndex];
172
+
173
+ if (name === targetName) {
174
+ const total = ff.getBarTotal(level, j);
175
+ // 找到 total 值最大的节点
176
+ if (!bestMatch || total > bestMatch.total) {
177
+ bestMatch = { i, j, total };
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ return bestMatch ? { i: bestMatch.i, j: bestMatch.j } : null;
184
+ }
185
+
141
186
  // the value must be negative or zero
142
187
  function neg(v: number) {
143
188
  return Math.min(0, v);
@@ -200,6 +245,8 @@ interface GetTableBodyRowsProps
200
245
  sortBy: string;
201
246
  sortByDirection: string;
202
247
  messages: FlamegraphMessages;
248
+ onRowContextMenu: (e: React.MouseEvent, name: string) => void;
249
+ onRowDoubleClick: (name: string) => void;
203
250
  }
204
251
 
205
252
  const getTableBody = ({
@@ -212,6 +259,8 @@ const getTableBody = ({
212
259
  palette,
213
260
  selectedItem,
214
261
  messages,
262
+ onRowContextMenu,
263
+ onRowDoubleClick,
215
264
  }: GetTableBodyRowsProps): TableBodyType => {
216
265
  const { numTicks, maxSelf, sampleRate, spyName, units } = flamebearer;
217
266
 
@@ -292,6 +341,8 @@ const getTableBody = ({
292
341
  'data-row': `${x.type};${x.name};${x.self};${x.total}`,
293
342
  isRowSelected: isRowSelected(x.name),
294
343
  onClick: () => handleTableItemClick(x),
344
+ onContextMenu: (e: React.MouseEvent) => onRowContextMenu(e, x.name),
345
+ onDoubleClick: () => onRowDoubleClick(x.name),
295
346
  cells: [
296
347
  { value: nameCell(x) },
297
348
  {
@@ -337,6 +388,8 @@ const getTableBody = ({
337
388
  'data-row': `${x.type};${x.name};${x.totalLeft};${x.leftTicks};${x.totalRght};${x.rightTicks}`,
338
389
  isRowSelected: isRowSelected(x.name),
339
390
  onClick: () => handleTableItemClick(x),
391
+ onContextMenu: (e: React.MouseEvent) => onRowContextMenu(e, x.name),
392
+ onDoubleClick: () => onRowDoubleClick(x.name),
340
393
  cells: [
341
394
  { value: nameCell(x) },
342
395
  { value: `${leftPercent} %` },
@@ -373,11 +426,11 @@ const getTableBody = ({
373
426
  return rows.length > 0
374
427
  ? { bodyRows: rows, type: 'filled' as const }
375
428
  : {
376
- value: (
377
- <div className="unsupported-format">{messages.noItemsFound}</div>
378
- ),
379
- type: 'not-filled' as const,
380
- };
429
+ value: (
430
+ <div className="unsupported-format">{messages.noItemsFound}</div>
431
+ ),
432
+ type: 'not-filled' as const,
433
+ };
381
434
  };
382
435
 
383
436
  export interface ProfilerTableProps {
@@ -389,6 +442,13 @@ export interface ProfilerTableProps {
389
442
  selectedItem: Maybe<string>;
390
443
 
391
444
  tableBodyRef: RefObject<HTMLTableSectionElement>;
445
+
446
+ // 新增:聚焦到火焰图节点
447
+ onFocusOnNode?: (i: number, j: number) => void;
448
+ // 新增:切换视图(用于 sandwich view)
449
+ updateView?: (v: ViewTypes) => void;
450
+ // 新增:是否启用 sandwich 视图菜单项
451
+ enableSandwichView?: boolean;
392
452
  }
393
453
 
394
454
  function Table({
@@ -400,53 +460,127 @@ function Table({
400
460
  highlightQuery,
401
461
  selectedItem,
402
462
  palette,
463
+ onFocusOnNode,
464
+ updateView,
465
+ enableSandwichView = true,
403
466
  }: ProfilerTableProps & { isDoubles: boolean }) {
404
467
  const i18n = useFlamegraphI18n();
468
+ const [menuProps, toggleMenu] = useMenuState({ transition: true });
469
+ const [anchorPoint, setAnchorPoint] = useState({ x: 0, y: 0 });
470
+ const [contextMenuTarget, setContextMenuTarget] = useState<string | null>(null);
471
+
472
+ // 双击处理:高亮 + 聚焦到火焰图
473
+ const handleDoubleClick = useCallback(
474
+ (name: string) => {
475
+ // 先确保该项被高亮(如果未高亮则高亮,如果已高亮则保持)
476
+ if (!selectedItem.isJust || selectedItem.value !== name) {
477
+ handleTableItemClick({ name });
478
+ }
479
+
480
+ // 然后聚焦到火焰图
481
+ if (!onFocusOnNode) return;
482
+
483
+ const node = findNodeByName(flamebearer, name);
484
+ if (node) {
485
+ onFocusOnNode(node.i, node.j);
486
+ }
487
+ },
488
+ [flamebearer, onFocusOnNode, selectedItem, handleTableItemClick]
489
+ );
490
+
491
+ // 右键菜单处理
492
+ const handleContextMenu = useCallback(
493
+ (e: React.MouseEvent, name: string) => {
494
+ e.preventDefault();
495
+ setAnchorPoint({ x: e.clientX, y: e.clientY });
496
+ setContextMenuTarget(name);
497
+ toggleMenu(true);
498
+ },
499
+ [toggleMenu]
500
+ );
501
+
502
+ const handleMenuClose = useCallback(() => {
503
+ toggleMenu(false);
504
+ setContextMenuTarget(null);
505
+ }, [toggleMenu]);
506
+
507
+ // 菜单项:聚焦到此函数
508
+ const handleFocusClick = useCallback(() => {
509
+ if (!contextMenuTarget || !onFocusOnNode) return;
510
+
511
+ const node = findNodeByName(flamebearer, contextMenuTarget);
512
+ if (node) {
513
+ onFocusOnNode(node.i, node.j);
514
+ }
515
+ handleMenuClose();
516
+ }, [contextMenuTarget, flamebearer, onFocusOnNode, handleMenuClose]);
517
+
518
+ // 菜单项:复制函数名
519
+ const handleCopyClick = useCallback(() => {
520
+ if (!contextMenuTarget || !navigator.clipboard) return;
521
+ navigator.clipboard.writeText(contextMenuTarget);
522
+ handleMenuClose();
523
+ }, [contextMenuTarget, handleMenuClose]);
524
+
525
+ // 菜单项:高亮相似节点
526
+ const handleHighlightClick = useCallback(() => {
527
+ if (!contextMenuTarget) return;
528
+ handleTableItemClick({ name: contextMenuTarget });
529
+ handleMenuClose();
530
+ }, [contextMenuTarget, handleTableItemClick, handleMenuClose]);
531
+
532
+ // 菜单项:在 sandwich 视图中打开
533
+ const handleSandwichClick = useCallback(() => {
534
+ if (!contextMenuTarget || !updateView) return;
535
+ updateView('sandwich');
536
+ handleTableItemClick({ name: contextMenuTarget });
537
+ handleMenuClose();
538
+ }, [contextMenuTarget, updateView, handleTableItemClick, handleMenuClose]);
405
539
 
406
540
  const tableFormat = React.useMemo(
407
541
  () =>
408
542
  isDoubles
409
543
  ? [
410
- {
411
- sortable: 1,
412
- name: 'name' as const,
413
- label: i18n.location,
414
- },
415
- {
416
- sortable: 1,
417
- name: 'baseline' as const,
418
- label: i18n.baseline,
419
- default: true,
420
- },
421
- {
422
- sortable: 1,
423
- name: 'comparison' as const,
424
- label: i18n.comparison,
425
- },
426
- {
427
- sortable: 1,
428
- name: 'diff' as const,
429
- label: i18n.diff,
430
- },
431
- ]
544
+ {
545
+ sortable: 1,
546
+ name: 'name' as const,
547
+ label: i18n.location,
548
+ },
549
+ {
550
+ sortable: 1,
551
+ name: 'baseline' as const,
552
+ label: i18n.baseline,
553
+ default: true,
554
+ },
555
+ {
556
+ sortable: 1,
557
+ name: 'comparison' as const,
558
+ label: i18n.comparison,
559
+ },
560
+ {
561
+ sortable: 1,
562
+ name: 'diff' as const,
563
+ label: i18n.diff,
564
+ },
565
+ ]
432
566
  : [
433
- {
434
- sortable: 1,
435
- name: 'name' as const,
436
- label: i18n.location,
437
- },
438
- {
439
- sortable: 1,
440
- name: 'self' as const,
441
- label: i18n.self,
442
- default: true,
443
- },
444
- {
445
- sortable: 1,
446
- name: 'total' as const,
447
- label: i18n.total,
448
- },
449
- ],
567
+ {
568
+ sortable: 1,
569
+ name: 'name' as const,
570
+ label: i18n.location,
571
+ },
572
+ {
573
+ sortable: 1,
574
+ name: 'self' as const,
575
+ label: i18n.self,
576
+ default: true,
577
+ },
578
+ {
579
+ sortable: 1,
580
+ name: 'total' as const,
581
+ label: i18n.total,
582
+ },
583
+ ],
450
584
  [i18n, isDoubles]
451
585
  );
452
586
 
@@ -463,19 +597,58 @@ function Table({
463
597
  palette,
464
598
  selectedItem,
465
599
  messages: i18n,
600
+ onRowContextMenu: handleContextMenu,
601
+ onRowDoubleClick: handleDoubleClick,
602
+ onFocusOnNode,
603
+ updateView,
604
+ enableSandwichView,
466
605
  }),
467
606
  };
468
607
 
608
+ const isHighlighted =
609
+ selectedItem.isJust && selectedItem.value === contextMenuTarget;
610
+
469
611
  return (
470
- <TableUI
471
- /* eslint-disable-next-line react/jsx-props-no-spreading */
472
- {...tableSortProps}
473
- tableBodyRef={tableBodyRef}
474
- table={table}
475
- className={cl('flamegraph-table', {
476
- 'flamegraph-table-doubles': isDoubles,
477
- })}
478
- />
612
+ <>
613
+ <TableUI
614
+ /* eslint-disable-next-line react/jsx-props-no-spreading */
615
+ {...tableSortProps}
616
+ tableBodyRef={tableBodyRef}
617
+ table={table}
618
+ className={cl('flamegraph-table', {
619
+ 'flamegraph-table-doubles': isDoubles,
620
+ })}
621
+ />
622
+
623
+ {/* 右键菜单 */}
624
+ <ControlledMenu
625
+ {...menuProps}
626
+ anchorPoint={anchorPoint}
627
+ onClose={handleMenuClose}
628
+ className={styles.tableContextMenu}
629
+ >
630
+ {onFocusOnNode && (
631
+ <MenuItem onClick={handleFocusClick}>
632
+ <FontAwesomeIcon icon={faCrosshairs} />
633
+ {i18n.focusOnThisFunction}
634
+ </MenuItem>
635
+ )}
636
+ <MenuItem onClick={handleCopyClick}>
637
+ <FontAwesomeIcon icon={faCopy} />
638
+ {i18n.copyFunctionName}
639
+ </MenuItem>
640
+ <MenuItem onClick={handleHighlightClick}>
641
+ <FontAwesomeIcon icon={faHighlighter} />
642
+ {isHighlighted ? i18n.clearHighlight : i18n.highlightSimilarNodes}
643
+ </MenuItem>
644
+ {updateView && enableSandwichView && (
645
+ <MenuItem onClick={handleSandwichClick} className={styles.sandwichItem}>
646
+ <SandwichIcon fill="currentColor" />
647
+ {i18n.openInSandwichView}
648
+ </MenuItem>
649
+ )}
650
+ </ControlledMenu>
651
+ </>
479
652
  );
480
653
  }
481
654
 
@@ -486,6 +659,9 @@ const ProfilerTable = React.memo(function ProfilerTable({
486
659
  highlightQuery,
487
660
  palette,
488
661
  selectedItem,
662
+ onFocusOnNode,
663
+ updateView,
664
+ enableSandwichView,
489
665
  }: Omit<ProfilerTableProps, 'tableBodyRef'>) {
490
666
  const tableBodyRef = useRef<HTMLTableSectionElement>(null);
491
667
  const isDoubles = flamebearer.format === 'double';
@@ -510,6 +686,9 @@ const ProfilerTable = React.memo(function ProfilerTable({
510
686
  handleTableItemClick={handleTableItemClick}
511
687
  palette={palette}
512
688
  selectedItem={selectedItem}
689
+ onFocusOnNode={onFocusOnNode}
690
+ updateView={updateView}
691
+ enableSandwichView={enableSandwichView}
513
692
  />
514
693
  </div>
515
694
 
package/src/i18n.tsx CHANGED
@@ -68,6 +68,15 @@ export type FlamegraphMessages = {
68
68
  searchPlaceholder: string;
69
69
  syncSearchBars: string;
70
70
  unsyncSearchBars: string;
71
+
72
+ // 表格右键菜单(新增)
73
+ focusOnThisFunction: string;
74
+ tableDoubleClickToFocus: string;
75
+ tableRightClickForOptions: string;
76
+
77
+ // 火焰图聚焦时的 collapsed 提示
78
+ collapsedLevelsSingular: string; // "total (1 level collapsed)"
79
+ collapsedLevelsPlural: string; // "total (n levels collapsed)"
71
80
  };
72
81
 
73
82
  const defaultTooltipUnitTitles: Record<Units, TooltipUnitMessages> = {
@@ -217,6 +226,15 @@ export const defaultMessages: FlamegraphMessages = {
217
226
  searchPlaceholder: 'Search...',
218
227
  syncSearchBars: 'Sync Search Bars',
219
228
  unsyncSearchBars: 'Unsync Search Bars',
229
+
230
+ // 表格右键菜单(新增)
231
+ focusOnThisFunction: 'Focus on this function',
232
+ tableDoubleClickToFocus: 'Double-click to focus in flamegraph',
233
+ tableRightClickForOptions: 'Right-click for more options',
234
+
235
+ // 火焰图聚焦时的 collapsed 提示
236
+ collapsedLevelsSingular: 'total (1 level collapsed)',
237
+ collapsedLevelsPlural: 'total ({n} levels collapsed)',
220
238
  };
221
239
 
222
240
  export const zhCNMessages: FlamegraphMessages = {
@@ -271,6 +289,15 @@ export const zhCNMessages: FlamegraphMessages = {
271
289
  searchPlaceholder: '搜索...',
272
290
  syncSearchBars: '同步搜索栏',
273
291
  unsyncSearchBars: '取消同步搜索栏',
292
+
293
+ // 表格右键菜单(新增)
294
+ focusOnThisFunction: '聚焦到此函数',
295
+ tableDoubleClickToFocus: '双击可聚焦到火焰图',
296
+ tableRightClickForOptions: '右键查看更多选项',
297
+
298
+ // 火焰图聚焦时的 collapsed 提示
299
+ collapsedLevelsSingular: '总计(已折叠 1 层)',
300
+ collapsedLevelsPlural: '总计(已折叠 {n} 层)',
274
301
  };
275
302
 
276
303
  const I18nContext = createContext<FlamegraphMessages>(defaultMessages);
@@ -49,10 +49,17 @@
49
49
  }
50
50
 
51
51
  tr {
52
+ /* 选中行样式 - 使用更明显的 antd 风格蓝色 */
52
53
  &.isRowSelected {
53
54
  cursor: pointer;
54
- background: var(--ps-table-highlight-row-bg) !important;
55
- color: var(--ps-table-highlight-row-text);
55
+ /* antd 选中蓝色,更明显但不会与红绿/红蓝火焰图冲突 */
56
+ background: var(--ps-table-highlight-row-bg, #bae0ff) !important;
57
+ color: var(--ps-table-highlight-row-text, #0958d9);
58
+
59
+ /* 左侧添加强调边框 */
60
+ td:first-child {
61
+ box-shadow: inset 3px 0 0 0 var(--ps-table-highlight-border, #1677ff);
62
+ }
56
63
  }
57
64
 
58
65
  &.isRowDisabled td {
@@ -1,5 +1,6 @@
1
+ // src/shims/Table.tsx
1
2
  /* eslint-disable react/jsx-props-no-spreading */
2
- import React, { useState, ReactNode, CSSProperties, RefObject } from 'react';
3
+ import React, { useState, useRef, ReactNode, CSSProperties, RefObject } from 'react';
3
4
  import { faChevronLeft } from '@fortawesome/free-solid-svg-icons/faChevronLeft';
4
5
  import { faChevronRight } from '@fortawesome/free-solid-svg-icons/faChevronRight';
5
6
  import clsx from 'clsx';
@@ -10,7 +11,7 @@ import styles from './Table.module.scss';
10
11
  import Button from './Button';
11
12
 
12
13
  interface CustomProp {
13
- [k: string]: string | CSSProperties | ReactNode | number | undefined;
14
+ [k: string]: string | CSSProperties | ReactNode | number | undefined | ((e: React.MouseEvent) => void) | (() => void);
14
15
  }
15
16
 
16
17
  export interface Cell extends CustomProp {
@@ -31,19 +32,21 @@ export interface BodyRow {
31
32
  isRowDisabled?: boolean;
32
33
  cells: Cell[];
33
34
  onClick?: () => void;
35
+ onDoubleClick?: () => void;
36
+ onContextMenu?: (e: React.MouseEvent) => void;
34
37
  className?: string;
35
38
  }
36
39
 
37
40
  export type TableBodyType =
38
41
  | {
39
- type: 'not-filled';
40
- value: string | ReactNode;
41
- bodyClassName?: string;
42
- }
42
+ type: 'not-filled';
43
+ value: string | ReactNode;
44
+ bodyClassName?: string;
45
+ }
43
46
  | {
44
- type: 'filled';
45
- bodyRows: BodyRow[];
46
- };
47
+ type: 'filled';
48
+ bodyRows: BodyRow[];
49
+ };
47
50
 
48
51
  type Table = TableBodyType & {
49
52
  headRow: HeadCell[];
@@ -91,6 +94,46 @@ interface TableProps {
91
94
  itemsPerPage?: number;
92
95
  }
93
96
 
97
+ /**
98
+ * 表格行组件
99
+ * 单击立即高亮,双击同时触发高亮和聚焦
100
+ */
101
+ function TableRow({ row }: { row: BodyRow }) {
102
+ const {
103
+ cells,
104
+ isRowSelected,
105
+ isRowDisabled,
106
+ className,
107
+ onClick,
108
+ onDoubleClick,
109
+ onContextMenu,
110
+ ...rest
111
+ } = row;
112
+
113
+ const renderID = useRef(Math.random()).current;
114
+
115
+ return (
116
+ <tr
117
+ {...rest}
118
+ onClick={onClick}
119
+ onDoubleClick={onDoubleClick}
120
+ onContextMenu={onContextMenu}
121
+ className={clsx(className, {
122
+ [styles.isRowSelected]: isRowSelected,
123
+ [styles.isRowDisabled]: isRowDisabled,
124
+ })}
125
+ >
126
+ {cells &&
127
+ cells.map(({ style, value, ...cellRest }: Cell, index: number) => (
128
+ // eslint-disable-next-line react/no-array-index-key
129
+ <td key={renderID + index} style={style} {...cellRest}>
130
+ {value}
131
+ </td>
132
+ ))}
133
+ </tr>
134
+ );
135
+ }
136
+
94
137
  function Table({
95
138
  sortByDirection,
96
139
  sortBy,
@@ -151,33 +194,9 @@ function Table({
151
194
  </tr>
152
195
  ) : (
153
196
  paginate(table.bodyRows, currPage, itemsPerPage).map(
154
- ({ cells, isRowSelected, isRowDisabled, className, ...rest }) => {
155
- // The problem is that when you switch apps or time-range and the function
156
- // names stay the same it leads to an issue where rows don't get re-rendered
157
- // So we force a rerender each time.
158
- const renderID = Math.random();
159
-
160
- return (
161
- <tr
162
- key={renderID}
163
- {...rest}
164
- className={clsx(className, {
165
- [styles.isRowSelected]: isRowSelected,
166
- [styles.isRowDisabled]: isRowDisabled,
167
- })}
168
- >
169
- {cells &&
170
- cells.map(
171
- ({ style, value, ...rest }: Cell, index: number) => (
172
- // eslint-disable-next-line react/no-array-index-key
173
- <td key={renderID + index} style={style} {...rest}>
174
- {value}
175
- </td>
176
- )
177
- )}
178
- </tr>
179
- );
180
- }
197
+ (row, idx) => (
198
+ <TableRow key={row['data-row'] ?? `row-${idx}`} row={row} />
199
+ )
181
200
  )
182
201
  )}
183
202
  </tbody>