@kylincloud/flamegraph 0.35.20 → 0.35.22

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,6 +1,6 @@
1
- import { ReactNode, CSSProperties, RefObject } from 'react';
1
+ import React, { ReactNode, CSSProperties, RefObject } from 'react';
2
2
  interface CustomProp {
3
- [k: string]: string | CSSProperties | ReactNode | number | undefined;
3
+ [k: string]: string | CSSProperties | ReactNode | number | undefined | ((e: React.MouseEvent) => void) | (() => void);
4
4
  }
5
5
  export interface Cell extends CustomProp {
6
6
  value: ReactNode | string;
@@ -18,6 +18,8 @@ export interface BodyRow {
18
18
  isRowDisabled?: boolean;
19
19
  cells: Cell[];
20
20
  onClick?: () => void;
21
+ onDoubleClick?: () => void;
22
+ onContextMenu?: (e: React.MouseEvent) => void;
21
23
  className?: string;
22
24
  }
23
25
  export type TableBodyType = {
@@ -1 +1 @@
1
- {"version":3,"file":"Table.d.ts","sourceRoot":"","sources":["../../src/shims/Table.tsx"],"names":[],"mappings":"AACA,OAAc,EAAY,SAAS,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAU7E,UAAU,UAAU;IAClB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,aAAa,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAAC;CACtE;AAED,MAAM,WAAW,IAAK,SAAQ,UAAU;IACtC,KAAK,EAAE,SAAS,GAAG,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AAED,UAAU,QAAS,SAAQ,UAAU;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,OAAO;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,aAAa,GACrB;IACE,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GACD;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB,CAAC;AAEN,KAAK,KAAK,GAAG,aAAa,GAAG;IAC3B,OAAO,EAAE,QAAQ,EAAE,CAAC;CACrB,CAAC;AAEF,UAAU,cAAc;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,eAAe,EAAE,MAAM,GAAG,KAAK,CAAC;CACjC;AAED,eAAO,MAAM,YAAY,YAAa,QAAQ,EAAE,KAAG,cAsBlD,CAAC;AAEF,UAAU,UAAU;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/C,KAAK,EAAE,KAAK,CAAC;IACb,YAAY,CAAC,EAAE,SAAS,CAAC,uBAAuB,CAAC,CAAC;IAClD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,iBAAS,KAAK,CAAC,EACb,eAAe,EACf,MAAM,EACN,gBAAgB,EAChB,KAAK,EACL,YAAY,EACZ,SAAS,EACT,SAAS,EACT,YAAY,GACb,EAAE,UAAU,2CA0FZ;AA2DD,eAAe,KAAK,CAAC"}
1
+ {"version":3,"file":"Table.d.ts","sourceRoot":"","sources":["../../src/shims/Table.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,EAAY,SAAS,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAU7E,UAAU,UAAU;IAClB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,aAAa,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;CACvH;AAED,MAAM,WAAW,IAAK,SAAQ,UAAU;IACtC,KAAK,EAAE,SAAS,GAAG,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AAED,UAAU,QAAS,SAAQ,UAAU;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,OAAO;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,aAAa,GACrB;IACE,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GACD;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB,CAAC;AAEN,KAAK,KAAK,GAAG,aAAa,GAAG;IAC3B,OAAO,EAAE,QAAQ,EAAE,CAAC;CACrB,CAAC;AAEF,UAAU,cAAc;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,eAAe,EAAE,MAAM,GAAG,KAAK,CAAC;CACjC;AAED,eAAO,MAAM,YAAY,YAAa,QAAQ,EAAE,KAAG,cAsBlD,CAAC;AAEF,UAAU,UAAU;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/C,KAAK,EAAE,KAAK,CAAC;IACb,YAAY,CAAC,EAAE,SAAS,CAAC,uBAAuB,CAAC,CAAC;IAClD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,iBAAS,KAAK,CAAC,EACb,eAAe,EACf,MAAM,EACN,gBAAgB,EAChB,KAAK,EACL,YAAY,EACZ,SAAS,EACT,SAAS,EACT,YAAY,GACb,EAAE,UAAU,2CAsGZ;AA2DD,eAAe,KAAK,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylincloud/flamegraph",
3
- "version": "0.35.20",
3
+ "version": "0.35.22",
4
4
  "description": "KylinCloud flamegraph renderer (Pyroscope-based)",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/index.node.cjs.js",
@@ -337,7 +337,7 @@ class FlameGraphRenderer extends Component<
337
337
  return (
338
338
  this.state.selectedItem.isJust ||
339
339
  JSON.stringify(this.initialFlamegraphState) !==
340
- JSON.stringify(this.state.flamegraphConfigs)
340
+ JSON.stringify(this.state.flamegraphConfigs)
341
341
  );
342
342
  };
343
343
 
@@ -371,6 +371,10 @@ class FlameGraphRenderer extends Component<
371
371
  selectedItem={this.state.selectedItem}
372
372
  handleTableItemClick={this.setActiveItem}
373
373
  palette={this.state.palette}
374
+ // 新增:传递聚焦和视图切换能力给表格
375
+ onFocusOnNode={this.onFocusOnNode}
376
+ updateView={this.props.onlyDisplay ? undefined : this.updateView}
377
+ enableSandwichView={this.props.enableSandwichView}
374
378
  />
375
379
  </div>
376
380
  );
@@ -0,0 +1,43 @@
1
+ // src/ProfilerTable.module.scss
2
+
3
+ .tableContextMenu {
4
+
5
+ // 继承 react-menu 的基础样式
6
+ :global(.szh-menu) {
7
+ background-color: var(--ps-ui-background, #fff);
8
+ border: 1px solid var(--ps-ui-border, #ccc);
9
+ border-radius: 4px;
10
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
11
+ padding: 4px 0;
12
+ min-width: 180px;
13
+ z-index: 1000;
14
+ }
15
+
16
+ :global(.szh-menu__item) {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 8px;
20
+ padding: 8px 12px;
21
+ cursor: pointer;
22
+ color: var(--ps-ui-foreground-text, #333);
23
+ font-size: 13px;
24
+ transition: background-color 0.15s;
25
+
26
+ &:hover {
27
+ background-color: var(--ps-ui-element-bg-highlight, #f0f0f0);
28
+ }
29
+
30
+ svg {
31
+ width: 14px;
32
+ height: 14px;
33
+ flex-shrink: 0;
34
+ }
35
+ }
36
+ }
37
+
38
+ .sandwichItem {
39
+ svg {
40
+ width: 16px;
41
+ height: 16px;
42
+ }
43
+ }
@@ -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} %` },
@@ -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,8 +460,76 @@ 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
+ if (!onFocusOnNode) return;
476
+
477
+ const node = findNodeByName(flamebearer, name);
478
+ if (node) {
479
+ onFocusOnNode(node.i, node.j);
480
+ }
481
+ },
482
+ [flamebearer, onFocusOnNode]
483
+ );
484
+
485
+ // 右键菜单处理
486
+ const handleContextMenu = useCallback(
487
+ (e: React.MouseEvent, name: string) => {
488
+ e.preventDefault();
489
+ setAnchorPoint({ x: e.clientX, y: e.clientY });
490
+ setContextMenuTarget(name);
491
+ toggleMenu(true);
492
+ },
493
+ [toggleMenu]
494
+ );
495
+
496
+ const handleMenuClose = useCallback(() => {
497
+ toggleMenu(false);
498
+ setContextMenuTarget(null);
499
+ }, [toggleMenu]);
500
+
501
+ // 菜单项:聚焦到此函数
502
+ const handleFocusClick = useCallback(() => {
503
+ if (!contextMenuTarget || !onFocusOnNode) return;
504
+
505
+ const node = findNodeByName(flamebearer, contextMenuTarget);
506
+ if (node) {
507
+ onFocusOnNode(node.i, node.j);
508
+ }
509
+ handleMenuClose();
510
+ }, [contextMenuTarget, flamebearer, onFocusOnNode, handleMenuClose]);
511
+
512
+ // 菜单项:复制函数名
513
+ const handleCopyClick = useCallback(() => {
514
+ if (!contextMenuTarget || !navigator.clipboard) return;
515
+ navigator.clipboard.writeText(contextMenuTarget);
516
+ handleMenuClose();
517
+ }, [contextMenuTarget, handleMenuClose]);
518
+
519
+ // 菜单项:高亮相似节点
520
+ const handleHighlightClick = useCallback(() => {
521
+ if (!contextMenuTarget) return;
522
+ handleTableItemClick({ name: contextMenuTarget });
523
+ handleMenuClose();
524
+ }, [contextMenuTarget, handleTableItemClick, handleMenuClose]);
525
+
526
+ // 菜单项:在 sandwich 视图中打开
527
+ const handleSandwichClick = useCallback(() => {
528
+ if (!contextMenuTarget || !updateView) return;
529
+ updateView('sandwich');
530
+ handleTableItemClick({ name: contextMenuTarget });
531
+ handleMenuClose();
532
+ }, [contextMenuTarget, updateView, handleTableItemClick, handleMenuClose]);
405
533
 
406
534
  const tableFormat = React.useMemo(
407
535
  () =>
@@ -463,19 +591,58 @@ function Table({
463
591
  palette,
464
592
  selectedItem,
465
593
  messages: i18n,
594
+ onRowContextMenu: handleContextMenu,
595
+ onRowDoubleClick: handleDoubleClick,
596
+ onFocusOnNode,
597
+ updateView,
598
+ enableSandwichView,
466
599
  }),
467
600
  };
468
601
 
602
+ const isHighlighted =
603
+ selectedItem.isJust && selectedItem.value === contextMenuTarget;
604
+
469
605
  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
- />
606
+ <>
607
+ <TableUI
608
+ /* eslint-disable-next-line react/jsx-props-no-spreading */
609
+ {...tableSortProps}
610
+ tableBodyRef={tableBodyRef}
611
+ table={table}
612
+ className={cl('flamegraph-table', {
613
+ 'flamegraph-table-doubles': isDoubles,
614
+ })}
615
+ />
616
+
617
+ {/* 右键菜单 */}
618
+ <ControlledMenu
619
+ {...menuProps}
620
+ anchorPoint={anchorPoint}
621
+ onClose={handleMenuClose}
622
+ className={styles.tableContextMenu}
623
+ >
624
+ {onFocusOnNode && (
625
+ <MenuItem onClick={handleFocusClick}>
626
+ <FontAwesomeIcon icon={faCrosshairs} />
627
+ {i18n.focusOnThisFunction}
628
+ </MenuItem>
629
+ )}
630
+ <MenuItem onClick={handleCopyClick}>
631
+ <FontAwesomeIcon icon={faCopy} />
632
+ {i18n.copyFunctionName}
633
+ </MenuItem>
634
+ <MenuItem onClick={handleHighlightClick}>
635
+ <FontAwesomeIcon icon={faHighlighter} />
636
+ {isHighlighted ? i18n.clearHighlight : i18n.highlightSimilarNodes}
637
+ </MenuItem>
638
+ {updateView && enableSandwichView && (
639
+ <MenuItem onClick={handleSandwichClick} className={styles.sandwichItem}>
640
+ <SandwichIcon fill="currentColor" />
641
+ {i18n.openInSandwichView}
642
+ </MenuItem>
643
+ )}
644
+ </ControlledMenu>
645
+ </>
479
646
  );
480
647
  }
481
648
 
@@ -486,6 +653,9 @@ const ProfilerTable = React.memo(function ProfilerTable({
486
653
  highlightQuery,
487
654
  palette,
488
655
  selectedItem,
656
+ onFocusOnNode,
657
+ updateView,
658
+ enableSandwichView,
489
659
  }: Omit<ProfilerTableProps, 'tableBodyRef'>) {
490
660
  const tableBodyRef = useRef<HTMLTableSectionElement>(null);
491
661
  const isDoubles = flamebearer.format === 'double';
@@ -510,6 +680,9 @@ const ProfilerTable = React.memo(function ProfilerTable({
510
680
  handleTableItemClick={handleTableItemClick}
511
681
  palette={palette}
512
682
  selectedItem={selectedItem}
683
+ onFocusOnNode={onFocusOnNode}
684
+ updateView={updateView}
685
+ enableSandwichView={enableSandwichView}
513
686
  />
514
687
  </div>
515
688
 
@@ -7,7 +7,7 @@
7
7
  color: var(--ps-tooltip-text);
8
8
  font-size: 12px;
9
9
  visibility: hidden;
10
- z-index: 2;
10
+ z-index: 5;
11
11
  pointer-events: none;
12
12
 
13
13
  &.flamegraphDiffTooltip {
@@ -7,6 +7,7 @@ import React, {
7
7
  useState,
8
8
  useRef,
9
9
  useCallback,
10
+ useLayoutEffect,
10
11
  Dispatch,
11
12
  SetStateAction,
12
13
  } from 'react';
@@ -210,6 +211,52 @@ function getDiffColorByText(
210
211
  return undefined;
211
212
  }
212
213
 
214
+ /**
215
+ * Tooltip 位置计算:默认右下;若越界则翻到左/上;最终 clamp 到视口内。
216
+ * 注意:Tooltip 使用 position: fixed,因此用 innerWidth/innerHeight 做视口碰撞检测最直接。
217
+ */
218
+ const TOOLTIP_GAP_X = 12;
219
+ const TOOLTIP_GAP_Y = 20;
220
+ const TOOLTIP_PAD = 8;
221
+
222
+ function clamp(v: number, min: number, max: number) {
223
+ if (max < min) return min;
224
+ return Math.max(min, Math.min(max, v));
225
+ }
226
+
227
+ function computeSafeTooltipPos(
228
+ x: number,
229
+ y: number,
230
+ tipW: number,
231
+ tipH: number
232
+ ) {
233
+ const vw = window.innerWidth;
234
+ const vh = window.innerHeight;
235
+
236
+ const w = Math.max(0, tipW || 0);
237
+ const h = Math.max(0, tipH || 0);
238
+
239
+ // 默认:右下
240
+ let left = x + TOOLTIP_GAP_X;
241
+ let top = y + TOOLTIP_GAP_Y;
242
+
243
+ // 右侧溢出:翻到左边
244
+ if (left + w + TOOLTIP_PAD > vw) {
245
+ left = x - w - TOOLTIP_GAP_X;
246
+ }
247
+
248
+ // 底部溢出:翻到上边
249
+ if (top + h + TOOLTIP_PAD > vh) {
250
+ top = y - h - TOOLTIP_GAP_Y;
251
+ }
252
+
253
+ // 最终夹取到视口内
254
+ left = clamp(left, TOOLTIP_PAD, vw - w - TOOLTIP_PAD);
255
+ top = clamp(top, TOOLTIP_PAD, vh - h - TOOLTIP_PAD);
256
+
257
+ return { left, top };
258
+ }
259
+
213
260
  export function Tooltip({
214
261
  shouldShowFooter = true,
215
262
  shouldShowTitle = true,
@@ -219,6 +266,8 @@ export function Tooltip({
219
266
  palette,
220
267
  }: TooltipProps) {
221
268
  const tooltipRef = useRef<HTMLDivElement>(null);
269
+ const lastPosRef = useRef<{ x: number; y: number } | null>(null);
270
+
222
271
  const [content, setContent] = React.useState({
223
272
  title: {
224
273
  text: '',
@@ -245,13 +294,17 @@ export function Tooltip({
245
294
  throw new Error('Missing tooltipElement');
246
295
  }
247
296
 
248
- const left = Math.min(
249
- e.clientX + 12,
250
- window.innerWidth - tooltipRef.current.clientWidth - 20
251
- );
252
- const top = e.clientY + 20;
297
+ // 记录最后鼠标位置(给 useLayoutEffect 二次校正用)
298
+ lastPosRef.current = { x: e.clientX, y: e.clientY };
253
299
 
300
+ // 先更新内容(tooltip 高度可能因此变化)
254
301
  setTooltipContent(setContent, onMouseOut, e);
302
+
303
+ // 用当前已渲染的尺寸先算一次位置(内容变化后的真实尺寸会在 useLayoutEffect 再校正)
304
+ const w = tooltipRef.current.clientWidth || 0;
305
+ const h = tooltipRef.current.clientHeight || 0;
306
+
307
+ const { left, top } = computeSafeTooltipPos(e.clientX, e.clientY, w, h);
255
308
  setStyle({ top, left, visibility: 'visible' });
256
309
  },
257
310
  [setTooltipContent]
@@ -278,6 +331,24 @@ export function Tooltip({
278
331
  };
279
332
  }, [dataSourceRef.current, memoizedOnMouseMove]);
280
333
 
334
+ // 内容变化后用真实 DOM 尺寸做二次校正,避免“首次显示尺寸尚未更新导致仍越界”
335
+ useLayoutEffect(() => {
336
+ if (!tooltipRef.current) return;
337
+ if (!lastPosRef.current) return;
338
+ if (content.tooltipData.length === 0) return;
339
+
340
+ const { x, y } = lastPosRef.current;
341
+ const rect = tooltipRef.current.getBoundingClientRect();
342
+ const { left, top } = computeSafeTooltipPos(x, y, rect.width, rect.height);
343
+
344
+ setStyle((prev) => {
345
+ if (prev && prev.left === left && prev.top === top && prev.visibility === 'visible') {
346
+ return prev;
347
+ }
348
+ return { ...(prev || {}), left, top, visibility: 'visible' };
349
+ });
350
+ }, [content.tooltipData.length, content.title.text]);
351
+
281
352
  return (
282
353
  <div
283
354
  data-testid="tooltip"
@@ -460,12 +531,10 @@ function TooltipTable({
460
531
  <tr>
461
532
  <td />
462
533
  <td>
463
- {i18n.self} (
464
- {i18n.tooltipUnitTitles[baselineData.units].total})
534
+ {i18n.self} ({i18n.tooltipUnitTitles[baselineData.units].total})
465
535
  </td>
466
536
  <td>
467
- {i18n.total} (
468
- {i18n.tooltipUnitTitles[baselineData.units].total})
537
+ {i18n.total} ({i18n.tooltipUnitTitles[baselineData.units].total})
469
538
  </td>
470
539
  </tr>
471
540
  </thead>
package/src/i18n.tsx CHANGED
@@ -68,6 +68,11 @@ 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;
71
76
  };
72
77
 
73
78
  const defaultTooltipUnitTitles: Record<Units, TooltipUnitMessages> = {
@@ -217,6 +222,11 @@ export const defaultMessages: FlamegraphMessages = {
217
222
  searchPlaceholder: 'Search...',
218
223
  syncSearchBars: 'Sync Search Bars',
219
224
  unsyncSearchBars: 'Unsync Search Bars',
225
+
226
+ // 表格右键菜单(新增)
227
+ focusOnThisFunction: 'Focus on this function',
228
+ tableDoubleClickToFocus: 'Double-click to focus in flamegraph',
229
+ tableRightClickForOptions: 'Right-click for more options',
220
230
  };
221
231
 
222
232
  export const zhCNMessages: FlamegraphMessages = {
@@ -271,6 +281,11 @@ export const zhCNMessages: FlamegraphMessages = {
271
281
  searchPlaceholder: '搜索...',
272
282
  syncSearchBars: '同步搜索栏',
273
283
  unsyncSearchBars: '取消同步搜索栏',
284
+
285
+ // 表格右键菜单(新增)
286
+ focusOnThisFunction: '聚焦到此函数',
287
+ tableDoubleClickToFocus: '双击可聚焦到火焰图',
288
+ tableRightClickForOptions: '右键查看更多选项',
274
289
  };
275
290
 
276
291
  const I18nContext = createContext<FlamegraphMessages>(defaultMessages);
@@ -1,3 +1,4 @@
1
+ // src/shims/Table.tsx
1
2
  /* eslint-disable react/jsx-props-no-spreading */
2
3
  import React, { useState, ReactNode, CSSProperties, RefObject } from 'react';
3
4
  import { faChevronLeft } from '@fortawesome/free-solid-svg-icons/faChevronLeft';
@@ -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[];
@@ -151,7 +154,16 @@ function Table({
151
154
  </tr>
152
155
  ) : (
153
156
  paginate(table.bodyRows, currPage, itemsPerPage).map(
154
- ({ cells, isRowSelected, isRowDisabled, className, ...rest }) => {
157
+ ({
158
+ cells,
159
+ isRowSelected,
160
+ isRowDisabled,
161
+ className,
162
+ onClick,
163
+ onDoubleClick,
164
+ onContextMenu,
165
+ ...rest
166
+ }) => {
155
167
  // The problem is that when you switch apps or time-range and the function
156
168
  // names stay the same it leads to an issue where rows don't get re-rendered
157
169
  // So we force a rerender each time.
@@ -161,6 +173,9 @@ function Table({
161
173
  <tr
162
174
  key={renderID}
163
175
  {...rest}
176
+ onClick={onClick}
177
+ onDoubleClick={onDoubleClick}
178
+ onContextMenu={onContextMenu}
164
179
  className={clsx(className, {
165
180
  [styles.isRowSelected]: isRowSelected,
166
181
  [styles.isRowDisabled]: isRowDisabled,