dsh-codegraph-visualizer 0.3.0 → 0.3.1

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.
package/dist/client.js CHANGED
@@ -2397,6 +2397,65 @@ body[data-ds-dark-theme] .cg-type-badge.cg-function {
2397
2397
  color: var(--cg-success);
2398
2398
  text-align: center;
2399
2399
  }
2400
+
2401
+ /* ---- Batch action bar (cycle-149 多选链路消费面) -------------------------- */
2402
+ /*
2403
+ * 悬浮在画布底部、状态栏上方的批量操作条。仅在 \`multiSelectedIds.size > 0\` 时由
2404
+ * BatchActionBar 渲染;空选区返回 null,因此这里无需 hidden 态样式。
2405
+ * 定位锚定 \`.cg-panel\`(position: absolute + z-index: var(--cg-z-panel)),
2406
+ * 与 health-score-card 等面板级浮层同层——不遮 ContextMenu(z-context-menu 更高)。
2407
+ */
2408
+
2409
+ .cg-batch-bar {
2410
+ position: absolute;
2411
+ bottom: 44px; /* 状态栏上方(状态栏高度 ≈ 32px + 间距) */
2412
+ left: 50%;
2413
+ transform: translateX(-50%);
2414
+ display: flex;
2415
+ align-items: center;
2416
+ gap: 8px;
2417
+ padding: 6px 10px;
2418
+ border-radius: var(--cg-radius-sm);
2419
+ border: 1px solid var(--cg-border-strong);
2420
+ background: var(--cg-surface);
2421
+ box-shadow: var(--cg-shadow-lg);
2422
+ z-index: var(--cg-z-panel);
2423
+ max-width: calc(100% - 32px);
2424
+ }
2425
+
2426
+ .cg-batch-bar-count {
2427
+ font-size: 12px;
2428
+ font-weight: 600;
2429
+ color: var(--cg-accent);
2430
+ white-space: nowrap;
2431
+ }
2432
+
2433
+ .cg-batch-bar-btn {
2434
+ padding: 4px 10px;
2435
+ border-radius: var(--cg-radius-xs);
2436
+ border: 1px solid var(--cg-border-strong);
2437
+ background: var(--cg-surface);
2438
+ color: var(--cg-text);
2439
+ font-size: 12px;
2440
+ cursor: pointer;
2441
+ white-space: nowrap;
2442
+ transition: background var(--cg-transition), border-color var(--cg-transition);
2443
+ }
2444
+
2445
+ .cg-batch-bar-btn:hover {
2446
+ background: var(--cg-surface-hover);
2447
+ }
2448
+
2449
+ .cg-batch-bar-btn:focus-visible {
2450
+ outline: 2px solid var(--cg-accent);
2451
+ outline-offset: 1px;
2452
+ }
2453
+
2454
+ /* 清除按钮的破坏性语义:hover 提示「这会丢掉选区」,但不用 error 底色(非错误态)。 */
2455
+ .cg-batch-bar-btn-clear:hover {
2456
+ border-color: var(--cg-error);
2457
+ color: var(--cg-error);
2458
+ }
2400
2459
 
2401
2460
  /* ---- Graph container ----------------------------------------------------- */
2402
2461
 
@@ -6141,993 +6200,1050 @@ button.cg-legend-row.cg-legend-toggle:hover {
6141
6200
  opacity: 0.4;
6142
6201
  cursor: not-allowed;
6143
6202
  }
