@hienlh/ppm 0.9.0-beta.7 → 0.9.0-beta.9
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/CHANGELOG.md +20 -0
- package/bun.lock +5 -0
- package/dist/web/assets/{_basePickBy-CZovQgWd.js → _basePickBy-3Xe18azI.js} +1 -1
- package/dist/web/assets/{_baseUniq-ClnvscgW.js → _baseUniq-Yy35llnn.js} +1 -1
- package/dist/web/assets/{api-settings--eVrUeZM.js → api-settings-CEMxVMCV.js} +1 -1
- package/dist/web/assets/{arc-C2Qaz-ch.js → arc-B9n1Gvb5.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-CFzkFKEL.js +1 -0
- package/dist/web/assets/{architectureDiagram-2XIMDMQ5-Jq91S_rs.js → architectureDiagram-2XIMDMQ5-DqAZP_F6.js} +1 -1
- package/dist/web/assets/arrow-up--LjUXLEt.js +1 -0
- package/dist/web/assets/{blockDiagram-WCTKOSBZ-CKGufRTy.js → blockDiagram-WCTKOSBZ-h3cDF2vI.js} +1 -1
- package/dist/web/assets/{browser-tab-DAvH4mv0.js → browser-tab-D1Zua62g.js} +1 -1
- package/dist/web/assets/{c4Diagram-IC4MRINW-BNP2L9r_.js → c4Diagram-IC4MRINW--pF1r5lr.js} +1 -1
- package/dist/web/assets/channel-C2fMafck.js +1 -0
- package/dist/web/assets/chat-tab-BnD27Vp9.js +7 -0
- package/dist/web/assets/chevron-right-CHnjJt4E.js +1 -0
- package/dist/web/assets/{chunk-4BX2VUAB-BptTlTyl.js → chunk-4BX2VUAB-C3aZvW7B.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-C4mUdyio.js → chunk-55IACEB6-D5cABeB9.js} +1 -1
- package/dist/web/assets/{chunk-7E7YKBS2-6xAQfBwa.js → chunk-7E7YKBS2-CkFGv6Zs.js} +1 -1
- package/dist/web/assets/{chunk-7R4GIKGN-DXaGAn_K.js → chunk-7R4GIKGN-Dvbyu4Zw.js} +2 -2
- package/dist/web/assets/{chunk-C72U2L5F-DOtEiN5f.js → chunk-C72U2L5F-CtqKiH4q.js} +1 -1
- package/dist/web/assets/{chunk-EGIJ26TM-D0KJTa_T.js → chunk-EGIJ26TM-Cpr87sBR.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-C_1aG0eb.js → chunk-FMBD7UC4-D23YVTOU.js} +1 -1
- package/dist/web/assets/{chunk-GEFDOKGD-DwVPiYfW.js → chunk-GEFDOKGD-tDjHsAUs.js} +1 -1
- package/dist/web/assets/chunk-GLR3WWYH-DBdWQ3zy.js +2 -0
- package/dist/web/assets/chunk-HHEYEP7N-BBw_z0fW.js +1 -0
- package/dist/web/assets/{chunk-JSJVCQXG-BSrqCL_3.js → chunk-JSJVCQXG-BBmymCjA.js} +1 -1
- package/dist/web/assets/{chunk-KX2RTZJC-BCxGmbzy.js → chunk-KX2RTZJC-DP36BDiU.js} +1 -1
- package/dist/web/assets/{chunk-KYZI473N-BKO5gMeU.js → chunk-KYZI473N-Djw13C-3.js} +1 -1
- package/dist/web/assets/{chunk-L3YUKLVL-3wBgkSvL.js → chunk-L3YUKLVL-HG_eMj_C.js} +1 -1
- package/dist/web/assets/{chunk-MX3YWQON-BgjSEzus.js → chunk-MX3YWQON-C2UEioMs.js} +1 -1
- package/dist/web/assets/{chunk-NQ4KR5QH-DLrZwBEm.js → chunk-NQ4KR5QH-DXUTQ-BL.js} +1 -1
- package/dist/web/assets/{chunk-O4XLMI2P-BurQy8tt.js → chunk-O4XLMI2P-BsUWb9d0.js} +1 -1
- package/dist/web/assets/{chunk-OZEHJAEY-YTn24bGg.js → chunk-OZEHJAEY-rG0P22U9.js} +1 -1
- package/dist/web/assets/{chunk-PQ6SQG4A-BxtUGYhW.js → chunk-PQ6SQG4A-DX0xW7kO.js} +1 -1
- package/dist/web/assets/{chunk-PU5JKC2W-B66ELkQm.js → chunk-PU5JKC2W-C7Gry6md.js} +1 -1
- package/dist/web/assets/chunk-QZHKN3VN-DFKFM_C1.js +1 -0
- package/dist/web/assets/{chunk-R5LLSJPH-euR2RxLN.js → chunk-R5LLSJPH-CMY0PkRK.js} +1 -1
- package/dist/web/assets/{chunk-WL4C6EOR-_2CBOJdI.js → chunk-WL4C6EOR-CXuQvlyu.js} +1 -1
- package/dist/web/assets/{chunk-XIRO2GV7-kqQ0g6wW.js → chunk-XIRO2GV7-DRJEb7Zb.js} +1 -1
- package/dist/web/assets/{chunk-XPW4576I-CtcaMb09.js → chunk-XPW4576I-BPEX8KhL.js} +1 -1
- package/dist/web/assets/{chunk-XZSTWKYB-BYxFzZwS.js → chunk-XZSTWKYB-Cb0iqycX.js} +1 -1
- package/dist/web/assets/{chunk-YBOYWFTD-Dx_fX35n.js → chunk-YBOYWFTD-av5aeHLq.js} +1 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-Dp4Kk3Yb.js +1 -0
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-D8IvcV_B.js +1 -0
- package/dist/web/assets/clone-B2hUek6n.js +1 -0
- package/dist/web/assets/code-editor-DGRg8stf.js +2 -0
- package/dist/web/assets/{cose-bilkent-S5V4N54A-CHHjH2dV.js → cose-bilkent-S5V4N54A-qudEiMCT.js} +1 -1
- package/dist/web/assets/csv-preview-DUbHtTAS.js +10 -0
- package/dist/web/assets/{dagre-CNtSxiE_.js → dagre-BFcnKyBF.js} +1 -1
- package/dist/web/assets/{dagre-KLK3FWXG-ChenfPp1.js → dagre-KLK3FWXG-C3O-MTLf.js} +1 -1
- package/dist/web/assets/database-viewer-DxCXZQcE.js +1 -0
- package/dist/web/assets/{diagram-E7M64L7V-CzKYZM0Y.js → diagram-E7M64L7V-DxPjK7_c.js} +1 -1
- package/dist/web/assets/{diagram-IFDJBPK2-ChB_paPo.js → diagram-IFDJBPK2-sqTog_XV.js} +1 -1
- package/dist/web/assets/{diagram-P4PSJMXO-D1eW1dkL.js → diagram-P4PSJMXO-hzmp0GHK.js} +1 -1
- package/dist/web/assets/diff-viewer-C1sDJG35.js +4 -0
- package/dist/web/assets/dist-CALwEtco.js +41 -0
- package/dist/web/assets/dist-DGDPTxs1.js +13 -0
- package/dist/web/assets/{erDiagram-INFDFZHY-mCvUFSn6.js → erDiagram-INFDFZHY-DLeYhAAT.js} +1 -1
- package/dist/web/assets/{flowDiagram-PKNHOUZH-14ohZ1M1.js → flowDiagram-PKNHOUZH-CRxlE9Sr.js} +1 -1
- package/dist/web/assets/{ganttDiagram-A5KZAMGK-DIX0pLbk.js → ganttDiagram-A5KZAMGK-BdjmoMLS.js} +1 -1
- package/dist/web/assets/git-graph-BDn-EiGE.js +1 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-CNlas3Rz.js +1 -0
- package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js → gitGraphDiagram-K3NZZRJ6-BeHSX7kk.js} +1 -1
- package/dist/web/assets/{graphlib-DhOZxqsh.js → graphlib-Duh_bWLa.js} +1 -1
- package/dist/web/assets/index-Bun94AK3.js +37 -0
- package/dist/web/assets/index-Db8uky1a.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-BDzTLc11.js +1 -0
- package/dist/web/assets/infoDiagram-LFFYTUFH-ZZmpgc6t.js +2 -0
- package/dist/web/assets/{isEmpty-C0YYdhYj.js → isEmpty-B9L-Ge-H.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-PHBUUO56-olazD6dZ.js → ishikawaDiagram-PHBUUO56-Cu0Rt1Ok.js} +1 -1
- package/dist/web/assets/{journeyDiagram-4ABVD52K-CttDH9bb.js → journeyDiagram-4ABVD52K-CgDI-UG4.js} +1 -1
- package/dist/web/assets/{kanban-definition-K7BYSVSG-BBXbI37U.js → kanban-definition-K7BYSVSG-h4g10UHL.js} +1 -1
- package/dist/web/assets/keybindings-store-COmK4Dte.js +1 -0
- package/dist/web/assets/lib-BeaDXEkP.js +4 -0
- package/dist/web/assets/{line-DBLLF7lH.js → line-B75-Rx70.js} +1 -1
- package/dist/web/assets/{linear-BLFWatDe.js → linear-Bcjv9FQt.js} +1 -1
- package/dist/web/assets/markdown-renderer-VIZB1GXE.js +69 -0
- package/dist/web/assets/{mermaid-parser.core-BKiGOTjR.js → mermaid-parser.core-8u2leTXI.js} +2 -2
- package/dist/web/assets/{mindmap-definition-YRQLILUH-DoT7m4Sz.js → mindmap-definition-YRQLILUH-BaOBwb-W.js} +1 -1
- package/dist/web/assets/{ordinal-CCj7PWgZ.js → ordinal-LFEjVtwQ.js} +1 -1
- package/dist/web/assets/packet-RMMSAZCW-IVa5F-go.js +1 -0
- package/dist/web/assets/pie-UPGHQEXC-CvXHKAzp.js +1 -0
- package/dist/web/assets/{pieDiagram-SKSYHLDU-Bkh2E4zE.js → pieDiagram-SKSYHLDU-At5Kz0KK.js} +1 -1
- package/dist/web/assets/postgres-viewer-CvQZ8gkh.js +1 -0
- package/dist/web/assets/{quadrantDiagram-337W2JSQ-B7zgALOL.js → quadrantDiagram-337W2JSQ-CdjGIDfw.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-Z-Tr5wtS.js +1 -0
- package/dist/web/assets/react-dom-Bpkvzu3U.js +1 -0
- package/dist/web/assets/{requirementDiagram-Z7DCOOCP-D_5GXNRo.js → requirementDiagram-Z7DCOOCP-B9F_Cx_p.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-BA9EFAAe.js → sankeyDiagram-WA2Y5GQK-RolPi8bU.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-2WXFIKYE-fyWIrHiG.js → sequenceDiagram-2WXFIKYE-DM-tMAhx.js} +1 -1
- package/dist/web/assets/settings-tab-RCnvZ29H.js +1 -0
- package/dist/web/assets/sqlite-viewer-CEEm2W4C.js +1 -0
- package/dist/web/assets/{stateDiagram-RAJIS63D-DfRBcaBu.js → stateDiagram-RAJIS63D-C4EMl6jf.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-B-UjZch3.js +1 -0
- package/dist/web/assets/{tab-store-DcIBZTD4.js → tab-store-Bjh6bXFP.js} +1 -1
- package/dist/web/assets/{terminal-tab-CAZtLK6i.js → terminal-tab-XhKfb4ei.js} +2 -2
- package/dist/web/assets/{timeline-definition-YZTLITO2-DYfwJ1jM.js → timeline-definition-YZTLITO2-A4PN_Efm.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-C9TYRE0k.js +1 -0
- package/dist/web/assets/use-monaco-theme-0p0-84jJ.js +11 -0
- package/dist/web/assets/{vennDiagram-LZ73GAT5-DqbKNRD9.js → vennDiagram-LZ73GAT5-ywK7LMaH.js} +1 -1
- package/dist/web/assets/{xychartDiagram-JWTSCODW-DhUL86qT.js → xychartDiagram-JWTSCODW-DylHYNtJ.js} +1 -1
- package/dist/web/index.html +11 -10
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +17 -5
- package/docs/design-guidelines.md +21 -0
- package/docs/project-changelog.md +28 -1
- package/docs/project-roadmap.md +2 -2
- package/docs/system-architecture.md +151 -0
- package/package.json +2 -1
- package/src/providers/claude-agent-sdk.ts +32 -10
- package/src/server/index.ts +6 -0
- package/src/server/routes/chat.ts +4 -2
- package/src/server/routes/mcp.ts +84 -0
- package/src/server/ws/chat.ts +18 -12
- package/src/services/account-selector.service.ts +8 -2
- package/src/services/account.service.ts +13 -8
- package/src/services/claude-usage.service.ts +37 -18
- package/src/services/cloud.service.ts +10 -6
- package/src/services/db.service.ts +53 -6
- package/src/services/mcp-config.service.ts +102 -0
- package/src/services/supervisor.ts +12 -2
- package/src/types/mcp.ts +47 -0
- package/src/web/components/editor/code-editor.tsx +36 -26
- package/src/web/components/editor/csv-preview.tsx +228 -0
- package/src/web/components/editor/editor-breadcrumb.tsx +216 -0
- package/src/web/components/editor/editor-toolbar.tsx +74 -0
- package/src/web/components/settings/mcp-server-dialog.tsx +208 -0
- package/src/web/components/settings/mcp-settings-section.tsx +143 -0
- package/src/web/components/settings/settings-tab.tsx +5 -2
- package/src/web/lib/api-mcp.ts +38 -0
- package/src/web/lib/csv-parser.ts +134 -0
- package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +0 -1
- package/dist/web/assets/channel-w7yboq56.js +0 -1
- package/dist/web/assets/chat-tab-WEBXxGgN.js +0 -7
- package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +0 -2
- package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +0 -1
- package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +0 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +0 -1
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +0 -1
- package/dist/web/assets/clone-BSi6cgDh.js +0 -1
- package/dist/web/assets/code-editor-B5sg_uJQ.js +0 -1
- package/dist/web/assets/database-viewer-CwtyWCkE.js +0 -1
- package/dist/web/assets/diff-viewer-CzE5M-Wd.js +0 -4
- package/dist/web/assets/dist-T0Vhi0Mh.js +0 -16
- package/dist/web/assets/git-graph-6yxCeeN9.js +0 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +0 -1
- package/dist/web/assets/index-DE8b9u8F.css +0 -2
- package/dist/web/assets/index-wuWZBO9y.js +0 -37
- package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +0 -1
- package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +0 -2
- package/dist/web/assets/input-Brjz2Vv-.js +0 -41
- package/dist/web/assets/keybindings-store-mkBHnWN1.js +0 -1
- package/dist/web/assets/markdown-renderer-CxWxvrzT.js +0 -69
- package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +0 -1
- package/dist/web/assets/postgres-viewer-UP3yv9Yh.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +0 -1
- package/dist/web/assets/settings-store-Bbhg_ptG.js +0 -2
- package/dist/web/assets/settings-tab-BoBXlVHe.js +0 -1
- package/dist/web/assets/sqlite-viewer-lzRVvM5j.js +0 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +0 -1
- package/dist/web/assets/use-monaco-theme-vwto-Vlf.js +0 -11
- /package/dist/web/assets/{api-client-DpGMOZNf.js → api-client-BKIT_Qeg.js} +0 -0
- /package/dist/web/assets/{array-BGFCBI0e.js → array-DqLCdDFv.js} +0 -0
- /package/dist/web/assets/{columns-2-ChOTgl3e.js → columns-2-DbesTfa7.js} +0 -0
- /package/dist/web/assets/{cytoscape.esm-Ccan6xou.js → cytoscape.esm-CWPXKqbJ.js} +0 -0
- /package/dist/web/assets/{defaultLocale-CRZydyG6.js → defaultLocale-CrJzLgRD.js} +0 -0
- /package/dist/web/assets/{dist-Cce3efmT.js → dist-Cep75xXf.js} +0 -0
- /package/dist/web/assets/{init-B8gtcn7T.js → init-C0r9Gk5G.js} +0 -0
- /package/dist/web/assets/{isArrayLikeObject-B4pdpV8V.js → isArrayLikeObject-CGBoxvCD.js} +0 -0
- /package/dist/web/assets/{katex-Bbu770d9.js → katex-DzXRfQ_m.js} +0 -0
- /package/dist/web/assets/{math-DwgHI-Cu.js → math-y9zN1W-N.js} +0 -0
- /package/dist/web/assets/{path-DZF-JdEe.js → path-DIKpVbHL.js} +0 -0
- /package/dist/web/assets/{preload-helper-qlgyTAkD.js → preload-helper-Bf_JiD2A.js} +0 -0
- /package/dist/web/assets/{react-BGf7KNLk.js → react-SKk5z-bm.js} +0 -0
- /package/dist/web/assets/{rough.esm-VLpapkIG.js → rough.esm-nHaDi0Kw.js} +0 -0
- /package/dist/web/assets/{src-BoSBNdA_.js → src-Dw4QhedI.js} +0 -0
- /package/dist/web/assets/{table-Yo02WRH-.js → table-CQVQM2SB.js} +0 -0
- /package/dist/web/assets/{tag-CaC1ng2E.js → tag-Q2dZiSPX.js} +0 -0
- /package/dist/web/assets/{utils-btZ8C8-R.js → utils-DMiycH3O.js} +0 -0
|
@@ -47,15 +47,20 @@ const POLL_INTERVAL = 300_000; // 5min
|
|
|
47
47
|
const ACCOUNT_STAGGER_MS = 1_000; // 1s between accounts
|
|
48
48
|
|
|
49
49
|
let inMemoryCostUsd = 0;
|
|
50
|
-
|
|
50
|
+
|
|
51
|
+
// Survive Bun --hot reloads: module-level vars reset on reload, globalThis persists.
|
|
52
|
+
// Without this, each hot-reload creates a NEW polling timer without clearing the old one,
|
|
53
|
+
// leading to N concurrent timers after N reloads (observed: 221 timers → 38k 429 errors/day).
|
|
54
|
+
const HOT_KEY = "__PPM_USAGE_POLL__" as const;
|
|
55
|
+
const hotState = ((globalThis as any)[HOT_KEY] ??= {
|
|
56
|
+
pollTimer: null as ReturnType<typeof setTimeout> | null,
|
|
57
|
+
inflightPoll: null as Promise<void> | null,
|
|
58
|
+
}) as { pollTimer: ReturnType<typeof setTimeout> | null; inflightPoll: Promise<void> | null };
|
|
51
59
|
|
|
52
60
|
// Per-token cooldown map: token prefix → earliest allowed fetch time
|
|
53
61
|
const tokenCooldowns = new Map<string, number>();
|
|
54
62
|
const MIN_COOLDOWN_MS = 60_000; // floor: at least 60s cooldown on 429
|
|
55
63
|
|
|
56
|
-
// Dedup: if a poll is already in-flight, reuse the same promise
|
|
57
|
-
let inflightPoll: Promise<void> | null = null;
|
|
58
|
-
|
|
59
64
|
// Legacy: Keychain token cache for users without accounts in DB
|
|
60
65
|
let tokenCache: { token: string; timestamp: number } | null = null;
|
|
61
66
|
const TOKEN_TTL = 300_000;
|
|
@@ -248,9 +253,14 @@ async function pollOnceInternal(): Promise<void> {
|
|
|
248
253
|
|
|
249
254
|
/** Deduped: concurrent callers share a single in-flight fetch */
|
|
250
255
|
async function pollOnce(): Promise<void> {
|
|
251
|
-
if (inflightPoll) return inflightPoll;
|
|
252
|
-
|
|
253
|
-
|
|
256
|
+
if (hotState.inflightPoll) return hotState.inflightPoll;
|
|
257
|
+
const thisPoll = pollOnceInternal().finally(() => {
|
|
258
|
+
// Only clear if still the current poll — prevents a stale .finally() from
|
|
259
|
+
// clearing a newer poll after timeout handler force-nulled inflightPoll.
|
|
260
|
+
if (hotState.inflightPoll === thisPoll) hotState.inflightPoll = null;
|
|
261
|
+
});
|
|
262
|
+
hotState.inflightPoll = thisPoll;
|
|
263
|
+
return thisPoll;
|
|
254
264
|
}
|
|
255
265
|
|
|
256
266
|
// ---------------------------------------------------------------------------
|
|
@@ -309,18 +319,18 @@ export function getCachedUsage(): ClaudeUsage & { activeAccountId?: string; acti
|
|
|
309
319
|
}
|
|
310
320
|
|
|
311
321
|
export function startUsagePolling(): void {
|
|
312
|
-
if (pollTimer) return;
|
|
322
|
+
if (hotState.pollTimer) return;
|
|
313
323
|
const POLL_TIMEOUT = 60_000; // max 60s per poll iteration
|
|
314
324
|
const scheduleNext = () => {
|
|
315
|
-
pollTimer = setTimeout(async () => {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
325
|
+
hotState.pollTimer = setTimeout(async () => {
|
|
326
|
+
const timeout = new Promise<"timeout">(r => setTimeout(() => r("timeout"), POLL_TIMEOUT));
|
|
327
|
+
const result = await Promise.race([
|
|
328
|
+
pollOnce().then(() => "done" as const),
|
|
329
|
+
timeout,
|
|
330
|
+
]).catch(() => "error" as const);
|
|
331
|
+
// If the poll timed out, force-clear inflightPoll so next scheduled poll
|
|
332
|
+
// starts a fresh fetch instead of reusing the stale hanging promise.
|
|
333
|
+
if (result === "timeout") hotState.inflightPoll = null;
|
|
324
334
|
scheduleNext();
|
|
325
335
|
}, POLL_INTERVAL);
|
|
326
336
|
};
|
|
@@ -328,7 +338,7 @@ export function startUsagePolling(): void {
|
|
|
328
338
|
}
|
|
329
339
|
|
|
330
340
|
export function stopUsagePolling(): void {
|
|
331
|
-
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
341
|
+
if (hotState.pollTimer) { clearTimeout(hotState.pollTimer); hotState.pollTimer = null; }
|
|
332
342
|
}
|
|
333
343
|
|
|
334
344
|
export function updateFromSdkEvent(_rateLimitType?: string, _utilization?: number, costUsd?: number): void {
|
|
@@ -339,3 +349,12 @@ export async function refreshUsageNow(): Promise<ClaudeUsage & { activeAccountId
|
|
|
339
349
|
await pollOnce();
|
|
340
350
|
return getCachedUsage();
|
|
341
351
|
}
|
|
352
|
+
|
|
353
|
+
/** @internal Test-only: reset module-level state between tests */
|
|
354
|
+
export function _resetForTesting(): void {
|
|
355
|
+
inMemoryCostUsd = 0;
|
|
356
|
+
if (hotState.pollTimer) { clearTimeout(hotState.pollTimer); hotState.pollTimer = null; }
|
|
357
|
+
tokenCooldowns.clear();
|
|
358
|
+
hotState.inflightPoll = null;
|
|
359
|
+
tokenCache = null;
|
|
360
|
+
}
|
|
@@ -362,12 +362,16 @@ export async function sendHeartbeat(tunnelUrl: string): Promise<boolean> {
|
|
|
362
362
|
}
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
-
|
|
365
|
+
// Survive Bun --hot reloads: persist timer ref across module re-evaluations
|
|
366
|
+
const CLOUD_HOT_KEY = "__PPM_CLOUD_HEARTBEAT__" as const;
|
|
367
|
+
const cloudHotState = ((globalThis as any)[CLOUD_HOT_KEY] ??= {
|
|
368
|
+
heartbeatTimer: null as ReturnType<typeof setInterval> | null,
|
|
369
|
+
}) as { heartbeatTimer: ReturnType<typeof setInterval> | null };
|
|
366
370
|
|
|
367
371
|
/** Start periodic heartbeat (call once after tunnel URL is obtained) */
|
|
368
372
|
export function startHeartbeat(tunnelUrl: string): void {
|
|
369
373
|
// Clear any existing heartbeat to prevent duplicates on restart
|
|
370
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
374
|
+
if (cloudHotState.heartbeatTimer) clearInterval(cloudHotState.heartbeatTimer);
|
|
371
375
|
|
|
372
376
|
// Initial heartbeat immediately
|
|
373
377
|
sendHeartbeat(tunnelUrl).then((ok) => {
|
|
@@ -376,16 +380,16 @@ export function startHeartbeat(tunnelUrl: string): void {
|
|
|
376
380
|
});
|
|
377
381
|
|
|
378
382
|
// Periodic heartbeat every 5 minutes
|
|
379
|
-
heartbeatTimer = setInterval(() => {
|
|
383
|
+
cloudHotState.heartbeatTimer = setInterval(() => {
|
|
380
384
|
sendHeartbeat(tunnelUrl).catch(() => {});
|
|
381
385
|
}, HEARTBEAT_INTERVAL_MS);
|
|
382
386
|
}
|
|
383
387
|
|
|
384
388
|
/** Stop periodic heartbeat */
|
|
385
389
|
export function stopHeartbeat(): void {
|
|
386
|
-
if (heartbeatTimer) {
|
|
387
|
-
clearInterval(heartbeatTimer);
|
|
388
|
-
heartbeatTimer = null;
|
|
390
|
+
if (cloudHotState.heartbeatTimer) {
|
|
391
|
+
clearInterval(cloudHotState.heartbeatTimer);
|
|
392
|
+
cloudHotState.heartbeatTimer = null;
|
|
389
393
|
}
|
|
390
394
|
}
|
|
391
395
|
|
|
@@ -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 =
|
|
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,26 @@ 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 mcp_servers (
|
|
235
|
+
name TEXT PRIMARY KEY,
|
|
236
|
+
transport TEXT NOT NULL DEFAULT 'stdio',
|
|
237
|
+
config TEXT NOT NULL,
|
|
238
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
239
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
CREATE TABLE IF NOT EXISTS session_titles (
|
|
243
|
+
session_id TEXT PRIMARY KEY,
|
|
244
|
+
title TEXT NOT NULL,
|
|
245
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
PRAGMA user_version = 8;
|
|
249
|
+
`);
|
|
250
|
+
}
|
|
231
251
|
}
|
|
232
252
|
|
|
233
253
|
// ---------------------------------------------------------------------------
|
|
@@ -311,6 +331,33 @@ export function getAllSessionMappings(): Record<string, string> {
|
|
|
311
331
|
return result;
|
|
312
332
|
}
|
|
313
333
|
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Session title helpers (user-set titles persisted in PPM DB)
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
export function getSessionTitle(sessionId: string): string | null {
|
|
339
|
+
const row = getDb().query("SELECT title FROM session_titles WHERE session_id = ?").get(sessionId) as { title: string } | null;
|
|
340
|
+
return row?.title ?? null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function setSessionTitle(sessionId: string, title: string): void {
|
|
344
|
+
getDb().query(
|
|
345
|
+
"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",
|
|
346
|
+
).run(sessionId, title);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Bulk-fetch DB titles for a list of session IDs. Returns map of id → title. */
|
|
350
|
+
export function getSessionTitles(sessionIds: string[]): Record<string, string> {
|
|
351
|
+
if (sessionIds.length === 0) return {};
|
|
352
|
+
const placeholders = sessionIds.map(() => "?").join(", ");
|
|
353
|
+
const rows = getDb().query(
|
|
354
|
+
`SELECT session_id, title FROM session_titles WHERE session_id IN (${placeholders})`,
|
|
355
|
+
).all(...sessionIds) as { session_id: string; title: string }[];
|
|
356
|
+
const result: Record<string, string> = {};
|
|
357
|
+
for (const r of rows) result[r.session_id] = r.title;
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
|
|
314
361
|
// ---------------------------------------------------------------------------
|
|
315
362
|
// Push subscription helpers
|
|
316
363
|
// ---------------------------------------------------------------------------
|
|
@@ -441,13 +488,13 @@ export function insertLimitSnapshot(data: Omit<LimitSnapshotRow, "id" | "recorde
|
|
|
441
488
|
|
|
442
489
|
export function getLatestLimitSnapshot(): LimitSnapshotRow | null {
|
|
443
490
|
return getDb().query(
|
|
444
|
-
"SELECT * FROM claude_limit_snapshots ORDER BY recorded_at DESC LIMIT 1",
|
|
491
|
+
"SELECT * FROM claude_limit_snapshots ORDER BY recorded_at DESC, id DESC LIMIT 1",
|
|
445
492
|
).get() as LimitSnapshotRow | null;
|
|
446
493
|
}
|
|
447
494
|
|
|
448
495
|
export function getLatestSnapshotForAccount(accountId: string): LimitSnapshotRow | null {
|
|
449
496
|
return getDb().query(
|
|
450
|
-
"SELECT * FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 1",
|
|
497
|
+
"SELECT * FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC, id DESC LIMIT 1",
|
|
451
498
|
).get(accountId) as LimitSnapshotRow | null;
|
|
452
499
|
}
|
|
453
500
|
|
|
@@ -455,17 +502,17 @@ export function getAllLatestSnapshots(): LimitSnapshotRow[] {
|
|
|
455
502
|
return getDb().query(
|
|
456
503
|
`SELECT s.* FROM claude_limit_snapshots s
|
|
457
504
|
INNER JOIN (
|
|
458
|
-
SELECT account_id, MAX(
|
|
505
|
+
SELECT account_id, MAX(id) as max_id
|
|
459
506
|
FROM claude_limit_snapshots WHERE account_id IS NOT NULL
|
|
460
507
|
GROUP BY account_id
|
|
461
|
-
) latest ON s.
|
|
508
|
+
) latest ON s.id = latest.max_id`,
|
|
462
509
|
).all() as LimitSnapshotRow[];
|
|
463
510
|
}
|
|
464
511
|
|
|
465
512
|
export function touchSnapshotTimestamp(accountId: string): void {
|
|
466
513
|
getDb().query(
|
|
467
514
|
`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)`,
|
|
515
|
+
WHERE id = (SELECT id FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC, id DESC LIMIT 1)`,
|
|
469
516
|
).run(accountId);
|
|
470
517
|
}
|
|
471
518
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type { McpServerConfig, McpTransportType } from "../types/mcp";
|
|
3
|
+
import { validateMcpName, validateMcpConfig } from "../types/mcp";
|
|
4
|
+
import { getDb } from "./db.service";
|
|
5
|
+
|
|
6
|
+
function resolveTransport(config: McpServerConfig): McpTransportType {
|
|
7
|
+
if ("type" in config && config.type) return config.type;
|
|
8
|
+
return "stdio";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function safeParse(json: string, label: string): McpServerConfig | null {
|
|
12
|
+
try { return JSON.parse(json); }
|
|
13
|
+
catch { console.warn(`[mcp] Skipping ${label}: corrupt config`); return null; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class McpConfigService {
|
|
17
|
+
private explicitDb: Database | null;
|
|
18
|
+
|
|
19
|
+
constructor(db?: Database) {
|
|
20
|
+
this.explicitDb = db ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Get DB — explicit (testing) or lazy singleton */
|
|
24
|
+
private get db(): Database {
|
|
25
|
+
return this.explicitDb ?? getDb();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** List all MCP servers as Record (SDK-compatible format) */
|
|
29
|
+
list(): Record<string, McpServerConfig> {
|
|
30
|
+
const rows = this.db.query("SELECT name, config FROM mcp_servers ORDER BY name").all() as { name: string; config: string }[];
|
|
31
|
+
const result: Record<string, McpServerConfig> = {};
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
const parsed = safeParse(row.config, row.name);
|
|
34
|
+
if (parsed) result[row.name] = parsed;
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** List as array with metadata (for UI) */
|
|
40
|
+
listWithMeta(): Array<{ name: string; transport: string; config: McpServerConfig; createdAt: string; updatedAt: string }> {
|
|
41
|
+
const rows = this.db.query("SELECT name, transport, config, created_at, updated_at FROM mcp_servers ORDER BY name").all() as {
|
|
42
|
+
name: string; transport: string; config: string; created_at: string; updated_at: string;
|
|
43
|
+
}[];
|
|
44
|
+
const result: Array<{ name: string; transport: string; config: McpServerConfig; createdAt: string; updatedAt: string }> = [];
|
|
45
|
+
for (const r of rows) {
|
|
46
|
+
const parsed = safeParse(r.config, r.name);
|
|
47
|
+
if (parsed) result.push({ name: r.name, transport: r.transport, config: parsed, createdAt: r.created_at, updatedAt: r.updated_at });
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Get single server */
|
|
53
|
+
get(name: string): McpServerConfig | null {
|
|
54
|
+
const row = this.db.query("SELECT config FROM mcp_servers WHERE name = ?").get(name) as { config: string } | null;
|
|
55
|
+
return row ? safeParse(row.config, name) : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Add or update server */
|
|
59
|
+
set(name: string, config: McpServerConfig): void {
|
|
60
|
+
const transport = resolveTransport(config);
|
|
61
|
+
this.db.query(`
|
|
62
|
+
INSERT INTO mcp_servers (name, transport, config, updated_at)
|
|
63
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
64
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
65
|
+
transport = excluded.transport,
|
|
66
|
+
config = excluded.config,
|
|
67
|
+
updated_at = datetime('now')
|
|
68
|
+
`).run(name, transport, JSON.stringify(config));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Remove server. Returns true if deleted. */
|
|
72
|
+
remove(name: string): boolean {
|
|
73
|
+
const result = this.db.query("DELETE FROM mcp_servers WHERE name = ?").run(name);
|
|
74
|
+
return result.changes > 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Check if name exists */
|
|
78
|
+
exists(name: string): boolean {
|
|
79
|
+
const row = this.db.query("SELECT 1 FROM mcp_servers WHERE name = ?").get(name);
|
|
80
|
+
return row != null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Bulk insert (for import) — validates entries, skips existing/invalid, wrapped in transaction */
|
|
84
|
+
bulkImport(servers: Record<string, McpServerConfig>): { imported: number; skipped: number } {
|
|
85
|
+
let imported = 0, skipped = 0;
|
|
86
|
+
const tx = this.db.transaction(() => {
|
|
87
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
88
|
+
if (this.exists(name)) { skipped++; continue; }
|
|
89
|
+
const nameErr = validateMcpName(name);
|
|
90
|
+
if (nameErr) { skipped++; continue; }
|
|
91
|
+
const configErrs = validateMcpConfig(config);
|
|
92
|
+
if (configErrs.length) { skipped++; continue; }
|
|
93
|
+
this.set(name, config);
|
|
94
|
+
imported++;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
tx();
|
|
98
|
+
return { imported, skipped };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const mcpConfigService = new McpConfigService();
|
|
@@ -328,6 +328,9 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
|
|
|
328
328
|
const currentSupervisorPid = process.pid;
|
|
329
329
|
|
|
330
330
|
try {
|
|
331
|
+
// Prevent spawnServer crash-restart loop from respawning killed children
|
|
332
|
+
shuttingDown = true;
|
|
333
|
+
|
|
331
334
|
// Kill server + tunnel children FIRST to free the port for the new supervisor
|
|
332
335
|
log("INFO", "Stopping server and tunnel before spawning new supervisor");
|
|
333
336
|
if (serverChild) { try { serverChild.kill(); } catch {} serverChild = null; }
|
|
@@ -365,12 +368,14 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
|
|
|
365
368
|
} catch {}
|
|
366
369
|
}
|
|
367
370
|
|
|
368
|
-
// Timeout — new supervisor didn't start
|
|
371
|
+
// Timeout — new supervisor didn't start, restore old supervisor
|
|
369
372
|
log("ERROR", "Self-replace timeout: new supervisor did not start");
|
|
370
373
|
try { child.kill(); } catch {}
|
|
374
|
+
shuttingDown = false;
|
|
371
375
|
return { success: false, error: "New supervisor failed to start within 30s" };
|
|
372
376
|
} catch (e) {
|
|
373
377
|
log("ERROR", `Self-replace error: ${e}`);
|
|
378
|
+
shuttingDown = false;
|
|
374
379
|
return { success: false, error: (e as Error).message };
|
|
375
380
|
}
|
|
376
381
|
}
|
|
@@ -436,7 +441,12 @@ export async function runSupervisor(opts: {
|
|
|
436
441
|
process.on("SIGUSR1", async () => {
|
|
437
442
|
log("INFO", "SIGUSR1 received, starting self-replace for upgrade");
|
|
438
443
|
const result = await selfReplace();
|
|
439
|
-
if (!result.success)
|
|
444
|
+
if (!result.success) {
|
|
445
|
+
log("ERROR", `Self-replace failed: ${result.error}, restarting children`);
|
|
446
|
+
// Respawn server (and tunnel if configured) since selfReplace killed them
|
|
447
|
+
spawnServer(serverArgs, logFd);
|
|
448
|
+
if (opts.share) spawnTunnel(opts.port);
|
|
449
|
+
}
|
|
440
450
|
});
|
|
441
451
|
|
|
442
452
|
// Start health checks
|
package/src/types/mcp.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** stdio transport */
|
|
2
|
+
export interface McpStdioConfig {
|
|
3
|
+
type?: "stdio";
|
|
4
|
+
command: string;
|
|
5
|
+
args?: string[];
|
|
6
|
+
env?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** HTTP transport */
|
|
10
|
+
export interface McpHttpConfig {
|
|
11
|
+
type: "http";
|
|
12
|
+
url: string;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** SSE transport */
|
|
17
|
+
export interface McpSseConfig {
|
|
18
|
+
type: "sse";
|
|
19
|
+
url: string;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type McpServerConfig = McpStdioConfig | McpHttpConfig | McpSseConfig;
|
|
24
|
+
export type McpTransportType = "stdio" | "http" | "sse";
|
|
25
|
+
|
|
26
|
+
export function validateMcpName(name: string): string | null {
|
|
27
|
+
if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) return "Name must start with a letter/digit, then alphanumeric, hyphens, or underscores";
|
|
28
|
+
if (name.length > 50) return "Name max 50 chars";
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateMcpConfig(config: unknown): string[] {
|
|
33
|
+
const errors: string[] = [];
|
|
34
|
+
if (!config || typeof config !== "object") return ["Config must be an object"];
|
|
35
|
+
const c = config as Record<string, unknown>;
|
|
36
|
+
const type = (c.type as string) ?? "stdio";
|
|
37
|
+
|
|
38
|
+
if (type === "stdio") {
|
|
39
|
+
if (!c.command || typeof c.command !== "string") errors.push("command is required for stdio");
|
|
40
|
+
} else if (type === "http" || type === "sse") {
|
|
41
|
+
if (!c.url || typeof c.url !== "string") errors.push("url is required for " + type);
|
|
42
|
+
if (c.url && typeof c.url === "string" && !/^https?:\/\/.+/.test(c.url)) errors.push("url must be HTTP(S)");
|
|
43
|
+
} else {
|
|
44
|
+
errors.push("type must be stdio, http, or sse");
|
|
45
|
+
}
|
|
46
|
+
return errors;
|
|
47
|
+
}
|
|
@@ -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
|
|
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
|
-
{
|
|
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} wordWrap={wordWrap} />
|
|
234
|
+
</Suspense>
|
|
235
|
+
) : isMarkdown && mdMode === "preview" ? (
|
|
226
236
|
<MarkdownPreview content={content ?? ""} />
|
|
227
237
|
) : (
|
|
228
238
|
<div className="flex-1 overflow-hidden">
|