@hienlh/ppm 0.8.64 → 0.8.65

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.
Files changed (170) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bun.lock +5 -0
  3. package/dist/web/assets/{_basePickBy-CZovQgWd.js → _basePickBy-CrBYAu1z.js} +1 -1
  4. package/dist/web/assets/{_baseUniq-ClnvscgW.js → _baseUniq-PMQMCyd4.js} +1 -1
  5. package/dist/web/assets/{api-settings--eVrUeZM.js → api-settings-Cj0OeEMH.js} +1 -1
  6. package/dist/web/assets/{arc-C2Qaz-ch.js → arc-Bx6TKJWg.js} +1 -1
  7. package/dist/web/assets/architecture-PBZL5I3N-DyGQgfy0.js +1 -0
  8. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-Jq91S_rs.js → architectureDiagram-2XIMDMQ5-Dinq4Kbf.js} +1 -1
  9. package/dist/web/assets/arrow-up--LjUXLEt.js +1 -0
  10. package/dist/web/assets/{blockDiagram-WCTKOSBZ-CKGufRTy.js → blockDiagram-WCTKOSBZ-CXh2T4Mt.js} +1 -1
  11. package/dist/web/assets/{browser-tab-CjjWgPDL.js → browser-tab-BT8iJmIO.js} +1 -1
  12. package/dist/web/assets/{c4Diagram-IC4MRINW-BNP2L9r_.js → c4Diagram-IC4MRINW-BCqRHxU0.js} +1 -1
  13. package/dist/web/assets/channel-GxJZ6KAL.js +1 -0
  14. package/dist/web/assets/chat-tab-B5tNFtvt.js +7 -0
  15. package/dist/web/assets/chevron-right-DeV0ehiG.js +1 -0
  16. package/dist/web/assets/{chunk-4BX2VUAB-BptTlTyl.js → chunk-4BX2VUAB-BNc3BCto.js} +1 -1
  17. package/dist/web/assets/{chunk-55IACEB6-C4mUdyio.js → chunk-55IACEB6-NTU3iwXh.js} +1 -1
  18. package/dist/web/assets/{chunk-7E7YKBS2-6xAQfBwa.js → chunk-7E7YKBS2-B3LDS0kJ.js} +1 -1
  19. package/dist/web/assets/{chunk-7R4GIKGN-DXaGAn_K.js → chunk-7R4GIKGN-Y5Bg-bKJ.js} +2 -2
  20. package/dist/web/assets/{chunk-C72U2L5F-DOtEiN5f.js → chunk-C72U2L5F-0CMBXF9Y.js} +1 -1
  21. package/dist/web/assets/{chunk-EGIJ26TM-D0KJTa_T.js → chunk-EGIJ26TM-BATHdF9E.js} +1 -1
  22. package/dist/web/assets/{chunk-FMBD7UC4-C_1aG0eb.js → chunk-FMBD7UC4-BrH-Lnio.js} +1 -1
  23. package/dist/web/assets/{chunk-GEFDOKGD-DwVPiYfW.js → chunk-GEFDOKGD-_UEQ_Hqk.js} +1 -1
  24. package/dist/web/assets/chunk-GLR3WWYH-BWjkRp6S.js +2 -0
  25. package/dist/web/assets/chunk-HHEYEP7N-DhIRtWNx.js +1 -0
  26. package/dist/web/assets/{chunk-JSJVCQXG-BSrqCL_3.js → chunk-JSJVCQXG-LNOdgWAh.js} +1 -1
  27. package/dist/web/assets/{chunk-KX2RTZJC-BCxGmbzy.js → chunk-KX2RTZJC-AniEyPkm.js} +1 -1
  28. package/dist/web/assets/{chunk-KYZI473N-BKO5gMeU.js → chunk-KYZI473N-drU3HQZZ.js} +1 -1
  29. package/dist/web/assets/{chunk-L3YUKLVL-3wBgkSvL.js → chunk-L3YUKLVL-GJ3l4AXc.js} +1 -1
  30. package/dist/web/assets/{chunk-MX3YWQON-BgjSEzus.js → chunk-MX3YWQON-OOf6bZzK.js} +1 -1
  31. package/dist/web/assets/{chunk-NQ4KR5QH-DLrZwBEm.js → chunk-NQ4KR5QH-DoAhAAPR.js} +1 -1
  32. package/dist/web/assets/{chunk-O4XLMI2P-BurQy8tt.js → chunk-O4XLMI2P-BBFcNFB_.js} +1 -1
  33. package/dist/web/assets/{chunk-OZEHJAEY-YTn24bGg.js → chunk-OZEHJAEY-CxBlPGUC.js} +1 -1
  34. package/dist/web/assets/{chunk-PQ6SQG4A-BxtUGYhW.js → chunk-PQ6SQG4A-DZGwvbzy.js} +1 -1
  35. package/dist/web/assets/{chunk-PU5JKC2W-B66ELkQm.js → chunk-PU5JKC2W-CCXYoac7.js} +1 -1
  36. package/dist/web/assets/chunk-QZHKN3VN-CzG4AaJv.js +1 -0
  37. package/dist/web/assets/{chunk-R5LLSJPH-euR2RxLN.js → chunk-R5LLSJPH-BTcrDJ0u.js} +1 -1
  38. package/dist/web/assets/{chunk-WL4C6EOR-_2CBOJdI.js → chunk-WL4C6EOR-BkioZxSz.js} +1 -1
  39. package/dist/web/assets/{chunk-XIRO2GV7-kqQ0g6wW.js → chunk-XIRO2GV7-BojyzPX8.js} +1 -1
  40. package/dist/web/assets/{chunk-XPW4576I-CtcaMb09.js → chunk-XPW4576I-no3g3yPs.js} +1 -1
  41. package/dist/web/assets/{chunk-XZSTWKYB-BYxFzZwS.js → chunk-XZSTWKYB-DTNisBJo.js} +1 -1
  42. package/dist/web/assets/{chunk-YBOYWFTD-Dx_fX35n.js → chunk-YBOYWFTD-DGKGjR6H.js} +1 -1
  43. package/dist/web/assets/classDiagram-VBA2DB6C-D3WeZMpV.js +1 -0
  44. package/dist/web/assets/classDiagram-v2-RAHNMMFH-CuONv6uF.js +1 -0
  45. package/dist/web/assets/clone-BGvnD7V5.js +1 -0
  46. package/dist/web/assets/code-editor-4UczMI-T.js +2 -0
  47. package/dist/web/assets/{cose-bilkent-S5V4N54A-CHHjH2dV.js → cose-bilkent-S5V4N54A-zoeAORzo.js} +1 -1
  48. package/dist/web/assets/csv-preview-CgHOY6hR.js +9 -0
  49. package/dist/web/assets/{dagre-CNtSxiE_.js → dagre-Bh_0hC-U.js} +1 -1
  50. package/dist/web/assets/{dagre-KLK3FWXG-ChenfPp1.js → dagre-KLK3FWXG-C5SfvcWS.js} +1 -1
  51. package/dist/web/assets/database-viewer-CUjlWuu_.js +1 -0
  52. package/dist/web/assets/{diagram-E7M64L7V-CzKYZM0Y.js → diagram-E7M64L7V-CZUkePtj.js} +1 -1
  53. package/dist/web/assets/{diagram-IFDJBPK2-ChB_paPo.js → diagram-IFDJBPK2-C_f4KWUQ.js} +1 -1
  54. package/dist/web/assets/{diagram-P4PSJMXO-D1eW1dkL.js → diagram-P4PSJMXO-DCIgnRzg.js} +1 -1
  55. package/dist/web/assets/diff-viewer-B4lDAzWv.js +4 -0
  56. package/dist/web/assets/dist-B21lOVUR.js +1 -0
  57. package/dist/web/assets/dist-DylI9XxN.js +13 -0
  58. package/dist/web/assets/dist-lF8CoYII.js +41 -0
  59. package/dist/web/assets/{erDiagram-INFDFZHY-mCvUFSn6.js → erDiagram-INFDFZHY-BmVZ3V1Q.js} +1 -1
  60. package/dist/web/assets/{flowDiagram-PKNHOUZH-14ohZ1M1.js → flowDiagram-PKNHOUZH-DQVk-izW.js} +1 -1
  61. package/dist/web/assets/{ganttDiagram-A5KZAMGK-DIX0pLbk.js → ganttDiagram-A5KZAMGK-BesueWL2.js} +1 -1
  62. package/dist/web/assets/git-graph-BTZTGZdP.js +1 -0
  63. package/dist/web/assets/gitGraph-HDMCJU4V-sd3XYN52.js +1 -0
  64. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js → gitGraphDiagram-K3NZZRJ6-BnKRAgIF.js} +1 -1
  65. package/dist/web/assets/{graphlib-DhOZxqsh.js → graphlib-BZkhmhks.js} +1 -1
  66. package/dist/web/assets/index-DPI-YVJI.css +2 -0
  67. package/dist/web/assets/index-hxAJJHpg.js +37 -0
  68. package/dist/web/assets/info-3K5VOQVL-DdzKhhLF.js +1 -0
  69. package/dist/web/assets/infoDiagram-LFFYTUFH-CnHorrOf.js +2 -0
  70. package/dist/web/assets/input-Bid961xi.js +1 -0
  71. package/dist/web/assets/{isEmpty-C0YYdhYj.js → isEmpty-CauZBoL-.js} +1 -1
  72. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-olazD6dZ.js → ishikawaDiagram-PHBUUO56-Proo1Zyp.js} +1 -1
  73. package/dist/web/assets/{journeyDiagram-4ABVD52K-CttDH9bb.js → journeyDiagram-4ABVD52K-86MYRgHA.js} +1 -1
  74. package/dist/web/assets/{kanban-definition-K7BYSVSG-BBXbI37U.js → kanban-definition-K7BYSVSG-lsZmnsv1.js} +1 -1
  75. package/dist/web/assets/keybindings-store-BHUZp2i1.js +1 -0
  76. package/dist/web/assets/lib-Dmwceoh0.js +4 -0
  77. package/dist/web/assets/{line-DBLLF7lH.js → line-CNR7Z1Dm.js} +1 -1
  78. package/dist/web/assets/{linear-BLFWatDe.js → linear-CGDVbc3k.js} +1 -1
  79. package/dist/web/assets/markdown-renderer-ljVdhEEq.js +69 -0
  80. package/dist/web/assets/{mermaid-parser.core-BKiGOTjR.js → mermaid-parser.core-C3bmPvHW.js} +2 -2
  81. package/dist/web/assets/{mindmap-definition-YRQLILUH-DoT7m4Sz.js → mindmap-definition-YRQLILUH-BqZbwYh4.js} +1 -1
  82. package/dist/web/assets/{ordinal-CCj7PWgZ.js → ordinal-D8VwtNz9.js} +1 -1
  83. package/dist/web/assets/packet-RMMSAZCW-BJANCvjY.js +1 -0
  84. package/dist/web/assets/pie-UPGHQEXC-DM2KFfMP.js +1 -0
  85. package/dist/web/assets/{pieDiagram-SKSYHLDU-Bkh2E4zE.js → pieDiagram-SKSYHLDU-CysZmHF5.js} +1 -1
  86. package/dist/web/assets/postgres-viewer-Dlkyzslt.js +1 -0
  87. package/dist/web/assets/{quadrantDiagram-337W2JSQ-B7zgALOL.js → quadrantDiagram-337W2JSQ-B1KHH43M.js} +1 -1
  88. package/dist/web/assets/radar-KQ55EAFF-BB-ywBD0.js +1 -0
  89. package/dist/web/assets/react-dom-Bpkvzu3U.js +1 -0
  90. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-D_5GXNRo.js → requirementDiagram-Z7DCOOCP-DpYZNbHV.js} +1 -1
  91. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-BA9EFAAe.js → sankeyDiagram-WA2Y5GQK-1QhDzoqT.js} +1 -1
  92. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-fyWIrHiG.js → sequenceDiagram-2WXFIKYE-yoBahL9m.js} +1 -1
  93. package/dist/web/assets/settings-tab-BSfQh_HW.js +1 -0
  94. package/dist/web/assets/sqlite-viewer-DraX5cHB.js +1 -0
  95. package/dist/web/assets/{stateDiagram-RAJIS63D-DfRBcaBu.js → stateDiagram-RAJIS63D-c6UuHXom.js} +1 -1
  96. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-0R9EtHx1.js +1 -0
  97. package/dist/web/assets/{tab-store-DcIBZTD4.js → tab-store-Ck-9RsH-.js} +1 -1
  98. package/dist/web/assets/{terminal-tab-B0TAHXjw.js → terminal-tab-C_jOKXBm.js} +2 -2
  99. package/dist/web/assets/{timeline-definition-YZTLITO2-DYfwJ1jM.js → timeline-definition-YZTLITO2-BayV-4fk.js} +1 -1
  100. package/dist/web/assets/treemap-KZPCXAKY-DGHYomZ7.js +1 -0
  101. package/dist/web/assets/use-monaco-theme-BHp8EuUZ.js +11 -0
  102. package/dist/web/assets/{vennDiagram-LZ73GAT5-DqbKNRD9.js → vennDiagram-LZ73GAT5-B_s6kymD.js} +1 -1
  103. package/dist/web/assets/{xychartDiagram-JWTSCODW-DhUL86qT.js → xychartDiagram-JWTSCODW-PwVoWxs2.js} +1 -1
  104. package/dist/web/index.html +13 -10
  105. package/dist/web/sw.js +1 -1
  106. package/docs/codebase-summary.md +9 -3
  107. package/docs/design-guidelines.md +21 -0
  108. package/package.json +2 -1
  109. package/src/providers/claude-agent-sdk.ts +14 -2
  110. package/src/server/index.ts +4 -0
  111. package/src/server/routes/chat.ts +4 -2
  112. package/src/server/ws/chat.ts +12 -6
  113. package/src/services/account-selector.service.ts +8 -2
  114. package/src/services/claude-usage.service.ts +24 -10
  115. package/src/services/db.service.ts +45 -6
  116. package/src/web/components/editor/code-editor.tsx +36 -26
  117. package/src/web/components/editor/csv-preview.tsx +207 -0
  118. package/src/web/components/editor/editor-breadcrumb.tsx +225 -0
  119. package/src/web/components/editor/editor-toolbar.tsx +74 -0
  120. package/src/web/lib/csv-parser.ts +134 -0
  121. package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +0 -1
  122. package/dist/web/assets/channel-w7yboq56.js +0 -1
  123. package/dist/web/assets/chat-tab--hD0r5RS.js +0 -7
  124. package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +0 -2
  125. package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +0 -1
  126. package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +0 -1
  127. package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +0 -1
  128. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +0 -1
  129. package/dist/web/assets/clone-BSi6cgDh.js +0 -1
  130. package/dist/web/assets/code-editor-EG9sb3gL.js +0 -1
  131. package/dist/web/assets/database-viewer-h1Zb9cFF.js +0 -1
  132. package/dist/web/assets/diff-viewer-DrTqG6RM.js +0 -4
  133. package/dist/web/assets/dist-T0Vhi0Mh.js +0 -16
  134. package/dist/web/assets/git-graph-Bx3h7BK1.js +0 -1
  135. package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +0 -1
  136. package/dist/web/assets/index-Beb248lR.css +0 -2
  137. package/dist/web/assets/index-DmfRyMpE.js +0 -37
  138. package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +0 -1
  139. package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +0 -2
  140. package/dist/web/assets/input-Brjz2Vv-.js +0 -41
  141. package/dist/web/assets/keybindings-store-BScuugqK.js +0 -1
  142. package/dist/web/assets/markdown-renderer-DwmzGpNI.js +0 -69
  143. package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +0 -1
  144. package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +0 -1
  145. package/dist/web/assets/postgres-viewer-Bp6mOne8.js +0 -1
  146. package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +0 -1
  147. package/dist/web/assets/settings-store-Bbhg_ptG.js +0 -2
  148. package/dist/web/assets/settings-tab-8lfbaK4W.js +0 -1
  149. package/dist/web/assets/sqlite-viewer-Cenucoym.js +0 -1
  150. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +0 -1
  151. package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +0 -1
  152. package/dist/web/assets/use-monaco-theme-vwto-Vlf.js +0 -11
  153. /package/dist/web/assets/{api-client-DpGMOZNf.js → api-client-DniUYaIY.js} +0 -0
  154. /package/dist/web/assets/{array-BGFCBI0e.js → array-Bxr4Uw0G.js} +0 -0
  155. /package/dist/web/assets/{columns-2-ChOTgl3e.js → columns-2-DpsNbZOc.js} +0 -0
  156. /package/dist/web/assets/{cytoscape.esm-Ccan6xou.js → cytoscape.esm-zgx_RusX.js} +0 -0
  157. /package/dist/web/assets/{defaultLocale-CRZydyG6.js → defaultLocale-BbBFINez.js} +0 -0
  158. /package/dist/web/assets/{dist-Cce3efmT.js → dist-CBQJF0q0.js} +0 -0
  159. /package/dist/web/assets/{init-B8gtcn7T.js → init-C9S7-8pU.js} +0 -0
  160. /package/dist/web/assets/{isArrayLikeObject-B4pdpV8V.js → isArrayLikeObject-sCNOrnc2.js} +0 -0
  161. /package/dist/web/assets/{katex-Bbu770d9.js → katex-C10ndCVt.js} +0 -0
  162. /package/dist/web/assets/{math-DwgHI-Cu.js → math-DO7stL4-.js} +0 -0
  163. /package/dist/web/assets/{path-DZF-JdEe.js → path-DPMyyJeP.js} +0 -0
  164. /package/dist/web/assets/{preload-helper-qlgyTAkD.js → preload-helper-uTix4PVD.js} +0 -0
  165. /package/dist/web/assets/{react-BGf7KNLk.js → react-ER-4DN55.js} +0 -0
  166. /package/dist/web/assets/{rough.esm-VLpapkIG.js → rough.esm-BWqoIFNR.js} +0 -0
  167. /package/dist/web/assets/{src-BoSBNdA_.js → src-BYKCXnff.js} +0 -0
  168. /package/dist/web/assets/{table-Yo02WRH-.js → table-C7X5UAEI.js} +0 -0
  169. /package/dist/web/assets/{tag-CaC1ng2E.js → tag-CCtdV063.js} +0 -0
  170. /package/dist/web/assets/{utils-btZ8C8-R.js → utils-BcFEFg4m.js} +0 -0