6144
- /* ---- Responsive ---------------------------------------------------------- */
6145
-
6146
- /* Extra-narrow panels (DSH sidebar tab minimal width).
6147
- * Hide the remaining toolbar extras to prioritize graph canvas.
6148
- * The center group is already gone at the 600px tier below. */
6149
- @container graphpanel (max-width: 380px) {
6150
- .cg-tour-btn,
6151
- .cg-minimap-btn,
6152
- .cg-legend-btn {
6153
- display: none;
6154
- }
6155
-
6156
- /* The separators are siblings of the \`.cg-toolbar-group\` elements, not descendants,
6157
- so the previous \`.cg-toolbar-group ... .cg-toolbar-separator\` selector never
6158
- matched anything. */
6159
- .cg-toolbar-separator {
6160
- display: none;
6161
- }
6162
- }
6163
-
6164
- /* Small panels (DSH sidebar tab, narrow bottom panel).
6165
- * Container queries respond to the PANEL width, not the viewport — the plugin
6166
- * renders inside DSH panels whose width is unrelated to the window size. */
6167
- @container graphpanel (max-width: 480px) {
6168
- .cg-graph-panel {
6169
- border-radius: var(--cg-radius-sm);
6170
- }
6171
-
6172
- .cg-graph-toolbar {
6173
- padding: 0 8px;
6174
- gap: 4px;
6175
- }
6176
-
6177
- .cg-toolbar-left {
6178
- font-size: 11px;
6179
- }
6180
-
6181
- .cg-toolbar-center {
6182
- gap: 4px;
6183
- }
6184
-
6185
- .cg-toolbar-right {
6186
- gap: 4px;
6187
- }
6188
-
6189
- .cg-layout-btn,
6190
- .cg-graph-type-btn,
6191
- .cg-rank-dir-btn {
6192
- padding: 3px 6px;
6193
- font-size: 10px;
6194
- }
6195
-
6196
- .cg-search-btn,
6197
- .cg-theme-btn,
6198
- .cg-export-btn,
6199
- .cg-collapse-btn,
6200
- .cg-chain-btn,
6201
- .cg-cycle-btn,
6202
- .cg-impact-btn,
6203
- .cg-inheritance-btn,
6204
- .cg-minimap-btn,
6205
- .cg-refresh-btn,
6206
- .cg-undo-btn,
6207
- .cg-redo-btn,
6208
- .cg-import-btn,
6209
- .cg-legend-btn,
6210
- .cg-lang-btn,
6211
- .cg-pathfind-btn,
6212
- .cg-health-btn,
6213
- .cg-rules-btn,
6214
- .cg-hotspot-btn,
6215
- .cg-godmodule-btn,
6216
- .cg-corefunction-btn,
6217
- .cg-analysis-dropdown-btn,
6218
- .cg-quality-dropdown-btn,
6219
- .cg-tour-btn {
6220
- width: 26px;
6221
- height: 26px;
6222
- font-size: 12px;
6223
- }
6224
-
6225
- .cg-dropdown-content {
6226
- min-width: 140px;
6227
- }
6228
-
6229
- .cg-node-detail-panel {
6230
- width: calc(100% - 24px);
6231
- left: 12px;
6232
- top: 12px;
6233
- }
6234
-
6235
- .cg-legend-panel {
6236
- width: 140px;
6237
- }
6238
-
6239
- .cg-mini-map {
6240
- width: 140px;
6241
- }
6242
-
6243
- .cg-type-badge {
6244
- display: none;
6245
- }
6246
-
6247
- .cg-toolbar-separator {
6248
- display: none;
6249
- }
6250
-
6251
- .cg-graph-type-buttons {
6252
- display: none;
6253
- }
6254
-
6255
- .cg-view-mode-buttons {
6256
- gap: 1px;
6257
- }
6258
-
6259
- .cg-view-mode-btn {
6260
- padding: 2px 4px;
6261
- font-size: 9px;
6262
- }
6263
-
6264
- .cg-search-panel,
6265
- .cg-path-panel {
6266
- width: calc(100% - 16px);
6267
- max-width: 320px;
6268
- }
6269
-
6270
- .cg-filter-panel {
6271
- width: calc(100% - 16px);
6272
- }
6273
-
6274
- .cg-heatmap-btn--activate,
6275
- .cg-heatmap-btn--deactivate {
6276
- width: 26px;
6277
- height: 26px;
6278
- font-size: 12px;
6279
- }
6280
-
6281
- }
6282
-
6283
- /* Medium panels (DSH sidebar medium width) */
6284
- @container graphpanel (max-width: 768px) {
6285
- .cg-graph-toolbar {
6286
- padding: 0 8px;
6287
- gap: 6px;
6288
- }
6289
-
6290
- .cg-toolbar-center {
6291
- gap: 6px;
6292
- }
6293
-
6294
- .cg-toolbar-right {
6295
- gap: 6px;
6296
- }
6297
-
6298
- .cg-layout-btn,
6299
- .cg-graph-type-btn {
6300
- padding: 3px 8px;
6301
- font-size: 11px;
6302
- }
6303
-
6304
- .cg-node-detail-panel {
6305
- width: 220px;
6306
- }
6307
-
6308
- .cg-legend-panel {
6309
- width: 160px;
6310
- }
6311
-
6312
- .cg-mini-map {
6313
- width: 160px;
6314
- }
6315
-
6316
- .cg-filter-select {
6317
- font-size: 11px;
6318
- }
6319
-
6320
- .cg-toolbar-separator {
6321
- margin: 0 2px;
6322
- }
6323
-
6324
- .cg-tour-btn {
6325
- display: none;
6326
- }
6327
-
6328
- }
6329
-
6330
- /* Medium-narrow panels — hide tour and reduce analysis button clutter */
6331
- @container graphpanel (max-width: 600px) {
6332
- .cg-tour-btn,
6333
- .cg-minimap-btn {
6334
- display: none;
6335
- }
6336
-
6337
- .cg-toolbar-left .cg-type-badge {
6338
- display: none;
6339
- }
6340
-
6341
- /* The center group (layout / view-mode / level) is the widest fixed-width block in the
6342
- toolbar: 150px for its three icon-only triggers, measured on the real host. At a 520px
6343
- panel it was being squeezed to 115px, which wrapped the level trigger onto two lines
6344
- and clipped it; at 600px it still has 45px of slack. Hiding it here instead of letting
6345
- it be crushed keeps the workspace chip and every right-hand control (export / more /
6346
- collapse) reachable. Of the three controls, only the layout keeps an alternative entry
6347
- point (Ctrl+L cycles it, see \`usePanelKeyboard\`); view mode and hierarchy level lose
6348
- their mouse entry below this tier — see the note in \`docs/需求分析.md\` §28.3. */
6349
- .cg-toolbar-center {
6350
- display: none;
6351
- }
6352
- }
6353
-
6354
- /* Very small panels */
6355
- @container graphpanel (max-width: 320px) {
6356
- .cg-graph-toolbar {
6357
- gap: 2px;
6358
- flex-wrap: wrap;
6359
- padding: 4px 4px;
6360
- }
6361
-
6362
- .cg-toolbar-left,
6363
- .cg-toolbar-center,
6364
- .cg-toolbar-right {
6365
- flex-wrap: wrap;
6366
- gap: 2px;
6367
- }
6368
-
6369
- .cg-layout-buttons,
6370
- .cg-graph-type-buttons {
6371
- padding: 1px;
6372
- }
6373
-
6374
- .cg-layout-btn,
6375
- .cg-graph-type-btn {
6376
- padding: 2px 4px;
6377
- font-size: 9px;
6378
- }
6379
-
6380
- .cg-filter-select {
6381
- padding: 3px 20px 3px 6px;
6382
- font-size: 10px;
6383
- }
6384
-
6385
- .cg-node-count {
6386
- font-size: 10px;
6387
- }
6388
-
6389
- .cg-legend-panel,
6390
- .cg-mini-map {
6391
- width: 120px;
6392
- }
6393
-
6394
- /* 下拉内容已 portal 到 document.body,不再是本容器的后代,此处的
6395
- * \`@container\` 抽屉规则永远匹配不到;抽屉形态改由 Dropdown 组件测量面板
6396
- * 宽度后加 \`.cg-portal-drawer\`(见 layout.css)实现。 */
6397
-
6398
- .cg-search-panel,
6399
- .cg-path-panel,
6400
- .cg-filter-panel {
6401
- width: calc(100% - 8px);
6402
- left: 4px;
6403
- right: 4px;
6404
- }
6405
-
6406
- .cg-view-mode-buttons {
6407
- flex-wrap: wrap;
6408
- }
6409
- }
6410
-
6411
- /* Large screens — allow fixed floating panel */
6412
- @media (min-width: 1200px) {
6413
- .cg-graph-panel.cg-floating {
6414
- position: fixed;
6415
- right: 16px;
6416
- bottom: 16px;
6417
- width: 640px;
6418
- height: 520px;
6419
- }
6420
- }
6421
-
6422
- /* ---- Sidebar tab mode (short height) -------------------------------------- */
6423
- /* When the plugin renders inside a DSH sidebar tab, the panel height can be
6424
- * very limited. Adapt the layout to prioritize the graph canvas. */
6425
- @container graphpanel (max-height: 400px) {
6426
- .cg-graph-toolbar {
6427
- padding: 0 4px;
6428
- gap: 2px;
6429
- }
6430
-
6431
- .cg-toolbar-left .cg-node-count {
6432
- font-size: 10px;
6433
- }
6434
-
6435
- .cg-type-badge {
6436
- display: none;
6437
- }
6438
-
6439
- .cg-toolbar-separator {
6440
- display: none;
6441
- }
6442
-
6443
- .cg-graph-type-buttons {
6444
- display: none;
6445
- }
6446
-
6447
- .cg-view-mode-buttons {
6448
- gap: 1px;
6449
- }
6450
-
6451
- .cg-view-mode-btn {
6452
- padding: 2px 4px;
6453
- font-size: 9px;
6454
- }
6455
-
6456
- .cg-node-detail-panel {
6457
- max-height: calc(100% - 60px);
6458
- overflow-y: auto;
6459
- }
6460
-
6461
- .cg-legend-panel {
6462
- max-height: 120px;
6463
- overflow-y: auto;
6464
- }
6465
-
6466
- .cg-mini-map {
6467
- max-height: 100px;
6468
- }
6469
-
6470
- .cg-search-panel,
6471
- .cg-path-panel,
6472
- .cg-filter-panel {
6473
- max-height: calc(100% - 60px);
6474
- overflow-y: auto;
6475
- }
6476
- }
6477
-
6478
- @container graphpanel (max-height: 240px) {
6479
- .cg-graph-toolbar {
6480
- flex-wrap: wrap;
6481
- padding: 2px;
6482
- }
6483
-
6484
- .cg-toolbar-left,
6485
- .cg-toolbar-center,
6486
- .cg-toolbar-right {
6487
- flex-wrap: wrap;
6488
- gap: 2px;
6489
- }
6490
-
6491
- .cg-layout-buttons {
6492
- display: none;
6493
- }
6494
-
6495
- .cg-rank-dir-buttons {
6496
- display: none;
6497
- }
6498
-
6499
- .cg-cluster-depth-buttons {
6500
- display: none;
6501
- }
6502
-
6503
- .cg-node-detail-panel {
6504
- width: calc(100% - 8px);
6505
- left: 4px;
6506
- top: 4px;
6507
- max-height: calc(100% - 40px);
6508
- }
6509
- }
6510
-
6511
- /* ---- Reduced motion ------------------------------------------------------ */
6512
-
6513
- @media (prefers-reduced-motion: reduce) {
6514
- .cg-graph-panel *,
6515
- .cg-graph-panel *::before,
6516
- .cg-graph-panel *::after,
6517
- .cg-floating-panel *,
6518
- .cg-floating-panel *::before,
6519
- .cg-floating-panel *::after,
6520
- .cg-floating-container *,
6521
- .cg-floating-container *::before,
6522
- .cg-floating-container *::after {
6523
- animation-duration: 0.01ms !important;
6524
- transition-duration: 0.01ms !important;
6525
- }
6526
- }
6527
-
6528
- /* ---- Touch device adaptation --------------------------------------------- */
6529
-
6530
- @media (pointer: coarse) {
6531
- .cg-layout-btn,
6532
- .cg-graph-type-btn,
6533
- .cg-search-btn,
6534
- .cg-theme-btn,
6535
- .cg-export-btn,
6536
- .cg-collapse-btn,
6537
- .cg-chain-btn,
6538
- .cg-cycle-btn,
6539
- .cg-impact-btn,
6540
- .cg-inheritance-btn,
6541
- .cg-minimap-btn,
6542
- .cg-refresh-btn,
6543
- .cg-undo-btn,
6544
- .cg-redo-btn,
6545
- .cg-import-btn,
6546
- .cg-legend-btn,
6547
- .cg-lang-btn,
6548
- .cg-pathfind-btn,
6549
- .cg-health-btn,
6550
- .cg-rules-btn,
6551
- .cg-hotspot-btn,
6552
- .cg-godmodule-btn,
6553
- .cg-corefunction-btn,
6554
- .cg-analysis-dropdown-btn,
6555
- .cg-quality-dropdown-btn,
6556
- .cg-tour-btn {
6557
- min-width: 44px;
6558
- min-height: 44px;
6559
- }
6560
-
6561
- .cg-node-detail-panel .cg-close-btn,
6562
- .cg-legend-close,
6563
- .cg-import-close,
6564
- .cg-close-btn {
6565
- min-width: 44px;
6566
- min-height: 44px;
6567
- }
6568
- }
6569
- /* ---- Responsive for new components ---------------------------------------- */
6570
-
6571
- @container graphpanel (max-width: 480px) {
6572
- .cg-path-bar,
6573
- .cg-health-score-card,
6574
- .cg-rule-panel {
6575
- width: calc(100% - 24px);
6576
- }
6577
-
6578
- .cg-analysis-panel {
6579
- width: calc(100% - 24px);
6580
- }
6581
-
6582
- .cg-focus-label {
6583
- max-width: 120px;
6584
- }
6585
- }
6586
-
6587
- @container graphpanel (max-width: 768px) {
6588
- .cg-path-bar {
6589
- width: 240px;
6590
- }
6591
-
6592
- .cg-health-score-card {
6593
- width: 220px;
6594
- }
6595
-
6596
- .cg-rule-panel {
6597
- width: 240px;
6598
- }
6599
-
6600
- .cg-analysis-panel {
6601
- width: 200px;
6602
- }
6603
- }
6604
- /* ---- Container Queries fallback ------------------------------------------- */
6605
- @supports not (container-type: inline-size) {
6606
- @media (max-width: 380px) {
6607
- .cg-tour-btn,
6608
- .cg-minimap-btn,
6609
- .cg-legend-btn {
6610
- display: none;
6611
- }
6612
- }
6613
-
6614
- @media (max-width: 480px) {
6615
- .cg-graph-panel {
6616
- border-radius: var(--cg-radius-sm);
6617
- }
6618
- .cg-graph-toolbar {
6619
- padding: 0 8px;
6620
- gap: 4px;
6621
- }
6622
- .cg-toolbar-left {
6623
- font-size: 11px;
6624
- }
6625
- .cg-toolbar-center {
6626
- gap: 4px;
6627
- }
6628
- .cg-toolbar-right {
6629
- gap: 4px;
6630
- }
6631
- .cg-layout-btn,
6632
- .cg-graph-type-btn,
6633
- .cg-rank-dir-btn {
6634
- padding: 3px 6px;
6635
- font-size: 10px;
6636
- }
6637
- .cg-search-btn,
6638
- .cg-theme-btn,
6639
- .cg-export-btn,
6640
- .cg-collapse-btn,
6641
- .cg-chain-btn,
6642
- .cg-cycle-btn,
6643
- .cg-impact-btn,
6644
- .cg-inheritance-btn,
6645
- .cg-minimap-btn,
6646
- .cg-refresh-btn,
6647
-
6648
- .cg-undo-btn,
6649
- .cg-redo-btn,
6650
- .cg-import-btn,
6651
- .cg-legend-btn,
6652
- .cg-lang-btn,
6653
- .cg-pathfind-btn,
6654
- .cg-health-btn,
6655
- .cg-rules-btn,
6656
- .cg-hotspot-btn,
6657
- .cg-godmodule-btn,
6658
- .cg-corefunction-btn,
6659
- .cg-analysis-dropdown-btn,
6660
- .cg-quality-dropdown-btn,
6661
- .cg-tour-btn {
6662
- width: 26px;
6663
- height: 26px;
6664
- font-size: 12px;
6665
- }
6666
- .cg-node-detail-panel {
6667
- width: calc(100% - 24px);
6668
- left: 12px;
6669
- top: 12px;
6670
- }
6671
- .cg-legend-panel {
6672
- width: 140px;
6673
- }
6674
- .cg-mini-map {
6675
- width: 140px;
6676
- }
6677
- .cg-type-badge {
6678
- display: none;
6679
- }
6680
- .cg-toolbar-separator {
6681
- display: none;
6682
- }
6683
- .cg-graph-type-buttons {
6684
- display: none;
6685
- }
6686
- .cg-dropdown-content {
6687
- min-width: 140px;
6688
- }
6689
- .cg-view-mode-buttons {
6690
- gap: 1px;
6691
- }
6692
- .cg-view-mode-btn {
6693
- padding: 2px 4px;
6694
- font-size: 9px;
6695
- }
6696
- .cg-search-panel,
6697
- .cg-path-panel {
6698
- width: calc(100% - 16px);
6699
- max-width: 320px;
6700
- }
6701
- .cg-filter-panel {
6702
- width: calc(100% - 16px);
6703
- }
6704
- .cg-heatmap-btn--activate,
6705
- .cg-heatmap-btn--deactivate {
6706
- width: 26px;
6707
- height: 26px;
6708
- font-size: 12px;
6709
- }
6710
- .cg-path-bar,
6711
- .cg-health-score-card,
6712
- .cg-rule-panel {
6713
- width: calc(100% - 24px);
6714
- }
6715
- .cg-analysis-panel {
6716
- width: calc(100% - 24px);
6717
- }
6718
- .cg-focus-label {
6719
- max-width: 120px;
6720
- }
6721
- }
6722
-
6723
- @media (max-width: 768px) {
6724
- .cg-graph-toolbar {
6725
- padding: 0 8px;
6726
- gap: 6px;
6727
- }
6728
- .cg-toolbar-center {
6729
- gap: 6px;
6730
- }
6731
- .cg-toolbar-right {
6732
- gap: 6px;
6733
- }
6734
- .cg-layout-btn,
6735
- .cg-graph-type-btn {
6736
- padding: 3px 8px;
6737
- font-size: 11px;
6738
- }
6739
- .cg-node-detail-panel {
6740
- width: 220px;
6741
- }
6742
- .cg-legend-panel {
6743
- width: 160px;
6744
- }
6745
- .cg-mini-map {
6746
- width: 160px;
6747
- }
6748
- .cg-filter-select {
6749
- font-size: 11px;
6750
- }
6751
- .cg-toolbar-separator {
6752
- margin: 0 2px;
6753
- }
6754
- .cg-tour-btn {
6755
- display: none;
6756
- }
6757
- .cg-path-bar {
6758
- width: 240px;
6759
- }
6760
- .cg-health-score-card {
6761
- width: 220px;
6762
- }
6763
- .cg-rule-panel {
6764
- width: 240px;
6765
- }
6766
- .cg-analysis-panel {
6767
- width: 200px;
6768
- }
6769
- }
6770
-
6771
- @media (max-width: 600px) {
6772
- .cg-tour-btn,
6773
- .cg-minimap-btn {
6774
- display: none;
6775
- }
6776
- .cg-toolbar-left .cg-type-badge {
6777
- display: none;
6778
- }
6779
- /* Mirrors the 600px container tier: the center group needs 150px for its three
6780
- icon-only triggers and gets crushed below that. */
6781
- .cg-toolbar-center {
6782
- display: none;
6783
- }
6784
- }
6785
-
6786
- @media (max-width: 320px) {
6787
- .cg-graph-toolbar {
6788
- gap: 2px;
6789
- flex-wrap: wrap;
6790
- padding: 4px 4px;
6791
- }
6792
- .cg-toolbar-left,
6793
- .cg-toolbar-center,
6794
- .cg-toolbar-right {
6795
- flex-wrap: wrap;
6796
- gap: 2px;
6797
- }
6798
- .cg-layout-buttons,
6799
- .cg-graph-type-buttons {
6800
- padding: 1px;
6801
- }
6802
- .cg-layout-btn,
6803
- .cg-graph-type-btn {
6804
- padding: 2px 4px;
6805
- font-size: 9px;
6806
- }
6807
- .cg-filter-select {
6808
- padding: 3px 20px 3px 6px;
6809
- font-size: 10px;
6810
- }
6811
- .cg-node-count {
6812
- font-size: 10px;
6813
- }
6814
- .cg-legend-panel,
6815
- .cg-mini-map {
6816
- width: 120px;
6817
- }
6818
- /* 同上:portal 到 body 后这里也匹配不到,抽屉由 \`.cg-portal-drawer\` 接管。 */
6819
- .cg-search-panel,
6820
- .cg-path-panel,
6821
- .cg-filter-panel {
6822
- width: calc(100% - 8px);
6823
- left: 4px;
6824
- right: 4px;
6825
- }
6826
- .cg-view-mode-buttons {
6827
- flex-wrap: wrap;
6828
- }
6829
- }
6830
-
6831
- @media (max-height: 400px) {
6832
- .cg-graph-toolbar {
6833
- padding: 0 4px;
6834
- gap: 2px;
6835
- }
6836
- .cg-toolbar-left .cg-node-count {
6837
- font-size: 10px;
6838
- }
6839
- .cg-type-badge {
6840
- display: none;
6841
- }
6842
- .cg-toolbar-separator {
6843
- display: none;
6844
- }
6845
- .cg-graph-type-buttons {
6846
- display: none;
6847
- }
6848
- .cg-view-mode-buttons {
6849
- gap: 1px;
6850
- }
6851
- .cg-view-mode-btn {
6852
- padding: 2px 4px;
6853
- font-size: 9px;
6854
- }
6855
- .cg-node-detail-panel {
6856
- max-height: calc(100% - 60px);
6857
- overflow-y: auto;
6858
- }
6859
- .cg-legend-panel {
6860
- max-height: 120px;
6861
- overflow-y: auto;
6862
- }
6863
- .cg-mini-map {
6864
- max-height: 100px;
6865
- }
6866
- .cg-search-panel,
6867
- .cg-path-panel,
6868
- .cg-filter-panel {
6869
- max-height: calc(100% - 60px);
6870
- overflow-y: auto;
6871
- }
6872
- }
6873
-
6874
- @media (max-height: 240px) {
6875
- .cg-graph-toolbar {
6876
- flex-wrap: wrap;
6877
- padding: 2px;
6878
- }
6879
- .cg-toolbar-left,
6880
- .cg-toolbar-center,
6881
- .cg-toolbar-right {
6882
- flex-wrap: wrap;
6883
- gap: 2px;
6884
- }
6885
- .cg-layout-buttons {
6886
- display: none;
6887
- }
6888
- .cg-rank-dir-buttons {
6889
- display: none;
6890
- }
6891
- .cg-cluster-depth-buttons {
6892
- display: none;
6893
- }
6894
- .cg-node-detail-panel {
6895
- width: calc(100% - 8px);
6896
- left: 4px;
6897
- top: 4px;
6898
- max-height: calc(100% - 40px);
6899
- }
6900
- }
6901
- }
6902
- /* ---- Conversation View Integration Mode ----------------------------------- */
6903
-
6904
- /* When integrated into DSH's conversation.view slot, the panel fills the
6905
- * conversation area. Toolbar is always visible; graph canvas fills remaining
6906
- * space. The panel must not collapse or float — it's the primary view. */
6907
- [data-cg-integration="conversation-view"] {
6908
- width: 100%;
6909
- height: 100%;
6910
- min-height: 200px;
6911
- display: flex;
6912
- flex-direction: column;
6913
- }
6914
-
6915
- [data-cg-integration="conversation-view"] .cg-graph-toolbar {
6916
- display: flex;
6917
- flex-shrink: 0;
6918
- }
6919
-
6920
- [data-cg-integration="conversation-view"] .cg-graph-container {
6921
- flex: 1;
6922
- min-height: 0;
6923
- }
6924
-
6925
- [data-cg-integration="conversation-view"] .cg-collapse-fab {
6926
- display: none;
6927
- }
6928
-
6929
- [data-cg-integration="conversation-view"] .cg-status-bar {
6930
- flex-shrink: 0;
6931
- }
6932
-
6933
- /* Conversation view integration (conversation.view slot) */
6934
- .cg-conversation-view-wrapper {
6935
- display: flex;
6936
- flex-direction: column;
6937
- width: 100%;
6938
- height: 100%;
6939
- min-height: 0;
6940
- }
6941
-
6942
- .cg-conversation-view-wrapper[data-cg-visible="false"] {
6943
- display: none;
6944
- }
6945
-
6946
- /* Generic host container for host-managed embedded panels
6947
- * (conversation.view / sidebar-right / better-sidebar). */
6948
- .cg-integration-host {
6949
- display: block;
6950
- width: 100%;
6951
- height: 100%;
6952
- min-height: 0;
6953
- }
6954
-
6955
- .cg-integration-host[data-cg-visible="false"] {
6956
- display: none;
6957
- }
6958
-
6959
-
6960
- /* In conversation-view mode, the panel should not have its own border/radius
6961
- * since it's embedded in the DSH conversation area */
6962
- [data-cg-integration="conversation-view"].cg-graph-panel:not(.cg-collapsed) {
6963
- border: none;
6964
- border-radius: 0;
6965
- }
6966
- /* ---- Sidebar Right Tab Integration Mode ----------------------------------- */
6967
-
6968
- /* When integrated into DSH's sidebar.right.pane.tab, the panel is in a
6969
- * right sidebar tab. Width is typically 300-500px, height is full window. */
6970
- [data-cg-integration="sidebar-right"] {
6971
- width: 100%;
6972
- height: 100%;
6973
- min-height: 300px;
6974
- display: flex;
6975
- flex-direction: column;
6976
- }
6977
-
6978
- [data-cg-integration="sidebar-right"] .cg-graph-toolbar {
6979
- display: flex;
6980
- flex-shrink: 0;
6981
- }
6982
-
6983
- [data-cg-integration="sidebar-right"] .cg-graph-container {
6984
- flex: 1;
6985
- min-height: 0;
6986
- }
6987
-
6988
- [data-cg-integration="sidebar-right"] .cg-collapse-fab {
6989
- display: none;
6990
- }
6991
-
6992
- [data-cg-integration="sidebar-right"].cg-graph-panel:not(.cg-collapsed) {
6993
- border: none;
6994
- border-radius: 0;
6995
- }
6996
-
6997
- /* ---- Better Sidebar Integration Mode -------------------------------------- */
6998
-
6999
- /* When integrated via betterSidebar API, the panel is in a sidebar tab.
7000
- * Similar to sidebar-right but may have different container constraints. */
7001
- [data-cg-integration="better-sidebar"] {
7002
- width: 100%;
7003
- height: 100%;
7004
- min-height: 200px;
7005
- display: flex;
7006
- flex-direction: column;
7007
- }
7008
-
7009
- [data-cg-integration="better-sidebar"] .cg-graph-toolbar {
7010
- display: flex;
7011
- flex-shrink: 0;
7012
- }
7013
-
7014
- [data-cg-integration="better-sidebar"] .cg-graph-container {
7015
- flex: 1;
7016
- min-height: 0;
7017
- }
7018
-
7019
- [data-cg-integration="better-sidebar"] .cg-collapse-fab {
7020
- display: none;
7021
- }
7022
-
7023
- [data-cg-integration="better-sidebar"].cg-graph-panel:not(.cg-collapsed) {
7024
- border: none;
7025
- border-radius: 0;
7026
- }
7027
-
7028
- /* ---- Remote narrow-screen carrier adaptation ------------------------------- */
7029
-
7030
- /* \`@linxin666/dsh-remote-web-ui\` is a real, widely-installed plugin, but it
7031
- * exposes no panel/tab API at all — its client half only occupies the
7032
- * \`sidebar.footer.action\` seat and writes DOM adaptation marks
7033
- * (\`lib/client.js:4155 ACTIVE_CLASS = "dsh-remote-portrait"\`, added to
7034
- * \`document.body\` at \`:4368\`). So the honest relationship is "detect it and let
7035
- * the layout adapt", not "call its API". The former \`remote-web-ui\`
7036
- * integration mode was a phantom (\`ctx.get('remoteWebUI')\` exists in no DSH
7037
- * generation) and has been removed; these rules replace it.
7038
- *
7039
- * The toolbar must stay reachable on a phone-width carrier, so it is allowed to
7040
- * wrap instead of overflowing. */
7041
- body.dsh-remote-portrait .cg-graph-toolbar {
7042
- flex-wrap: wrap;
7043
- row-gap: 4px;
7044
- }
7045
-
7046
- body.dsh-remote-portrait .cg-graph-panel:not(.cg-collapsed) {
7047
- border: none;
7048
- border-radius: 0;
7049
- }
7050
-
7051
- /* Below 480px the toolbar's own compact breakpoint (see Toolbar.tsx) already
7052
- * collapses secondary controls; the graph area just needs the remaining
7053
- * height. */
7054
- body.dsh-remote-portrait .cg-graph-container {
7055
- flex: 1;
7056
- min-height: 0;
7057
- }
7058
-
7059
- /* ---- Toolbar always visible in non-floating modes -------------------------- */
7060
- /* In all sidebar/slot integration modes, the toolbar must remain visible
7061
- * even when there's no graph data. The toolbar is the primary interaction
7062
- * surface and should not disappear.
7063
- * Expressed as one rule over \`[data-cg-integration]\` (the attribute lives on
7064
- * \`.cg-graph-panel\` itself, see GraphPanel.tsx) so every current and future
7065
- * integration mode is covered. The \`:not(.cg-collapsed)\` guard is load-bearing:
7066
- * without it this \`!important\` declaration outranks
7067
- * \`.cg-graph-panel.cg-collapsed .cg-graph-toolbar { display: none }\` and the
7068
- * toolbar keeps painting inside the 52px collapsed FAB. */
7069
- .cg-graph-panel[data-cg-integration]:not(.cg-collapsed) .cg-graph-toolbar {
7070
- display: flex !important;
7071
- }
7072
-
7073
- /* ---- Desktop vs Web distinction ------------------------------------------- */
7074
- /* On desktop platforms, the panel may have more space. On web, it's
7075
- * constrained by the browser window. */
7076
- @media (min-width: 1024px) {
7077
- [data-cg-integration="conversation-view"] .cg-graph-toolbar {
7078
- padding: 0 12px;
7079
- gap: 8px;
7080
- }
7081
- }
7082
-
7083
- /* ---- Narrow-tier sheets (cycle-110 / T3) ---------------------------------- */
7084
- /*
7085
- * 窄面板(≤480px)下的两处版式修正:
7086
- * 1. 节点详情面板改为**底部抽屉**(贴边、限高),不再 \`max-height: calc(100% - 24px)\`
7087
- * 从上往下盖住整个画布;
7088
- * 2. 抽屉打开时小地图与图例**临时让位**,关闭后回归。
7089
- *
7090
- * 为什么两条都要:三者都是 \`position: absolute\` + \`z-index: var(--cg-z-panel)\` 的绝对定位
7091
- * 元素,窄档下面板几乎占满高度,小地图(右下 200px)与图例(左下 180px)会被盖住一半,
7092
- * 面板底部的按钮也会与图例重叠 —— 点击落在错误元素上,而所有 DOM 断言照旧通过。
7093
- *
7094
- * 两套机制**声明必须一致**:
7095
- * - \`@container graphpanel (max-width: 480px)\`:主机制,按面板宽度响应(与视口无关);
7096
- * - \`[data-cg-tier="narrow"]\`:宿主不支持容器查询、或容器查询尺寸不可读时的兜底,
7097
- * 由 \`hooks/usePanelTier.ts\` 实测宽度后写在根元素上。
7098
- * \`tests/unit/panelTierContract.test.ts\` 会逐条比对这两处的声明,任一侧改动都会变红。
7099
- */
7100
- @container graphpanel (max-width: 480px) {
7101
- .cg-node-detail-panel {
7102
- top: auto;
7103
- bottom: calc(var(--cg-statusbar-h) + 8px);
7104
- left: 8px;
7105
- right: 8px;
7106
- width: auto;
7107
- max-height: 52%;
7108
- }
7109
-
7110
- /* 抽屉打开 ⇒ 两个角落的叠加层让位(关闭后自动回归,不需要 JS 复位) */
7111
- .cg-graph-panel[data-cg-sheet="open"] .cg-mini-map,
7112
- .cg-graph-panel[data-cg-sheet="open"] .cg-legend-panel {
7113
- display: none;
7114
- }
7115
- }
7116
-
7117
- /* 兜底机制(JS 实测档位):与上面的容器查询分支**逐条等价** */
7118
- .cg-graph-panel[data-cg-tier="narrow"] .cg-node-detail-panel {
7119
- top: auto;
7120
- bottom: calc(var(--cg-statusbar-h) + 8px);
7121
- left: 8px;
7122
- right: 8px;
7123
- width: auto;
7124
- max-height: 52%;
7125
- }
7126
-
7127
- .cg-graph-panel[data-cg-tier="narrow"][data-cg-sheet="open"] .cg-mini-map,
7128
- .cg-graph-panel[data-cg-tier="narrow"][data-cg-sheet="open"] .cg-legend-panel {
7129
- display: none;
7130
- }
6203
+ /* ---- Responsive ---------------------------------------------------------- */
6204
+
6205
+ /* Extra-narrow panels (DSH sidebar tab minimal width).
6206
+ * Hide the remaining toolbar extras to prioritize graph canvas.
6207
+ * The center group is already gone at the 600px tier below. */
6208
+ @container graphpanel (max-width: 380px) {
6209
+ .cg-tour-btn,
6210
+ .cg-minimap-btn,
6211
+ .cg-legend-btn {
6212
+ display: none;
6213
+ }
6214
+
6215
+ /* The separators are siblings of the \`.cg-toolbar-group\` elements, not descendants,
6216
+ so the previous \`.cg-toolbar-group ... .cg-toolbar-separator\` selector never
6217
+ matched anything. */
6218
+ .cg-toolbar-separator {
6219
+ display: none;
6220
+ }
6221
+ }
6222
+
6223
+ /* Small panels (DSH sidebar tab, narrow bottom panel).
6224
+ * Container queries respond to the PANEL width, not the viewport — the plugin
6225
+ * renders inside DSH panels whose width is unrelated to the window size. */
6226
+ @container graphpanel (max-width: 480px) {
6227
+ .cg-graph-panel {
6228
+ border-radius: var(--cg-radius-sm);
6229
+ }
6230
+
6231
+ .cg-graph-toolbar {
6232
+ padding: 0 8px;
6233
+ gap: 4px;
6234
+ }
6235
+
6236
+ .cg-toolbar-left {
6237
+ font-size: 11px;
6238
+ }
6239
+
6240
+ .cg-toolbar-center {
6241
+ gap: 4px;
6242
+ }
6243
+
6244
+ .cg-toolbar-right {
6245
+ gap: 4px;
6246
+ }
6247
+
6248
+ .cg-layout-btn,
6249
+ .cg-graph-type-btn,
6250
+ .cg-rank-dir-btn {
6251
+ padding: 3px 6px;
6252
+ font-size: 10px;
6253
+ }
6254
+
6255
+ .cg-search-btn,
6256
+ .cg-theme-btn,
6257
+ .cg-export-btn,
6258
+ .cg-collapse-btn,
6259
+ .cg-chain-btn,
6260
+ .cg-cycle-btn,
6261
+ .cg-impact-btn,
6262
+ .cg-inheritance-btn,
6263
+ .cg-minimap-btn,
6264
+ .cg-refresh-btn,
6265
+ .cg-undo-btn,
6266
+ .cg-redo-btn,
6267
+ .cg-import-btn,
6268
+ .cg-legend-btn,
6269
+ .cg-lang-btn,
6270
+ .cg-pathfind-btn,
6271
+ .cg-health-btn,
6272
+ .cg-rules-btn,
6273
+ .cg-hotspot-btn,
6274
+ .cg-godmodule-btn,
6275
+ .cg-corefunction-btn,
6276
+ .cg-analysis-dropdown-btn,
6277
+ .cg-quality-dropdown-btn,
6278
+ .cg-tour-btn {
6279
+ width: 26px;
6280
+ height: 26px;
6281
+ font-size: 12px;
6282
+ }
6283
+
6284
+ .cg-dropdown-content {
6285
+ min-width: 140px;
6286
+ }
6287
+
6288
+ .cg-node-detail-panel {
6289
+ width: calc(100% - 24px);
6290
+ left: 12px;
6291
+ top: 12px;
6292
+ }
6293
+
6294
+ .cg-legend-panel {
6295
+ width: 140px;
6296
+ }
6297
+
6298
+ .cg-mini-map {
6299
+ width: 140px;
6300
+ }
6301
+
6302
+ .cg-type-badge {
6303
+ display: none;
6304
+ }
6305
+
6306
+ .cg-toolbar-separator {
6307
+ display: none;
6308
+ }
6309
+
6310
+ .cg-graph-type-buttons {
6311
+ display: none;
6312
+ }
6313
+
6314
+ .cg-view-mode-buttons {
6315
+ gap: 1px;
6316
+ }
6317
+
6318
+ .cg-view-mode-btn {
6319
+ padding: 2px 4px;
6320
+ font-size: 9px;
6321
+ }
6322
+
6323
+ .cg-search-panel,
6324
+ .cg-path-panel {
6325
+ width: calc(100% - 16px);
6326
+ max-width: 320px;
6327
+ }
6328
+
6329
+ .cg-filter-panel {
6330
+ width: calc(100% - 16px);
6331
+ }
6332
+
6333
+ .cg-heatmap-btn--activate,
6334
+ .cg-heatmap-btn--deactivate {
6335
+ width: 26px;
6336
+ height: 26px;
6337
+ font-size: 12px;
6338
+ }
6339
+
6340
+ }
6341
+
6342
+ /* Medium panels (DSH sidebar medium width) */
6343
+ @container graphpanel (max-width: 768px) {
6344
+ .cg-graph-toolbar {
6345
+ padding: 0 8px;
6346
+ gap: 6px;
6347
+ }
6348
+
6349
+ .cg-toolbar-center {
6350
+ gap: 6px;
6351
+ }
6352
+
6353
+ .cg-toolbar-right {
6354
+ gap: 6px;
6355
+ }
6356
+
6357
+ .cg-layout-btn,
6358
+ .cg-graph-type-btn {
6359
+ padding: 3px 8px;
6360
+ font-size: 11px;
6361
+ }
6362
+
6363
+ .cg-node-detail-panel {
6364
+ width: 220px;
6365
+ }
6366
+
6367
+ .cg-legend-panel {
6368
+ width: 160px;
6369
+ }
6370
+
6371
+ .cg-mini-map {
6372
+ width: 160px;
6373
+ }
6374
+
6375
+ .cg-filter-select {
6376
+ font-size: 11px;
6377
+ }
6378
+
6379
+ .cg-toolbar-separator {
6380
+ margin: 0 2px;
6381
+ }
6382
+
6383
+ .cg-tour-btn {
6384
+ display: none;
6385
+ }
6386
+
6387
+ }
6388
+
6389
+ /* Medium-narrow panels — hide tour and reduce analysis button clutter */
6390
+ @container graphpanel (max-width: 600px) {
6391
+ .cg-tour-btn,
6392
+ .cg-minimap-btn {
6393
+ display: none;
6394
+ }
6395
+
6396
+ .cg-toolbar-left .cg-type-badge {
6397
+ display: none;
6398
+ }
6399
+
6400
+ /* The center group (layout / view-mode / level) is the widest fixed-width block in the
6401
+ toolbar: 150px for its three icon-only triggers, measured on the real host. At a 520px
6402
+ panel it was being squeezed to 115px, which wrapped the level trigger onto two lines
6403
+ and clipped it; at 600px it still has 45px of slack. Hiding it here instead of letting
6404
+ it be crushed keeps the workspace chip and every right-hand control (export / more /
6405
+ collapse) reachable. Of the three controls, only the layout keeps an alternative entry
6406
+ point (Ctrl+L cycles it, see \`usePanelKeyboard\`); view mode and hierarchy level lose
6407
+ their mouse entry below this tier — see the note in \`docs/需求分析.md\` §28.3. */
6408
+ .cg-toolbar-center {
6409
+ display: none;
6410
+ }
6411
+ }
6412
+
6413
+ /* Very small panels */
6414
+ @container graphpanel (max-width: 320px) {
6415
+ .cg-graph-toolbar {
6416
+ gap: 2px;
6417
+ flex-wrap: wrap;
6418
+ padding: 4px 4px;
6419
+ }
6420
+
6421
+ .cg-toolbar-left,
6422
+ .cg-toolbar-center,
6423
+ .cg-toolbar-right {
6424
+ flex-wrap: wrap;
6425
+ gap: 2px;
6426
+ }
6427
+
6428
+ .cg-layout-buttons,
6429
+ .cg-graph-type-buttons {
6430
+ padding: 1px;
6431
+ }
6432
+
6433
+ .cg-layout-btn,
6434
+ .cg-graph-type-btn {
6435
+ padding: 2px 4px;
6436
+ font-size: 9px;
6437
+ }
6438
+
6439
+ .cg-filter-select {
6440
+ padding: 3px 20px 3px 6px;
6441
+ font-size: 10px;
6442
+ }
6443
+
6444
+ .cg-node-count {
6445
+ font-size: 10px;
6446
+ }
6447
+
6448
+ .cg-legend-panel,
6449
+ .cg-mini-map {
6450
+ width: 120px;
6451
+ }
6452
+
6453
+ /* 下拉内容已 portal 到 document.body,不再是本容器的后代,此处的
6454
+ * \`@container\` 抽屉规则永远匹配不到;抽屉形态改由 Dropdown 组件测量面板
6455
+ * 宽度后加 \`.cg-portal-drawer\`(见 layout.css)实现。 */
6456
+
6457
+ .cg-search-panel,
6458
+ .cg-path-panel,
6459
+ .cg-filter-panel {
6460
+ width: calc(100% - 8px);
6461
+ left: 4px;
6462
+ right: 4px;
6463
+ }
6464
+
6465
+ .cg-view-mode-buttons {
6466
+ flex-wrap: wrap;
6467
+ }
6468
+ }
6469
+
6470
+ /* Large screens — allow fixed floating panel */
6471
+ @media (min-width: 1200px) {
6472
+ .cg-graph-panel.cg-floating {
6473
+ position: fixed;
6474
+ right: 16px;
6475
+ bottom: 16px;
6476
+ width: 640px;
6477
+ height: 520px;
6478
+ }
6479
+ }
6480
+
6481
+ /* ---- Sidebar tab mode (short height) -------------------------------------- */
6482
+ /* When the plugin renders inside a DSH sidebar tab, the panel height can be
6483
+ * very limited. Adapt the layout to prioritize the graph canvas. */
6484
+ @container graphpanel (max-height: 400px) {
6485
+ .cg-graph-toolbar {
6486
+ padding: 0 4px;
6487
+ gap: 2px;
6488
+ }
6489
+
6490
+ .cg-toolbar-left .cg-node-count {
6491
+ font-size: 10px;
6492
+ }
6493
+
6494
+ .cg-type-badge {
6495
+ display: none;
6496
+ }
6497
+
6498
+ .cg-toolbar-separator {
6499
+ display: none;
6500
+ }
6501
+
6502
+ .cg-graph-type-buttons {
6503
+ display: none;
6504
+ }
6505
+
6506
+ .cg-view-mode-buttons {
6507
+ gap: 1px;
6508
+ }
6509
+
6510
+ .cg-view-mode-btn {
6511
+ padding: 2px 4px;
6512
+ font-size: 9px;
6513
+ }
6514
+
6515
+ .cg-node-detail-panel {
6516
+ max-height: calc(100% - 60px);
6517
+ overflow-y: auto;
6518
+ }
6519
+
6520
+ .cg-legend-panel {
6521
+ max-height: 120px;
6522
+ overflow-y: auto;
6523
+ }
6524
+
6525
+ .cg-mini-map {
6526
+ max-height: 100px;
6527
+ }
6528
+
6529
+ .cg-search-panel,
6530
+ .cg-path-panel,
6531
+ .cg-filter-panel {
6532
+ max-height: calc(100% - 60px);
6533
+ overflow-y: auto;
6534
+ }
6535
+ }
6536
+
6537
+ @container graphpanel (max-height: 240px) {
6538
+ .cg-graph-toolbar {
6539
+ flex-wrap: wrap;
6540
+ padding: 2px;
6541
+ }
6542
+
6543
+ .cg-toolbar-left,
6544
+ .cg-toolbar-center,
6545
+ .cg-toolbar-right {
6546
+ flex-wrap: wrap;
6547
+ gap: 2px;
6548
+ }
6549
+
6550
+ .cg-layout-buttons {
6551
+ display: none;
6552
+ }
6553
+
6554
+ .cg-rank-dir-buttons {
6555
+ display: none;
6556
+ }
6557
+
6558
+ .cg-cluster-depth-buttons {
6559
+ display: none;
6560
+ }
6561
+
6562
+ .cg-node-detail-panel {
6563
+ width: calc(100% - 8px);
6564
+ left: 4px;
6565
+ top: 4px;
6566
+ max-height: calc(100% - 40px);
6567
+ }
6568
+ }
6569
+
6570
+ /* ---- Reduced motion ------------------------------------------------------ */
6571
+
6572
+ @media (prefers-reduced-motion: reduce) {
6573
+ .cg-graph-panel *,
6574
+ .cg-graph-panel *::before,
6575
+ .cg-graph-panel *::after,
6576
+ .cg-floating-panel *,
6577
+ .cg-floating-panel *::before,
6578
+ .cg-floating-panel *::after,
6579
+ .cg-floating-container *,
6580
+ .cg-floating-container *::before,
6581
+ .cg-floating-container *::after {
6582
+ animation-duration: 0.01ms !important;
6583
+ transition-duration: 0.01ms !important;
6584
+ }
6585
+ }
6586
+
6587
+ /* ---- Touch device adaptation --------------------------------------------- */
6588
+
6589
+ @media (pointer: coarse) {
6590
+ .cg-layout-btn,
6591
+ .cg-graph-type-btn,
6592
+ .cg-search-btn,
6593
+ .cg-theme-btn,
6594
+ .cg-export-btn,
6595
+ .cg-collapse-btn,
6596
+ .cg-chain-btn,
6597
+ .cg-cycle-btn,
6598
+ .cg-impact-btn,
6599
+ .cg-inheritance-btn,
6600
+ .cg-minimap-btn,
6601
+ .cg-refresh-btn,
6602
+ .cg-undo-btn,
6603
+ .cg-redo-btn,
6604
+ .cg-import-btn,
6605
+ .cg-legend-btn,
6606
+ .cg-lang-btn,
6607
+ .cg-pathfind-btn,
6608
+ .cg-health-btn,
6609
+ .cg-rules-btn,
6610
+ .cg-hotspot-btn,
6611
+ .cg-godmodule-btn,
6612
+ .cg-corefunction-btn,
6613
+ .cg-analysis-dropdown-btn,
6614
+ .cg-quality-dropdown-btn,
6615
+ .cg-tour-btn {
6616
+ min-width: 44px;
6617
+ min-height: 44px;
6618
+ }
6619
+
6620
+ .cg-node-detail-panel .cg-close-btn,
6621
+ .cg-legend-close,
6622
+ .cg-import-close,
6623
+ .cg-close-btn {
6624
+ min-width: 44px;
6625
+ min-height: 44px;
6626
+ }
6627
+ }
6628
+ /* ---- Responsive for new components ---------------------------------------- */
6629
+
6630
+ @container graphpanel (max-width: 480px) {
6631
+ .cg-path-bar,
6632
+ .cg-health-score-card,
6633
+ .cg-rule-panel {
6634
+ width: calc(100% - 24px);
6635
+ }
6636
+
6637
+ .cg-analysis-panel {
6638
+ width: calc(100% - 24px);
6639
+ }
6640
+
6641
+ .cg-focus-label {
6642
+ max-width: 120px;
6643
+ }
6644
+ }
6645
+
6646
+ @container graphpanel (max-width: 768px) {
6647
+ .cg-path-bar {
6648
+ width: 240px;
6649
+ }
6650
+
6651
+ .cg-health-score-card {
6652
+ width: 220px;
6653
+ }
6654
+
6655
+ .cg-rule-panel {
6656
+ width: 240px;
6657
+ }
6658
+
6659
+ .cg-analysis-panel {
6660
+ width: 200px;
6661
+ }
6662
+ }
6663
+ /* ---- Container Queries fallback ------------------------------------------- */
6664
+ @supports not (container-type: inline-size) {
6665
+ @media (max-width: 380px) {
6666
+ .cg-tour-btn,
6667
+ .cg-minimap-btn,
6668
+ .cg-legend-btn {
6669
+ display: none;
6670
+ }
6671
+ }
6672
+
6673
+ @media (max-width: 480px) {
6674
+ .cg-graph-panel {
6675
+ border-radius: var(--cg-radius-sm);
6676
+ }
6677
+ .cg-graph-toolbar {
6678
+ padding: 0 8px;
6679
+ gap: 4px;
6680
+ }
6681
+ .cg-toolbar-left {
6682
+ font-size: 11px;
6683
+ }
6684
+ .cg-toolbar-center {
6685
+ gap: 4px;
6686
+ }
6687
+ .cg-toolbar-right {
6688
+ gap: 4px;
6689
+ }
6690
+ .cg-layout-btn,
6691
+ .cg-graph-type-btn,
6692
+ .cg-rank-dir-btn {
6693
+ padding: 3px 6px;
6694
+ font-size: 10px;
6695
+ }
6696
+ .cg-search-btn,
6697
+ .cg-theme-btn,
6698
+ .cg-export-btn,
6699
+ .cg-collapse-btn,
6700
+ .cg-chain-btn,
6701
+ .cg-cycle-btn,
6702
+ .cg-impact-btn,
6703
+ .cg-inheritance-btn,
6704
+ .cg-minimap-btn,
6705
+ .cg-refresh-btn,
6706
+
6707
+ .cg-undo-btn,
6708
+ .cg-redo-btn,
6709
+ .cg-import-btn,
6710
+ .cg-legend-btn,
6711
+ .cg-lang-btn,
6712
+ .cg-pathfind-btn,
6713
+ .cg-health-btn,
6714
+ .cg-rules-btn,
6715
+ .cg-hotspot-btn,
6716
+ .cg-godmodule-btn,
6717
+ .cg-corefunction-btn,
6718
+ .cg-analysis-dropdown-btn,
6719
+ .cg-quality-dropdown-btn,
6720
+ .cg-tour-btn {
6721
+ width: 26px;
6722
+ height: 26px;
6723
+ font-size: 12px;
6724
+ }
6725
+ .cg-node-detail-panel {
6726
+ width: calc(100% - 24px);
6727
+ left: 12px;
6728
+ top: 12px;
6729
+ }
6730
+ .cg-legend-panel {
6731
+ width: 140px;
6732
+ }
6733
+ .cg-mini-map {
6734
+ width: 140px;
6735
+ }
6736
+ .cg-type-badge {
6737
+ display: none;
6738
+ }
6739
+ .cg-toolbar-separator {
6740
+ display: none;
6741
+ }
6742
+ .cg-graph-type-buttons {
6743
+ display: none;
6744
+ }
6745
+ .cg-dropdown-content {
6746
+ min-width: 140px;
6747
+ }
6748
+ .cg-view-mode-buttons {
6749
+ gap: 1px;
6750
+ }
6751
+ .cg-view-mode-btn {
6752
+ padding: 2px 4px;
6753
+ font-size: 9px;
6754
+ }
6755
+ .cg-search-panel,
6756
+ .cg-path-panel {
6757
+ width: calc(100% - 16px);
6758
+ max-width: 320px;
6759
+ }
6760
+ .cg-filter-panel {
6761
+ width: calc(100% - 16px);
6762
+ }
6763
+ .cg-heatmap-btn--activate,
6764
+ .cg-heatmap-btn--deactivate {
6765
+ width: 26px;
6766
+ height: 26px;
6767
+ font-size: 12px;
6768
+ }
6769
+ .cg-path-bar,
6770
+ .cg-health-score-card,
6771
+ .cg-rule-panel {
6772
+ width: calc(100% - 24px);
6773
+ }
6774
+ .cg-analysis-panel {
6775
+ width: calc(100% - 24px);
6776
+ }
6777
+ .cg-focus-label {
6778
+ max-width: 120px;
6779
+ }
6780
+ }
6781
+
6782
+ @media (max-width: 768px) {
6783
+ .cg-graph-toolbar {
6784
+ padding: 0 8px;
6785
+ gap: 6px;
6786
+ }
6787
+ .cg-toolbar-center {
6788
+ gap: 6px;
6789
+ }
6790
+ .cg-toolbar-right {
6791
+ gap: 6px;
6792
+ }
6793
+ .cg-layout-btn,
6794
+ .cg-graph-type-btn {
6795
+ padding: 3px 8px;
6796
+ font-size: 11px;
6797
+ }
6798
+ .cg-node-detail-panel {
6799
+ width: 220px;
6800
+ }
6801
+ .cg-legend-panel {
6802
+ width: 160px;
6803
+ }
6804
+ .cg-mini-map {
6805
+ width: 160px;
6806
+ }
6807
+ .cg-filter-select {
6808
+ font-size: 11px;
6809
+ }
6810
+ .cg-toolbar-separator {
6811
+ margin: 0 2px;
6812
+ }
6813
+ .cg-tour-btn {
6814
+ display: none;
6815
+ }
6816
+ .cg-path-bar {
6817
+ width: 240px;
6818
+ }
6819
+ .cg-health-score-card {
6820
+ width: 220px;
6821
+ }
6822
+ .cg-rule-panel {
6823
+ width: 240px;
6824
+ }
6825
+ .cg-analysis-panel {
6826
+ width: 200px;
6827
+ }
6828
+ }
6829
+
6830
+ @media (max-width: 600px) {
6831
+ .cg-tour-btn,
6832
+ .cg-minimap-btn {
6833
+ display: none;
6834
+ }
6835
+ .cg-toolbar-left .cg-type-badge {
6836
+ display: none;
6837
+ }
6838
+ /* Mirrors the 600px container tier: the center group needs 150px for its three
6839
+ icon-only triggers and gets crushed below that. */
6840
+ .cg-toolbar-center {
6841
+ display: none;
6842
+ }
6843
+ }
6844
+
6845
+ @media (max-width: 320px) {
6846
+ .cg-graph-toolbar {
6847
+ gap: 2px;
6848
+ flex-wrap: wrap;
6849
+ padding: 4px 4px;
6850
+ }
6851
+ .cg-toolbar-left,
6852
+ .cg-toolbar-center,
6853
+ .cg-toolbar-right {
6854
+ flex-wrap: wrap;
6855
+ gap: 2px;
6856
+ }
6857
+ .cg-layout-buttons,
6858
+ .cg-graph-type-buttons {
6859
+ padding: 1px;
6860
+ }
6861
+ .cg-layout-btn,
6862
+ .cg-graph-type-btn {
6863
+ padding: 2px 4px;
6864
+ font-size: 9px;
6865
+ }
6866
+ .cg-filter-select {
6867
+ padding: 3px 20px 3px 6px;
6868
+ font-size: 10px;
6869
+ }
6870
+ .cg-node-count {
6871
+ font-size: 10px;
6872
+ }
6873
+ .cg-legend-panel,
6874
+ .cg-mini-map {
6875
+ width: 120px;
6876
+ }
6877
+ /* 同上:portal 到 body 后这里也匹配不到,抽屉由 \`.cg-portal-drawer\` 接管。 */
6878
+ .cg-search-panel,
6879
+ .cg-path-panel,
6880
+ .cg-filter-panel {
6881
+ width: calc(100% - 8px);
6882
+ left: 4px;
6883
+ right: 4px;
6884
+ }
6885
+ .cg-view-mode-buttons {
6886
+ flex-wrap: wrap;
6887
+ }
6888
+ }
6889
+
6890
+ @media (max-height: 400px) {
6891
+ .cg-graph-toolbar {
6892
+ padding: 0 4px;
6893
+ gap: 2px;
6894
+ }
6895
+ .cg-toolbar-left .cg-node-count {
6896
+ font-size: 10px;
6897
+ }
6898
+ .cg-type-badge {
6899
+ display: none;
6900
+ }
6901
+ .cg-toolbar-separator {
6902
+ display: none;
6903
+ }
6904
+ .cg-graph-type-buttons {
6905
+ display: none;
6906
+ }
6907
+ .cg-view-mode-buttons {
6908
+ gap: 1px;
6909
+ }
6910
+ .cg-view-mode-btn {
6911
+ padding: 2px 4px;
6912
+ font-size: 9px;
6913
+ }
6914
+ .cg-node-detail-panel {
6915
+ max-height: calc(100% - 60px);
6916
+ overflow-y: auto;
6917
+ }
6918
+ .cg-legend-panel {
6919
+ max-height: 120px;
6920
+ overflow-y: auto;
6921
+ }
6922
+ .cg-mini-map {
6923
+ max-height: 100px;
6924
+ }
6925
+ .cg-search-panel,
6926
+ .cg-path-panel,
6927
+ .cg-filter-panel {
6928
+ max-height: calc(100% - 60px);
6929
+ overflow-y: auto;
6930
+ }
6931
+ }
6932
+
6933
+ @media (max-height: 240px) {
6934
+ .cg-graph-toolbar {
6935
+ flex-wrap: wrap;
6936
+ padding: 2px;
6937
+ }
6938
+ .cg-toolbar-left,
6939
+ .cg-toolbar-center,
6940
+ .cg-toolbar-right {
6941
+ flex-wrap: wrap;
6942
+ gap: 2px;
6943
+ }
6944
+ .cg-layout-buttons {
6945
+ display: none;
6946
+ }
6947
+ .cg-rank-dir-buttons {
6948
+ display: none;
6949
+ }
6950
+ .cg-cluster-depth-buttons {
6951
+ display: none;
6952
+ }
6953
+ .cg-node-detail-panel {
6954
+ width: calc(100% - 8px);
6955
+ left: 4px;
6956
+ top: 4px;
6957
+ max-height: calc(100% - 40px);
6958
+ }
6959
+ }
6960
+ }
6961
+ /* ---- Conversation View Integration Mode ----------------------------------- */
6962
+
6963
+ /* When integrated into DSH's conversation.view slot, the panel fills the
6964
+ * conversation area. Toolbar is always visible; graph canvas fills remaining
6965
+ * space. The panel must not collapse or float — it's the primary view. */
6966
+ [data-cg-integration="conversation-view"] {
6967
+ width: 100%;
6968
+ height: 100%;
6969
+ min-height: 200px;
6970
+ display: flex;
6971
+ flex-direction: column;
6972
+ }
6973
+
6974
+ [data-cg-integration="conversation-view"] .cg-graph-toolbar {
6975
+ display: flex;
6976
+ flex-shrink: 0;
6977
+ }
6978
+
6979
+ [data-cg-integration="conversation-view"] .cg-graph-container {
6980
+ flex: 1;
6981
+ min-height: 0;
6982
+ }
6983
+
6984
+ [data-cg-integration="conversation-view"] .cg-collapse-fab {
6985
+ display: none;
6986
+ }
6987
+
6988
+ [data-cg-integration="conversation-view"] .cg-status-bar {
6989
+ flex-shrink: 0;
6990
+ }
6991
+
6992
+ /* Conversation view integration (conversation.view slot) */
6993
+ .cg-conversation-view-wrapper {
6994
+ display: flex;
6995
+ flex-direction: column;
6996
+ width: 100%;
6997
+ height: 100%;
6998
+ min-height: 0;
6999
+ }
7000
+
7001
+ .cg-conversation-view-wrapper[data-cg-visible="false"] {
7002
+ display: none;
7003
+ }
7004
+
7005
+ /* Generic host container for host-managed embedded panels
7006
+ * (conversation.view / sidebar-right / better-sidebar). */
7007
+ .cg-integration-host {
7008
+ display: block;
7009
+ width: 100%;
7010
+ height: 100%;
7011
+ min-height: 0;
7012
+ }
7013
+
7014
+ .cg-integration-host[data-cg-visible="false"] {
7015
+ display: none;
7016
+ }
7017
+
7018
+
7019
+ /* In conversation-view mode, the panel should not have its own border/radius
7020
+ * since it's embedded in the DSH conversation area */
7021
+ [data-cg-integration="conversation-view"].cg-graph-panel:not(.cg-collapsed) {
7022
+ border: none;
7023
+ border-radius: 0;
7024
+ }
7025
+ /* ---- Sidebar Right Tab Integration Mode ----------------------------------- */
7026
+
7027
+ /* When integrated into DSH's sidebar.right.pane.tab, the panel is in a
7028
+ * right sidebar tab. Width is typically 300-500px, height is full window. */
7029
+ [data-cg-integration="sidebar-right"] {
7030
+ width: 100%;
7031
+ height: 100%;
7032
+ min-height: 300px;
7033
+ display: flex;
7034
+ flex-direction: column;
7035
+ }
7036
+
7037
+ [data-cg-integration="sidebar-right"] .cg-graph-toolbar {
7038
+ display: flex;
7039
+ flex-shrink: 0;
7040
+ }
7041
+
7042
+ [data-cg-integration="sidebar-right"] .cg-graph-container {
7043
+ flex: 1;
7044
+ min-height: 0;
7045
+ }
7046
+
7047
+ [data-cg-integration="sidebar-right"] .cg-collapse-fab {
7048
+ display: none;
7049
+ }
7050
+
7051
+ [data-cg-integration="sidebar-right"].cg-graph-panel:not(.cg-collapsed) {
7052
+ border: none;
7053
+ border-radius: 0;
7054
+ }
7055
+
7056
+ /* ---- Better Sidebar Integration Mode -------------------------------------- */
7057
+
7058
+ /* When integrated via betterSidebar API, the panel is in a sidebar tab.
7059
+ * Similar to sidebar-right but may have different container constraints. */
7060
+ [data-cg-integration="better-sidebar"] {
7061
+ width: 100%;
7062
+ height: 100%;
7063
+ min-height: 200px;
7064
+ display: flex;
7065
+ flex-direction: column;
7066
+ }
7067
+
7068
+ [data-cg-integration="better-sidebar"] .cg-graph-toolbar {
7069
+ display: flex;
7070
+ flex-shrink: 0;
7071
+ }
7072
+
7073
+ [data-cg-integration="better-sidebar"] .cg-graph-container {
7074
+ flex: 1;
7075
+ min-height: 0;
7076
+ }
7077
+
7078
+ [data-cg-integration="better-sidebar"] .cg-collapse-fab {
7079
+ display: none;
7080
+ }
7081
+
7082
+ [data-cg-integration="better-sidebar"].cg-graph-panel:not(.cg-collapsed) {
7083
+ border: none;
7084
+ border-radius: 0;
7085
+ }
7086
+
7087
+ /* ---- Remote narrow-screen carrier adaptation ------------------------------- */
7088
+
7089
+ /* \`@linxin666/dsh-remote-web-ui\` is a real, widely-installed plugin, but it
7090
+ * exposes no panel/tab API at all — its client half only occupies the
7091
+ * \`sidebar.footer.action\` seat and writes DOM adaptation marks
7092
+ * (\`lib/client.js:4155 ACTIVE_CLASS = "dsh-remote-portrait"\`, added to
7093
+ * \`document.body\` at \`:4368\`). So the honest relationship is "detect it and let
7094
+ * the layout adapt", not "call its API". The former \`remote-web-ui\`
7095
+ * integration mode was a phantom (\`ctx.get('remoteWebUI')\` exists in no DSH
7096
+ * generation) and has been removed; these rules replace it.
7097
+ *
7098
+ * The toolbar must stay reachable on a phone-width carrier, so it is allowed to
7099
+ * wrap instead of overflowing. */
7100
+ body.dsh-remote-portrait .cg-graph-toolbar {
7101
+ flex-wrap: wrap;
7102
+ row-gap: 4px;
7103
+ }
7104
+
7105
+ body.dsh-remote-portrait .cg-graph-panel:not(.cg-collapsed) {
7106
+ border: none;
7107
+ border-radius: 0;
7108
+ }
7109
+
7110
+ /* Below 480px the toolbar's own compact breakpoint (see Toolbar.tsx) already
7111
+ * collapses secondary controls; the graph area just needs the remaining
7112
+ * height. */
7113
+ body.dsh-remote-portrait .cg-graph-container {
7114
+ flex: 1;
7115
+ min-height: 0;
7116
+ }
7117
+
7118
+ /* ---- Toolbar always visible in non-floating modes -------------------------- */
7119
+ /* In all sidebar/slot integration modes, the toolbar must remain visible
7120
+ * even when there's no graph data. The toolbar is the primary interaction
7121
+ * surface and should not disappear.
7122
+ * Expressed as one rule over \`[data-cg-integration]\` (the attribute lives on
7123
+ * \`.cg-graph-panel\` itself, see GraphPanel.tsx) so every current and future
7124
+ * integration mode is covered. The \`:not(.cg-collapsed)\` guard is load-bearing:
7125
+ * without it this \`!important\` declaration outranks
7126
+ * \`.cg-graph-panel.cg-collapsed .cg-graph-toolbar { display: none }\` and the
7127
+ * toolbar keeps painting inside the 52px collapsed FAB. */
7128
+ .cg-graph-panel[data-cg-integration]:not(.cg-collapsed) .cg-graph-toolbar {
7129
+ display: flex !important;
7130
+ }
7131
+
7132
+ /* ---- Desktop vs Web distinction ------------------------------------------- */
7133
+ /* On desktop platforms, the panel may have more space. On web, it's
7134
+ * constrained by the browser window. */
7135
+ @media (min-width: 1024px) {
7136
+ [data-cg-integration="conversation-view"] .cg-graph-toolbar {
7137
+ padding: 0 12px;
7138
+ gap: 8px;
7139
+ }
7140
+ }
7141
+
7142
+ /* ---- Narrow-tier sheets (cycle-110 / T3) ---------------------------------- */
7143
+ /*
7144
+ * 窄面板(≤480px)下的两处版式修正:
7145
+ * 1. 节点详情面板改为**底部抽屉**(贴边、限高),不再 \`max-height: calc(100% - 24px)\`
7146
+ * 从上往下盖住整个画布;
7147
+ * 2. 抽屉打开时小地图与图例**临时让位**,关闭后回归。
7148
+ *
7149
+ * 为什么两条都要:三者都是 \`position: absolute\` + \`z-index: var(--cg-z-panel)\` 的绝对定位
7150
+ * 元素,窄档下面板几乎占满高度,小地图(右下 200px)与图例(左下 180px)会被盖住一半,
7151
+ * 面板底部的按钮也会与图例重叠 —— 点击落在错误元素上,而所有 DOM 断言照旧通过。
7152
+ *
7153
+ * 两套机制**声明必须一致**:
7154
+ * - \`@container graphpanel (max-width: 480px)\`:主机制,按面板宽度响应(与视口无关);
7155
+ * - \`[data-cg-tier="narrow"]\`:宿主不支持容器查询、或容器查询尺寸不可读时的兜底,
7156
+ * 由 \`hooks/usePanelTier.ts\` 实测宽度后写在根元素上。
7157
+ * \`tests/unit/panelTierContract.test.ts\` 会逐条比对这两处的声明,任一侧改动都会变红。
7158
+ */
7159
+ @container graphpanel (max-width: 480px) {
7160
+ .cg-node-detail-panel {
7161
+ top: auto;
7162
+ bottom: calc(var(--cg-statusbar-h) + 8px);
7163
+ left: 8px;
7164
+ right: 8px;
7165
+ width: auto;
7166
+ max-height: 52%;
7167
+ }
7168
+
7169
+ /* 抽屉打开 ⇒ 两个角落的叠加层让位(关闭后自动回归,不需要 JS 复位) */
7170
+ .cg-graph-panel[data-cg-sheet="open"] .cg-mini-map,
7171
+ .cg-graph-panel[data-cg-sheet="open"] .cg-legend-panel {
7172
+ display: none;
7173
+ }
7174
+ }
7175
+
7176
+ /* 兜底机制(JS 实测档位):与上面的容器查询分支**逐条等价** */
7177
+ .cg-graph-panel[data-cg-tier="narrow"] .cg-node-detail-panel {
7178
+ top: auto;
7179
+ bottom: calc(var(--cg-statusbar-h) + 8px);
7180
+ left: 8px;
7181
+ right: 8px;
7182
+ width: auto;
7183
+ max-height: 52%;
7184
+ }
7185
+
7186
+ .cg-graph-panel[data-cg-tier="narrow"][data-cg-sheet="open"] .cg-mini-map,
7187
+ .cg-graph-panel[data-cg-tier="narrow"][data-cg-sheet="open"] .cg-legend-panel {
7188
+ display: none;
7189
+ }
7190
+
7191
+ /* ---- FBX-05: panel stack responsive tiers --------------------------------- */
7192
+ /*
7193
+ * 面板堆栈与栈内面板在窄容器下的收窄:只**新增**规则、不改任何现有行;面板根
7194
+ * 自身的固定 px 宽(160-300px)不在此重写,统一用 \`max-width: calc(100% - 16px)\`
7195
+ * 封顶(保留左右 8px 呼吸边)。两档阈值必须与 hooks/usePanelTier.ts 的
7196
+ * PANEL_TIER_* 常量同源——tests/unit/panelStackResponsiveContract.test.ts
7197
+ * 逐条比对,任一侧漂移即红。
7198
+ */
7199
+ @container graphpanel (max-width: 768px) {
7200
+ .cg-panel-stack-left .cg-search-bar,
7201
+ .cg-panel-stack-left .cg-path-bar,
7202
+ .cg-panel-stack-left .cg-health-score-card,
7203
+ .cg-panel-stack-left .cg-rule-panel,
7204
+ .cg-panel-stack-left .cg-node-detail-panel,
7205
+ .cg-panel-stack-left .cg-legend-panel,
7206
+ .cg-panel-stack-left .cg-import-panel {
7207
+ max-width: calc(100% - 16px);
7208
+ }
7209
+
7210
+ .cg-panel-stack-left .cg-impact-panel,
7211
+ .cg-panel-stack-left .cg-cycle-panel,
7212
+ .cg-panel-stack-right .cg-analysis-panel,
7213
+ .cg-panel-stack-right .cg-cycle-panel,
7214
+ .cg-panel-stack-right .cg-filter-panel,
7215
+ .cg-panel-stack-right .cg-deadcode-panel {
7216
+ max-width: calc(100% - 16px);
7217
+ }
7218
+ }
7219
+
7220
+ @container graphpanel (max-width: 480px) {
7221
+ .cg-panel-stack-left,
7222
+ .cg-panel-stack-right {
7223
+ left: 8px;
7224
+ right: 8px;
7225
+ max-width: calc(100% - 16px);
7226
+ }
7227
+
7228
+ .cg-panel-stack-left .cg-search-bar,
7229
+ .cg-panel-stack-left .cg-path-bar,
7230
+ .cg-panel-stack-left .cg-health-score-card,
7231
+ .cg-panel-stack-left .cg-rule-panel,
7232
+ .cg-panel-stack-left .cg-node-detail-panel,
7233
+ .cg-panel-stack-left .cg-legend-panel,
7234
+ .cg-panel-stack-left .cg-import-panel {
7235
+ max-width: calc(100% - 16px);
7236
+ }
7237
+
7238
+ .cg-panel-stack-left .cg-impact-panel,
7239
+ .cg-panel-stack-left .cg-cycle-panel,
7240
+ .cg-panel-stack-right .cg-analysis-panel,
7241
+ .cg-panel-stack-right .cg-cycle-panel,
7242
+ .cg-panel-stack-right .cg-filter-panel,
7243
+ .cg-panel-stack-right .cg-deadcode-panel {
7244
+ max-width: calc(100% - 16px);
7245
+ }
7246
+ }
7131
7247
 
