@hienlh/ppm 0.9.0-beta.7 → 0.9.0-beta.8
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 +15 -0
- package/bun.lock +5 -0
- package/dist/web/assets/{_basePickBy-CZovQgWd.js → _basePickBy-5PGDJbfF.js} +1 -1
- package/dist/web/assets/{_baseUniq-ClnvscgW.js → _baseUniq-BT4Ow4Kk.js} +1 -1
- package/dist/web/assets/{api-settings--eVrUeZM.js → api-settings-D21InCnR.js} +1 -1
- package/dist/web/assets/{arc-C2Qaz-ch.js → arc-BAOivWpI.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-DEO2f3VD.js +1 -0
- package/dist/web/assets/{architectureDiagram-2XIMDMQ5-Jq91S_rs.js → architectureDiagram-2XIMDMQ5-Z-4eN4za.js} +1 -1
- package/dist/web/assets/arrow-up--LjUXLEt.js +1 -0
- package/dist/web/assets/{blockDiagram-WCTKOSBZ-CKGufRTy.js → blockDiagram-WCTKOSBZ-BCLqzhuZ.js} +1 -1
- package/dist/web/assets/browser-tab-BEe89aSD.js +1 -0
- package/dist/web/assets/{c4Diagram-IC4MRINW-BNP2L9r_.js → c4Diagram-IC4MRINW-0Vp0Jeas.js} +1 -1
- package/dist/web/assets/channel-By7bn0Yq.js +1 -0
- package/dist/web/assets/chat-tab-9lqvWozA.js +7 -0
- package/dist/web/assets/chevron-right-CHnjJt4E.js +1 -0
- package/dist/web/assets/{chunk-4BX2VUAB-BptTlTyl.js → chunk-4BX2VUAB-D4tOov49.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-C4mUdyio.js → chunk-55IACEB6-DJ6BynZ4.js} +1 -1
- package/dist/web/assets/{chunk-7E7YKBS2-6xAQfBwa.js → chunk-7E7YKBS2-CiyUJxNI.js} +1 -1
- package/dist/web/assets/{chunk-7R4GIKGN-DXaGAn_K.js → chunk-7R4GIKGN-Dv-4cAYn.js} +2 -2
- package/dist/web/assets/{chunk-C72U2L5F-DOtEiN5f.js → chunk-C72U2L5F-D21mS_6G.js} +1 -1
- package/dist/web/assets/{chunk-EGIJ26TM-D0KJTa_T.js → chunk-EGIJ26TM-DzqmU2Z7.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-C_1aG0eb.js → chunk-FMBD7UC4-DXncblvW.js} +1 -1
- package/dist/web/assets/{chunk-GEFDOKGD-DwVPiYfW.js → chunk-GEFDOKGD-D-pKjlVd.js} +1 -1
- package/dist/web/assets/chunk-GLR3WWYH-DKikpoJM.js +2 -0
- package/dist/web/assets/chunk-HHEYEP7N-C7vxA5i9.js +1 -0
- package/dist/web/assets/{chunk-JSJVCQXG-BSrqCL_3.js → chunk-JSJVCQXG-99JzIdPr.js} +1 -1
- package/dist/web/assets/{chunk-KX2RTZJC-BCxGmbzy.js → chunk-KX2RTZJC-CRq1OBZv.js} +1 -1
- package/dist/web/assets/{chunk-KYZI473N-BKO5gMeU.js → chunk-KYZI473N-Bb0MCaIO.js} +1 -1
- package/dist/web/assets/{chunk-L3YUKLVL-3wBgkSvL.js → chunk-L3YUKLVL-C7qGJrfV.js} +1 -1
- package/dist/web/assets/{chunk-MX3YWQON-BgjSEzus.js → chunk-MX3YWQON-BpS_PtKp.js} +1 -1
- package/dist/web/assets/{chunk-NQ4KR5QH-DLrZwBEm.js → chunk-NQ4KR5QH-z_blpjxi.js} +1 -1
- package/dist/web/assets/{chunk-O4XLMI2P-BurQy8tt.js → chunk-O4XLMI2P-nDhi_cVu.js} +1 -1
- package/dist/web/assets/{chunk-OZEHJAEY-YTn24bGg.js → chunk-OZEHJAEY-BXhYx3nO.js} +1 -1
- package/dist/web/assets/{chunk-PQ6SQG4A-BxtUGYhW.js → chunk-PQ6SQG4A-TF58UVMU.js} +1 -1
- package/dist/web/assets/{chunk-PU5JKC2W-B66ELkQm.js → chunk-PU5JKC2W-ek7k4QVB.js} +1 -1
- package/dist/web/assets/chunk-QZHKN3VN-CYaTbeZf.js +1 -0
- package/dist/web/assets/{chunk-R5LLSJPH-euR2RxLN.js → chunk-R5LLSJPH-CFwSJijQ.js} +1 -1
- package/dist/web/assets/{chunk-WL4C6EOR-_2CBOJdI.js → chunk-WL4C6EOR-ByUrSRin.js} +1 -1
- package/dist/web/assets/{chunk-XIRO2GV7-kqQ0g6wW.js → chunk-XIRO2GV7-Djlmrely.js} +1 -1
- package/dist/web/assets/{chunk-XPW4576I-CtcaMb09.js → chunk-XPW4576I-BPQQBakK.js} +1 -1
- package/dist/web/assets/{chunk-XZSTWKYB-BYxFzZwS.js → chunk-XZSTWKYB-DxAOx4hG.js} +1 -1
- package/dist/web/assets/{chunk-YBOYWFTD-Dx_fX35n.js → chunk-YBOYWFTD-rQG3QH5s.js} +1 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-BA8Nj-_C.js +1 -0
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-DjYu-6mn.js +1 -0
- package/dist/web/assets/clone-LRxlvnMj.js +1 -0
- package/dist/web/assets/code-editor-COAIZx-B.js +2 -0
- package/dist/web/assets/{cose-bilkent-S5V4N54A-CHHjH2dV.js → cose-bilkent-S5V4N54A-B_AWZsOP.js} +1 -1
- package/dist/web/assets/csv-preview-DLqYtXxt.js +10 -0
- package/dist/web/assets/{dagre-CNtSxiE_.js → dagre-DHq9bhnd.js} +1 -1
- package/dist/web/assets/{dagre-KLK3FWXG-ChenfPp1.js → dagre-KLK3FWXG-BdJr7Byp.js} +1 -1
- package/dist/web/assets/database-viewer-aRR9n_Ui.js +1 -0
- package/dist/web/assets/{diagram-E7M64L7V-CzKYZM0Y.js → diagram-E7M64L7V-_db4pBVA.js} +1 -1
- package/dist/web/assets/{diagram-IFDJBPK2-ChB_paPo.js → diagram-IFDJBPK2-xKoeuiJx.js} +1 -1
- package/dist/web/assets/{diagram-P4PSJMXO-D1eW1dkL.js → diagram-P4PSJMXO-C8tjJsev.js} +1 -1
- package/dist/web/assets/diff-viewer-C4KMvpHr.js +4 -0
- package/dist/web/assets/dist-CALwEtco.js +41 -0
- package/dist/web/assets/dist-CVTST7Gc.js +1 -0
- package/dist/web/assets/dist-DGDPTxs1.js +13 -0
- package/dist/web/assets/{erDiagram-INFDFZHY-mCvUFSn6.js → erDiagram-INFDFZHY-BSh2z9Df.js} +1 -1
- package/dist/web/assets/{flowDiagram-PKNHOUZH-14ohZ1M1.js → flowDiagram-PKNHOUZH-oYaovqyp.js} +1 -1
- package/dist/web/assets/{ganttDiagram-A5KZAMGK-DIX0pLbk.js → ganttDiagram-A5KZAMGK-DmL26q2P.js} +1 -1
- package/dist/web/assets/git-graph-CfJjl4E3.js +1 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-Bwna3and.js +1 -0
- package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js → gitGraphDiagram-K3NZZRJ6-CMoukSrY.js} +1 -1
- package/dist/web/assets/{graphlib-DhOZxqsh.js → graphlib-BcsNnGcW.js} +1 -1
- package/dist/web/assets/index-Db8uky1a.css +2 -0
- package/dist/web/assets/index-DxZuwBDe.js +37 -0
- package/dist/web/assets/info-3K5VOQVL-_vRxVNUm.js +1 -0
- package/dist/web/assets/infoDiagram-LFFYTUFH-DWwumDkq.js +2 -0
- package/dist/web/assets/{isEmpty-C0YYdhYj.js → isEmpty-bnrF3Qbc.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-PHBUUO56-olazD6dZ.js → ishikawaDiagram-PHBUUO56-D05_LyL7.js} +1 -1
- package/dist/web/assets/{journeyDiagram-4ABVD52K-CttDH9bb.js → journeyDiagram-4ABVD52K-B_L20qMe.js} +1 -1
- package/dist/web/assets/{kanban-definition-K7BYSVSG-BBXbI37U.js → kanban-definition-K7BYSVSG-CZ535BbZ.js} +1 -1
- package/dist/web/assets/keybindings-store-_uWVCZMv.js +1 -0
- package/dist/web/assets/lib-BQ34Db2e.js +4 -0
- package/dist/web/assets/{line-DBLLF7lH.js → line-CVvo3dRu.js} +1 -1
- package/dist/web/assets/{linear-BLFWatDe.js → linear-DP4mkX3m.js} +1 -1
- package/dist/web/assets/markdown-renderer-DklUd_Gv.js +69 -0
- package/dist/web/assets/{mermaid-parser.core-BKiGOTjR.js → mermaid-parser.core-C7UwoIh6.js} +2 -2
- package/dist/web/assets/{mindmap-definition-YRQLILUH-DoT7m4Sz.js → mindmap-definition-YRQLILUH-x0MTutJp.js} +1 -1
- package/dist/web/assets/{ordinal-CCj7PWgZ.js → ordinal-_K3x1fkz.js} +1 -1
- package/dist/web/assets/packet-RMMSAZCW-DY5PNnZU.js +1 -0
- package/dist/web/assets/pie-UPGHQEXC-BHncZutv.js +1 -0
- package/dist/web/assets/{pieDiagram-SKSYHLDU-Bkh2E4zE.js → pieDiagram-SKSYHLDU-C1Gjrtzy.js} +1 -1
- package/dist/web/assets/postgres-viewer-DEAvAyaX.js +1 -0
- package/dist/web/assets/{quadrantDiagram-337W2JSQ-B7zgALOL.js → quadrantDiagram-337W2JSQ-C8bzJCjQ.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-DH0AOkUy.js +1 -0
- package/dist/web/assets/react-dom-Bpkvzu3U.js +1 -0
- package/dist/web/assets/{requirementDiagram-Z7DCOOCP-D_5GXNRo.js → requirementDiagram-Z7DCOOCP-pQyah6WB.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-BA9EFAAe.js → sankeyDiagram-WA2Y5GQK-T6RgG-N8.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-2WXFIKYE-fyWIrHiG.js → sequenceDiagram-2WXFIKYE-BQDJ4CVs.js} +1 -1
- package/dist/web/assets/settings-tab-BQedc-No.js +1 -0
- package/dist/web/assets/sqlite-viewer-BPA5idzT.js +1 -0
- package/dist/web/assets/{stateDiagram-RAJIS63D-DfRBcaBu.js → stateDiagram-RAJIS63D-66vhiIuk.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-BGVqj_g9.js +1 -0
- package/dist/web/assets/{tab-store-DcIBZTD4.js → tab-store-DhK6EpBT.js} +1 -1
- package/dist/web/assets/{terminal-tab-CAZtLK6i.js → terminal-tab-CqRuiIFn.js} +2 -2
- package/dist/web/assets/{timeline-definition-YZTLITO2-DYfwJ1jM.js → timeline-definition-YZTLITO2-DwZqB3nn.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-B2Xkyv-K.js +1 -0
- package/dist/web/assets/use-monaco-theme-Dcz3aLAE.js +11 -0
- package/dist/web/assets/{vennDiagram-LZ73GAT5-DqbKNRD9.js → vennDiagram-LZ73GAT5-s9Z71fz-.js} +1 -1
- package/dist/web/assets/{xychartDiagram-JWTSCODW-DhUL86qT.js → xychartDiagram-JWTSCODW-DRa_TH4B.js} +1 -1
- package/dist/web/index.html +12 -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/claude-usage.service.ts +24 -10
- 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 +225 -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/browser-tab-DAvH4mv0.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-BfBM3I7n.js} +0 -0
- /package/dist/web/assets/{array-BGFCBI0e.js → array-B9UHiPd-.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-BW-DbntU.js} +0 -0
- /package/dist/web/assets/{defaultLocale-CRZydyG6.js → defaultLocale-5eAKkKJC.js} +0 -0
- /package/dist/web/assets/{dist-Cce3efmT.js → dist-CSJdAyA9.js} +0 -0
- /package/dist/web/assets/{init-B8gtcn7T.js → init-DlZdxViB.js} +0 -0
- /package/dist/web/assets/{isArrayLikeObject-B4pdpV8V.js → isArrayLikeObject-B_v2FtYn.js} +0 -0
- /package/dist/web/assets/{katex-Bbu770d9.js → katex-Bqvo_ZG0.js} +0 -0
- /package/dist/web/assets/{math-DwgHI-Cu.js → math-069Z4SuC.js} +0 -0
- /package/dist/web/assets/{path-DZF-JdEe.js → path-6uRLdFF7.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-JX0wREDd.js} +0 -0
- /package/dist/web/assets/{src-BoSBNdA_.js → src-BqX54PbV.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-BNytJOb1.js} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Code, Eye, WrapText, Table } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
interface EditorToolbarProps {
|
|
4
|
+
ext: string;
|
|
5
|
+
mdMode?: "edit" | "preview";
|
|
6
|
+
onMdModeChange?: (mode: "edit" | "preview") => void;
|
|
7
|
+
csvMode?: "table" | "raw";
|
|
8
|
+
onCsvModeChange?: (mode: "table" | "raw") => void;
|
|
9
|
+
wordWrap: boolean;
|
|
10
|
+
onToggleWordWrap: () => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ToolbarButton({
|
|
15
|
+
active,
|
|
16
|
+
onClick,
|
|
17
|
+
icon: Icon,
|
|
18
|
+
label,
|
|
19
|
+
}: {
|
|
20
|
+
active: boolean;
|
|
21
|
+
onClick: () => void;
|
|
22
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
23
|
+
label: string;
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
onClick={onClick}
|
|
29
|
+
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
|
|
30
|
+
active ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"
|
|
31
|
+
}`}
|
|
32
|
+
>
|
|
33
|
+
<Icon className="size-3" />
|
|
34
|
+
<span className="hidden sm:inline">{label}</span>
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function EditorToolbar({
|
|
40
|
+
ext,
|
|
41
|
+
mdMode,
|
|
42
|
+
onMdModeChange,
|
|
43
|
+
csvMode,
|
|
44
|
+
onCsvModeChange,
|
|
45
|
+
wordWrap,
|
|
46
|
+
onToggleWordWrap,
|
|
47
|
+
className,
|
|
48
|
+
}: EditorToolbarProps) {
|
|
49
|
+
const isMarkdown = ext === "md" || ext === "mdx";
|
|
50
|
+
const isCsv = ext === "csv";
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className={className}>
|
|
54
|
+
{isMarkdown && onMdModeChange && (
|
|
55
|
+
<>
|
|
56
|
+
<ToolbarButton active={mdMode === "edit"} onClick={() => onMdModeChange("edit")} icon={Code} label="Edit" />
|
|
57
|
+
<ToolbarButton active={mdMode === "preview"} onClick={() => onMdModeChange("preview")} icon={Eye} label="Preview" />
|
|
58
|
+
</>
|
|
59
|
+
)}
|
|
60
|
+
{isCsv && onCsvModeChange && (
|
|
61
|
+
<>
|
|
62
|
+
<ToolbarButton active={csvMode === "table"} onClick={() => onCsvModeChange("table")} icon={Table} label="Table" />
|
|
63
|
+
<ToolbarButton active={csvMode === "raw"} onClick={() => onCsvModeChange("raw")} icon={Code} label="Raw" />
|
|
64
|
+
</>
|
|
65
|
+
)}
|
|
66
|
+
<ToolbarButton
|
|
67
|
+
active={wordWrap}
|
|
68
|
+
onClick={onToggleWordWrap}
|
|
69
|
+
icon={WrapText}
|
|
70
|
+
label="Wrap"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Plus, X } from "lucide-react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import {
|
|
6
|
+
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
|
7
|
+
} from "@/components/ui/dialog";
|
|
8
|
+
import { addMcpServer, updateMcpServer, type McpServerEntry } from "@/lib/api-mcp";
|
|
9
|
+
import { validateMcpName, validateMcpConfig, type McpTransportType } from "../../../types/mcp";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
open: boolean;
|
|
13
|
+
onClose: (saved?: boolean) => void;
|
|
14
|
+
editServer: McpServerEntry | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TRANSPORTS: McpTransportType[] = ["stdio", "http", "sse"];
|
|
18
|
+
|
|
19
|
+
export function McpServerDialog({ open, onClose, editServer }: Props) {
|
|
20
|
+
const isEdit = !!editServer;
|
|
21
|
+
const [name, setName] = useState("");
|
|
22
|
+
const [transport, setTransport] = useState<McpTransportType>("stdio");
|
|
23
|
+
const [command, setCommand] = useState("");
|
|
24
|
+
const [args, setArgs] = useState("");
|
|
25
|
+
const [url, setUrl] = useState("");
|
|
26
|
+
const [kvPairs, setKvPairs] = useState<Array<{ key: string; value: string }>>([]);
|
|
27
|
+
const [saving, setSaving] = useState(false);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
// Reset form when dialog opens
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open) return;
|
|
33
|
+
setError(null);
|
|
34
|
+
setSaving(false);
|
|
35
|
+
if (editServer) {
|
|
36
|
+
setName(editServer.name);
|
|
37
|
+
setTransport((editServer.transport as McpTransportType) || "stdio");
|
|
38
|
+
const c = editServer.config;
|
|
39
|
+
if ("command" in c) {
|
|
40
|
+
setCommand(c.command || "");
|
|
41
|
+
setArgs((c.args ?? []).join(" "));
|
|
42
|
+
setKvPairs(objToKv(c.env));
|
|
43
|
+
} else if ("url" in c) {
|
|
44
|
+
setUrl(c.url || "");
|
|
45
|
+
setKvPairs(objToKv(c.headers));
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
setName(""); setTransport("stdio"); setCommand(""); setArgs(""); setUrl("");
|
|
49
|
+
setKvPairs([]);
|
|
50
|
+
}
|
|
51
|
+
}, [open, editServer]);
|
|
52
|
+
|
|
53
|
+
const buildConfig = () => {
|
|
54
|
+
const kv = kvToObj(kvPairs);
|
|
55
|
+
if (transport === "stdio") {
|
|
56
|
+
return {
|
|
57
|
+
type: "stdio" as const,
|
|
58
|
+
command,
|
|
59
|
+
...(args.trim() && { args: args.trim().split(/\s+/) }),
|
|
60
|
+
...(Object.keys(kv).length > 0 && { env: kv }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
type: transport,
|
|
65
|
+
url,
|
|
66
|
+
...(Object.keys(kv).length > 0 && { headers: kv }),
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleSave = async () => {
|
|
71
|
+
setError(null);
|
|
72
|
+
if (!isEdit) {
|
|
73
|
+
const nameErr = validateMcpName(name);
|
|
74
|
+
if (nameErr) { setError(nameErr); return; }
|
|
75
|
+
}
|
|
76
|
+
const config = buildConfig();
|
|
77
|
+
const configErrs = validateMcpConfig(config);
|
|
78
|
+
if (configErrs.length) { setError(configErrs.join("; ")); return; }
|
|
79
|
+
|
|
80
|
+
setSaving(true);
|
|
81
|
+
try {
|
|
82
|
+
if (isEdit) {
|
|
83
|
+
await updateMcpServer(name, config);
|
|
84
|
+
} else {
|
|
85
|
+
await addMcpServer(name, config);
|
|
86
|
+
}
|
|
87
|
+
onClose(true);
|
|
88
|
+
} catch (e: any) {
|
|
89
|
+
setError(e.message || "Save failed");
|
|
90
|
+
} finally {
|
|
91
|
+
setSaving(false);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const addKvPair = () => setKvPairs([...kvPairs, { key: "", value: "" }]);
|
|
96
|
+
const removeKvPair = (i: number) => setKvPairs(kvPairs.filter((_, idx) => idx !== i));
|
|
97
|
+
const updateKv = (i: number, field: "key" | "value", val: string) => {
|
|
98
|
+
setKvPairs(kvPairs.map((p, idx) =>
|
|
99
|
+
idx === i ? { key: field === "key" ? val : p.key, value: field === "value" ? val : p.value } : p
|
|
100
|
+
));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const isStdio = transport === "stdio";
|
|
104
|
+
const kvLabel = isStdio ? "Environment Variables" : "Headers";
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
|
|
108
|
+
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-md">
|
|
109
|
+
<DialogHeader>
|
|
110
|
+
<DialogTitle className="text-sm">{isEdit ? "Edit MCP Server" : "Add MCP Server"}</DialogTitle>
|
|
111
|
+
<DialogDescription className="text-[11px]">
|
|
112
|
+
Configure a Model Context Protocol server connection.
|
|
113
|
+
</DialogDescription>
|
|
114
|
+
</DialogHeader>
|
|
115
|
+
|
|
116
|
+
<div className="space-y-3">
|
|
117
|
+
{/* Name */}
|
|
118
|
+
<div className="space-y-1">
|
|
119
|
+
<label className="text-[11px] font-medium text-muted-foreground">Name</label>
|
|
120
|
+
<Input
|
|
121
|
+
value={name} onChange={(e) => setName(e.target.value)}
|
|
122
|
+
placeholder="my-mcp-server" className="h-8 text-xs" disabled={isEdit}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{/* Transport toggle */}
|
|
127
|
+
<div className="space-y-1">
|
|
128
|
+
<label className="text-[11px] font-medium text-muted-foreground">Transport</label>
|
|
129
|
+
<div className="flex gap-1">
|
|
130
|
+
{TRANSPORTS.map((t) => (
|
|
131
|
+
<Button key={t} variant={transport === t ? "default" : "outline"}
|
|
132
|
+
size="sm" className="flex-1 h-7 text-xs cursor-pointer"
|
|
133
|
+
onClick={() => { setTransport(t); setKvPairs([]); setCommand(""); setArgs(""); setUrl(""); }}
|
|
134
|
+
>{t}</Button>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Conditional fields */}
|
|
140
|
+
{isStdio ? (
|
|
141
|
+
<>
|
|
142
|
+
<div className="space-y-1">
|
|
143
|
+
<label className="text-[11px] font-medium text-muted-foreground">Command *</label>
|
|
144
|
+
<Input value={command} onChange={(e) => setCommand(e.target.value)}
|
|
145
|
+
placeholder="npx" className="h-8 text-xs" />
|
|
146
|
+
</div>
|
|
147
|
+
<div className="space-y-1">
|
|
148
|
+
<label className="text-[11px] font-medium text-muted-foreground">Arguments (space-separated)</label>
|
|
149
|
+
<Input value={args} onChange={(e) => setArgs(e.target.value)}
|
|
150
|
+
placeholder="@playwright/mcp@latest" className="h-8 text-xs" />
|
|
151
|
+
</div>
|
|
152
|
+
</>
|
|
153
|
+
) : (
|
|
154
|
+
<div className="space-y-1">
|
|
155
|
+
<label className="text-[11px] font-medium text-muted-foreground">URL *</label>
|
|
156
|
+
<Input value={url} onChange={(e) => setUrl(e.target.value)}
|
|
157
|
+
placeholder="https://mcp.example.com" className="h-8 text-xs" />
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{/* Key-value pairs */}
|
|
162
|
+
<div className="space-y-1.5">
|
|
163
|
+
<label className="text-[11px] font-medium text-muted-foreground">{kvLabel}</label>
|
|
164
|
+
{kvPairs.map((pair, i) => (
|
|
165
|
+
<div key={i} className="flex gap-1 items-center">
|
|
166
|
+
<Input value={pair.key} onChange={(e) => updateKv(i, "key", e.target.value)}
|
|
167
|
+
placeholder="KEY" className="h-7 text-xs flex-1" />
|
|
168
|
+
<Input value={pair.value} onChange={(e) => updateKv(i, "value", e.target.value)}
|
|
169
|
+
placeholder="value" className="h-7 text-xs flex-1" />
|
|
170
|
+
<Button variant="ghost" size="icon" className="size-7 shrink-0 cursor-pointer"
|
|
171
|
+
onClick={() => removeKvPair(i)}>
|
|
172
|
+
<X className="size-3" />
|
|
173
|
+
</Button>
|
|
174
|
+
</div>
|
|
175
|
+
))}
|
|
176
|
+
<Button variant="outline" size="sm" className="h-7 text-xs gap-1 cursor-pointer" onClick={addKvPair}>
|
|
177
|
+
<Plus className="size-3" /> Add {isStdio ? "Variable" : "Header"}
|
|
178
|
+
</Button>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{error && <p className="text-[11px] text-destructive">{error}</p>}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<DialogFooter>
|
|
185
|
+
<Button variant="outline" size="sm" className="h-8 text-xs cursor-pointer" onClick={() => onClose()}>
|
|
186
|
+
Cancel
|
|
187
|
+
</Button>
|
|
188
|
+
<Button size="sm" className="h-8 text-xs cursor-pointer" onClick={handleSave} disabled={saving}>
|
|
189
|
+
{saving ? "Saving..." : "Save"}
|
|
190
|
+
</Button>
|
|
191
|
+
</DialogFooter>
|
|
192
|
+
</DialogContent>
|
|
193
|
+
</Dialog>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function objToKv(obj?: Record<string, string>): Array<{ key: string; value: string }> {
|
|
198
|
+
if (!obj) return [];
|
|
199
|
+
return Object.entries(obj).map(([key, value]) => ({ key, value }));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function kvToObj(pairs: Array<{ key: string; value: string }>): Record<string, string> {
|
|
203
|
+
const result: Record<string, string> = {};
|
|
204
|
+
for (const { key, value } of pairs) {
|
|
205
|
+
if (key.trim()) result[key.trim()] = value;
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { Plus, Download, Trash2, Pencil, Server } from "lucide-react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
getMcpServers, deleteMcpServer, importMcpServers,
|
|
6
|
+
type McpServerEntry,
|
|
7
|
+
} from "@/lib/api-mcp";
|
|
8
|
+
import { McpServerDialog } from "./mcp-server-dialog";
|
|
9
|
+
|
|
10
|
+
export function McpSettingsSection() {
|
|
11
|
+
const [servers, setServers] = useState<McpServerEntry[]>([]);
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
14
|
+
const [editingServer, setEditingServer] = useState<McpServerEntry | null>(null);
|
|
15
|
+
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
16
|
+
const [importing, setImporting] = useState(false);
|
|
17
|
+
const [importMsg, setImportMsg] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
const fetchServers = useCallback(async () => {
|
|
20
|
+
try {
|
|
21
|
+
const data = await getMcpServers();
|
|
22
|
+
setServers(data);
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.error("Failed to load MCP servers:", e);
|
|
25
|
+
} finally {
|
|
26
|
+
setLoading(false);
|
|
27
|
+
}
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
useEffect(() => { fetchServers(); }, [fetchServers]);
|
|
31
|
+
|
|
32
|
+
const handleDelete = async (name: string) => {
|
|
33
|
+
try {
|
|
34
|
+
await deleteMcpServer(name);
|
|
35
|
+
setDeleteConfirm(null);
|
|
36
|
+
fetchServers();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error("Failed to delete:", e);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const handleImport = async () => {
|
|
43
|
+
setImporting(true);
|
|
44
|
+
setImportMsg(null);
|
|
45
|
+
try {
|
|
46
|
+
const result = await importMcpServers();
|
|
47
|
+
setImportMsg(`Imported ${result.imported}, skipped ${result.skipped}`);
|
|
48
|
+
fetchServers();
|
|
49
|
+
} catch (e: any) {
|
|
50
|
+
setImportMsg(e.message || "Import failed");
|
|
51
|
+
} finally {
|
|
52
|
+
setImporting(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleDialogClose = (saved?: boolean) => {
|
|
57
|
+
setDialogOpen(false);
|
|
58
|
+
setEditingServer(null);
|
|
59
|
+
if (saved) fetchServers();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const openAdd = () => { setEditingServer(null); setDialogOpen(true); };
|
|
63
|
+
const openEdit = (s: McpServerEntry) => { setEditingServer(s); setDialogOpen(true); };
|
|
64
|
+
|
|
65
|
+
if (loading) {
|
|
66
|
+
return (
|
|
67
|
+
<div className="space-y-3">
|
|
68
|
+
{[1, 2, 3].map((i) => (
|
|
69
|
+
<div key={i} className="h-16 rounded-lg bg-muted animate-pulse" />
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="space-y-3">
|
|
77
|
+
{servers.length === 0 ? (
|
|
78
|
+
<div className="text-center py-8 space-y-2">
|
|
79
|
+
<Server className="size-8 mx-auto text-muted-foreground" />
|
|
80
|
+
<p className="text-xs text-muted-foreground">
|
|
81
|
+
No MCP servers configured. Add one or import from Claude Code.
|
|
82
|
+
</p>
|
|
83
|
+
</div>
|
|
84
|
+
) : (
|
|
85
|
+
<div className="space-y-2">
|
|
86
|
+
{servers.map((s) => (
|
|
87
|
+
<div key={s.name} className="rounded-lg border bg-card p-3 space-y-1.5">
|
|
88
|
+
<div className="flex items-center gap-2">
|
|
89
|
+
<span className="text-xs font-medium truncate flex-1">{s.name}</span>
|
|
90
|
+
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
|
91
|
+
{s.transport}
|
|
92
|
+
</span>
|
|
93
|
+
</div>
|
|
94
|
+
<p className="text-[11px] text-muted-foreground truncate">
|
|
95
|
+
{serverPreview(s)}
|
|
96
|
+
</p>
|
|
97
|
+
<div className="flex justify-end gap-1.5">
|
|
98
|
+
{deleteConfirm === s.name ? (
|
|
99
|
+
<>
|
|
100
|
+
<span className="text-[11px] text-destructive self-center mr-1">Delete?</span>
|
|
101
|
+
<Button variant="destructive" size="sm" className="h-8 min-w-[44px] text-xs cursor-pointer" onClick={() => handleDelete(s.name)}>Yes</Button>
|
|
102
|
+
<Button variant="outline" size="sm" className="h-8 min-w-[44px] text-xs cursor-pointer" onClick={() => setDeleteConfirm(null)}>No</Button>
|
|
103
|
+
</>
|
|
104
|
+
) : (
|
|
105
|
+
<>
|
|
106
|
+
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1 cursor-pointer" onClick={() => openEdit(s)}>
|
|
107
|
+
<Pencil className="size-3" /> Edit
|
|
108
|
+
</Button>
|
|
109
|
+
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1 text-destructive hover:text-destructive cursor-pointer" onClick={() => setDeleteConfirm(s.name)}>
|
|
110
|
+
<Trash2 className="size-3" /> Delete
|
|
111
|
+
</Button>
|
|
112
|
+
</>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{/* Actions — thumb zone */}
|
|
121
|
+
<div className="space-y-2 pt-1">
|
|
122
|
+
<Button className="w-full h-10 text-xs gap-1.5 cursor-pointer" onClick={openAdd}>
|
|
123
|
+
<Plus className="size-3.5" /> Add MCP Server
|
|
124
|
+
</Button>
|
|
125
|
+
<Button variant="outline" className="w-full h-10 text-xs gap-1.5 cursor-pointer" onClick={handleImport} disabled={importing}>
|
|
126
|
+
<Download className="size-3.5" /> {importing ? "Importing..." : "Import from Claude Code"}
|
|
127
|
+
</Button>
|
|
128
|
+
{importMsg && <p className="text-[11px] text-muted-foreground text-center">{importMsg}</p>}
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<McpServerDialog open={dialogOpen} onClose={handleDialogClose} editServer={editingServer} />
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function serverPreview(s: McpServerEntry): string {
|
|
137
|
+
const c = s.config;
|
|
138
|
+
if (s.transport === "stdio" || !("url" in c)) {
|
|
139
|
+
const stdio = c as { command?: string; args?: string[] };
|
|
140
|
+
return [stdio.command, ...(stdio.args ?? [])].join(" ");
|
|
141
|
+
}
|
|
142
|
+
return (c as { url?: string }).url ?? "";
|
|
143
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Moon, Sun, Monitor, Bell, BellOff, Check, ChevronRight, ArrowLeft,
|
|
4
|
-
Bot, BellRing, Keyboard, Globe,
|
|
4
|
+
Bot, BellRing, Keyboard, Globe, Plug,
|
|
5
5
|
} from "lucide-react";
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
7
|
import { Input } from "@/components/ui/input";
|
|
@@ -13,6 +13,7 @@ import { AISettingsSection } from "./ai-settings-section";
|
|
|
13
13
|
import { KeyboardShortcutsSection } from "./keyboard-shortcuts-section";
|
|
14
14
|
import { TelegramSettingsSection } from "./telegram-settings-section";
|
|
15
15
|
import { ProxySettingsSection } from "./proxy-settings-section";
|
|
16
|
+
import { McpSettingsSection } from "./mcp-settings-section";
|
|
16
17
|
import { usePushNotification } from "@/hooks/use-push-notification";
|
|
17
18
|
|
|
18
19
|
const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
|
|
@@ -25,13 +26,14 @@ const pushSupported = "PushManager" in window && "serviceWorker" in navigator;
|
|
|
25
26
|
const isIosNonPwa = /iPhone|iPad/.test(navigator.userAgent) &&
|
|
26
27
|
!window.matchMedia("(display-mode: standalone)").matches;
|
|
27
28
|
|
|
28
|
-
type SettingsCategory = "ai" | "notifications" | "proxy" | "shortcuts";
|
|
29
|
+
type SettingsCategory = "ai" | "notifications" | "proxy" | "shortcuts" | "mcp";
|
|
29
30
|
|
|
30
31
|
const CATEGORIES: { value: SettingsCategory; label: string; subtitle: string; icon: React.ElementType }[] = [
|
|
31
32
|
{ value: "ai", label: "AI Provider", subtitle: "Model, execution mode, limits", icon: Bot },
|
|
32
33
|
{ value: "notifications", label: "Notifications", subtitle: "Push & Telegram alerts", icon: BellRing },
|
|
33
34
|
{ value: "proxy", label: "API Proxy", subtitle: "Expose accounts as Anthropic API", icon: Globe },
|
|
34
35
|
{ value: "shortcuts", label: "Keyboard Shortcuts", subtitle: "Customize key bindings", icon: Keyboard },
|
|
36
|
+
{ value: "mcp", label: "MCP Servers", subtitle: "Model Context Protocol tools", icon: Plug },
|
|
35
37
|
];
|
|
36
38
|
|
|
37
39
|
export function SettingsTab() {
|
|
@@ -84,6 +86,7 @@ export function SettingsTab() {
|
|
|
84
86
|
{activeCategory === "notifications" && <NotificationsContent isSubscribed={isSubscribed} loading={loading} permission={permission} pushError={pushError} subscribe={subscribe} unsubscribe={unsubscribe} />}
|
|
85
87
|
{activeCategory === "proxy" && <ProxySettingsSection />}
|
|
86
88
|
{activeCategory === "shortcuts" && <KeyboardShortcutsSection />}
|
|
89
|
+
{activeCategory === "mcp" && <McpSettingsSection />}
|
|
87
90
|
</div>
|
|
88
91
|
</ScrollArea>
|
|
89
92
|
</div>
|