@@ -57,8 +57,14 @@ class AccountSelectorService {
57
57
  // Clear expired cooldowns
58
58
  for (const acc of allAccounts) {
59
59
  if (acc.status === "cooldown" && acc.cooldownUntil && acc.cooldownUntil <= now) {
60
- accountService.setEnabled(acc.id);
61
- this.retryCounts.delete(acc.id);
60
+ try {
61
+ accountService.setEnabled(acc.id);
62
+ this.retryCounts.delete(acc.id);
63
+ } catch {
64
+ // Account expired or cannot be re-enabled — disable it
65
+ accountService.setDisabled(acc.id);
66
+ this.retryCounts.delete(acc.id);
67
+ }
62
68
  }
63
69
  }
64
70
 
@@ -249,8 +249,13 @@ async function pollOnceInternal(): Promise<void> {
249
249
  /** Deduped: concurrent callers share a single in-flight fetch */
250
250
  async function pollOnce(): Promise<void> {
251
251
  if (inflightPoll) return inflightPoll;
252
- inflightPoll = pollOnceInternal().finally(() => { inflightPoll = null; });
253
- return inflightPoll;
252
+ const thisPoll = pollOnceInternal().finally(() => {
253
+ // Only clear if still the current poll — prevents a stale .finally() from
254
+ // clearing a newer poll after timeout handler force-nulled inflightPoll.
255
+ if (inflightPoll === thisPoll) inflightPoll = null;
256
+ });
257
+ inflightPoll = thisPoll;
258
+ return thisPoll;
254
259
  }
255
260
 
256
261
  // ---------------------------------------------------------------------------
@@ -313,14 +318,14 @@ export function startUsagePolling(): void {
313
318
  const POLL_TIMEOUT = 60_000; // max 60s per poll iteration
314
319
  const scheduleNext = () => {
315
320
  pollTimer = setTimeout(async () => {
316
- try {
317
- await Promise.race([
318
- pollOnce(),
319
- new Promise<void>(r => setTimeout(r, POLL_TIMEOUT)),
320
- ]);
321
- } catch {
322
- // ignore scheduleNext runs regardless
323
- }
321
+ const timeout = new Promise<"timeout">(r => setTimeout(() => r("timeout"), POLL_TIMEOUT));
322
+ const result = await Promise.race([
323
+ pollOnce().then(() => "done" as const),
324
+ timeout,
325
+ ]).catch(() => "error" as const);
326
+ // If the poll timed out, force-clear inflightPoll so next scheduled poll
327
+ // starts a fresh fetch instead of reusing the stale hanging promise.
328
+ if (result === "timeout") inflightPoll = null;
324
329
  scheduleNext();
325
330
  }, POLL_INTERVAL);
326
331
  };
@@ -339,3 +344,12 @@ export async function refreshUsageNow(): Promise<ClaudeUsage & { activeAccountId
339
344
  await pollOnce();
340
345
  return getCachedUsage();
341
346
  }
347
+
348
+ /** @internal Test-only: reset module-level state between tests */
349
+ export function _resetForTesting(): void {
350
+ inMemoryCostUsd = 0;
351
+ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
352
+ tokenCooldowns.clear();
353
+ inflightPoll = null;
354
+ tokenCache = null;
355
+ }
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { mkdirSync, existsSync } from "node:fs";
5
5
 
6
6
  const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
7
- const CURRENT_SCHEMA_VERSION = 5;
7
+ const CURRENT_SCHEMA_VERSION = 8;
8
8
 
9
9
  let db: Database | null = null;
10
10
  let dbProfile: string | null = null;
@@ -228,6 +228,18 @@ function runMigrations(database: Database): void {
228
228
  }
229
229
  database.exec(`PRAGMA user_version = 7`);
230
230
  }
231
+
232
+ if (current < 8) {
233
+ database.exec(`
234
+ CREATE TABLE IF NOT EXISTS session_titles (
235
+ session_id TEXT PRIMARY KEY,
236
+ title TEXT NOT NULL,
237
+ updated_at TEXT DEFAULT (datetime('now'))
238
+ );
239
+
240
+ PRAGMA user_version = 8;
241
+ `);
242
+ }
231
243
  }
232
244
 
233
245
  // ---------------------------------------------------------------------------
@@ -311,6 +323,33 @@ export function getAllSessionMappings(): Record<string, string> {
311
323
  return result;
312
324
  }
313
325
 
326
+ // ---------------------------------------------------------------------------
327
+ // Session title helpers (user-set titles persisted in PPM DB)
328
+ // ---------------------------------------------------------------------------
329
+
330
+ export function getSessionTitle(sessionId: string): string | null {
331
+ const row = getDb().query("SELECT title FROM session_titles WHERE session_id = ?").get(sessionId) as { title: string } | null;
332
+ return row?.title ?? null;
333
+ }
334
+
335
+ export function setSessionTitle(sessionId: string, title: string): void {
336
+ getDb().query(
337
+ "INSERT INTO session_titles (session_id, title, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(session_id) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at",
338
+ ).run(sessionId, title);
339
+ }
340
+
341
+ /** Bulk-fetch DB titles for a list of session IDs. Returns map of id → title. */
342
+ export function getSessionTitles(sessionIds: string[]): Record<string, string> {
343
+ if (sessionIds.length === 0) return {};
344
+ const placeholders = sessionIds.map(() => "?").join(", ");
345
+ const rows = getDb().query(
346
+ `SELECT session_id, title FROM session_titles WHERE session_id IN (${placeholders})`,
347
+ ).all(...sessionIds) as { session_id: string; title: string }[];
348
+ const result: Record<string, string> = {};
349
+ for (const r of rows) result[r.session_id] = r.title;
350
+ return result;
351
+ }
352
+
314
353
  // ---------------------------------------------------------------------------
315
354
  // Push subscription helpers
316
355
  // ---------------------------------------------------------------------------
@@ -441,13 +480,13 @@ export function insertLimitSnapshot(data: Omit<LimitSnapshotRow, "id" | "recorde
441
480
 
442
481
  export function getLatestLimitSnapshot(): LimitSnapshotRow | null {
443
482
  return getDb().query(
444
- "SELECT * FROM claude_limit_snapshots ORDER BY recorded_at DESC LIMIT 1",
483
+ "SELECT * FROM claude_limit_snapshots ORDER BY recorded_at DESC, id DESC LIMIT 1",
445
484
  ).get() as LimitSnapshotRow | null;
446
485
  }
447
486
 
448
487
  export function getLatestSnapshotForAccount(accountId: string): LimitSnapshotRow | null {
449
488
  return getDb().query(
450
- "SELECT * FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 1",
489
+ "SELECT * FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC, id DESC LIMIT 1",
451
490
  ).get(accountId) as LimitSnapshotRow | null;
452
491
  }
453
492
 
@@ -455,17 +494,17 @@ export function getAllLatestSnapshots(): LimitSnapshotRow[] {
455
494
  return getDb().query(
456
495
  `SELECT s.* FROM claude_limit_snapshots s
457
496
  INNER JOIN (
458
- SELECT account_id, MAX(recorded_at) as max_recorded
497
+ SELECT account_id, MAX(id) as max_id
459
498
  FROM claude_limit_snapshots WHERE account_id IS NOT NULL
460
499
  GROUP BY account_id
461
- ) latest ON s.account_id = latest.account_id AND s.recorded_at = latest.max_recorded`,
500
+ ) latest ON s.id = latest.max_id`,
462
501
  ).all() as LimitSnapshotRow[];
463
502
  }
464
503
 
465
504
  export function touchSnapshotTimestamp(accountId: string): void {
466
505
  getDb().query(
467
506
  `UPDATE claude_limit_snapshots SET recorded_at = datetime('now')
468
- WHERE id = (SELECT id FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 1)`,
507
+ WHERE id = (SELECT id FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC, id DESC LIMIT 1)`,
469
508
  ).run(accountId);
470
509
  }
471
510
 
@@ -7,7 +7,12 @@ import { useTabStore } from "@/stores/tab-store";
7
7
  import { useSettingsStore } from "@/stores/settings-store";
8
8
  import { basename } from "@/lib/utils";
9
9
  import { useMonacoTheme } from "@/lib/use-monaco-theme";
10
- import { Loader2, FileWarning, ExternalLink, Code, Eye, WrapText } from "lucide-react";
10
+ import { Loader2, FileWarning, ExternalLink } from "lucide-react";
11
+ import { EditorBreadcrumb } from "./editor-breadcrumb";
12
+ import { EditorToolbar } from "./editor-toolbar";
13
+ import { lazy, Suspense } from "react";
14
+
15
+ const CsvPreview = lazy(() => import("./csv-preview").then((m) => ({ default: m.CsvPreview })));
11
16
 
12
17
  /** Image extensions renderable inline */
13
18
  const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico"]);
@@ -58,7 +63,9 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
58
63
  const isPdf = ext === "pdf";
59
64
  const isSqlite = SQLITE_EXTS.has(ext);
60
65
  const isMarkdown = ext === "md" || ext === "mdx";
66
+ const isCsv = ext === "csv";
61
67
  const [mdMode, setMdMode] = useState<"edit" | "preview">("preview");
68
+ const [csvMode, setCsvMode] = useState<"table" | "raw">("table");
62
69
 
63
70
  // Redirect .db files to sqlite viewer by changing tab type
64
71
  useEffect(() => {
@@ -196,33 +203,36 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
196
203
  );
197
204
  }
198
205
 
199
- const mdModeButtons = isMarkdown ? (
200
- <>
201
- <button type="button" onClick={() => setMdMode("edit")}
202
- className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${mdMode === "edit" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
203
- >
204
- <Code className="size-3" /> Edit
205
- </button>
206
- <button type="button" onClick={() => setMdMode("preview")}
207
- className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${mdMode === "preview" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
208
- >
209
- <Eye className="size-3" /> Preview
210
- </button>
211
- </>
212
- ) : null;
213
-
214
- const wrapBtn = (
215
- <button type="button" onClick={toggleWordWrap} title="Toggle word wrap (Alt+Z)"
216
- className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${wordWrap ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
217
- >
218
- <WrapText className="size-3" />
219
- <span className="hidden sm:inline">Wrap</span>
220
- </button>
221
- );
222
-
223
206
  return (
224
207
  <div className="flex flex-col h-full w-full overflow-hidden">
225
- {isMarkdown && mdMode === "preview" ? (
208
+ {/* Breadcrumb + Toolbar bar desktop only */}
209
+ {filePath && projectName && tabId && (
210
+ <div className="hidden md:flex items-center h-7 border-b border-border bg-background shrink-0">
211
+ <EditorBreadcrumb
212
+ filePath={filePath}
213
+ projectName={projectName}
214
+ tabId={tabId}
215
+ className="flex items-center flex-1 min-w-0 overflow-x-auto scrollbar-none px-2 gap-0.5"
216
+ />
217
+ <EditorToolbar
218
+ ext={ext}
219
+ mdMode={mdMode}
220
+ onMdModeChange={setMdMode}
221
+ csvMode={csvMode}
222
+ onCsvModeChange={setCsvMode}
223
+ wordWrap={wordWrap}
224
+ onToggleWordWrap={toggleWordWrap}
225
+ className="shrink-0 flex items-center gap-1 px-2"
226
+ />
227
+ </div>
228
+ )}
229
+
230
+ {/* Content area */}
231
+ {isCsv && csvMode === "table" ? (
232
+ <Suspense fallback={<div className="flex items-center justify-center h-full"><Loader2 className="size-5 animate-spin text-text-subtle" /></div>}>
233
+ <CsvPreview content={content ?? ""} onContentChange={handleChange} />
234
+ </Suspense>
235
+ ) : isMarkdown && mdMode === "preview" ? (
226
236
  <MarkdownPreview content={content ?? ""} />
227
237
  ) : (
228
238
  <div className="flex-1 overflow-hidden">
@@ -0,0 +1,207 @@
1
+ import { useState, useMemo, useRef, useCallback, useEffect } from "react";
2
+ import {
3
+ useReactTable,
4
+ getCoreRowModel,
5
+ getSortedRowModel,
6
+ flexRender,
7
+ type ColumnDef,
8
+ type SortingState,
9
+ } from "@tanstack/react-table";
10
+ import { useVirtualizer } from "@tanstack/react-virtual";
11
+ import { parseCsv, serializeCsv } from "@/lib/csv-parser";
12
+ import { ArrowUp, ArrowDown } from "lucide-react";
13
+
14
+ interface CsvPreviewProps {
15
+ content: string;
16
+ onContentChange: (csv: string) => void;
17
+ }
18
+
19
+ export function CsvPreview({ content, onContentChange }: CsvPreviewProps) {
20
+ const parsed = useMemo(() => parseCsv(content), [content]);
21
+ const [rows, setRows] = useState<string[][]>(() => parsed.rows);
22
+ const [sorting, setSorting] = useState<SortingState>([]);
23
+ const scrollRef = useRef<HTMLDivElement>(null);
24
+ const internalEditRef = useRef(false);
25
+
26
+ // Sync when content changes externally (e.g. file reload) — skip if we triggered it
27
+ useEffect(() => {
28
+ if (internalEditRef.current) {
29
+ internalEditRef.current = false;
30
+ return;
31
+ }
32
+ setRows(parsed.rows);
33
+ }, [parsed.rows]);
34
+
35
+ const headers = parsed.headers;
36
+
37
+ const updateCell = useCallback(
38
+ (rowIndex: number, colIndex: number, value: string) => {
39
+ setRows((prev) => {
40
+ const next = prev.map((r, i) => (i === rowIndex ? [...r] : r));
41
+ next[rowIndex]![colIndex] = value;
42
+ internalEditRef.current = true;
43
+ onContentChange(serializeCsv(headers, next));
44
+ return next;
45
+ });
46
+ },
47
+ [headers, onContentChange],
48
+ );
49
+
50
+ const columns = useMemo<ColumnDef<string[], string>[]>(
51
+ () =>
52
+ headers.map((h, i) => ({
53
+ id: `col-${i}`,
54
+ header: h || `Column ${i + 1}`,
55
+ accessorFn: (row: string[]) => row[i] ?? "",
56
+ cell: ({ row, getValue }) => (
57
+ <CsvCell
58
+ value={getValue()}
59
+ onSave={(v) => updateCell(row.index, i, v)}
60
+ />
61
+ ),
62
+ size: 150,
63
+ minSize: 80,
64
+ })),
65
+ [headers, updateCell],
66
+ );
67
+
68
+ const table = useReactTable({
69
+ data: rows,
70
+ columns,
71
+ state: { sorting },
72
+ onSortingChange: setSorting,
73
+ getCoreRowModel: getCoreRowModel(),
74
+ getSortedRowModel: getSortedRowModel(),
75
+ enableColumnResizing: true,
76
+ columnResizeMode: "onChange",
77
+ });
78
+
79
+ const { rows: tableRows } = table.getRowModel();
80
+
81
+ const virtualizer = useVirtualizer({
82
+ count: tableRows.length,
83
+ getScrollElement: () => scrollRef.current,
84
+ estimateSize: () => 32,
85
+ overscan: 20,
86
+ });
87
+
88
+ if (headers.length === 0) {
89
+ return (
90
+ <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
91
+ Empty CSV file
92
+ </div>
93
+ );
94
+ }
95
+
96
+ return (
97
+ <div ref={scrollRef} className="flex-1 overflow-auto">
98
+ <table className="w-full text-xs font-mono border-collapse">
99
+ <thead className="sticky top-0 bg-background z-10 border-b border-border block">
100
+ {table.getHeaderGroups().map((hg) => (
101
+ <tr key={hg.id} className="flex w-full">
102
+ {hg.headers.map((header) => (
103
+ <th
104
+ key={header.id}
105
+ className="relative text-left px-2 py-1.5 font-medium text-muted-foreground select-none cursor-pointer hover:bg-muted/50 border-r border-border last:border-r-0"
106
+ style={{ width: header.getSize(), minWidth: header.getSize() }}
107
+ onClick={header.column.getToggleSortingHandler()}
108
+ >
109
+ <div className="flex items-center gap-1">
110
+ <span className="truncate">
111
+ {flexRender(header.column.columnDef.header, header.getContext())}
112
+ </span>
113
+ {header.column.getIsSorted() === "asc" && <ArrowUp className="size-3 shrink-0" />}
114
+ {header.column.getIsSorted() === "desc" && <ArrowDown className="size-3 shrink-0" />}
115
+ </div>
116
+ {/* Resize handle */}
117
+ <div
118
+ onMouseDown={header.getResizeHandler()}
119
+ onTouchStart={header.getResizeHandler()}
120
+ onClick={(e) => e.stopPropagation()}
121
+ className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary/50 active:bg-primary"
122
+ />
123
+ </th>
124
+ ))}
125
+ </tr>
126
+ ))}
127
+ </thead>
128
+ <tbody style={{ height: virtualizer.getTotalSize(), position: "relative", display: "block" }}>
129
+ {virtualizer.getVirtualItems().map((vRow) => {
130
+ const row = tableRows[vRow.index]!;
131
+ return (
132
+ <tr
133
+ key={row.id}
134
+ data-index={vRow.index}
135
+ ref={(node) => virtualizer.measureElement(node)}
136
+ style={{
137
+ position: "absolute",
138
+ top: 0,
139
+ left: 0,
140
+ width: "100%",
141
+ transform: `translateY(${vRow.start}px)`,
142
+ display: "flex",
143
+ }}
144
+ >
145
+ {row.getVisibleCells().map((cell) => (
146
+ <td
147
+ key={cell.id}
148
+ className="px-2 py-1 border-b border-border/50 border-r border-r-border/30 last:border-r-0 truncate"
149
+ style={{ width: cell.column.getSize(), minWidth: cell.column.getSize() }}
150
+ >
151
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
152
+ </td>
153
+ ))}
154
+ </tr>
155
+ );
156
+ })}
157
+ </tbody>
158
+ </table>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ function CsvCell({ value, onSave }: { value: string; onSave: (v: string) => void }) {
164
+ const [editing, setEditing] = useState(false);
165
+ const [draft, setDraft] = useState(value);
166
+ const inputRef = useRef<HTMLInputElement>(null);
167
+
168
+ useEffect(() => {
169
+ if (editing) inputRef.current?.focus();
170
+ }, [editing]);
171
+
172
+ if (!editing) {
173
+ return (
174
+ <span
175
+ className="block truncate cursor-text"
176
+ onClick={() => {
177
+ setDraft(value);
178
+ setEditing(true);
179
+ }}
180
+ >
181
+ {value || "\u00A0"}
182
+ </span>
183
+ );
184
+ }
185
+
186
+ return (
187
+ <input
188
+ ref={inputRef}
189
+ className="w-full bg-transparent outline-none border-b border-primary text-xs font-mono"
190
+ value={draft}
191
+ onChange={(e) => setDraft(e.target.value)}
192
+ onBlur={() => {
193
+ setEditing(false);
194
+ if (draft !== value) onSave(draft);
195
+ }}
196
+ onKeyDown={(e) => {
197
+ if (e.key === "Enter") {
198
+ setEditing(false);
199
+ if (draft !== value) onSave(draft);
200
+ } else if (e.key === "Escape") {
201
+ setEditing(false);
202
+ setDraft(value);
203
+ }
204
+ }}
205
+ />
206
+ );
207
+ }
@@ -0,0 +1,225 @@
1
+ import { useMemo, useRef, useEffect } from "react";
2
+ import { ChevronRight, Folder, File, FileCode, FileJson, FileText, FileType } from "lucide-react";
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuTrigger,
8
+ DropdownMenuSub,
9
+ DropdownMenuSubTrigger,
10
+ DropdownMenuSubContent,
11
+ } from "@/components/ui/dropdown-menu";
12
+ import { ScrollArea } from "@/components/ui/scroll-area";
13
+ import { useFileStore, type FileNode } from "@/stores/file-store";
14
+ import { useTabStore } from "@/stores/tab-store";
15
+ import { basename } from "@/lib/utils";
16
+
17
+ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
18
+ ts: FileCode, tsx: FileCode, js: FileCode, jsx: FileCode,
19
+ py: FileCode, rs: FileCode, go: FileCode, html: FileCode,
20
+ css: FileCode, scss: FileCode,
21
+ json: FileJson,
22
+ md: FileText, txt: FileText,
23
+ yaml: FileType, yml: FileType,
24
+ };
25
+
26
+ function getIcon(name: string, isDir: boolean) {
27
+ if (isDir) return Folder;
28
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
29
+ return ICON_MAP[ext] ?? File;
30
+ }
31
+
32
+ interface BreadcrumbSegment {
33
+ name: string;
34
+ fullPath: string;
35
+ node: FileNode | null;
36
+ siblings: FileNode[];
37
+ }
38
+
39
+ function walkTree(tree: FileNode[], segments: string[]): BreadcrumbSegment[] {
40
+ const result: BreadcrumbSegment[] = [];
41
+ let current: FileNode[] = tree;
42
+
43
+ for (let i = 0; i < segments.length; i++) {
44
+ const seg = segments[i]!;
45
+ const fullPath = segments.slice(0, i + 1).join("/");
46
+ const match = current.find((n) => n.name === seg);
47
+ result.push({
48
+ name: seg,
49
+ fullPath,
50
+ node: match ?? null,
51
+ siblings: current,
52
+ });
53
+ if (match?.children) {
54
+ current = match.children;
55
+ } else {
56
+ // Remaining segments have no tree data — add as plain
57
+ for (let j = i + 1; j < segments.length; j++) {
58
+ result.push({
59
+ name: segments[j]!,
60
+ fullPath: segments.slice(0, j + 1).join("/"),
61
+ node: null,
62
+ siblings: [],
63
+ });
64
+ }
65
+ break;
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+
71
+ function sortNodes(nodes: FileNode[]): FileNode[] {
72
+ return [...nodes].sort((a, b) => {
73
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
74
+ return a.name.localeCompare(b.name);
75
+ });
76
+ }
77
+
78
+ interface EditorBreadcrumbProps {
79
+ filePath: string;
80
+ projectName: string;
81
+ tabId: string;
82
+ className?: string;
83
+ }
84
+
85
+ export function EditorBreadcrumb({ filePath, projectName, tabId, className }: EditorBreadcrumbProps) {
86
+ const tree = useFileStore((s) => s.tree);
87
+ const { updateTab, openTab } = useTabStore();
88
+ const scrollRef = useRef<HTMLDivElement>(null);
89
+
90
+ const segments = useMemo(
91
+ () => walkTree(tree, filePath.split("/").filter(Boolean)),
92
+ [tree, filePath],
93
+ );
94
+
95
+ // Auto-scroll to rightmost segment
96
+ useEffect(() => {
97
+ if (scrollRef.current) {
98
+ scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
99
+ }
100
+ }, [segments]);
101
+
102
+ function handleFileClick(path: string, e: React.MouseEvent) {
103
+ const name = basename(path);
104
+ if (e.metaKey || e.ctrlKey) {
105
+ openTab({ type: "editor", title: name, metadata: { filePath: path, projectName }, projectId: projectName, closable: true });
106
+ } else {
107
+ updateTab(tabId, { title: name, metadata: { filePath: path, projectName } });
108
+ }
109
+ }
110
+
111
+ return (
112
+ <div ref={scrollRef} className={className}>
113
+ {segments.map((seg, i) => (
114
+ <div key={seg.fullPath} className="flex items-center shrink-0">
115
+ {i > 0 && <ChevronRight className="size-3 text-muted-foreground shrink-0 mx-0.5" />}
116
+ {seg.siblings.length > 0 ? (
117
+ <SegmentDropdown
118
+ segment={seg}
119
+ isLast={i === segments.length - 1}
120
+ projectName={projectName}
121
+ onFileClick={handleFileClick}
122
+ />
123
+ ) : (
124
+ <span className="text-xs text-muted-foreground px-1 py-0.5">{seg.name}</span>
125
+ )}
126
+ </div>
127
+ ))}
128
+ </div>
129
+ );
130
+ }
131
+
132
+ interface SegmentDropdownProps {
133
+ segment: BreadcrumbSegment;
134
+ isLast: boolean;
135
+ projectName: string;
136
+ onFileClick: (path: string, e: React.MouseEvent) => void;
137
+ }
138
+
139
+ function SegmentDropdown({ segment, isLast, projectName, onFileClick }: SegmentDropdownProps) {
140
+ const sorted = useMemo(() => sortNodes(segment.siblings), [segment.siblings]);
141
+
142
+ return (
143
+ <DropdownMenu>
144
+ <DropdownMenuTrigger asChild>
145
+ <button
146
+ type="button"
147
+ className={`text-xs px-1 py-0.5 rounded hover:bg-muted transition-colors truncate max-w-[120px] ${
148
+ isLast ? "text-foreground font-medium" : "text-muted-foreground"
149
+ }`}
150
+ >
151
+ {segment.name}
152
+ </button>
153
+ </DropdownMenuTrigger>
154
+ <DropdownMenuContent align="start" className="max-h-[300px] overflow-hidden p-0">
155
+ <ScrollArea className="max-h-[300px]">
156
+ <div className="p-1">
157
+ {sorted.map((node) => (
158
+ <NodeMenuItem
159
+ key={node.path}
160
+ node={node}
161
+ projectName={projectName}
162
+ activePath={segment.fullPath}
163
+ onFileClick={onFileClick}
164
+ />
165
+ ))}
166
+ </div>
167
+ </ScrollArea>
168
+ </DropdownMenuContent>
169
+ </DropdownMenu>
170
+ );
171
+ }
172
+
173
+ interface NodeMenuItemProps {
174
+ node: FileNode;
175
+ projectName: string;
176
+ activePath: string;
177
+ onFileClick: (path: string, e: React.MouseEvent) => void;
178
+ }
179
+
180
+ function NodeMenuItem({ node, projectName, activePath, onFileClick }: NodeMenuItemProps) {
181
+ const Icon = getIcon(node.name, node.type === "directory");
182
+ const isActive = node.path === activePath;
183
+
184
+ if (node.type === "directory" && node.children && node.children.length > 0) {
185
+ return (
186
+ <DropdownMenuSub>
187
+ <DropdownMenuSubTrigger className={`text-xs gap-1.5 ${isActive ? "bg-muted" : ""}`}>
188
+ <Icon className="size-3.5 shrink-0 text-muted-foreground" />
189
+ <span className="truncate">{node.name}</span>
190
+ </DropdownMenuSubTrigger>
191
+ <DropdownMenuSubContent className="max-h-[300px] overflow-hidden p-0">
192
+ <ScrollArea className="max-h-[300px]">
193
+ <div className="p-1">
194
+ {sortNodes(node.children).map((child) => (
195
+ <NodeMenuItem
196
+ key={child.path}
197
+ node={child}
198
+ projectName={projectName}
199
+ activePath={activePath}
200
+ onFileClick={onFileClick}
201
+ />
202
+ ))}
203
+ </div>
204
+ </ScrollArea>
205
+ </DropdownMenuSubContent>
206
+ </DropdownMenuSub>
207
+ );
208
+ }
209
+
210
+ return (
211
+ <DropdownMenuItem
212
+ className={`text-xs gap-1.5 cursor-pointer ${isActive ? "bg-muted" : ""}`}
213
+ onSelect={(e) => {
214
+ // onSelect doesn't give MouseEvent, use click handler for Ctrl detection
215
+ }}
216
+ onClick={(e) => {
217
+ if (node.type === "directory") return;
218
+ onFileClick(node.path, e);
219
+ }}
220
+ >
221
+ <Icon className="size-3.5 shrink-0 text-muted-foreground" />
222
+ <span className="truncate">{node.name}</span>
223
+ </DropdownMenuItem>
224
+ );
225
+ }