7132
7248
  /* Visually hidden — for screen reader only content */
7133
7249
  .cg-visually-hidden {
@@ -11586,7 +11702,7 @@ function resetLoggerListeners() {
11586
11702
 
11587
11703
  //#endregion
11588
11704
  //#region src/client/cache/IndexedDBCache.ts
11589
- const log$45 = scoped("indexeddb-cache");
11705
+ const log$46 = scoped("indexeddb-cache");
11590
11706
  const DB_PREFIX = "codegraph-cache-";
11591
11707
  const CLUSTER_STORE = "cluster-cache";
11592
11708
  const DIAGRAM_STORE = "diagram-cache";
@@ -11637,7 +11753,7 @@ var IndexedDBCache = class {
11637
11753
  }
11638
11754
  getDB() {
11639
11755
  if (!this.dbPromise) this.dbPromise = openDB(this.repoId).catch((err) => {
11640
- log$45.warn("failed to open IndexedDB", {
11756
+ log$46.warn("failed to open IndexedDB", {
11641
11757
  repoId: this.repoId,
11642
11758
  error: err
11643
11759
  });
@@ -11668,7 +11784,7 @@ var IndexedDBCache = class {
11668
11784
  tx.onerror = () => reject(tx.error);
11669
11785
  });
11670
11786
  } catch (e) {
11671
- log$45.warn("IndexedDB invalidateCluster failed", { error: e });
11787
+ log$46.warn("IndexedDB invalidateCluster failed", { error: e });
11672
11788
  }
11673
11789
  }
11674
11790
  async invalidateDiagram(viewMode) {
@@ -11695,7 +11811,7 @@ var IndexedDBCache = class {
11695
11811
  tx.onerror = () => reject(tx.error);
11696
11812
  });
11697
11813
  } catch (e) {
11698
- log$45.warn("IndexedDB invalidateDiagram failed", {
11814
+ log$46.warn("IndexedDB invalidateDiagram failed", {
11699
11815
  viewMode,
11700
11816
  error: e
11701
11817
  });
@@ -11722,7 +11838,7 @@ var IndexedDBCache = class {
11722
11838
  async setMany(storeName, entries) {
11723
11839
  if (entries.length === 0) return;
11724
11840
  if (entries.length > INDEXED_DB.BATCH.MAX_ENTRIES) {
11725
- log$45.warn("setMany batch exceeds limit, splitting", {
11841
+ log$46.warn("setMany batch exceeds limit, splitting", {
11726
11842
  count: entries.length,
11727
11843
  limit: INDEXED_DB.BATCH.MAX_ENTRIES
11728
11844
  });
@@ -11748,7 +11864,7 @@ var IndexedDBCache = class {
11748
11864
  tx.onerror = () => reject(tx.error);
11749
11865
  });
11750
11866
  } catch (e) {
11751
- log$45.warn(`IndexedDB setMany(${storeName}) failed`, {
11867
+ log$46.warn(`IndexedDB setMany(${storeName}) failed`, {
11752
11868
  count: entries.length,
11753
11869
  error: e
11754
11870
  });
@@ -11764,7 +11880,7 @@ var IndexedDBCache = class {
11764
11880
  usageRatio: quota > 0 ? usage / quota : 0
11765
11881
  };
11766
11882
  } catch (e) {
11767
- log$45.warn("storage.estimate() failed", { error: e });
11883
+ log$46.warn("storage.estimate() failed", { error: e });
11768
11884
  return null;
11769
11885
  }
11770
11886
  }
@@ -11776,7 +11892,7 @@ var IndexedDBCache = class {
11776
11892
  async purgeExpired() {
11777
11893
  let totalPurged = 0;
11778
11894
  for (const storeName of ALL_STORES) totalPurged += await this.purgeExpiredStore(storeName);
11779
- if (totalPurged > 0) log$45.info("purged expired entries", { count: totalPurged });
11895
+ if (totalPurged > 0) log$46.info("purged expired entries", { count: totalPurged });
11780
11896
  return totalPurged;
11781
11897
  }
11782
11898
  async purgeExpiredStore(storeName) {
@@ -11808,7 +11924,7 @@ var IndexedDBCache = class {
11808
11924
  });
11809
11925
  return expiredKeys.length;
11810
11926
  } catch (e) {
11811
- log$45.warn(`purgeExpiredStore(${storeName}) failed`, { error: e });
11927
+ log$46.warn(`purgeExpiredStore(${storeName}) failed`, { error: e });
11812
11928
  return 0;
11813
11929
  }
11814
11930
  }
@@ -11831,7 +11947,7 @@ var IndexedDBCache = class {
11831
11947
  }
11832
11948
  return raw;
11833
11949
  } catch (e) {
11834
- log$45.warn(`IndexedDB getFromStore(${storeName}) failed`, {
11950
+ log$46.warn(`IndexedDB getFromStore(${storeName}) failed`, {
11835
11951
  key,
11836
11952
  error: e
11837
11953
  });
@@ -11854,7 +11970,7 @@ var IndexedDBCache = class {
11854
11970
  tx.onerror = () => reject(tx.error);
11855
11971
  });
11856
11972
  } catch (e) {
11857
- log$45.warn(`IndexedDB setToStore(${storeName}) failed`, {
11973
+ log$46.warn(`IndexedDB setToStore(${storeName}) failed`, {
11858
11974
  key,
11859
11975
  error: e
11860
11976
  });
@@ -11870,7 +11986,7 @@ var IndexedDBCache = class {
11870
11986
  tx.onerror = () => reject(tx.error);
11871
11987
  });
11872
11988
  } catch (e) {
11873
- log$45.warn(`IndexedDB deleteFromStore(${storeName}) failed`, {
11989
+ log$46.warn(`IndexedDB deleteFromStore(${storeName}) failed`, {
11874
11990
  key,
11875
11991
  error: e
11876
11992
  });
@@ -11886,7 +12002,7 @@ var IndexedDBCache = class {
11886
12002
 
11887
12003
  //#endregion
11888
12004
  //#region src/client/cache/DiagramCache.ts
11889
- const log$44 = scoped("cache");
12005
+ const log$45 = scoped("cache");
11890
12006
  function computeDataHash(nodes, edges) {
11891
12007
  let nodeHash = 2166136261;
11892
12008
  for (const n of nodes) {
@@ -11978,7 +12094,7 @@ var DiagramCache = class {
11978
12094
  return diagram;
11979
12095
  }
11980
12096
  } catch (e) {
11981
- log$44.warn("IndexedDB diagram getAsync failed", e);
12097
+ log$45.warn("IndexedDB diagram getAsync failed", e);
11982
12098
  }
11983
12099
  return null;
11984
12100
  }
@@ -11990,13 +12106,13 @@ var DiagramCache = class {
11990
12106
  if (firstKey) this.cache.delete(firstKey);
11991
12107
  }
11992
12108
  this.cache.set(key, diagram);
11993
- if (this.idbCache) this.idbCache.setDiagram(key, diagram).catch((e) => log$44.warn("IndexedDB diagram set failed", e));
12109
+ if (this.idbCache) this.idbCache.setDiagram(key, diagram).catch((e) => log$45.warn("IndexedDB diagram set failed", e));
11994
12110
  }
11995
12111
  invalidate(viewMode) {
11996
12112
  if (viewMode) {
11997
12113
  for (const key of this.cache.keys()) if (key.startsWith(`${viewMode}:`)) this.cache.delete(key);
11998
12114
  } else this.cache.clear();
11999
- if (this.idbCache) this.idbCache.invalidateDiagram(viewMode).catch((e) => log$44.warn("IndexedDB diagram invalidate failed", e));
12115
+ if (this.idbCache) this.idbCache.invalidateDiagram(viewMode).catch((e) => log$45.warn("IndexedDB diagram invalidate failed", e));
12000
12116
  }
12001
12117
  size() {
12002
12118
  return this.cache.size;
@@ -12041,7 +12157,7 @@ var ClusterCache = class {
12041
12157
  return result;
12042
12158
  }
12043
12159
  } catch (e) {
12044
- log$44.warn("IndexedDB cluster getAsync failed", e);
12160
+ log$45.warn("IndexedDB cluster getAsync failed", e);
12045
12161
  }
12046
12162
  return null;
12047
12163
  }
@@ -12053,11 +12169,11 @@ var ClusterCache = class {
12053
12169
  if (firstKey) this.cache.delete(firstKey);
12054
12170
  }
12055
12171
  this.cache.set(key, result);
12056
- if (this.idbCache) this.idbCache.setCluster(key, result).catch((e) => log$44.warn("IndexedDB cluster set failed", e));
12172
+ if (this.idbCache) this.idbCache.setCluster(key, result).catch((e) => log$45.warn("IndexedDB cluster set failed", e));
12057
12173
  }
12058
12174
  invalidate() {
12059
12175
  this.cache.clear();
12060
- if (this.idbCache) this.idbCache.invalidateCluster().catch((e) => log$44.warn("IndexedDB cluster invalidate failed", e));
12176
+ if (this.idbCache) this.idbCache.invalidateCluster().catch((e) => log$45.warn("IndexedDB cluster invalidate failed", e));
12061
12177
  }
12062
12178
  size() {
12063
12179
  return this.cache.size;
@@ -13711,6 +13827,25 @@ function buildStylesheet(theme, nodeCount = 0) {
13711
13827
  "border-color": successColor
13712
13828
  }
13713
13829
  },
13830
+ {
13831
+ selector: "node.multi-selected",
13832
+ style: {
13833
+ "border-width": 3,
13834
+ "border-color": accentColor,
13835
+ "background-color": accentColor,
13836
+ "background-opacity": .35,
13837
+ "z-index": 26
13838
+ }
13839
+ },
13840
+ {
13841
+ selector: "node.canvas-focused",
13842
+ style: {
13843
+ "border-width": 2,
13844
+ "border-color": accentColor,
13845
+ "border-style": "dashed",
13846
+ "z-index": 24
13847
+ }
13848
+ },
13714
13849
  {
13715
13850
  selector: ".call-chain",
13716
13851
  style: {
@@ -15039,7 +15174,7 @@ var CallChainAggregator = class {
15039
15174
 
15040
15175
  //#endregion
15041
15176
  //#region src/shared/PerformanceMonitor.ts
15042
- const log$43 = scoped("perf");
15177
+ const log$44 = scoped("perf");
15043
15178
  const MAX_HISTORY = 100;
15044
15179
  const measurements = [];
15045
15180
  const thresholds = new Map();
@@ -15141,8 +15276,8 @@ function recordMeasurement(name$1, durationMs) {
15141
15276
  if (measurements.length > MAX_HISTORY) measurements.shift();
15142
15277
  const threshold = thresholds.get(name$1);
15143
15278
  if (threshold) {
15144
- if (threshold.degradeMs !== void 0 && durationMs >= threshold.degradeMs && threshold.degradationKey) log$43.warn(`${name$1} exceeded degrade threshold: ${durationMs.toFixed(1)}ms >= ${threshold.degradeMs}ms`);
15145
- else if (threshold.warnMs !== void 0 && durationMs >= threshold.warnMs) log$43.info(`${name$1} exceeded warn threshold: ${durationMs.toFixed(1)}ms >= ${threshold.warnMs}ms`);
15279
+ if (threshold.degradeMs !== void 0 && durationMs >= threshold.degradeMs && threshold.degradationKey) log$44.warn(`${name$1} exceeded degrade threshold: ${durationMs.toFixed(1)}ms >= ${threshold.degradeMs}ms`);
15280
+ else if (threshold.warnMs !== void 0 && durationMs >= threshold.warnMs) log$44.info(`${name$1} exceeded warn threshold: ${durationMs.toFixed(1)}ms >= ${threshold.warnMs}ms`);
15146
15281
  }
15147
15282
  }
15148
15283
  function getMeasurements(name$1) {
@@ -15152,7 +15287,7 @@ function getMeasurements(name$1) {
15152
15287
 
15153
15288
  //#endregion
15154
15289
  //#region src/client/store/internals.ts
15155
- const log$42 = scoped("store");
15290
+ const log$43 = scoped("store");
15156
15291
  const diagramCache = new DiagramCache(10);
15157
15292
  const clusterCache = new ClusterCache(20);
15158
15293
  const callChainAggregator = new CallChainAggregator();
@@ -15270,7 +15405,7 @@ function mergeAndRecluster(newNodes, newEdges, set$1, logLabel, logDetail) {
15270
15405
  nodes: result.nodes,
15271
15406
  edges: result.edges
15272
15407
  });
15273
- log$42.info(logLabel, {
15408
+ log$43.info(logLabel, {
15274
15409
  ...logDetail,
15275
15410
  newNodes: filteredNodes.length,
15276
15411
  newEdges: filteredEdges.length
@@ -15442,7 +15577,7 @@ function normalizeEdgeType(type) {
15442
15577
 
15443
15578
  //#endregion
15444
15579
  //#region src/shared/GraphValidator.ts
15445
- const log$41 = scoped("validator");
15580
+ const log$42 = scoped("validator");
15446
15581
  function coerceNode(raw, i$1) {
15447
15582
  if (!raw || typeof raw !== "object") return null;
15448
15583
  const o = raw;
@@ -16702,6 +16837,7 @@ const en = {
16702
16837
  "canvasControls.shortcutZoomIn": "+ Zoom in",
16703
16838
  "canvasControls.shortcutZoomOut": "- Zoom out",
16704
16839
  "canvasControls.shortcutFit": "F Fit to view",
16840
+ "canvasControls.shortcutBoxSelect": "Shift+Drag Box-select nodes",
16705
16841
  "canvasControls.multiSelect": "Multi-select mode",
16706
16842
  "micro.closeView": "Close micro view",
16707
16843
  "micro.functionBar": "Micro view toolbar",
@@ -16865,6 +17001,12 @@ const en = {
16865
17001
  "contextMenu.findPath": "Find Path From Here",
16866
17002
  "contextMenu.showAll": "Show All Hidden Nodes",
16867
17003
  "contextMenu.openSource": "Open Source",
17004
+ "contextMenu.expandHere": "Expand From Here",
17005
+ "contextMenu.collapseHere": "Collapse To Here",
17006
+ "contextMenu.pinNode": "Pin Node",
17007
+ "contextMenu.unpinNode": "Unpin Node",
17008
+ "contextMenu.copyId": "Copy Symbol ID",
17009
+ "contextMenu.copied": "Copied",
16868
17010
  "a11y.graphContainer": "Code graph interactive area",
16869
17011
  "a11y.graphContainerStats": "Code graph interactive area: {viewMode}, {nodeCount} nodes, {edgeCount} edges",
16870
17012
  "a11y.selectedNode": "Selected node",
@@ -17046,7 +17188,17 @@ const en = {
17046
17188
  "views.srcAuto": "Collected by host",
17047
17189
  "views.srcQuery": "Provided by caller",
17048
17190
  "views.srcDuration": "{ms}ms",
17049
- "views.srcTruncated": "truncated"
17191
+ "views.srcTruncated": "truncated",
17192
+ "batchbar.ariaLabel": "Batch actions",
17193
+ "batchbar.count": "{count} selected",
17194
+ "batchbar.expand": "Expand",
17195
+ "batchbar.expandAria": "Expand all selected nodes",
17196
+ "batchbar.collapse": "Collapse",
17197
+ "batchbar.collapseAria": "Collapse all selected nodes",
17198
+ "batchbar.export": "Export JSON",
17199
+ "batchbar.exportAria": "Export selected nodes as JSON",
17200
+ "batchbar.clear": "Clear",
17201
+ "batchbar.clearAria": "Clear multi-selection"
17050
17202
  };
17051
17203
 
17052
17204
  //#endregion
@@ -17534,6 +17686,7 @@ const zh = {
17534
17686
  "canvasControls.shortcutZoomIn": "+ 放大",
17535
17687
  "canvasControls.shortcutZoomOut": "- 缩小",
17536
17688
  "canvasControls.shortcutFit": "F 适应窗口",
17689
+ "canvasControls.shortcutBoxSelect": "Shift+拖拽 框选节点",
17537
17690
  "canvasControls.multiSelect": "多选模式",
17538
17691
  "micro.closeView": "关闭微观视图",
17539
17692
  "micro.functionBar": "微观视图工具栏",
@@ -17697,6 +17850,12 @@ const zh = {
17697
17850
  "contextMenu.findPath": "从此节点查找路径",
17698
17851
  "contextMenu.showAll": "显示所有隐藏节点",
17699
17852
  "contextMenu.openSource": "打开源码",
17853
+ "contextMenu.expandHere": "从此展开",
17854
+ "contextMenu.collapseHere": "收起到这里",
17855
+ "contextMenu.pinNode": "固定节点",
17856
+ "contextMenu.unpinNode": "取消固定",
17857
+ "contextMenu.copyId": "复制符号 ID",
17858
+ "contextMenu.copied": "已复制",
17700
17859
  "a11y.graphContainer": "代码图谱交互区域",
17701
17860
  "a11y.graphContainerStats": "代码图谱交互区域: {viewMode}, {nodeCount} 节点, {edgeCount} 边",
17702
17861
  "a11y.selectedNode": "已选中节点",
@@ -17878,7 +18037,17 @@ const zh = {
17878
18037
  "views.srcAuto": "宿主自动采集",
17879
18038
  "views.srcQuery": "调用方传入",
17880
18039
  "views.srcDuration": "耗时 {ms}ms",
17881
- "views.srcTruncated": "已截断"
18040
+ "views.srcTruncated": "已截断",
18041
+ "batchbar.ariaLabel": "批量操作条",
18042
+ "batchbar.count": "已选 {count} 个",
18043
+ "batchbar.expand": "展开",
18044
+ "batchbar.expandAria": "批量展开选中的节点",
18045
+ "batchbar.collapse": "收起",
18046
+ "batchbar.collapseAria": "批量收起选中的节点",
18047
+ "batchbar.export": "导出 JSON",
18048
+ "batchbar.exportAria": "导出选中节点为 JSON 文件",
18049
+ "batchbar.clear": "清除",
18050
+ "batchbar.clearAria": "清除多选"
17882
18051
  };
17883
18052
 
17884
18053
  //#endregion
@@ -18886,14 +19055,14 @@ function etagKeyForEdges(fromId, scope, cursor, limit$1, detail) {
18886
19055
 
18887
19056
  //#endregion
18888
19057
  //#region src/client/api/requestCore.ts
18889
- const log$40 = scoped("client");
19058
+ const log$41 = scoped("client");
18890
19059
  const DEFAULT_TIMEOUT = API.DEFAULT_TIMEOUT_MS;
18891
19060
  async function withAbortGuard(name$1, run$3, fallback, logContext) {
18892
19061
  try {
18893
19062
  return await run$3();
18894
19063
  } catch (e) {
18895
19064
  if (e instanceof DOMException && e.name === "AbortError") return fallback;
18896
- log$40.warn(`${name$1} failed`, {
19065
+ log$41.warn(`${name$1} failed`, {
18897
19066
  ...logContext,
18898
19067
  error: e instanceof Error ? e.message : String(e)
18899
19068
  });
@@ -19226,7 +19395,7 @@ async function fetchPathEnumerate(source, target, options = {}) {
19226
19395
 
19227
19396
  //#endregion
19228
19397
  //#region src/client/api/index.ts
19229
- const log$39 = scoped("client");
19398
+ const log$40 = scoped("client");
19230
19399
  /** 构造一个空 sink;调用方先建后传,取数函数负责填充。 */
19231
19400
  function createEtagSink() {
19232
19401
  return {
@@ -19427,7 +19596,7 @@ async function fetchGraphDataIncremental(clientEtag, scopeRepoId) {
19427
19596
  const data$2 = coerceGraphData(await res.json());
19428
19597
  if (!data$2) return null;
19429
19598
  if (isForeignRepo(data$2.metadata.repoId, scopeRepoId)) {
19430
- log$39.info("incremental graph fetch ignored for another workspace", {
19599
+ log$40.info("incremental graph fetch ignored for another workspace", {
19431
19600
  repoId: data$2.metadata.repoId,
19432
19601
  activeRepoId: scopeRepoId
19433
19602
  });
@@ -19568,7 +19737,7 @@ async function fetchEdges(fromId, scope = "file", cursor, limit$1, signal, works
19568
19737
 
19569
19738
  //#endregion
19570
19739
  //#region src/client/diagram/index.ts
19571
- const log$38 = scoped("diagram");
19740
+ const log$39 = scoped("diagram");
19572
19741
  const macroAssemblerRegistry = new Map([
19573
19742
  ["inheritance", new InheritanceAssembler()],
19574
19743
  ["callgraph", new CallGraphAssembler()],
@@ -19768,7 +19937,7 @@ async function assembleMicroDiagram(viewMode, filePath, functionName, workspace)
19768
19937
  body: JSON.stringify({ filePath })
19769
19938
  });
19770
19939
  } catch (e) {
19771
- log$38.warn("AST fetch failed", e);
19940
+ log$39.warn("AST fetch failed", e);
19772
19941
  throw new Error(`Network error: unable to reach AST endpoint`);
19773
19942
  }
19774
19943
  if (!response.ok) {
@@ -19783,7 +19952,7 @@ async function assembleMicroDiagram(viewMode, filePath, functionName, workspace)
19783
19952
  const parsed = JSON.parse(errorBody);
19784
19953
  serverMsg = parsed.error || "";
19785
19954
  } catch (e) {
19786
- log$38.warn("AST error body parse failed", e);
19955
+ log$39.warn("AST error body parse failed", e);
19787
19956
  serverMsg = errorBody;
19788
19957
  }
19789
19958
  throw new Error(serverMsg || `AST parsing failed for "${filePath}"`);
@@ -19918,7 +20087,7 @@ function createEdgeSlice(_set, _get) {
19918
20087
  nodeEdgeTotals: totals$1,
19919
20088
  nodeEdgeCursors: cursors$1
19920
20089
  });
19921
- log$42.info("node edges loaded (cache)", {
20090
+ log$43.info("node edges loaded (cache)", {
19922
20091
  nodeId,
19923
20092
  scope,
19924
20093
  edges: data$3.edges.length
@@ -19931,7 +20100,7 @@ function createEdgeSlice(_set, _get) {
19931
20100
  const data$2 = await fetchEdges(nodeId, scope, cursor, EDGE_DETAIL.PAGE_SIZE, controller.signal, _get().currentWorkspace, detail, sink);
19932
20101
  if (!isCurrent() || gen !== internals.expandGeneration) return;
19933
20102
  if (sink.notModified) {
19934
- log$42.info("node edges not modified (304)", {
20103
+ log$43.info("node edges not modified (304)", {
19935
20104
  nodeId,
19936
20105
  scope,
19937
20106
  cursor,
@@ -19940,7 +20109,7 @@ function createEdgeSlice(_set, _get) {
19940
20109
  return;
19941
20110
  }
19942
20111
  if (!data$2) {
19943
- log$42.warn("loadNodeEdges: no response", {
20112
+ log$43.warn("loadNodeEdges: no response", {
19944
20113
  nodeId,
19945
20114
  scope,
19946
20115
  cursor,
@@ -19967,7 +20136,7 @@ function createEdgeSlice(_set, _get) {
19967
20136
  edges: merged,
19968
20137
  total: data$2.total
19969
20138
  });
19970
- log$42.info("node edges loaded", {
20139
+ log$43.info("node edges loaded", {
19971
20140
  nodeId,
19972
20141
  scope,
19973
20142
  detail,
@@ -19978,7 +20147,7 @@ function createEdgeSlice(_set, _get) {
19978
20147
  });
19979
20148
  commitEtag(sink);
19980
20149
  } catch (err) {
19981
- if (!(err instanceof DOMException && err.name === "AbortError")) log$42.warn("loadNodeEdges failed", {
20150
+ if (!(err instanceof DOMException && err.name === "AbortError")) log$43.warn("loadNodeEdges failed", {
19982
20151
  nodeId,
19983
20152
  scope,
19984
20153
  err
@@ -20101,7 +20270,7 @@ function createGraphDataSlice(_set, _get) {
20101
20270
  ...emptyDiffState()
20102
20271
  });
20103
20272
  } catch (err) {
20104
- log$42.error("setGraphData failed", { err });
20273
+ log$43.error("setGraphData failed", { err });
20105
20274
  _set({
20106
20275
  error: err instanceof Error ? err.message : String(err),
20107
20276
  isLoading: false
@@ -20170,12 +20339,12 @@ function createGraphDataSlice(_set, _get) {
20170
20339
  ...emptyEdgeState(),
20171
20340
  ...emptyDiffState()
20172
20341
  });
20173
- log$42.info("bootstrap data set", {
20342
+ log$43.info("bootstrap data set", {
20174
20343
  rootNodes: sanitized.nodes.length,
20175
20344
  totalNodeCount: metadata.totalNodeCount
20176
20345
  });
20177
20346
  } catch (err) {
20178
- log$42.error("setBootstrapData failed", { err });
20347
+ log$43.error("setBootstrapData failed", { err });
20179
20348
  _set({
20180
20349
  error: err instanceof Error ? err.message : String(err),
20181
20350
  isLoading: false
@@ -20218,7 +20387,7 @@ function createGraphDataSlice(_set, _get) {
20218
20387
  nodes: rawNodes,
20219
20388
  edges: rawEdges
20220
20389
  }, delta, { minRetainRatio: GRAPH_DELTA_SHRINK.MIN_RETAIN_RATIO })) {
20221
- log$42.info("applyGraphDelta refused: delta does not match the current revision or would shrink the graph", {
20390
+ log$43.info("applyGraphDelta refused: delta does not match the current revision or would shrink the graph", {
20222
20391
  storedNodes: rawNodes.length,
20223
20392
  storedEdges: rawEdges.length,
20224
20393
  ...graphDeltaSize(delta)
@@ -20280,7 +20449,7 @@ function createGraphDataSlice(_set, _get) {
20280
20449
  ...emptyEdgeState(),
20281
20450
  ...emptyDiffState()
20282
20451
  });
20283
- log$42.info("applied graph delta", {
20452
+ log$43.info("applied graph delta", {
20284
20453
  addedNodes: delta.addedNodes.length,
20285
20454
  removedNodes: delta.removedNodes.length,
20286
20455
  modifiedNodes: delta.modifiedNodes.length,
@@ -20290,7 +20459,7 @@ function createGraphDataSlice(_set, _get) {
20290
20459
  });
20291
20460
  return true;
20292
20461
  } catch (err) {
20293
- log$42.error("applyGraphDelta failed", { err });
20462
+ log$43.error("applyGraphDelta failed", { err });
20294
20463
  _set({
20295
20464
  error: err instanceof Error ? err.message : String(err),
20296
20465
  isLoading: false
@@ -20636,7 +20805,7 @@ function createSessionSlice(_set, _get) {
20636
20805
 
20637
20806
  //#endregion
20638
20807
  //#region src/client/utils/urlState.ts
20639
- const log$37 = scoped("url-state");
20808
+ const log$38 = scoped("url-state");
20640
20809
  const PREFIX_DIR = "d:";
20641
20810
  const PREFIX_FILE = "f:";
20642
20811
  const PREFIX_SYMBOL = "s:";
@@ -20693,7 +20862,7 @@ function updateUrlExpand(expandedNodeIds) {
20693
20862
  if (urlString.length > URL_STATE.MAX_LENGTH) {
20694
20863
  const trimmed = expandParam.split(",").filter((p$1) => !p$1.startsWith(PREFIX_SYMBOL));
20695
20864
  url.searchParams.set("expand", trimmed.join(","));
20696
- log$37.warn("URL too long, trimmed symbol-level expansions");
20865
+ log$38.warn("URL too long, trimmed symbol-level expansions");
20697
20866
  }
20698
20867
  window.history.replaceState(null, "", url.toString());
20699
20868
  }
@@ -20788,7 +20957,7 @@ async function loadFunctionBodyAst(nodeId, signal, gen, _set, _get, cursor = 0)
20788
20957
  const data$2 = await measureAsync("ast-fetch", () => fetchSymbolAST(nodeId, signal, _get().currentWorkspace, cursor, sink));
20789
20958
  if (gen !== internals.expandGeneration || !isCurrent()) return;
20790
20959
  if (sink.notModified) {
20791
- log$42.info("symbol AST not modified (304)", {
20960
+ log$43.info("symbol AST not modified (304)", {
20792
20961
  nodeId,
20793
20962
  cursor
20794
20963
  });
@@ -20796,7 +20965,7 @@ async function loadFunctionBodyAst(nodeId, signal, gen, _set, _get, cursor = 0)
20796
20965
  }
20797
20966
  if (!data$2) return;
20798
20967
  const { astNodes: truncatedAstNodes, astEdges: truncatedAstEdges, sourceLines: truncatedSourceLines } = truncateAstData(data$2);
20799
- if (truncatedAstNodes.length < data$2.astNodes.length) log$42.warn("symbol AST truncated", {
20968
+ if (truncatedAstNodes.length < data$2.astNodes.length) log$43.warn("symbol AST truncated", {
20800
20969
  nodeId,
20801
20970
  original: data$2.astNodes.length,
20802
20971
  truncated: truncatedAstNodes.length
@@ -20816,7 +20985,7 @@ async function loadFunctionBodyAst(nodeId, signal, gen, _set, _get, cursor = 0)
20816
20985
  if (internals.persistentCache && !data$2.hasMore && cursor === 0) await internals.persistentCache.setAST(nodeId, data$2);
20817
20986
  commitEtag(sink);
20818
20987
  } catch (err) {
20819
- log$42.warn("expandNode: fetchSymbolAST failed", {
20988
+ log$43.warn("expandNode: fetchSymbolAST failed", {
20820
20989
  nodeId,
20821
20990
  err
20822
20991
  });
@@ -20843,7 +21012,7 @@ function createExpandSlice(_set, _get) {
20843
21012
  if (expandingFunctionBody) enforceFunctionBodyBudget(effectiveExpanded, rawNodes, FUNCTION_BODY.MAX_EXPANDED - 1, pinnedIds);
20844
21013
  effectiveExpanded.add(nodeId);
20845
21014
  collapseExcessClusters(nodes, effectiveExpanded, maxVisible, cost, pinnedIds);
20846
- if (cost > CLUSTER.EXPAND_BUDGET_SINGLE) log$42.warn("expand budget exceeded", {
21015
+ if (cost > CLUSTER.EXPAND_BUDGET_SINGLE) log$43.warn("expand budget exceeded", {
20847
21016
  nodeId,
20848
21017
  cost,
20849
21018
  budget: CLUSTER.EXPAND_BUDGET_SINGLE
@@ -20858,7 +21027,7 @@ function createExpandSlice(_set, _get) {
20858
21027
  const expandController = replaceController(internals.expandAbortControllers, nodeId);
20859
21028
  _get().loadNodeChildren(nodeId, { signal: expandController.signal }).catch((err) => {
20860
21029
  if (err instanceof DOMException && err.name === "AbortError") return;
20861
- log$42.warn("expandNode: loadNodeChildren failed", {
21030
+ log$43.warn("expandNode: loadNodeChildren failed", {
20862
21031
  nodeId,
20863
21032
  err
20864
21033
  });
@@ -20880,7 +21049,7 @@ function createExpandSlice(_set, _get) {
20880
21049
  fetchFileOutline(nodeId, expandController.signal, _get().currentWorkspace, sink).then(async (data$2) => {
20881
21050
  if (gen !== internals.expandGeneration) return;
20882
21051
  if (sink.notModified) {
20883
- log$42.info("file outline not modified (304)", { nodeId });
21052
+ log$43.info("file outline not modified (304)", { nodeId });
20884
21053
  return;
20885
21054
  }
20886
21055
  if (data$2 && data$2.symbols.length > 0) {
@@ -20889,7 +21058,7 @@ function createExpandSlice(_set, _get) {
20889
21058
  commitEtag(sink);
20890
21059
  }
20891
21060
  }).catch((err) => {
20892
- log$42.warn("expandNode: fetchFileOutline failed", {
21061
+ log$43.warn("expandNode: fetchFileOutline failed", {
20893
21062
  nodeId,
20894
21063
  err
20895
21064
  });
@@ -20927,7 +21096,7 @@ function createExpandSlice(_set, _get) {
20927
21096
  loadedChildrenByNodeId: newLoaded$1,
20928
21097
  pendingRequests: clearedPending$1
20929
21098
  });
20930
- log$42.info("node children not modified (304)", { nodeId });
21099
+ log$43.info("node children not modified (304)", { nodeId });
20931
21100
  return;
20932
21101
  }
20933
21102
  if (!data$2 || data$2.nodes.length === 0) {
@@ -20976,7 +21145,7 @@ function createExpandSlice(_set, _get) {
20976
21145
  loadedChildrenByNodeId: newLoaded,
20977
21146
  pendingRequests: clearedPending
20978
21147
  });
20979
- log$42.info("node children loaded", {
21148
+ log$43.info("node children loaded", {
20980
21149
  nodeId,
20981
21150
  newNodes: newNodes.length,
20982
21151
  newEdges: newEdges.length
@@ -20995,7 +21164,7 @@ function createExpandSlice(_set, _get) {
20995
21164
  pendingRequests: clearedPending,
20996
21165
  loadErrorsByNodeId: newErrors
20997
21166
  });
20998
- log$42.warn("loadNodeChildren failed", {
21167
+ log$43.warn("loadNodeChildren failed", {
20999
21168
  nodeId,
21000
21169
  err
21001
21170
  });
@@ -21006,7 +21175,7 @@ function createExpandSlice(_set, _get) {
21006
21175
  const ws = _get().currentWorkspace;
21007
21176
  const data$2 = await fetchBootstrap(void 0, ws);
21008
21177
  if (!data$2) {
21009
- log$42.info("bootstrap returned no payload, falling back to full load");
21178
+ log$43.info("bootstrap returned no payload, falling back to full load");
21010
21179
  return false;
21011
21180
  }
21012
21181
  _get().setIndexStatus(data$2.indexStatus);
@@ -21017,11 +21186,11 @@ function createExpandSlice(_set, _get) {
21017
21186
  timestamp: data$2.metadata.timestamp,
21018
21187
  graphLayer: data$2.metadata.graphLayer
21019
21188
  });
21020
- log$42.info("bootstrap: index building, empty skeleton installed", { repoId: data$2.metadata.repoId });
21189
+ log$43.info("bootstrap: index building, empty skeleton installed", { repoId: data$2.metadata.repoId });
21021
21190
  return true;
21022
21191
  }
21023
21192
  if (data$2.topLevelNodes.length === 0) {
21024
- log$42.info("bootstrap returned empty, falling back to full load");
21193
+ log$43.info("bootstrap returned empty, falling back to full load");
21025
21194
  return false;
21026
21195
  }
21027
21196
  const state = _get();
@@ -21031,14 +21200,14 @@ function createExpandSlice(_set, _get) {
21031
21200
  timestamp: data$2.metadata.timestamp,
21032
21201
  graphLayer: data$2.metadata.graphLayer
21033
21202
  });
21034
- log$42.info("bootstrap loaded", {
21203
+ log$43.info("bootstrap loaded", {
21035
21204
  rootNodes: data$2.topLevelNodes.length,
21036
21205
  totalNodeCount: data$2.metadata.totalNodeCount,
21037
21206
  indexStatus: data$2.indexStatus
21038
21207
  });
21039
21208
  return true;
21040
21209
  } catch (err) {
21041
- log$42.warn("loadBootstrap failed, falling back to full load", { err });
21210
+ log$43.warn("loadBootstrap failed, falling back to full load", { err });
21042
21211
  return false;
21043
21212
  }
21044
21213
  },
@@ -21198,7 +21367,7 @@ function createExpandSlice(_set, _get) {
21198
21367
  if (!exists) {
21199
21368
  const parentId = nodeId.includes("/") ? nodeId.substring(0, nodeId.lastIndexOf("/")) : null;
21200
21369
  if (parentId && !state.loadedChildrenByNodeId.has(parentId)) await state.loadNodeChildren(parentId, { signal: restoreController.signal }).catch((err) => {
21201
- if (!(err instanceof DOMException && err.name === "AbortError")) log$42.warn("restoreFromUrl: loadNodeChildren failed", {
21370
+ if (!(err instanceof DOMException && err.name === "AbortError")) log$43.warn("restoreFromUrl: loadNodeChildren failed", {
21202
21371
  parentId,
21203
21372
  err
21204
21373
  });
@@ -21223,7 +21392,7 @@ function createExpandSlice(_set, _get) {
21223
21392
  }
21224
21393
  internals.restoreTimeoutHandle = null;
21225
21394
  _set({ urlRestored: true });
21226
- log$42.info("URL restore completed", { restoredCount: sortedIds.length });
21395
+ log$43.info("URL restore completed", { restoredCount: sortedIds.length });
21227
21396
  }
21228
21397
  };
21229
21398
  }
@@ -21405,7 +21574,7 @@ function createAnalysisSlice(_set, _get) {
21405
21574
  isLoading: false
21406
21575
  });
21407
21576
  } catch (e) {
21408
- log$42.warn("assembleMultiFunction failed", e);
21577
+ log$43.warn("assembleMultiFunction failed", e);
21409
21578
  _set({
21410
21579
  error: t("error.callChainFailed"),
21411
21580
  isLoading: false
@@ -21491,7 +21660,7 @@ function createPanelLodSlice(_set, _get) {
21491
21660
 
21492
21661
  //#endregion
21493
21662
  //#region src/client/api/keywordQuery.ts
21494
- const log$36 = scoped("client");
21663
+ const log$37 = scoped("client");
21495
21664
  /**
21496
21665
 
21497
21666
  * 条件请求的请求键(与宿主 `computeQueryEtag` 的**参数域**对齐)。
@@ -21550,7 +21719,7 @@ async function fetchKeywordQuery(params, signal) {
21550
21719
  res = await fetchWithTimeout(`/api/v1/codegraph/query?${search.toString()}`, options);
21551
21720
  } catch (e) {
21552
21721
  const aborted = e instanceof DOMException && e.name === "AbortError";
21553
- log$36.warn("fetchKeywordQuery failed", {
21722
+ log$37.warn("fetchKeywordQuery failed", {
21554
21723
  aborted,
21555
21724
  externalAborted: signal?.aborted === true,
21556
21725
  error: e instanceof Error ? e.message : String(e)
@@ -21567,7 +21736,7 @@ async function fetchKeywordQuery(params, signal) {
21567
21736
  try {
21568
21737
  payload = await res.json();
21569
21738
  } catch (e) {
21570
- log$36.warn("fetchKeywordQuery: non-JSON body", { error: e instanceof Error ? e.message : String(e) });
21739
+ log$37.warn("fetchKeywordQuery: non-JSON body", { error: e instanceof Error ? e.message : String(e) });
21571
21740
  return {
21572
21741
  kind: "failed",
21573
21742
  status: res.status
@@ -21705,7 +21874,7 @@ function createKeywordQuerySlice(_set, _get) {
21705
21874
  });
21706
21875
  if (seq !== _get().keywordQuerySeq) return false;
21707
21876
  if (outcome.kind === "not-modified") {
21708
- log$42.info("keyword query not modified (304)", { q: trimmed });
21877
+ log$43.info("keyword query not modified (304)", { q: trimmed });
21709
21878
  const previous = _get().keywordQueryResult;
21710
21879
  _set({
21711
21880
  keywordQueryStatus: previous ? "ready" : "idle",
@@ -21754,7 +21923,7 @@ function createKeywordQuerySlice(_set, _get) {
21754
21923
  const state = _get();
21755
21924
  const exists = state.rawNodes.some((node) => node.id === nodeId);
21756
21925
  if (!exists) {
21757
- log$42.warn("selectKeywordSeed: node not in current graph", { nodeId });
21926
+ log$43.warn("selectKeywordSeed: node not in current graph", { nodeId });
21758
21927
  return;
21759
21928
  }
21760
21929
  _set({
@@ -21776,7 +21945,7 @@ function createKeywordQuerySlice(_set, _get) {
21776
21945
  ...workspace && workspace !== "." ? { workspace } : {}
21777
21946
  });
21778
21947
  if (outcome.kind !== "data") {
21779
- log$42.warn("revealRemoteNode: query failed", {
21948
+ log$43.warn("revealRemoteNode: query failed", {
21780
21949
  nodeId,
21781
21950
  kind: outcome.kind
21782
21951
  });
@@ -21784,12 +21953,12 @@ function createKeywordQuerySlice(_set, _get) {
21784
21953
  }
21785
21954
  const target = outcome.data.nodes.find((n) => n.id === nodeId);
21786
21955
  if (!target) {
21787
- log$42.warn("revealRemoteNode: payload does not contain the requested node", { nodeId });
21956
+ log$43.warn("revealRemoteNode: payload does not contain the requested node", { nodeId });
21788
21957
  return false;
21789
21958
  }
21790
21959
  mergeAndRecluster(outcome.data.nodes, outcome.data.edges, _set, "remote search hit merged", { nodeId });
21791
21960
  if (!_get().rawNodes.some((node) => node.id === nodeId)) {
21792
- log$42.warn("revealRemoteNode: node still absent after merge", { nodeId });
21961
+ log$43.warn("revealRemoteNode: node still absent after merge", { nodeId });
21793
21962
  return false;
21794
21963
  }
21795
21964
  _set({
@@ -21839,7 +22008,7 @@ function truncationReasonKey(reason) {
21839
22008
 
21840
22009
  //#endregion
21841
22010
  //#region src/client/api/pathPattern.ts
21842
- const log$35 = scoped("client");
22011
+ const log$36 = scoped("client");
21843
22012
  /**
21844
22013
  * 把宿主响应归一成 `PathPatternData`。
21845
22014
  *
@@ -21899,7 +22068,7 @@ async function fetchPathsByPattern(params, signal) {
21899
22068
  res = await fetchWithTimeout(`/api/v1/codegraph/paths?${search.toString()}`, options);
21900
22069
  } catch (e) {
21901
22070
  const aborted = e instanceof DOMException && e.name === "AbortError";
21902
- log$35.warn("fetchPathsByPattern failed", {
22071
+ log$36.warn("fetchPathsByPattern failed", {
21903
22072
  aborted,
21904
22073
  externalAborted: signal?.aborted === true,
21905
22074
  error: e instanceof Error ? e.message : String(e)
@@ -21920,7 +22089,7 @@ async function fetchPathsByPattern(params, signal) {
21920
22089
  const record$1 = body && typeof body === "object" ? body : {};
21921
22090
  const errorCode = typeof record$1.error === "string" ? record$1.error : "invalid-pattern";
21922
22091
  const diagnostic = typeof record$1.diagnostic === "string" ? record$1.diagnostic : typeof record$1.message === "string" ? record$1.message : "";
21923
- log$35.warn("fetchPathsByPattern: invalid pattern", {
22092
+ log$36.warn("fetchPathsByPattern: invalid pattern", {
21924
22093
  errorCode,
21925
22094
  diagnostic
21926
22095
  });
@@ -21938,7 +22107,7 @@ async function fetchPathsByPattern(params, signal) {
21938
22107
  try {
21939
22108
  payload = await res.json();
21940
22109
  } catch (e) {
21941
- log$35.warn("fetchPathsByPattern: non-JSON body", { error: e instanceof Error ? e.message : String(e) });
22110
+ log$36.warn("fetchPathsByPattern: non-JSON body", { error: e instanceof Error ? e.message : String(e) });
21942
22111
  return {
21943
22112
  kind: "failed",
21944
22113
  status: res.status
@@ -21967,7 +22136,7 @@ async function fetchPathPatternSyntax(pattern, signal) {
21967
22136
  res = await fetchWithTimeout(`/api/v1/codegraph/path-pattern/syntax?${search.toString()}`, options);
21968
22137
  } catch (e) {
21969
22138
  const aborted = e instanceof DOMException && e.name === "AbortError";
21970
- log$35.warn("fetchPathPatternSyntax failed", {
22139
+ log$36.warn("fetchPathPatternSyntax failed", {
21971
22140
  aborted,
21972
22141
  externalAborted: signal?.aborted === true,
21973
22142
  error: e instanceof Error ? e.message : String(e)
@@ -21990,7 +22159,7 @@ async function fetchPathPatternSyntax(pattern, signal) {
21990
22159
  try {
21991
22160
  payload = await res.json();
21992
22161
  } catch (e) {
21993
- log$35.warn("fetchPathPatternSyntax: non-JSON body", { error: e instanceof Error ? e.message : String(e) });
22162
+ log$36.warn("fetchPathPatternSyntax: non-JSON body", { error: e instanceof Error ? e.message : String(e) });
21994
22163
  return {
21995
22164
  ok: false,
21996
22165
  kind: "failed",
@@ -22076,7 +22245,7 @@ function applyOutcome(outcome, pattern, workspace, basePaths, set$1) {
22076
22245
  return false;
22077
22246
  }
22078
22247
  if (outcome.kind === "invalid") {
22079
- log$42.warn("ρ pattern rejected (invalid syntax)", {
22248
+ log$43.warn("ρ pattern rejected (invalid syntax)", {
22080
22249
  pattern,
22081
22250
  diagnostic: outcome.diagnostic
22082
22251
  });
@@ -22213,9 +22382,76 @@ function summarizePathPatternResult(result) {
22213
22382
  };
22214
22383
  }
22215
22384
 
22385
+ //#endregion
22386
+ //#region src/client/store/slices/multiSelectSlice.ts
22387
+ /**
22388
+ * 多选链路的**消费面**(cycle-149)。
22389
+ *
22390
+ * cycle-141 D 在渲染器侧交付了 Shift+Click 多选(`InteractionController.multiSelectedIds`
22391
+ * + `multi-selected` class + `onSelectionToggle` 回调),但该回调在全仓**零消费方**——
22392
+ * 多选集合只活在渲染器内部,store/UI 全都看不到。本切片把这条链路接完:
22393
+ *
22394
+ * - `multiSelectedIds` 是渲染器多选集合的**镜像**(单一事实源仍是渲染器,
22395
+ * store 只做镜像 + 批量动作的消费面;两边经 `syncMultiSelection`/`clearMultiSelection`
22396
+ * 同步,避免双集合各自演化后漂移)。
22397
+ * - `batchExpandSelected` 复用 expandSlice 的 `batchExpandNodes`(函数体预算 / pinned
22398
+ * 语义不重写——两处实现必然漂移,CLAUDE.md「Two data paths for the same semantics
22399
+ * must share ONE implementation」)。
22400
+ * - `batchCollapseSelected` 与 expandSlice `collapseAll` 同形:**单次** recluster
22401
+ * (逐个 collapseNode 会 N 次重聚类),pinned 节点不收(与 `collapseNode` 语义一致)。
22402
+ * - `exportSelectedNodes` 从 `rawNodes` 过滤导出;**聚合节点(cluster)不在 rawNodes 里**,
22403
+ * 选中聚合节点时该 id 被静默跳过——这是有意的诚实缺口(渲染器合并态 store 不可见),
22404
+ * UI 侧以「导出 N 个」计数如实呈现,不伪造。
22405
+ *
22406
+ * `multiSelectedIds` **不持久化**:画布选区是会话瞬时态,reload 后节点可能已变
22407
+ * (与 `xuanjiSignal` 同判据);`partialize` 白名单不收录即天然排除。
22408
+ */
22409
+ function createMultiSelectSlice(_set, _get) {
22410
+ return {
22411
+ multiSelectedIds: new Set(),
22412
+ syncMultiSelection: (ids) => {
22413
+ const current$2 = _get().multiSelectedIds;
22414
+ if (current$2.size === ids.length && [...ids].every((id) => current$2.has(id))) return;
22415
+ _set({ multiSelectedIds: new Set(ids) });
22416
+ },
22417
+ clearMultiSelection: () => {
22418
+ if (_get().multiSelectedIds.size === 0) return;
22419
+ _set({ multiSelectedIds: new Set() });
22420
+ },
22421
+ batchExpandSelected: () => {
22422
+ const ids = [..._get().multiSelectedIds];
22423
+ if (ids.length === 0) return 0;
22424
+ _get().batchExpandNodes(ids);
22425
+ return ids.length;
22426
+ },
22427
+ batchCollapseSelected: () => {
22428
+ const { multiSelectedIds, expandedNodeIds, expandedNodeIdsByViewMode, viewMode, pinnedIds } = _get();
22429
+ if (multiSelectedIds.size === 0) return 0;
22430
+ const newExpanded = new Set(expandedNodeIds);
22431
+ let collapsed = 0;
22432
+ for (const id of multiSelectedIds) {
22433
+ if (pinnedIds.has(id)) continue;
22434
+ if (newExpanded.delete(id)) collapsed++;
22435
+ }
22436
+ if (collapsed === 0) return 0;
22437
+ const newExpandedByViewMode = {
22438
+ ...expandedNodeIdsByViewMode,
22439
+ [viewMode]: newExpanded
22440
+ };
22441
+ reclusterWith(_get, _set, newExpanded, newExpandedByViewMode);
22442
+ return collapsed;
22443
+ },
22444
+ exportSelectedNodes: () => {
22445
+ const { multiSelectedIds, rawNodes } = _get();
22446
+ if (multiSelectedIds.size === 0) return [];
22447
+ return rawNodes.filter((n) => multiSelectedIds.has(n.id));
22448
+ }
22449
+ };
22450
+ }
22451
+
22216
22452
  //#endregion
22217
22453
  //#region src/client/utils/safeStorage.ts
22218
- const log$34 = scoped("client");
22454
+ const log$35 = scoped("client");
22219
22455
  /** 纯内存实现:同一页面的多次读写一致,刷新即丢。 */
22220
22456
  function createMemoryStorage() {
22221
22457
  const map$1 = new Map();
@@ -22273,7 +22509,7 @@ function getSafeStorage(label = "generic") {
22273
22509
  try {
22274
22510
  return local.getItem(name$1);
22275
22511
  } catch (e) {
22276
- log$34.debug("safeStorage.getItem failed", {
22512
+ log$35.debug("safeStorage.getItem failed", {
22277
22513
  label,
22278
22514
  error: e
22279
22515
  });
@@ -22284,7 +22520,7 @@ function getSafeStorage(label = "generic") {
22284
22520
  try {
22285
22521
  local.setItem(name$1, value);
22286
22522
  } catch (e) {
22287
- log$34.debug("safeStorage.setItem failed", {
22523
+ log$35.debug("safeStorage.setItem failed", {
22288
22524
  label,
22289
22525
  error: e
22290
22526
  });
@@ -22294,7 +22530,7 @@ function getSafeStorage(label = "generic") {
22294
22530
  try {
22295
22531
  local.removeItem(name$1);
22296
22532
  } catch (e) {
22297
- log$34.debug("safeStorage.removeItem failed", {
22533
+ log$35.debug("safeStorage.removeItem failed", {
22298
22534
  label,
22299
22535
  error: e
22300
22536
  });
@@ -22303,7 +22539,7 @@ function getSafeStorage(label = "generic") {
22303
22539
  };
22304
22540
  const session = isStorageUsable(() => window.sessionStorage);
22305
22541
  if (session) {
22306
- log$34.info("localStorage unavailable, falling back to sessionStorage", { label });
22542
+ log$35.info("localStorage unavailable, falling back to sessionStorage", { label });
22307
22543
  return {
22308
22544
  getItem: (name$1) => {
22309
22545
  try {
@@ -22324,7 +22560,7 @@ function getSafeStorage(label = "generic") {
22324
22560
  }
22325
22561
  };
22326
22562
  }
22327
- log$34.info("no Web Storage available, falling back to in-memory storage", { label });
22563
+ log$35.info("no Web Storage available, falling back to in-memory storage", { label });
22328
22564
  return createMemoryStorage();
22329
22565
  }
22330
22566
  /**
@@ -22385,7 +22621,8 @@ function disposeStore() {
22385
22621
  nodeEdgeTotals: new Map(),
22386
22622
  nodeEdgeCursors: new Map(),
22387
22623
  loadingEdgeNodeIds: new Set(),
22388
- detailEdgeNodeIds: new Set()
22624
+ detailEdgeNodeIds: new Set(),
22625
+ multiSelectedIds: new Set()
22389
22626
  });
22390
22627
  if (internals.restoreTimeoutHandle !== null) {
22391
22628
  clearTimeout(internals.restoreTimeoutHandle);
@@ -22424,7 +22661,8 @@ const useGraphStore = create$1()(persist((set$1, get$3) => {
22424
22661
  ...createAnalysisSlice(set$1, get$3),
22425
22662
  ...createPanelLodSlice(set$1, get$3),
22426
22663
  ...createKeywordQuerySlice(set$1, get$3),
22427
- ...createPathPatternSlice(set$1, get$3)
22664
+ ...createPathPatternSlice(set$1, get$3),
22665
+ ...createMultiSelectSlice(set$1, get$3)
22428
22666
  };
22429
22667
  registerStoreGetter(get$3);
22430
22668
  return store;
@@ -67884,7 +68122,7 @@ var OverlapRemover = class {
67884
68122
 
67885
68123
  //#endregion
67886
68124
  //#region src/client/renderer/LayoutController.ts
67887
- const log$33 = scoped("layout");
68125
+ const log$34 = scoped("layout");
67888
68126
  var LayoutController = class {
67889
68127
  cy;
67890
68128
  layoutRaf = null;
@@ -67927,17 +68165,17 @@ var LayoutController = class {
67927
68165
  this.worker?.terminate();
67928
68166
  this.worker = null;
67929
68167
  this.workerInitFailed = true;
67930
- log$33.warn("layout worker error — falling back to main-thread layout");
68168
+ log$34.warn("layout worker error — falling back to main-thread layout");
67931
68169
  this.failPendingLayout();
67932
68170
  this.retryWorkerInit();
67933
68171
  };
67934
68172
  } else {
67935
68173
  this.workerInitFailed = true;
67936
- log$33.warn("layout worker not available — using main-thread layout");
68174
+ log$34.warn("layout worker not available — using main-thread layout");
67937
68175
  }
67938
68176
  } catch (e) {
67939
68177
  this.workerInitFailed = true;
67940
- log$33.warn("layout worker init failed — using main-thread layout", e);
68178
+ log$34.warn("layout worker init failed — using main-thread layout", e);
67941
68179
  }
67942
68180
  }
67943
68181
  /** Resolve an in-flight worker layout with no positions, so the caller falls back immediately. */
@@ -67963,7 +68201,7 @@ var LayoutController = class {
67963
68201
  if (this.workerGaveUp) return;
67964
68202
  if (this.workerRetryCount >= RENDERER.WORKER_MAX_INIT_RETRIES) {
67965
68203
  this.workerGaveUp = true;
67966
- log$33.warn(`layout worker unavailable after ${RENDERER.WORKER_MAX_INIT_RETRIES} retries — main-thread layout for the rest of this session`);
68204
+ log$34.warn(`layout worker unavailable after ${RENDERER.WORKER_MAX_INIT_RETRIES} retries — main-thread layout for the rest of this session`);
67967
68205
  return;
67968
68206
  }
67969
68207
  this.workerRetryCount++;
@@ -67984,13 +68222,13 @@ var LayoutController = class {
67984
68222
  const timeoutMs = this.workerReady ? RENDERER.LAYOUT_TIMEOUT_MS : RENDERER.WORKER_HANDSHAKE_TIMEOUT_MS;
67985
68223
  return new Promise((resolve) => {
67986
68224
  this.workerTimeout = setTimeout(() => {
67987
- log$33.warn("layout worker timeout — falling back to main-thread layout");
68225
+ log$34.warn("layout worker timeout — falling back to main-thread layout");
67988
68226
  this.failPendingLayout();
67989
68227
  if (this.worker) {
67990
68228
  this.worker.terminate();
67991
68229
  this.worker = null;
67992
68230
  this.workerInitFailed = true;
67993
- log$33.warn("layout worker terminated due to timeout — retrying within the retry budget");
68231
+ log$34.warn("layout worker terminated due to timeout — retrying within the retry budget");
67994
68232
  this.retryWorkerInit();
67995
68233
  }
67996
68234
  }, timeoutMs);
@@ -68064,7 +68302,7 @@ var LayoutController = class {
68064
68302
  try {
68065
68303
  this.runIncrementalLayout(layout$2, newNodeIds);
68066
68304
  } catch (e) {
68067
- log$33.warn("incremental layout failed — keeping the previous positions", e);
68305
+ log$34.warn("incremental layout failed — keeping the previous positions", e);
68068
68306
  } finally {
68069
68307
  this.layoutRaf = null;
68070
68308
  }
@@ -68828,7 +69066,7 @@ function detectCyclesDFS(adjacency, maxCycles = MAX_CYCLES_DEFAULT) {
68828
69066
 
68829
69067
  //#endregion
68830
69068
  //#region src/client/renderer/CycleDetector.ts
68831
- const log$32 = scoped("cycle-detector");
69069
+ const log$33 = scoped("cycle-detector");
68832
69070
  var CycleDetector = class {
68833
69071
  cy;
68834
69072
  adjacencyCache;
@@ -68870,7 +69108,7 @@ var CycleDetector = class {
68870
69108
  const cycles = new Set();
68871
69109
  const nodeCount = this.cy.nodes().length;
68872
69110
  if (nodeCount > CYCLE_DETECTION.MAX_NODES_FOR_DETECTION) {
68873
- log$32.warn("cycle detection skipped: graph too large", { nodeCount });
69111
+ log$33.warn("cycle detection skipped: graph too large", { nodeCount });
68874
69112
  return cycles;
68875
69113
  }
68876
69114
  const adjacencySet = this.getForwardAdjacency();
@@ -68883,7 +69121,7 @@ var CycleDetector = class {
68883
69121
  detectCyclesDetailed() {
68884
69122
  const nodeCount = this.cy.nodes().length;
68885
69123
  if (nodeCount > CYCLE_DETECTION.MAX_NODES_FOR_DETECTION) {
68886
- log$32.warn("detailed cycle detection skipped: graph too large", { nodeCount });
69124
+ log$33.warn("detailed cycle detection skipped: graph too large", { nodeCount });
68887
69125
  return [];
68888
69126
  }
68889
69127
  const adjacencySet = this.getForwardAdjacency();
@@ -69447,7 +69685,7 @@ function pushId(map$1, key, edgeId) {
69447
69685
 
69448
69686
  //#endregion
69449
69687
  //#region src/client/renderer/DataController.ts
69450
- const log$31 = scoped("data-controller");
69688
+ const log$32 = scoped("data-controller");
69451
69689
  /** 缺省结果:任何未表态路径都必须回到「必须失效」的安全方向。 */
69452
69690
  const DIRTY = { kind: "dirty" };
69453
69691
  var DataController = class {
@@ -69705,7 +69943,7 @@ var DataController = class {
69705
69943
  expectedVersion: cache$1.getVersion()
69706
69944
  };
69707
69945
  } catch (err) {
69708
- log$31.warn("adjacency reconcile failed — invalidating cache", { err: String(err) });
69946
+ log$32.warn("adjacency reconcile failed — invalidating cache", { err: String(err) });
69709
69947
  cache$1.invalidate();
69710
69948
  return DIRTY;
69711
69949
  }
@@ -69771,7 +70009,7 @@ var DataController = class {
69771
70009
  }
69772
70010
  addEdge(e) {
69773
70011
  if (this.cy.getElementById(e.source).empty() || this.cy.getElementById(e.target).empty()) {
69774
- log$31.warn("edge dropped: missing endpoint", {
70012
+ log$32.warn("edge dropped: missing endpoint", {
69775
70013
  edgeId: e.id,
69776
70014
  source: e.source,
69777
70015
  target: e.target
@@ -69934,7 +70172,7 @@ var DataController = class {
69934
70172
 
69935
70173
  //#endregion
69936
70174
  //#region src/client/renderer/FilterController.ts
69937
- const log$30 = scoped("filter");
70175
+ const log$31 = scoped("filter");
69938
70176
  var FilterController = class {
69939
70177
  cy;
69940
70178
  fanInCache = null;
@@ -70091,7 +70329,7 @@ var FilterController = class {
70091
70329
  if (/(\+|\*|\{)\{/.test(pattern)) return null;
70092
70330
  return new RegExp(pattern);
70093
70331
  } catch (e) {
70094
- log$30.warn("invalid regex pattern", e);
70332
+ log$31.warn("invalid regex pattern", e);
70095
70333
  return null;
70096
70334
  }
70097
70335
  }
@@ -70259,7 +70497,7 @@ var Trie = class {
70259
70497
 
70260
70498
  //#endregion
70261
70499
  //#region src/client/renderer/SearchController.ts
70262
- const log$29 = scoped("search");
70500
+ const log$30 = scoped("search");
70263
70501
  const BUILD_CHUNK_SIZE = 200;
70264
70502
  const BUILD_CHUNK_DELAY = 16;
70265
70503
  function requestIdleCallbackCompat(cb) {
@@ -70301,7 +70539,7 @@ var SearchController = class {
70301
70539
  this.trieBuilt = true;
70302
70540
  this.indexBuilding = false;
70303
70541
  this.buildHandle = null;
70304
- log$29.info(`Trie index built: ${entries.length} nodes`);
70542
+ log$30.info(`Trie index built: ${entries.length} nodes`);
70305
70543
  }
70306
70544
  };
70307
70545
  processChunk();
@@ -71072,10 +71310,7 @@ var InteractionController = class InteractionController {
71072
71310
  this.callbacks.onSelectionToggle?.(id, selected, [...this.multiSelectedIds]);
71073
71311
  this.callbacks.onNodeTap?.(id);
71074
71312
  } else {
71075
- if (this.multiSelectedIds.size > 0) {
71076
- this.multiSelectedIds.clear();
71077
- this.cy.nodes().removeClass("multi-selected");
71078
- }
71313
+ this.clearMultiSelect();
71079
71314
  this.callbacks.onNodeTap?.(id);
71080
71315
  }
71081
71316
  this.focusNodeId = id;
@@ -71126,6 +71361,14 @@ var InteractionController = class InteractionController {
71126
71361
  y: pos.y
71127
71362
  });
71128
71363
  });
71364
+ this.cy.on("boxend", () => {
71365
+ const ids = this.cy.$(":selected").nodes().map((n) => n.id());
71366
+ for (const id of ids) this.multiSelectedIds.add(id);
71367
+ this.cy.nodes().forEach((n) => {
71368
+ if (this.multiSelectedIds.has(n.id())) n.addClass("multi-selected");
71369
+ });
71370
+ this.callbacks.onSelectionToggle?.(ids[0] ?? "", ids.length > 0, [...this.multiSelectedIds]);
71371
+ });
71129
71372
  }
71130
71373
  /**
71131
71374
  * 绑定画布容器上的键盘 / 焦点事件。
@@ -71274,6 +71517,20 @@ var InteractionController = class InteractionController {
71274
71517
  this.callbacks = callbacks;
71275
71518
  }
71276
71519
  /**
71520
+ * 清空多选集合(cycle-149):集合 + `multi-selected` class + 通知消费方一体完成。
71521
+ *
71522
+ * 两个触发方共用本方法(单一实现,防漂移):普通点击的静默清空、
71523
+ * 批量操作条「清除」按钮经渲染器 `clearMultiSelect` 的显式清空。
71524
+ * `onSelectionClear` 让 store 镜像同步——缺了它消费方留下幽灵计数
71525
+ * (「画布无高亮但 BatchActionBar 还在」)。
71526
+ */
71527
+ clearMultiSelect() {
71528
+ if (this.multiSelectedIds.size === 0) return;
71529
+ this.multiSelectedIds.clear();
71530
+ this.cy.nodes().removeClass("multi-selected");
71531
+ this.callbacks.onSelectionClear?.();
71532
+ }
71533
+ /**
71277
71534
  * 切换节点 / 边标签的可见性(cycle-141 / D)。
71278
71535
  *
71279
71536
  * 实现放在本控制器而不是 `StyleController`:标签显隐是**交互态**而非主题态,
@@ -71305,6 +71562,7 @@ var InteractionController = class InteractionController {
71305
71562
  this.cy.off("mouseover", "edge");
71306
71563
  this.cy.off("mouseout", "edge");
71307
71564
  this.cy.off("cxttap", "node");
71565
+ this.cy.off("boxend");
71308
71566
  }
71309
71567
  };
71310
71568
 
@@ -71802,7 +72060,7 @@ var VirtualViewportController = class {
71802
72060
 
71803
72061
  //#endregion
71804
72062
  //#region src/client/renderer/CytoscapeRenderer.ts
71805
- const log$28 = scoped("cytoscape-renderer");
72063
+ const log$29 = scoped("cytoscape-renderer");
71806
72064
  function ensureExtensionsRegistered() {
71807
72065
  const cy = cytoscape$1;
71808
72066
  if (!cy.__dagreRegistered) {
@@ -71837,7 +72095,7 @@ var CytoscapeRenderer = class {
71837
72095
  try {
71838
72096
  return fn$7();
71839
72097
  } catch (err) {
71840
- log$28.error(`${name$1} failed`, err);
72098
+ log$29.error(`${name$1} failed`, err);
71841
72099
  this.lastError = err instanceof Error ? err : new Error(String(err));
71842
72100
  return fallback;
71843
72101
  }
@@ -71846,7 +72104,7 @@ var CytoscapeRenderer = class {
71846
72104
  try {
71847
72105
  fn$7();
71848
72106
  } catch (err) {
71849
- log$28.error(`${name$1} failed`, err);
72107
+ log$29.error(`${name$1} failed`, err);
71850
72108
  this.lastError = err instanceof Error ? err : new Error(String(err));
71851
72109
  }
71852
72110
  }
@@ -71893,7 +72151,9 @@ var CytoscapeRenderer = class {
71893
72151
  onNodeHoverOut: this.options.onNodeHoverOut ?? (() => {}),
71894
72152
  onEdgeHover: this.options.onEdgeHover ?? (() => {}),
71895
72153
  onEdgeHoverOut: this.options.onEdgeHoverOut ?? (() => {}),
71896
- ...this.options.onNodeContextTap ? { onNodeContextTap: this.options.onNodeContextTap } : {}
72154
+ ...this.options.onNodeContextTap ? { onNodeContextTap: this.options.onNodeContextTap } : {},
72155
+ onSelectionToggle: this.options.onSelectionToggle ?? (() => {}),
72156
+ onSelectionClear: this.options.onSelectionClear
71897
72157
  });
71898
72158
  this.pathFinder = new PathFinder(cy, this.adjacencyCache);
71899
72159
  this.virtualViewportCtrl = new VirtualViewportController(cy);
@@ -71972,27 +72232,16 @@ var CytoscapeRenderer = class {
71972
72232
  this.layoutCtrl?.applyWorkerLayout(positions);
71973
72233
  }
71974
72234
  /**
71975
-
71976
72235
  * 开启虚拟视口(视口外的节点不渲染)。
71977
-
71978
72236
  *
71979
-
71980
72237
  * 这里额外往容器上写一个 `data-cg-virtual-scroll` 标记,原因是这个特性在 DOM 上
71981
-
71982
72238
  * **完全不可观测**:它只改 cytoscape 内部的 `display` 状态,元素还在、canvas 还在,
71983
-
71984
72239
  * 任何「元素可见」类断言在开启与未开启时结果相同。cycle-93 之前 E2E 的 J35
71985
-
71986
72240
  * 因此只能断言「容器可见」,零鉴别力。
71987
-
71988
72241
  *
71989
-
71990
72242
  * 标记沿用本仓库既有的 `data-cg-*` 集成标记约定(`useGraphDerivedValues.ts:71` 的
71991
-
71992
72243
  * `data-cg-integration`)。写成「只有真的启用成功才标记」,避免
71993
-
71994
72244
  * `virtualViewportCtrl` 为 null 时留下一个骗人的标记。
71995
-
71996
72245
  */
71997
72246
  enableVirtualScroll() {
71998
72247
  this.virtualViewportCtrl?.enable();
@@ -72039,17 +72288,11 @@ var CytoscapeRenderer = class {
72039
72288
  this.viewportCtrl?.focusOnNode(nodeId);
72040
72289
  }
72041
72290
  /**
72042
-
72043
72291
  * Bring a node the user explicitly asked for (a search hit, a host symbol link) into view even
72044
-
72045
72292
  * when it is currently hidden, then centre the viewport on it. Returns `false` when the node is
72046
-
72047
72293
  * not in the rendered graph at all, or when it is excluded by an active type/connection filter —
72048
-
72049
72294
  * silently panning to an element the filter keeps invisible is what made "search a hidden node"
72050
-
72051
72295
  * look like a no-op.
72052
-
72053
72296
  */
72054
72297
  revealNode(nodeId) {
72055
72298
  return this.safeCall("revealNode", () => {
@@ -72073,15 +72316,10 @@ var CytoscapeRenderer = class {
72073
72316
  return el.data("typeFiltered") === true || el.data("connectionFiltered") === true;
72074
72317
  }
72075
72318
  /**
72076
-
72077
72319
  * Viewport-space position used to anchor the context menu when the user opens it without a
72078
-
72079
72320
  * pointer. Cytoscape keeps reporting the last computed position for `display: none` elements,
72080
-
72081
72321
  * and the menu clamps itself into the viewport, so only a node that is really absent returns
72082
-
72083
72322
  * `null`. Cytoscape 的渲染坐标相对画布容器,这里叠加容器原点换算成视口坐标。
72084
-
72085
72323
  */
72086
72324
  getNodeRenderedPosition(nodeId) {
72087
72325
  return this.safeCall("getNodeRenderedPosition", () => {
@@ -72132,6 +72370,10 @@ var CytoscapeRenderer = class {
72132
72370
  focusNeighborhood(nodeId) {
72133
72371
  this.safeCallVoid("focusNeighborhood", () => this.viewportCtrl?.focusNeighborhood(nodeId));
72134
72372
  }
72373
+ /** 清空多选集合(cycle-149):委托 InteractionController(集合+class+回调一体)。 */
72374
+ clearMultiSelect() {
72375
+ this.safeCallVoid("clearMultiSelect", () => this.interactionCtrl?.clearMultiSelect());
72376
+ }
72135
72377
  getThumbnail() {
72136
72378
  return this.viewportCtrl?.getThumbnail() ?? null;
72137
72379
  }
@@ -72201,7 +72443,7 @@ var CytoscapeRenderer = class {
72201
72443
  try {
72202
72444
  fn$7();
72203
72445
  } catch (err) {
72204
- log$28.warn(`destroy: ${label} cleanup failed`, err);
72446
+ log$29.warn(`destroy: ${label} cleanup failed`, err);
72205
72447
  }
72206
72448
  };
72207
72449
  safeDestroy("interactionCtrl", () => this.interactionCtrl?.destroy());
@@ -72295,21 +72537,13 @@ var CytoscapeRenderer = class {
72295
72537
  });
72296
72538
  }
72297
72539
  /**
72298
-
72299
72540
  * diff 标记的渲染器入口(研究报告 §六 #13)。
72300
-
72301
72541
  *
72302
-
72303
72542
  * 实现全部委托给 `HighlightController`:它已经持有 `cy` 并且是「视图态 class」的归属地,
72304
-
72305
72543
  * 把标记逻辑写两份必然漂移(`markDeadCode` 就是直接写在这里的,代价是它没有复用任何
72306
-
72307
72544
  * 控制器设施)。渲染器本身只做 `safeCall` 包裹与「控制器不存在时给空统计」的兜底——
72308
-
72309
72545
  * 后者很重要:控制器为 `null` 时若返回 `undefined`,调用方读 `.counts.added` 会抛错,
72310
-
72311
72546
  * 而这条路径在「渲染器尚未完成 init」时是真实可达的。
72312
-
72313
72547
  */
72314
72548
  applyDiffMarkers(index) {
72315
72549
  const empty = {
@@ -72333,7 +72567,7 @@ var CytoscapeRenderer = class {
72333
72567
  this.cy.nodes().stop();
72334
72568
  this.cy.edges().stop();
72335
72569
  } catch (e) {
72336
- log$28.warn("pauseAnimations failed", e);
72570
+ log$29.warn("pauseAnimations failed", e);
72337
72571
  }
72338
72572
  }
72339
72573
  resumeAnimations() {
@@ -72341,7 +72575,7 @@ var CytoscapeRenderer = class {
72341
72575
  try {
72342
72576
  this.cy.resize();
72343
72577
  } catch (e) {
72344
- log$28.warn("resumeAnimations failed", e);
72578
+ log$29.warn("resumeAnimations failed", e);
72345
72579
  }
72346
72580
  }
72347
72581
  /** 缩放画布:factor > 1 放大,factor < 1 缩小。带 200ms 动画过渡。 */
@@ -72380,7 +72614,7 @@ var CytoscapeRenderer = class {
72380
72614
 
72381
72615
  //#endregion
72382
72616
  //#region src/client/hooks/useGraphRenderer.ts
72383
- const log$27 = scoped("renderer-hook");
72617
+ const log$28 = scoped("renderer-hook");
72384
72618
  function useGraphRenderer(config) {
72385
72619
  const { data: data$2, display, interaction, filters, analysis, callbacks, showCallChainRef } = config;
72386
72620
  const { nodes, edges, rawNodes, repoId } = data$2;
@@ -72422,17 +72656,19 @@ function useGraphRenderer(config) {
72422
72656
  onNodeHoverOut: () => callbacksRef.current.onNodeHoverOut(),
72423
72657
  onEdgeTap: (edgeId, pos) => callbacksRef.current.onEdgeTap(edgeId, pos),
72424
72658
  onEdgeHover: (edgeId, pos) => callbacksRef.current.onEdgeHover(edgeId, pos),
72425
- onEdgeHoverOut: () => callbacksRef.current.onEdgeHoverOut()
72659
+ onEdgeHoverOut: () => callbacksRef.current.onEdgeHoverOut(),
72660
+ onSelectionToggle: (nodeId, selected, selectedIds) => callbacksRef.current.onSelectionToggle?.(nodeId, selected, selectedIds),
72661
+ onSelectionClear: () => callbacksRef.current.onSelectionClear?.()
72426
72662
  });
72427
72663
  renderer$1.init();
72428
72664
  rendererRef.current = renderer$1;
72429
- log$27.info("renderer initialized", {
72665
+ log$28.info("renderer initialized", {
72430
72666
  theme,
72431
72667
  w,
72432
72668
  h
72433
72669
  });
72434
72670
  } catch (err) {
72435
- log$27.error("renderer init failed", { err });
72671
+ log$28.error("renderer init failed", { err });
72436
72672
  renderer$1?.destroy();
72437
72673
  renderer$1 = null;
72438
72674
  useGraphStore.getState().setError(err instanceof Error ? err.message : String(err));
@@ -72680,7 +72916,7 @@ function useGraphRenderer(config) {
72680
72916
  a.download = `codegraph-${Date.now()}.${extension$1}`;
72681
72917
  a.click();
72682
72918
  setTimeout(() => URL.revokeObjectURL(url), UI.URL_REVOKE_DELAY_MS);
72683
- log$27.info(`exported ${format}`);
72919
+ log$28.info(`exported ${format}`);
72684
72920
  }
72685
72921
  }, [theme]);
72686
72922
  const updateTheme = (0, react.useCallback)((newTheme) => {
@@ -72816,7 +73052,7 @@ function useFocusMode(opts) {
72816
73052
 
72817
73053
  //#endregion
72818
73054
  //#region src/client/hooks/useNodeInteraction.ts
72819
- const log$26 = scoped("panel");
73055
+ const log$27 = scoped("panel");
72820
73056
  function useNodeInteraction(opts) {
72821
73057
  const { setSelectedNode, setSelectedNodeData, getDataRef, adjacencyMap, setTooltip, expandedNodeIds, collapseNode, expandNode, handleSelectFunction, setFocusNodeId, handleNodeDoubleTapRef } = opts;
72822
73058
  const handleNodeTap = (0, react.useCallback)((id) => {
@@ -72825,7 +73061,7 @@ function useNodeInteraction(opts) {
72825
73061
  const data$2 = getDataRef.current?.getNodeData(id) ?? null;
72826
73062
  setSelectedNodeData(data$2);
72827
73063
  } catch (err) {
72828
- log$26.error("handleNodeTap failed", {
73064
+ log$27.error("handleNodeTap failed", {
72829
73065
  id,
72830
73066
  err
72831
73067
  });
@@ -72857,7 +73093,7 @@ function useNodeInteraction(opts) {
72857
73093
  });
72858
73094
  }
72859
73095
  } catch (err) {
72860
- log$26.error("handleNodeHover failed", {
73096
+ log$27.error("handleNodeHover failed", {
72861
73097
  nodeId,
72862
73098
  err
72863
73099
  });
@@ -72908,7 +73144,7 @@ function useNodeInteraction(opts) {
72908
73144
  });
72909
73145
  }
72910
73146
  } catch (err) {
72911
- log$26.error("handleEdgeTap failed", {
73147
+ log$27.error("handleEdgeTap failed", {
72912
73148
  edgeId,
72913
73149
  err
72914
73150
  });
@@ -72935,7 +73171,7 @@ function useNodeInteraction(opts) {
72935
73171
  return nodeId;
72936
73172
  });
72937
73173
  } catch (err) {
72938
- log$26.error("handleNodeDoubleTap failed", {
73174
+ log$27.error("handleNodeDoubleTap failed", {
72939
73175
  nodeId,
72940
73176
  err
72941
73177
  });
@@ -73301,7 +73537,7 @@ function useDeadCodeMarker({ showDeadCode, deadCodeResult, rendererRef }) {
73301
73537
 
73302
73538
  //#endregion
73303
73539
  //#region src/client/hooks/useDiffComparison.ts
73304
- const log$25 = scoped("diff");
73540
+ const log$26 = scoped("diff");
73305
73541
  /**
73306
73542
 
73307
73543
  * 版本对比的编排层(研究报告 §六 #13)。
@@ -73394,7 +73630,7 @@ function useDiffComparison(params) {
73394
73630
  return next$1;
73395
73631
  } catch (e) {
73396
73632
  if (!aliveRef.current || gen !== generationRef.current) return null;
73397
- log$25.warn("diff comparison failed", e);
73633
+ log$26.warn("diff comparison failed", e);
73398
73634
  setError(e instanceof Error ? e.message : String(e));
73399
73635
  setIsComparing(false);
73400
73636
  return null;
@@ -73901,19 +74137,12 @@ const KEYBOARD_SHORTCUTS = [
73901
74137
  }
73902
74138
  ];
73903
74139
  /**
73904
-
73905
74140
  * 画布级快捷键(cycle-141 / D)。
73906
-
73907
74141
  *
73908
-
73909
74142
  * 与 `KEYBOARD_SHORTCUTS` 分开声明,**不注册全局监听**:面板级快捷键挂在
73910
-
73911
74143
  * `document` 上、任何焦点下都生效;画布级只在 `.cg-graph-container` 聚焦时由
73912
-
73913
74144
  * `InteractionController` 接管。此处仅作为键盘提示浮层与文档的元数据,
73914
-
73915
74145
  * 避免方向键劫持页面滚动、也避免 `usePanelKeyboard` 的测试长度断言被悄悄改变。
73916
-
73917
74146
  */
73918
74147
  const CANVAS_KEYBOARD_SHORTCUTS = [
73919
74148
  {
@@ -73951,6 +74180,10 @@ const CANVAS_KEYBOARD_SHORTCUTS = [
73951
74180
  {
73952
74181
  key: "F",
73953
74182
  descKey: "canvasControls.shortcutFit"
74183
+ },
74184
+ {
74185
+ key: "Shift+Drag",
74186
+ descKey: "canvasControls.shortcutBoxSelect"
73954
74187
  }
73955
74188
  ];
73956
74189
  function formatShortcutKey(s) {
@@ -74014,7 +74247,7 @@ function usePanelKeyboard(layout$2, setLayout, rendererRef, handlers) {
74014
74247
 
74015
74248
  //#endregion
74016
74249
  //#region src/client/hooks/useViewMode.ts
74017
- const log$24 = scoped("viewMode");
74250
+ const log$25 = scoped("viewMode");
74018
74251
  const MICRO_MODES = [
74019
74252
  "cfg",
74020
74253
  "flowchart",
@@ -74057,7 +74290,7 @@ function useViewMode(viewMode, currentWorkspace, depsFetchAbortRef) {
74057
74290
  if (controller.signal.aborted) return;
74058
74291
  if (!data$2.nodes || data$2.nodes.length === 0) return;
74059
74292
  if (isForeignRepo(data$2.metadata?.repoId, requestedWorkspace)) {
74060
- log$24.info("dependency graph ignored for another workspace", {
74293
+ log$25.info("dependency graph ignored for another workspace", {
74061
74294
  repoId: data$2.metadata?.repoId,
74062
74295
  requestedWorkspace,
74063
74296
  activeWorkspace: currentWorkspace
@@ -74082,7 +74315,7 @@ function useViewMode(viewMode, currentWorkspace, depsFetchAbortRef) {
74082
74315
  else setError(t$1("error.loadGraphData"));
74083
74316
  setLoading(false);
74084
74317
  }).catch((e) => {
74085
- log$24.warn("loadBootstrap failed", e);
74318
+ log$25.warn("loadBootstrap failed", e);
74086
74319
  setError(t$1("error.loadGraph"));
74087
74320
  setLoading(false);
74088
74321
  });
@@ -74111,7 +74344,7 @@ function useViewMode(viewMode, currentWorkspace, depsFetchAbortRef) {
74111
74344
  }).then((data$2) => {
74112
74345
  if (controller.signal.aborted) return;
74113
74346
  if (isForeignRepo(data$2.metadata?.repoId, requestedWorkspace)) {
74114
- log$24.info("macro view graph ignored for another workspace", {
74347
+ log$25.info("macro view graph ignored for another workspace", {
74115
74348
  repoId: data$2.metadata?.repoId,
74116
74349
  requestedWorkspace,
74117
74350
  activeWorkspace: currentWorkspace
@@ -74119,7 +74352,7 @@ function useViewMode(viewMode, currentWorkspace, depsFetchAbortRef) {
74119
74352
  return;
74120
74353
  }
74121
74354
  if (!data$2.nodes || data$2.nodes.length === 0) return;
74122
- log$24.info("macro view data loaded", {
74355
+ log$25.info("macro view data loaded", {
74123
74356
  viewMode: newMode,
74124
74357
  nodes: data$2.nodes.length,
74125
74358
  edges: data$2.edges?.length ?? 0,
@@ -76166,7 +76399,7 @@ function useWorkspaceCache(opts) {
76166
76399
 
76167
76400
  //#endregion
76168
76401
  //#region src/client/hooks/useSymbolSelectListener.ts
76169
- const log$23 = scoped("symbol-select");
76402
+ const log$24 = scoped("symbol-select");
76170
76403
  function useSymbolSelectListener(opts) {
76171
76404
  (0, react.useEffect)(() => {
76172
76405
  const symbolHandler = (e) => {
@@ -76178,7 +76411,7 @@ function useSymbolSelectListener(opts) {
76178
76411
  if (!match$1) return;
76179
76412
  store.setSelectedNode(NodeId(match$1.id));
76180
76413
  const revealed = opts.rendererRef.current?.revealNode(match$1.id) ?? false;
76181
- if (!revealed) log$23.warn("symbol match is not revealable in the rendered graph", {
76414
+ if (!revealed) log$24.warn("symbol match is not revealable in the rendered graph", {
76182
76415
  id: match$1.id,
76183
76416
  symbol: sym
76184
76417
  });
@@ -76274,7 +76507,7 @@ function useMiniMapSync(opts) {
76274
76507
 
76275
76508
  //#endregion
76276
76509
  //#region src/client/hooks/useAutoBootstrap.ts
76277
- const log$22 = scoped("auto-bootstrap");
76510
+ const log$23 = scoped("auto-bootstrap");
76278
76511
  function useAutoBootstrap(opts) {
76279
76512
  const hasAutoScannedRef = (0, react.useRef)(false);
76280
76513
  (0, react.useEffect)(() => {
@@ -76284,10 +76517,10 @@ function useAutoBootstrap(opts) {
76284
76517
  if (!opts.codegraphReady) return;
76285
76518
  if (!opts.currentWorkspace) return;
76286
76519
  hasAutoScannedRef.current = true;
76287
- log$22.info("auto-fill: triggering bootstrap on first open", { path: opts.currentWorkspace });
76520
+ log$23.info("auto-fill: triggering bootstrap on first open", { path: opts.currentWorkspace });
76288
76521
  useGraphStore.getState().loadBootstrap().then((ok) => {
76289
76522
  if (!ok) {
76290
- log$22.info("auto-fill: bootstrap failed, falling back to scan", { path: opts.currentWorkspace });
76523
+ log$23.info("auto-fill: bootstrap failed, falling back to scan", { path: opts.currentWorkspace });
76291
76524
  opts.handleScanWorkspace();
76292
76525
  }
76293
76526
  });
@@ -76542,7 +76775,7 @@ function useViewModePanelGuard(opts) {
76542
76775
 
76543
76776
  //#endregion
76544
76777
  //#region src/client/hooks/useSearchInteraction.ts
76545
- const log$21 = scoped("panel");
76778
+ const log$22 = scoped("panel");
76546
76779
  function useSearchInteraction(opts) {
76547
76780
  const { searchQuery, rawNodes, setSelectedNode, setSelectedNodeData, getDataRef, setShowSearch, isRemoteHit } = opts;
76548
76781
  const debouncedSearch = useDebounce(searchQuery, UI.SEARCH_DEBOUNCE_MS);
@@ -76594,7 +76827,7 @@ function useSearchInteraction(opts) {
76594
76827
  setShowSearch(false);
76595
76828
  const hitLabel = mergedResults.find((r) => r.id === id)?.label ?? id;
76596
76829
  getDataRef.current.revealRemoteNode(id, hitLabel).then((ok) => {
76597
- if (!ok) log$21.warn("remote search hit could not be revealed", { id });
76830
+ if (!ok) log$22.warn("remote search hit could not be revealed", { id });
76598
76831
  });
76599
76832
  return;
76600
76833
  }
@@ -76602,10 +76835,10 @@ function useSearchInteraction(opts) {
76602
76835
  const data$2 = getDataRef.current?.getNodeData(NodeId(id)) ?? null;
76603
76836
  setSelectedNodeData(data$2);
76604
76837
  const revealed = getDataRef.current?.revealNode(id) ?? false;
76605
- if (!revealed) log$21.warn("search result is not revealable in the rendered graph", { id });
76838
+ if (!revealed) log$22.warn("search result is not revealable in the rendered graph", { id });
76606
76839
  setShowSearch(false);
76607
76840
  } catch (err) {
76608
- log$21.error("handleSelectSearchResult failed", {
76841
+ log$22.error("handleSelectSearchResult failed", {
76609
76842
  id,
76610
76843
  err
76611
76844
  });
@@ -76627,7 +76860,7 @@ function useSearchInteraction(opts) {
76627
76860
 
76628
76861
  //#endregion
76629
76862
  //#region src/client/components/ErrorBoundary.tsx
76630
- const log$20 = scoped("boundary");
76863
+ const log$21 = scoped("boundary");
76631
76864
  function ErrorFallback({ error: error$1, onRetry }) {
76632
76865
  const t$1 = useT();
76633
76866
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -76670,7 +76903,7 @@ var GraphErrorBoundary = class extends react.Component {
76670
76903
  }
76671
76904
  componentDidCatch(error$1, errorInfo) {
76672
76905
  this.props.onError?.(error$1, errorInfo);
76673
- log$20.error("render error caught by boundary", {
76906
+ log$21.error("render error caught by boundary", {
76674
76907
  error: error$1,
76675
76908
  componentStack: errorInfo.componentStack
76676
76909
  });
@@ -78683,6 +78916,10 @@ const ContextMenu = (0, react.memo)(function ContextMenu$1(props) {
78683
78916
  const menuRef = (0, react.useRef)(null);
78684
78917
  const [focusedIndex, setFocusedIndex] = (0, react.useState)(0);
78685
78918
  const triggerRef = (0, react.useRef)(null);
78919
+ const isPinned = useGraphStore((s) => s.pinnedIds.has(props.nodeId));
78920
+ const pinNode = useGraphStore((s) => s.pinNode);
78921
+ const unpinNode = useGraphStore((s) => s.unpinNode);
78922
+ const [copyState, setCopyState] = (0, react.useState)("idle");
78686
78923
  const onCloseRef = (0, react.useRef)(props.onClose);
78687
78924
  onCloseRef.current = props.onClose;
78688
78925
  const handleClose = (0, react.useCallback)(() => {
@@ -78841,6 +79078,43 @@ const ContextMenu = (0, react.memo)(function ContextMenu$1(props) {
78841
79078
  handleClose();
78842
79079
  }
78843
79080
  });
79081
+ actions.push({
79082
+ id: "pinNode",
79083
+ label: t$1(isPinned ? "contextMenu.unpinNode" : "contextMenu.pinNode"),
79084
+ onClick: () => {
79085
+ if (isPinned) unpinNode(props.nodeId);
79086
+ else pinNode(props.nodeId);
79087
+ handleClose();
79088
+ }
79089
+ });
79090
+ actions.push({
79091
+ id: "copyId",
79092
+ label: copyState === "copied" ? t$1("contextMenu.copied") : t$1("contextMenu.copyId"),
79093
+ onClick: () => {
79094
+ try {
79095
+ navigator.clipboard?.writeText(props.nodeId);
79096
+ setCopyState("copied");
79097
+ window.setTimeout(() => setCopyState("idle"), 1500);
79098
+ } catch {}
79099
+ }
79100
+ });
79101
+ actions.push({
79102
+ id: "expandHere",
79103
+ label: t$1("contextMenu.expandHere"),
79104
+ onClick: () => {
79105
+ props.onExpandNode(props.nodeId);
79106
+ handleClose();
79107
+ },
79108
+ separatorAfter: true
79109
+ });
79110
+ actions.push({
79111
+ id: "collapseHere",
79112
+ label: t$1("contextMenu.collapseHere"),
79113
+ onClick: () => {
79114
+ props.onCollapseNode(props.nodeId);
79115
+ handleClose();
79116
+ }
79117
+ });
78844
79118
  const menuStyle = {
78845
79119
  position: "fixed",
78846
79120
  left: `${props.position.x}px`,
@@ -78854,6 +79128,7 @@ const ContextMenu = (0, react.memo)(function ContextMenu$1(props) {
78854
79128
  children: actions.map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.default.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
78855
79129
  className: "cg-context-menu-item",
78856
79130
  role: "menuitem",
79131
+ "data-action-id": action.id,
78857
79132
  disabled: action.disabled,
78858
79133
  onClick: action.onClick,
78859
79134
  children: [action.icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -78868,6 +79143,87 @@ const ContextMenu = (0, react.memo)(function ContextMenu$1(props) {
78868
79143
  }) });
78869
79144
  });
78870
79145
 
79146
+ //#endregion
79147
+ //#region src/client/components/BatchActionBar.tsx
79148
+ const log$20 = scoped("batch-bar");
79149
+ function BatchActionBar({ onClearSelection }) {
79150
+ const t$1 = useT();
79151
+ const multiSelectedIds = useGraphStore((s) => s.multiSelectedIds);
79152
+ const batchExpandSelected = useGraphStore((s) => s.batchExpandSelected);
79153
+ const batchCollapseSelected = useGraphStore((s) => s.batchCollapseSelected);
79154
+ const exportSelectedNodes = useGraphStore((s) => s.exportSelectedNodes);
79155
+ const clearMultiSelection = useGraphStore((s) => s.clearMultiSelection);
79156
+ if (multiSelectedIds.size === 0) return null;
79157
+ const handleClear = () => {
79158
+ if (onClearSelection) onClearSelection();
79159
+ else clearMultiSelection();
79160
+ };
79161
+ const handleExport = () => {
79162
+ try {
79163
+ const nodes = exportSelectedNodes();
79164
+ const payload = {
79165
+ exportedAt: new Date().toISOString(),
79166
+ selectedCount: multiSelectedIds.size,
79167
+ exportedCount: nodes.length,
79168
+ nodes
79169
+ };
79170
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
79171
+ const url = URL.createObjectURL(blob);
79172
+ const a = document.createElement("a");
79173
+ a.href = url;
79174
+ a.download = `codegraph-selection-${Date.now()}.json`;
79175
+ a.click();
79176
+ URL.revokeObjectURL(url);
79177
+ } catch (err) {
79178
+ log$20.error("export selection failed", { err });
79179
+ }
79180
+ };
79181
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
79182
+ className: "cg-batch-bar",
79183
+ role: "toolbar",
79184
+ "aria-label": t$1("batchbar.ariaLabel"),
79185
+ children: [
79186
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
79187
+ className: "cg-batch-bar-count",
79188
+ "aria-live": "polite",
79189
+ children: t$1("batchbar.count", { count: multiSelectedIds.size })
79190
+ }),
79191
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
79192
+ type: "button",
79193
+ className: "cg-batch-bar-btn",
79194
+ onClick: () => {
79195
+ batchExpandSelected();
79196
+ },
79197
+ "aria-label": t$1("batchbar.expandAria"),
79198
+ children: t$1("batchbar.expand")
79199
+ }),
79200
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
79201
+ type: "button",
79202
+ className: "cg-batch-bar-btn",
79203
+ onClick: () => {
79204
+ batchCollapseSelected();
79205
+ },
79206
+ "aria-label": t$1("batchbar.collapseAria"),
79207
+ children: t$1("batchbar.collapse")
79208
+ }),
79209
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
79210
+ type: "button",
79211
+ className: "cg-batch-bar-btn",
79212
+ onClick: handleExport,
79213
+ "aria-label": t$1("batchbar.exportAria"),
79214
+ children: t$1("batchbar.export")
79215
+ }),
79216
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
79217
+ type: "button",
79218
+ className: "cg-batch-bar-btn cg-batch-bar-btn-clear",
79219
+ onClick: handleClear,
79220
+ "aria-label": t$1("batchbar.clearAria"),
79221
+ children: t$1("batchbar.clear")
79222
+ })
79223
+ ]
79224
+ });
79225
+ }
79226
+
78871
79227
  //#endregion
78872
79228
  //#region src/client/components/LoadingOverlay.tsx
78873
79229
  const LoadingOverlay = (0, react.memo)(function LoadingOverlay$1({ visible }) {
@@ -79265,7 +79621,7 @@ const StatusBar = (0, react.memo)(function StatusBar$1({ error: error$1, isLoadi
79265
79621
  const statusText = error$1 ? t$1("state.error") : isLoading ? t$1("state.loading") : t$1("state.ready");
79266
79622
  const wsName = workspaceName ? deriveWorkspaceName(workspaceName) : "";
79267
79623
  const hasDegradations = performanceDegradations && performanceDegradations.length > 0;
79268
- const degradationLabels = hasDegradations ? performanceDegradations.map((key) => DEGRADATION_KEYS[key] ? t$1(DEGRADATION_KEYS[key]) : key).join(", ") : "";
79624
+ const degradationLabels = hasDegradations && performanceDegradations ? performanceDegradations.map((key) => DEGRADATION_KEYS[key] ? t$1(DEGRADATION_KEYS[key]) : key).join(", ") : "";
79269
79625
  const degradedRules = xuanjiSignal?.degradedRules ?? [];
79270
79626
  const violatedRules = xuanjiSignal?.invariantViolations ?? [];
79271
79627
  const showXuanji = xuanjiSignal?.applied === true;
@@ -86711,8 +87067,8 @@ function InteractiveMiniMap({ thumbnail, viewportInfo, onFocusArea, nodePosition
86711
87067
  whiteSpace: "nowrap",
86712
87068
  background: "var(--cg-bg-elevated)",
86713
87069
  color: "var(--cg-text)",
86714
- border: "1px solid var(--cg-border-strong))",
86715
- boxShadow: "var(--cg-shadow-sm))",
87070
+ border: "1px solid var(--cg-border-strong)",
87071
+ boxShadow: "var(--cg-shadow-sm)",
86716
87072
  pointerEvents: "none"
86717
87073
  },
86718
87074
  children: t$1("canvasControls.minimapFocusHint")
@@ -86743,8 +87099,8 @@ function KeyboardHintOverlay({ onClose }) {
86743
87099
  padding: "10px 12px",
86744
87100
  borderRadius: "var(--cg-radius-sm)",
86745
87101
  background: "var(--cg-surface)",
86746
- border: "1px solid var(--cg-border-strong))",
86747
- boxShadow: "var(--cg-shadow))",
87102
+ border: "1px solid var(--cg-border-strong)",
87103
+ boxShadow: "var(--cg-shadow)",
86748
87104
  pointerEvents: "auto"
86749
87105
  },
86750
87106
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -87258,7 +87614,9 @@ function GraphPanelInner({ className = "", sessionId, useSessions }) {
87258
87614
  onNodeContextTap: (nodeId, pos) => setContextMenu({
87259
87615
  nodeId,
87260
87616
  position: pos
87261
- })
87617
+ }),
87618
+ onSelectionToggle: (_nodeId, _selected, selectedIds) => useGraphStore.getState().syncMultiSelection(selectedIds),
87619
+ onSelectionClear: () => useGraphStore.getState().clearMultiSelection()
87262
87620
  },
87263
87621
  showCallChainRef
87264
87622
  });
@@ -87871,6 +88229,7 @@ function GraphPanelInner({ className = "", sessionId, useSessions }) {
87871
88229
  }
87872
88230
  }),
87873
88231
  tooltip && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GraphTooltip, { data: tooltip }),
88232
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BatchActionBar, { onClearSelection: () => renderer$1.rendererRef.current?.clearMultiSelect() }),
87874
88233
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(KeyboardHelp, {
87875
88234
  visible: showHelp,
87876
88235
  onClose: () => setShowHelp(false)
@@ -87901,7 +88260,7 @@ function GraphPanel(props) {
87901
88260
 
87902
88261
  //#endregion
87903
88262
  //#region src/generated/version.ts
87904
- const PLUGIN_VERSION = "0.3.0";
88263
+ const PLUGIN_VERSION = "0.3.1";
87905
88264
 
87906
88265
  //#endregion
87907
88266
  //#region src/client/utils/safeCheck.ts