@hienlh/ppm 0.8.54 → 0.8.55
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 +19 -0
- package/bun.lock +250 -1
- package/dist/web/assets/_basePickBy-CZovQgWd.js +1 -0
- package/dist/web/assets/_baseUniq-ClnvscgW.js +1 -0
- package/dist/web/assets/{api-client-TUmacMRS.js → api-client-DpGMOZNf.js} +1 -1
- package/dist/web/assets/api-settings--eVrUeZM.js +1 -0
- package/dist/web/assets/arc-C2Qaz-ch.js +1 -0
- package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +1 -0
- package/dist/web/assets/architectureDiagram-2XIMDMQ5-Jq91S_rs.js +36 -0
- package/dist/web/assets/array-BGFCBI0e.js +1 -0
- package/dist/web/assets/blockDiagram-WCTKOSBZ-CKGufRTy.js +132 -0
- package/dist/web/assets/c4Diagram-IC4MRINW-BNP2L9r_.js +10 -0
- package/dist/web/assets/channel-w7yboq56.js +1 -0
- package/dist/web/assets/chat-tab-BUOCxR2G.js +7 -0
- package/dist/web/assets/chunk-4BX2VUAB-BptTlTyl.js +1 -0
- package/dist/web/assets/chunk-55IACEB6-C4mUdyio.js +1 -0
- package/dist/web/assets/chunk-7E7YKBS2-6xAQfBwa.js +1 -0
- package/dist/web/assets/chunk-7R4GIKGN-DXaGAn_K.js +80 -0
- package/dist/web/assets/chunk-C72U2L5F-DOtEiN5f.js +1 -0
- package/dist/web/assets/chunk-CFjPhJqf.js +1 -0
- package/dist/web/assets/chunk-EGIJ26TM-D0KJTa_T.js +1 -0
- package/dist/web/assets/chunk-FMBD7UC4-C_1aG0eb.js +15 -0
- package/dist/web/assets/chunk-GEFDOKGD-DwVPiYfW.js +2 -0
- package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +2 -0
- package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +1 -0
- package/dist/web/assets/chunk-JSJVCQXG-BSrqCL_3.js +1 -0
- package/dist/web/assets/chunk-KX2RTZJC-BCxGmbzy.js +1 -0
- package/dist/web/assets/chunk-KYZI473N-BKO5gMeU.js +53 -0
- package/dist/web/assets/chunk-L3YUKLVL-3wBgkSvL.js +1 -0
- package/dist/web/assets/chunk-MX3YWQON-BgjSEzus.js +1 -0
- package/dist/web/assets/chunk-NQ4KR5QH-DLrZwBEm.js +220 -0
- package/dist/web/assets/chunk-O4XLMI2P-BurQy8tt.js +7 -0
- package/dist/web/assets/chunk-OZEHJAEY-YTn24bGg.js +1 -0
- package/dist/web/assets/chunk-PQ6SQG4A-BxtUGYhW.js +1 -0
- package/dist/web/assets/chunk-PU5JKC2W-B66ELkQm.js +70 -0
- package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +1 -0
- package/dist/web/assets/chunk-R5LLSJPH-euR2RxLN.js +1 -0
- package/dist/web/assets/chunk-WL4C6EOR-_2CBOJdI.js +189 -0
- package/dist/web/assets/chunk-XIRO2GV7-kqQ0g6wW.js +1 -0
- package/dist/web/assets/chunk-XPW4576I-CtcaMb09.js +32 -0
- package/dist/web/assets/chunk-XZSTWKYB-BYxFzZwS.js +94 -0
- package/dist/web/assets/chunk-YBOYWFTD-Dx_fX35n.js +1 -0
- package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +1 -0
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +1 -0
- package/dist/web/assets/clone-BSi6cgDh.js +1 -0
- package/dist/web/assets/code-editor-os78eUN8.js +1 -0
- package/dist/web/assets/columns-2-ChOTgl3e.js +1 -0
- package/dist/web/assets/cose-bilkent-S5V4N54A-CHHjH2dV.js +1 -0
- package/dist/web/assets/cytoscape.esm-Ccan6xou.js +321 -0
- package/dist/web/assets/dagre-CNtSxiE_.js +1 -0
- package/dist/web/assets/dagre-KLK3FWXG-ChenfPp1.js +4 -0
- package/dist/web/assets/database-viewer-DTwe0h8F.js +1 -0
- package/dist/web/assets/defaultLocale-CRZydyG6.js +1 -0
- package/dist/web/assets/diagram-E7M64L7V-CzKYZM0Y.js +24 -0
- package/dist/web/assets/diagram-IFDJBPK2-ChB_paPo.js +43 -0
- package/dist/web/assets/diagram-P4PSJMXO-D1eW1dkL.js +24 -0
- package/dist/web/assets/diff-viewer-CSyOOmS2.js +4 -0
- package/dist/web/assets/dist-Cce3efmT.js +1 -0
- package/dist/web/assets/{dist-QgqOdSYG.js → dist-T0Vhi0Mh.js} +1 -1
- package/dist/web/assets/erDiagram-INFDFZHY-mCvUFSn6.js +70 -0
- package/dist/web/assets/flowDiagram-PKNHOUZH-14ohZ1M1.js +162 -0
- package/dist/web/assets/ganttDiagram-A5KZAMGK-DIX0pLbk.js +292 -0
- package/dist/web/assets/git-graph-CwYW3F4P.js +1 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +1 -0
- package/dist/web/assets/gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js +65 -0
- package/dist/web/assets/graphlib-DhOZxqsh.js +1 -0
- package/dist/web/assets/index-WKLuYsBY.css +2 -0
- package/dist/web/assets/index-yMR7OUDx.js +37 -0
- package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +1 -0
- package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +2 -0
- package/dist/web/assets/init-B8gtcn7T.js +1 -0
- package/dist/web/assets/input-Brjz2Vv-.js +41 -0
- package/dist/web/assets/isArrayLikeObject-B4pdpV8V.js +1 -0
- package/dist/web/assets/isEmpty-C0YYdhYj.js +1 -0
- package/dist/web/assets/ishikawaDiagram-PHBUUO56-olazD6dZ.js +70 -0
- package/dist/web/assets/journeyDiagram-4ABVD52K-CttDH9bb.js +139 -0
- package/dist/web/assets/jsx-runtime-BRW_vwa9.js +1 -0
- package/dist/web/assets/kanban-definition-K7BYSVSG-BBXbI37U.js +89 -0
- package/dist/web/assets/katex-Bbu770d9.js +265 -0
- package/dist/web/assets/keybindings-store-B-BLLKiZ.js +1 -0
- package/dist/web/assets/line-DBLLF7lH.js +1 -0
- package/dist/web/assets/linear-BLFWatDe.js +1 -0
- package/dist/web/assets/markdown-renderer-DQWY7QvX.js +69 -0
- package/dist/web/assets/math-DwgHI-Cu.js +1 -0
- package/dist/web/assets/mermaid-parser.core-BKiGOTjR.js +4 -0
- package/dist/web/assets/mindmap-definition-YRQLILUH-DoT7m4Sz.js +68 -0
- package/dist/web/assets/ordinal-CCj7PWgZ.js +1 -0
- package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +1 -0
- package/dist/web/assets/path-DZF-JdEe.js +1 -0
- package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +1 -0
- package/dist/web/assets/pieDiagram-SKSYHLDU-Bkh2E4zE.js +30 -0
- package/dist/web/assets/postgres-viewer-Ctv7NTI_.js +1 -0
- package/dist/web/assets/preload-helper-qlgyTAkD.js +1 -0
- package/dist/web/assets/quadrantDiagram-337W2JSQ-B7zgALOL.js +7 -0
- package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +1 -0
- package/dist/web/assets/react-BGf7KNLk.js +1 -0
- package/dist/web/assets/react-nm2Ru1Pt.js +1 -0
- package/dist/web/assets/requirementDiagram-Z7DCOOCP-D_5GXNRo.js +73 -0
- package/dist/web/assets/rough.esm-VLpapkIG.js +1 -0
- package/dist/web/assets/sankeyDiagram-WA2Y5GQK-BA9EFAAe.js +10 -0
- package/dist/web/assets/sequenceDiagram-2WXFIKYE-fyWIrHiG.js +145 -0
- package/dist/web/assets/settings-store-Bbhg_ptG.js +2 -0
- package/dist/web/assets/settings-tab-Daap0c_B.js +1 -0
- package/dist/web/assets/sqlite-viewer-DtNk76CE.js +1 -0
- package/dist/web/assets/src-BoSBNdA_.js +1 -0
- package/dist/web/assets/stateDiagram-RAJIS63D-DfRBcaBu.js +1 -0
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +1 -0
- package/dist/web/assets/{tab-store-NOBndc0_.js → tab-store-dpsCvqhH.js} +1 -1
- package/dist/web/assets/{table-B6neW6Hr.js → table-Yo02WRH-.js} +1 -1
- package/dist/web/assets/{tag-DJUYe5BQ.js → tag-CaC1ng2E.js} +1 -1
- package/dist/web/assets/{terminal-tab-0Y48dynP.js → terminal-tab-JEpjt3RD.js} +2 -2
- package/dist/web/assets/timeline-definition-YZTLITO2-DYfwJ1jM.js +61 -0
- package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +1 -0
- package/dist/web/assets/{use-monaco-theme-DwP4EHdO.js → use-monaco-theme-DHbyUrzJ.js} +1 -1
- package/dist/web/assets/vennDiagram-LZ73GAT5-DqbKNRD9.js +34 -0
- package/dist/web/assets/xychartDiagram-JWTSCODW-DhUL86qT.js +7 -0
- package/dist/web/index.html +13 -11
- package/dist/web/sw.js +1 -1
- package/package.json +2 -1
- package/snapshot-state.md +1526 -0
- package/src/providers/claude-agent-sdk.ts +7 -0
- package/src/server/index.ts +25 -16
- package/src/server/routes/accounts.ts +3 -8
- package/src/services/account-selector.service.ts +52 -9
- package/src/services/fs-browse.service.ts +10 -7
- package/src/web/app.tsx +8 -0
- package/src/web/components/chat/account-dialogs.tsx +377 -0
- package/src/web/components/chat/message-list.tsx +196 -45
- package/src/web/components/chat/usage-badge.tsx +56 -20
- package/src/web/components/settings/settings-tab.tsx +2 -5
- package/src/web/components/shared/diagram-overlay.tsx +139 -0
- package/src/web/components/shared/image-overlay.tsx +45 -0
- package/src/web/components/shared/markdown-renderer.tsx +55 -2
- package/src/web/stores/diagram-overlay-store.ts +16 -0
- package/src/web/stores/image-overlay-store.ts +18 -0
- package/test-tokens.mjs +212 -0
- package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
- package/dist/web/assets/api-settings-D4bgXrLU.js +0 -1
- package/dist/web/assets/chat-tab-CgVh-OsO.js +0 -7
- package/dist/web/assets/code-editor-DgvZlpB7.js +0 -1
- package/dist/web/assets/columns-2-BZ5wv2wA.js +0 -1
- package/dist/web/assets/database-viewer-CRZksTo-.js +0 -1
- package/dist/web/assets/diff-viewer-CPNLuddT.js +0 -4
- package/dist/web/assets/git-graph-BCtMSQwB.js +0 -1
- package/dist/web/assets/index-CfSJP_Fv.css +0 -2
- package/dist/web/assets/index-DcJqqWbL.js +0 -37
- package/dist/web/assets/input-CE3bFwLk.js +0 -41
- package/dist/web/assets/jsx-runtime-wQxeESYQ.js +0 -1
- package/dist/web/assets/keybindings-store-C1HiSDRb.js +0 -1
- package/dist/web/assets/markdown-renderer-Ci7qz558.js +0 -59
- package/dist/web/assets/postgres-viewer-C8PRJ87B.js +0 -1
- package/dist/web/assets/react-CYzKIDNi.js +0 -1
- package/dist/web/assets/react-rgzL83kk.js +0 -1
- package/dist/web/assets/settings-store-DL2KEbtc.js +0 -2
- package/dist/web/assets/settings-tab-CqnP28Dq.js +0 -1
- package/dist/web/assets/sqlite-viewer-BSceyudC.js +0 -1
- /package/dist/web/assets/{utils-DC-bdPS3.js → utils-btZ8C8-R.js} +0 -0
|
@@ -995,6 +995,13 @@ function parseSessionMessage(msg: { uuid: string; type: string; message: unknown
|
|
|
995
995
|
textContent = extractText(message);
|
|
996
996
|
}
|
|
997
997
|
|
|
998
|
+
// SDK-generated user messages (containing tool_result) carry system text
|
|
999
|
+
// like <task-notification> XML — not actual user input. Clear it so it
|
|
1000
|
+
// doesn't render as a user bubble. Real user messages never have tool_result blocks.
|
|
1001
|
+
if (role === "user" && events.some((e) => e.type === "tool_result")) {
|
|
1002
|
+
textContent = "";
|
|
1003
|
+
}
|
|
1004
|
+
|
|
998
1005
|
return {
|
|
999
1006
|
id: msg.uuid,
|
|
1000
1007
|
role,
|
package/src/server/index.ts
CHANGED
|
@@ -166,22 +166,28 @@ export async function startServer(options: {
|
|
|
166
166
|
// Setup log file (both foreground and daemon modes)
|
|
167
167
|
await setupLogFile();
|
|
168
168
|
|
|
169
|
-
// Check if port is already in use before starting
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
169
|
+
// Check if port is already in use before starting.
|
|
170
|
+
// Skip in hot-reload mode — Bun.serve() replaces the previous server on the same port,
|
|
171
|
+
// but a net.createServer() probe would see it as "in use" and exit prematurely.
|
|
172
|
+
// globalThis persists across bun --hot reloads, so we use a flag set after first start.
|
|
173
|
+
const isHotReload = !!(globalThis as any).__PPM_SERVER_STARTED__;
|
|
174
|
+
if (!isHotReload) {
|
|
175
|
+
const portInUse = await new Promise<boolean>((resolve) => {
|
|
176
|
+
const net = require("node:net") as typeof import("node:net");
|
|
177
|
+
const tester = net.createServer()
|
|
178
|
+
.once("error", (err: NodeJS.ErrnoException) => {
|
|
179
|
+
resolve(err.code === "EADDRINUSE");
|
|
180
|
+
})
|
|
181
|
+
.once("listening", () => {
|
|
182
|
+
tester.close(() => resolve(false));
|
|
183
|
+
})
|
|
184
|
+
.listen(port, host);
|
|
185
|
+
});
|
|
186
|
+
if (portInUse) {
|
|
187
|
+
console.error(`\n ✗ Port ${port} is already in use.`);
|
|
188
|
+
console.error(` Run 'ppm stop' first or use a different port with --port.\n`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
const isDaemon = !options.foreground;
|
|
@@ -393,6 +399,9 @@ export async function startServer(options: {
|
|
|
393
399
|
} as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
|
|
394
400
|
});
|
|
395
401
|
|
|
402
|
+
// Mark server as started — survives bun --hot reloads (globalThis persists)
|
|
403
|
+
(globalThis as any).__PPM_SERVER_STARTED__ = true;
|
|
404
|
+
|
|
396
405
|
// Start background usage polling
|
|
397
406
|
import("../services/claude-usage.service.ts").then(({ startUsagePolling }) => startUsagePolling()).catch(() => {});
|
|
398
407
|
|
|
@@ -38,14 +38,9 @@ accountsRoutes.get("/", (c) => {
|
|
|
38
38
|
|
|
39
39
|
/** GET /api/accounts/active — which account will be used next */
|
|
40
40
|
accountsRoutes.get("/active", (c) => {
|
|
41
|
-
const
|
|
42
|
-
if (!
|
|
43
|
-
|
|
44
|
-
const accounts = accountService.list().filter((a) => a.status === "active");
|
|
45
|
-
if (accounts.length === 0) return c.json(ok(null));
|
|
46
|
-
return c.json(ok(accounts[0]));
|
|
47
|
-
}
|
|
48
|
-
const account = accountService.list().find((a) => a.id === lastId) ?? null;
|
|
41
|
+
const peeked = accountSelector.peek();
|
|
42
|
+
if (!peeked) return c.json(ok(null));
|
|
43
|
+
const account = accountService.list().find((a) => a.id === peeked.id) ?? null;
|
|
49
44
|
return c.json(ok(account));
|
|
50
45
|
});
|
|
51
46
|
|
|
@@ -90,29 +90,72 @@ class AccountSelectorService {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
* Peek at which account the current strategy would pick, without consuming it.
|
|
94
|
+
* Returns null if no active accounts.
|
|
95
|
+
*/
|
|
96
|
+
peek(): AccountWithTokens | null {
|
|
97
|
+
const now = Math.floor(Date.now() / 1000);
|
|
98
|
+
const active = accountService.list().filter(
|
|
99
|
+
(a) => a.status === "active" || (a.status === "cooldown" && (a.cooldownUntil ?? 0) <= now),
|
|
100
|
+
);
|
|
101
|
+
if (active.length === 0) return null;
|
|
102
|
+
|
|
103
|
+
const strategy = this.getStrategy();
|
|
104
|
+
let pickedId: string;
|
|
105
|
+
if (strategy === "lowest-usage") {
|
|
106
|
+
pickedId = this.pickLowestUsage(active);
|
|
107
|
+
} else if (strategy === "fill-first") {
|
|
108
|
+
const sorted = [...active].sort((a, b) => b.priority - a.priority || a.createdAt - b.createdAt);
|
|
109
|
+
pickedId = sorted[0]!.id;
|
|
110
|
+
} else {
|
|
111
|
+
const idx = this.cursor % active.length;
|
|
112
|
+
pickedId = active[idx]!.id;
|
|
113
|
+
}
|
|
114
|
+
return accountService.getWithTokens(pickedId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Weighted sustainability score.
|
|
119
|
+
* Considers 5-hour utilization, weekly utilization, and time until weekly reset.
|
|
120
|
+
*
|
|
121
|
+
* score = 0.35 × (1 - 5hr) + 0.65 × min(weeklyRemaining / resetRatio, 1.0)
|
|
122
|
+
*
|
|
123
|
+
* weeklyRemaining / resetRatio normalizes remaining capacity by time until reset:
|
|
124
|
+
* - 4% remaining with 34h left → low sustainability (0.20)
|
|
125
|
+
* - 78% remaining with 113h left → high sustainability (1.0, capped)
|
|
126
|
+
* - 20% remaining with 6h left → decent (resets soon, so it's fine)
|
|
97
127
|
*/
|
|
98
128
|
private pickLowestUsage(active: { id: string; createdAt: number }[]): string {
|
|
99
129
|
const scored = active.map((acc) => {
|
|
100
130
|
const snap = getLatestSnapshotForAccount(acc.id);
|
|
101
131
|
const fiveHour = snap?.five_hour_util ?? 0;
|
|
102
132
|
const weekly = snap?.weekly_util ?? 0;
|
|
103
|
-
// weekly >= 1.0 means fully exhausted — mark as unavailable
|
|
104
133
|
const exhausted = weekly >= 1.0 || fiveHour >= 1.0;
|
|
105
|
-
|
|
134
|
+
|
|
135
|
+
// Compute hours until weekly reset (default 168h = full week if unknown)
|
|
136
|
+
let weeklyResetHours = 168;
|
|
137
|
+
if (snap?.weekly_resets_at) {
|
|
138
|
+
const diff = new Date(snap.weekly_resets_at).getTime() - Date.now();
|
|
139
|
+
weeklyResetHours = Math.max(diff / 3_600_000, 0.1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const immediate = 1 - fiveHour;
|
|
143
|
+
const weeklyRemaining = 1 - weekly;
|
|
144
|
+
const resetRatio = weeklyResetHours / 168;
|
|
145
|
+
const sustainability = Math.min(weeklyRemaining / Math.max(resetRatio, 0.05), 1.0);
|
|
146
|
+
const score = 0.35 * immediate + 0.65 * sustainability;
|
|
147
|
+
|
|
148
|
+
return { id: acc.id, score, exhausted };
|
|
106
149
|
});
|
|
107
150
|
|
|
108
151
|
const available = scored.filter((s) => !s.exhausted);
|
|
109
152
|
if (available.length > 0) {
|
|
110
|
-
available.sort((a, b) =>
|
|
153
|
+
available.sort((a, b) => b.score - a.score);
|
|
111
154
|
return available[0]!.id;
|
|
112
155
|
}
|
|
113
156
|
|
|
114
|
-
// All exhausted —
|
|
115
|
-
scored.sort((a, b) =>
|
|
157
|
+
// All exhausted — pick highest score as fallback
|
|
158
|
+
scored.sort((a, b) => b.score - a.score);
|
|
116
159
|
return scored[0]!.id;
|
|
117
160
|
}
|
|
118
161
|
|
|
@@ -144,7 +144,9 @@ function buildBreadcrumbs(
|
|
|
144
144
|
|
|
145
145
|
// ── List (moved from index.ts inline) ──────────────────────────────
|
|
146
146
|
|
|
147
|
-
/**
|
|
147
|
+
/** Breadth-first file listing for command palette.
|
|
148
|
+
* Lists all files at each level before descending into subdirectories,
|
|
149
|
+
* so root-level files (e.g. ~/.npmrc) are always found before the limit. */
|
|
148
150
|
export function list(dir: string): string[] {
|
|
149
151
|
const resolved = resolvePath(dir);
|
|
150
152
|
if (!isAllowedPath(resolved)) {
|
|
@@ -152,28 +154,29 @@ export function list(dir: string): string[] {
|
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
const files: string[] = [];
|
|
157
|
+
const queue: { path: string; depth: number }[] = [{ path: resolved, depth: 0 }];
|
|
155
158
|
|
|
156
|
-
|
|
157
|
-
|
|
159
|
+
while (queue.length > 0 && files.length < LIST_MAX_FILES) {
|
|
160
|
+
const { path: dirPath, depth } = queue.shift()!;
|
|
161
|
+
if (depth > LIST_MAX_DEPTH) continue;
|
|
158
162
|
let entries: import("node:fs").Dirent[];
|
|
159
163
|
try {
|
|
160
164
|
entries = readdirSync(dirPath, { withFileTypes: true });
|
|
161
165
|
} catch {
|
|
162
|
-
|
|
166
|
+
continue;
|
|
163
167
|
}
|
|
164
168
|
for (const entry of entries) {
|
|
165
169
|
if (SKIP_NAMES.has(entry.name)) continue;
|
|
166
170
|
const full = resolve(dirPath, entry.name);
|
|
167
171
|
if (entry.isFile()) {
|
|
168
172
|
files.push(full);
|
|
169
|
-
if (files.length >= LIST_MAX_FILES) return;
|
|
173
|
+
if (files.length >= LIST_MAX_FILES) return files;
|
|
170
174
|
} else if (entry.isDirectory()) {
|
|
171
|
-
|
|
175
|
+
queue.push({ path: full, depth: depth + 1 });
|
|
172
176
|
}
|
|
173
177
|
}
|
|
174
178
|
}
|
|
175
179
|
|
|
176
|
-
walk(resolved, 0);
|
|
177
180
|
return files;
|
|
178
181
|
}
|
|
179
182
|
|
package/src/web/app.tsx
CHANGED
|
@@ -22,6 +22,8 @@ import { useServerReload } from "@/hooks/use-server-reload";
|
|
|
22
22
|
import { CommandPalette } from "@/components/layout/command-palette";
|
|
23
23
|
import { BugReportPopup } from "@/components/shared/bug-report-popup";
|
|
24
24
|
import { UpgradeBanner } from "@/components/layout/upgrade-banner";
|
|
25
|
+
import { ImageOverlay } from "@/components/shared/image-overlay";
|
|
26
|
+
import { DiagramOverlay } from "@/components/shared/diagram-overlay";
|
|
25
27
|
import { cn } from "@/lib/utils";
|
|
26
28
|
|
|
27
29
|
type AuthState = "checking" | "authenticated" | "unauthenticated";
|
|
@@ -280,6 +282,12 @@ export function App() {
|
|
|
280
282
|
{/* Global bug report popup */}
|
|
281
283
|
<BugReportPopup />
|
|
282
284
|
|
|
285
|
+
{/* Global image lightbox */}
|
|
286
|
+
<ImageOverlay />
|
|
287
|
+
|
|
288
|
+
{/* Global diagram lightbox (mermaid) */}
|
|
289
|
+
<DiagramOverlay />
|
|
290
|
+
|
|
283
291
|
{/* Toast notifications */}
|
|
284
292
|
<Toaster
|
|
285
293
|
position="bottom-left"
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { Label } from "@/components/ui/label";
|
|
6
|
+
import { Loader2, Download, Copy, Lock } from "lucide-react";
|
|
7
|
+
import { getAuthToken } from "../../lib/api-client";
|
|
8
|
+
import {
|
|
9
|
+
addAccount,
|
|
10
|
+
getOAuthUrl,
|
|
11
|
+
exchangeOAuthCode,
|
|
12
|
+
importAccounts,
|
|
13
|
+
type AccountInfo,
|
|
14
|
+
} from "../../lib/api-settings";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PASSWORD = "ppm-hienlh";
|
|
17
|
+
|
|
18
|
+
// ── Add Account Dialog ─────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
interface AddAccountDialogProps {
|
|
21
|
+
open: boolean;
|
|
22
|
+
onOpenChange: (open: boolean) => void;
|
|
23
|
+
onSuccess: (msg?: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function AddAccountDialog({ open, onOpenChange, onSuccess }: AddAccountDialogProps) {
|
|
27
|
+
const [newToken, setNewToken] = useState("");
|
|
28
|
+
const [newLabel, setNewLabel] = useState("");
|
|
29
|
+
const [adding, setAdding] = useState(false);
|
|
30
|
+
const [addError, setAddError] = useState<string | null>(null);
|
|
31
|
+
const [oauthState, setOauthState] = useState<string | null>(null);
|
|
32
|
+
const [oauthCode, setOauthCode] = useState("");
|
|
33
|
+
const [oauthLoading, setOauthLoading] = useState(false);
|
|
34
|
+
const [oauthStep, setOauthStep] = useState<"idle" | "waiting">("idle");
|
|
35
|
+
|
|
36
|
+
function resetOAuth() {
|
|
37
|
+
setOauthState(null);
|
|
38
|
+
setOauthCode("");
|
|
39
|
+
setOauthStep("idle");
|
|
40
|
+
setAddError(null);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleClose() {
|
|
44
|
+
onOpenChange(false);
|
|
45
|
+
resetOAuth();
|
|
46
|
+
setNewToken("");
|
|
47
|
+
setNewLabel("");
|
|
48
|
+
setAddError(null);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function handleOAuthLogin() {
|
|
52
|
+
setOauthLoading(true);
|
|
53
|
+
setAddError(null);
|
|
54
|
+
try {
|
|
55
|
+
const { url, state } = await getOAuthUrl();
|
|
56
|
+
setOauthState(state);
|
|
57
|
+
setOauthStep("waiting");
|
|
58
|
+
window.open(url, "_blank");
|
|
59
|
+
} catch (e) {
|
|
60
|
+
setAddError((e as Error).message);
|
|
61
|
+
}
|
|
62
|
+
setOauthLoading(false);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function handleOAuthExchange() {
|
|
66
|
+
if (!oauthCode.trim() || !oauthState) return;
|
|
67
|
+
setOauthLoading(true);
|
|
68
|
+
setAddError(null);
|
|
69
|
+
try {
|
|
70
|
+
let code = oauthCode.trim();
|
|
71
|
+
if (code.includes("#")) code = code.split("#")[0] ?? code;
|
|
72
|
+
await exchangeOAuthCode(code, oauthState);
|
|
73
|
+
handleClose();
|
|
74
|
+
onSuccess("Account connected via OAuth!");
|
|
75
|
+
} catch (e) {
|
|
76
|
+
setAddError((e as Error).message);
|
|
77
|
+
}
|
|
78
|
+
setOauthLoading(false);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function handleAddToken() {
|
|
82
|
+
if (!newToken.trim()) return;
|
|
83
|
+
setAdding(true);
|
|
84
|
+
setAddError(null);
|
|
85
|
+
try {
|
|
86
|
+
await addAccount({ apiKey: newToken.trim(), label: newLabel.trim() || undefined });
|
|
87
|
+
handleClose();
|
|
88
|
+
onSuccess("Account added!");
|
|
89
|
+
} catch (e) {
|
|
90
|
+
setAddError((e as Error).message);
|
|
91
|
+
}
|
|
92
|
+
setAdding(false);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const tokenHint = newToken.trim()
|
|
96
|
+
? newToken.trim().startsWith("sk-ant-oat") ? "OAuth token (Claude Max/Pro)"
|
|
97
|
+
: newToken.trim().startsWith("sk-ant-api") ? "API key"
|
|
98
|
+
: "Unknown format"
|
|
99
|
+
: "";
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
|
103
|
+
<DialogContent className="sm:max-w-md">
|
|
104
|
+
<DialogHeader>
|
|
105
|
+
<DialogTitle className="text-sm">Add Claude Account</DialogTitle>
|
|
106
|
+
<DialogDescription className="text-xs leading-relaxed">
|
|
107
|
+
Connect via OAuth (recommended) or paste a token manually.
|
|
108
|
+
</DialogDescription>
|
|
109
|
+
</DialogHeader>
|
|
110
|
+
<div className="space-y-3">
|
|
111
|
+
{/* OAuth login */}
|
|
112
|
+
<div className="rounded-md border p-3 space-y-2">
|
|
113
|
+
<p className="text-[11px] font-medium">Recommended: Login with Claude</p>
|
|
114
|
+
{oauthStep === "idle" ? (
|
|
115
|
+
<Button size="sm" className="w-full h-8 text-xs" onClick={handleOAuthLogin} disabled={oauthLoading}>
|
|
116
|
+
{oauthLoading ? <><Loader2 className="size-3 animate-spin mr-1" /> Opening...</> : "Login with Claude"}
|
|
117
|
+
</Button>
|
|
118
|
+
) : (
|
|
119
|
+
<div className="space-y-2">
|
|
120
|
+
<p className="text-[10px] text-muted-foreground">Authorize in the opened tab, then paste the code:</p>
|
|
121
|
+
<Input placeholder="Paste code here..." value={oauthCode} onChange={(e) => setOauthCode(e.target.value)} className="text-xs h-8 font-mono" autoFocus />
|
|
122
|
+
<div className="flex gap-1.5">
|
|
123
|
+
<Button size="sm" className="flex-1 h-7 text-xs" onClick={handleOAuthExchange} disabled={!oauthCode.trim() || oauthLoading}>
|
|
124
|
+
{oauthLoading ? <><Loader2 className="size-3 animate-spin mr-1" /> Connecting...</> : "Connect"}
|
|
125
|
+
</Button>
|
|
126
|
+
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={resetOAuth}>Cancel</Button>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
<div className="flex items-center gap-2">
|
|
132
|
+
<div className="flex-1 border-t" />
|
|
133
|
+
<span className="text-[10px] text-muted-foreground">or paste token</span>
|
|
134
|
+
<div className="flex-1 border-t" />
|
|
135
|
+
</div>
|
|
136
|
+
{/* Manual token */}
|
|
137
|
+
<div className="space-y-1.5">
|
|
138
|
+
<Label htmlFor="add-token" className="text-xs">Token</Label>
|
|
139
|
+
<Input id="add-token" type="password" placeholder="sk-ant-..." value={newToken} onChange={(e) => setNewToken(e.target.value)} className="text-xs h-8 font-mono" />
|
|
140
|
+
{tokenHint && <p className="text-[10px] text-muted-foreground">Detected: {tokenHint}</p>}
|
|
141
|
+
</div>
|
|
142
|
+
<div className="space-y-1.5">
|
|
143
|
+
<Label htmlFor="add-label" className="text-xs">Label (optional)</Label>
|
|
144
|
+
<Input id="add-label" placeholder="e.g. Personal, Work" value={newLabel} onChange={(e) => setNewLabel(e.target.value)} className="text-xs h-8" />
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
{addError && <div className="text-[11px] p-2 rounded bg-red-500/10 text-red-600">{addError}</div>}
|
|
148
|
+
<DialogFooter>
|
|
149
|
+
<Button size="sm" variant="outline" className="text-xs h-7" onClick={handleClose}>Cancel</Button>
|
|
150
|
+
<Button size="sm" className="text-xs h-7" onClick={handleAddToken} disabled={!newToken.trim() || adding}>
|
|
151
|
+
{adding ? "Adding..." : "Add Token"}
|
|
152
|
+
</Button>
|
|
153
|
+
</DialogFooter>
|
|
154
|
+
</DialogContent>
|
|
155
|
+
</Dialog>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Export Accounts Dialog ──────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
interface ExportAccountsDialogProps {
|
|
162
|
+
open: boolean;
|
|
163
|
+
onOpenChange: (open: boolean) => void;
|
|
164
|
+
accounts: AccountInfo[];
|
|
165
|
+
preselectId?: string | null;
|
|
166
|
+
onMessage?: (msg: string) => void;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function ExportAccountsDialog({ open, onOpenChange, accounts, preselectId, onMessage }: ExportAccountsDialogProps) {
|
|
170
|
+
const exportable = accounts.filter((a) => a.hasRefreshToken);
|
|
171
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
172
|
+
const [password, setPassword] = useState("");
|
|
173
|
+
const [fullTransfer, setFullTransfer] = useState(false);
|
|
174
|
+
const [refreshBefore, setRefreshBefore] = useState(false);
|
|
175
|
+
const [exporting, setExporting] = useState(false);
|
|
176
|
+
const [initialized, setInitialized] = useState(false);
|
|
177
|
+
|
|
178
|
+
// Initialize selection when dialog opens
|
|
179
|
+
if (open && !initialized) {
|
|
180
|
+
setSelected(preselectId ? new Set([preselectId]) : new Set(exportable.map((a) => a.id)));
|
|
181
|
+
setInitialized(true);
|
|
182
|
+
}
|
|
183
|
+
if (!open && initialized) {
|
|
184
|
+
setInitialized(false);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function handleClose() {
|
|
188
|
+
onOpenChange(false);
|
|
189
|
+
setPassword("");
|
|
190
|
+
setFullTransfer(false);
|
|
191
|
+
setRefreshBefore(false);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function doExport(toClipboard: boolean) {
|
|
195
|
+
if (selected.size === 0) return;
|
|
196
|
+
setExporting(true);
|
|
197
|
+
const effectivePassword = password.trim() || DEFAULT_PASSWORD;
|
|
198
|
+
try {
|
|
199
|
+
const headers: HeadersInit = { "Content-Type": "application/json" };
|
|
200
|
+
const token = getAuthToken();
|
|
201
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
202
|
+
const res = await fetch("/api/accounts/export", {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers,
|
|
205
|
+
body: JSON.stringify({ password: effectivePassword, accountIds: [...selected], includeRefreshToken: fullTransfer, refreshBeforeExport: refreshBefore }),
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok) { const j = await res.json() as any; throw new Error(j.error ?? `Export failed: ${res.status}`); }
|
|
208
|
+
const text = await res.text();
|
|
209
|
+
if (toClipboard) {
|
|
210
|
+
try {
|
|
211
|
+
await navigator.clipboard.writeText(text);
|
|
212
|
+
onMessage?.("Backup copied to clipboard!");
|
|
213
|
+
} catch {
|
|
214
|
+
downloadBlob(text);
|
|
215
|
+
onMessage?.("Backup downloaded.");
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
downloadBlob(text);
|
|
219
|
+
onMessage?.("Backup downloaded.");
|
|
220
|
+
}
|
|
221
|
+
handleClose();
|
|
222
|
+
} catch { /* silent */ }
|
|
223
|
+
setExporting(false);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const valid = selected.size > 0 && !exporting;
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
|
230
|
+
<DialogContent className="sm:max-w-md">
|
|
231
|
+
<DialogHeader>
|
|
232
|
+
<DialogTitle className="text-sm flex items-center gap-1.5"><Lock className="size-3.5" /> Export Accounts</DialogTitle>
|
|
233
|
+
<DialogDescription className="text-xs">Select accounts and set a password to protect the backup.</DialogDescription>
|
|
234
|
+
</DialogHeader>
|
|
235
|
+
<div className="space-y-3">
|
|
236
|
+
{/* Account selection */}
|
|
237
|
+
<div className="space-y-1">
|
|
238
|
+
<div className="flex items-center justify-between mb-1">
|
|
239
|
+
<p className="text-[11px] font-medium text-muted-foreground">Accounts to export</p>
|
|
240
|
+
<button className="text-[10px] text-primary hover:underline cursor-pointer" onClick={() => setSelected(selected.size === exportable.length ? new Set() : new Set(exportable.map((a) => a.id)))}>
|
|
241
|
+
{selected.size === exportable.length ? "Deselect all" : "Select all"}
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
{exportable.length === 0 ? (
|
|
245
|
+
<p className="text-[10px] text-muted-foreground p-2 border rounded">No exportable accounts.</p>
|
|
246
|
+
) : (
|
|
247
|
+
<div className="max-h-36 overflow-y-auto space-y-1 border rounded p-2">
|
|
248
|
+
{exportable.map((acc) => (
|
|
249
|
+
<div key={acc.id} className="flex items-center gap-2">
|
|
250
|
+
<input type="checkbox" id={`exp-${acc.id}`} checked={selected.has(acc.id)} onChange={(e) => { const s = new Set(selected); e.target.checked ? s.add(acc.id) : s.delete(acc.id); setSelected(s); }} className="size-3.5 accent-primary cursor-pointer" />
|
|
251
|
+
<label htmlFor={`exp-${acc.id}`} className="text-xs cursor-pointer truncate">
|
|
252
|
+
{acc.label ?? acc.email ?? acc.id.slice(0, 8)}
|
|
253
|
+
</label>
|
|
254
|
+
</div>
|
|
255
|
+
))}
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
{/* Password (optional) */}
|
|
260
|
+
<div className="space-y-1.5">
|
|
261
|
+
<Label className="text-xs">Password <span className="text-muted-foreground font-normal">(optional)</span></Label>
|
|
262
|
+
<Input type="password" placeholder="Leave empty for default" value={password} onChange={(e) => setPassword(e.target.value)} className="text-xs h-8" autoComplete="new-password" />
|
|
263
|
+
</div>
|
|
264
|
+
{/* Options */}
|
|
265
|
+
<div className="flex items-center gap-2">
|
|
266
|
+
<input type="checkbox" id="exp-full" checked={fullTransfer} onChange={(e) => setFullTransfer(e.target.checked)} className="size-3.5 accent-primary cursor-pointer" />
|
|
267
|
+
<label htmlFor="exp-full" className="text-[11px] cursor-pointer">Include refresh tokens (full transfer)</label>
|
|
268
|
+
</div>
|
|
269
|
+
<div className="flex items-center gap-2">
|
|
270
|
+
<input type="checkbox" id="exp-refresh" checked={refreshBefore} onChange={(e) => setRefreshBefore(e.target.checked)} className="size-3.5 accent-primary cursor-pointer" />
|
|
271
|
+
<label htmlFor="exp-refresh" className="text-[11px] cursor-pointer">Refresh tokens before export</label>
|
|
272
|
+
</div>
|
|
273
|
+
{/* Warning */}
|
|
274
|
+
{fullTransfer ? (
|
|
275
|
+
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-2.5">
|
|
276
|
+
<p className="text-[10px] font-medium text-red-600">Full transfer — source accounts will expire</p>
|
|
277
|
+
<p className="text-[10px] text-muted-foreground">Refresh tokens included. Source machine expires in ~1h after target refreshes.</p>
|
|
278
|
+
</div>
|
|
279
|
+
) : refreshBefore ? (
|
|
280
|
+
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-2.5">
|
|
281
|
+
<p className="text-[10px] font-medium text-amber-600">Refresh before export — invalidates previous shares</p>
|
|
282
|
+
</div>
|
|
283
|
+
) : (
|
|
284
|
+
<div className="rounded-md border border-green-500/30 bg-green-500/5 p-2.5">
|
|
285
|
+
<p className="text-[10px] font-medium text-green-600">Share current token (safe)</p>
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
<p className="text-[10px] text-muted-foreground">Encrypted with AES-256-GCM + scrypt.</p>
|
|
289
|
+
</div>
|
|
290
|
+
<DialogFooter className="gap-1.5 flex-col sm:flex-row">
|
|
291
|
+
<Button size="sm" variant="outline" className="text-xs h-7 cursor-pointer" onClick={handleClose}>Cancel</Button>
|
|
292
|
+
<Button size="sm" variant="outline" className="text-xs h-7 cursor-pointer" disabled={!valid} onClick={() => doExport(true)}>
|
|
293
|
+
<Copy className="size-3 mr-1" /> Copy
|
|
294
|
+
</Button>
|
|
295
|
+
<Button size="sm" className="text-xs h-7 cursor-pointer" disabled={!valid} onClick={() => doExport(false)}>
|
|
296
|
+
{exporting ? <><Loader2 className="size-3 animate-spin mr-1" /> Exporting...</> : <><Download className="size-3 mr-1" /> Download</>}
|
|
297
|
+
</Button>
|
|
298
|
+
</DialogFooter>
|
|
299
|
+
</DialogContent>
|
|
300
|
+
</Dialog>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Import Accounts Dialog ─────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
interface ImportAccountsDialogProps {
|
|
307
|
+
open: boolean;
|
|
308
|
+
onOpenChange: (open: boolean) => void;
|
|
309
|
+
onSuccess: (msg?: string) => void;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function ImportAccountsDialog({ open, onOpenChange, onSuccess }: ImportAccountsDialogProps) {
|
|
313
|
+
const [data, setData] = useState("");
|
|
314
|
+
const [password, setPassword] = useState("");
|
|
315
|
+
const [importing, setImporting] = useState(false);
|
|
316
|
+
const [error, setError] = useState<string | null>(null);
|
|
317
|
+
|
|
318
|
+
function handleClose() {
|
|
319
|
+
onOpenChange(false);
|
|
320
|
+
setData("");
|
|
321
|
+
setPassword("");
|
|
322
|
+
setError(null);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function doImport() {
|
|
326
|
+
if (!data.trim()) return;
|
|
327
|
+
setImporting(true);
|
|
328
|
+
setError(null);
|
|
329
|
+
try {
|
|
330
|
+
const result = await importAccounts({ data: data.trim(), password: password.trim() || DEFAULT_PASSWORD });
|
|
331
|
+
handleClose();
|
|
332
|
+
onSuccess(`Imported ${result.imported} account(s)`);
|
|
333
|
+
} catch (e) {
|
|
334
|
+
setError((e as Error).message || "Import failed");
|
|
335
|
+
}
|
|
336
|
+
setImporting(false);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
|
341
|
+
<DialogContent className="sm:max-w-md">
|
|
342
|
+
<DialogHeader>
|
|
343
|
+
<DialogTitle className="text-sm flex items-center gap-1.5"><Lock className="size-3.5" /> Import Accounts</DialogTitle>
|
|
344
|
+
<DialogDescription className="text-xs">Paste backup data and enter the export password. Imported accounts are temporary (~1h).</DialogDescription>
|
|
345
|
+
</DialogHeader>
|
|
346
|
+
<div className="space-y-3">
|
|
347
|
+
<div className="space-y-1.5">
|
|
348
|
+
<Label className="text-xs">Backup data</Label>
|
|
349
|
+
<textarea value={data} onChange={(e) => setData(e.target.value)} placeholder="Paste backup JSON here..." rows={4} className="w-full text-xs p-2 rounded border border-border bg-background font-mono resize-none focus:outline-none focus:ring-1 focus:ring-primary" />
|
|
350
|
+
</div>
|
|
351
|
+
<div className="space-y-1.5">
|
|
352
|
+
<Label className="text-xs">Password <span className="text-muted-foreground font-normal">(optional)</span></Label>
|
|
353
|
+
<Input type="password" placeholder="Leave empty for default" value={password} onChange={(e) => setPassword(e.target.value)} className="text-xs h-8" autoComplete="current-password" />
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
{error && <div className="text-[11px] p-2 rounded bg-red-500/10 text-red-600">{error}</div>}
|
|
357
|
+
<DialogFooter>
|
|
358
|
+
<Button size="sm" variant="outline" className="text-xs h-7 cursor-pointer" onClick={handleClose}>Cancel</Button>
|
|
359
|
+
<Button size="sm" className="text-xs h-7 cursor-pointer" disabled={!data.trim() || importing} onClick={doImport}>
|
|
360
|
+
{importing ? <><Loader2 className="size-3 animate-spin mr-1" /> Importing...</> : "Import"}
|
|
361
|
+
</Button>
|
|
362
|
+
</DialogFooter>
|
|
363
|
+
</DialogContent>
|
|
364
|
+
</Dialog>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
function downloadBlob(text: string) {
|
|
371
|
+
const blob = new Blob([text], { type: "application/json" });
|
|
372
|
+
const a = document.createElement("a");
|
|
373
|
+
a.href = URL.createObjectURL(blob);
|
|
374
|
+
a.download = "ppm-accounts-backup.json";
|
|
375
|
+
a.click();
|
|
376
|
+
URL.revokeObjectURL(a.href);
|
|
377
|
+
}
|