@hienlh/ppm 0.10.4 → 0.11.0
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 +39 -0
- package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
- package/dist/web/assets/{api-settings-CoKe_BdR.js → api-settings-2eTz4SgY.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-BRW4VwMk.js +1 -0
- package/dist/web/assets/chat-tab-CbguR_l0.js +10 -0
- package/dist/web/assets/code-editor-DbZP0Dnj.js +8 -0
- package/dist/web/assets/{conflict-editor-HvxI1A29.js → conflict-editor-BzrH1UpC.js} +3 -3
- package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
- package/dist/web/assets/{database-viewer-BgCXPc4e.js → database-viewer-CqMOv2Sg.js} +2 -2
- package/dist/web/assets/{diff-viewer-blzXAJHd.js → diff-viewer-B6a2oYYn.js} +1 -1
- package/dist/web/assets/{esm-K1XIK4vc.js → esm-B99v94EE.js} +1 -1
- package/dist/web/assets/{extension-store-3yZYn07W.js → extension-store-CkyOvGbF.js} +1 -1
- package/dist/web/assets/extension-webview-CZr_fvOm.js +3 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
- package/dist/web/assets/index-C68PuiOm.js +26 -0
- package/dist/web/assets/index-iZHWllzQ.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-ySD5z855.js +1 -0
- package/dist/web/assets/{keybindings-store-D2N-Tq4N.js → keybindings-store-CpP5_miA.js} +1 -1
- package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
- package/dist/web/assets/{markdown-renderer-Hcj-59AX.js → markdown-renderer-BhNYbXCp.js} +3 -3
- package/dist/web/assets/packet-RMMSAZCW-CLxaXgIf.js +1 -0
- package/dist/web/assets/pie-UPGHQEXC-C9wPZfkn.js +1 -0
- package/dist/web/assets/port-forwarding-tab-Dw9MUu5a.js +1 -0
- package/dist/web/assets/{postgres-viewer-BEUI1N1X.js → postgres-viewer-YKyNjTLp.js} +3 -3
- package/dist/web/assets/{project-store-Ciq-cK1O.js → project-store-CczGNZyf.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
- package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
- package/dist/web/assets/settings-tab-2tdZuQIn.js +1 -0
- package/dist/web/assets/{sql-query-editor-DZ9xskL8.js → sql-query-editor-CVEi0jLM.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-sQs615K6.js → sqlite-viewer-Fx9qDD4-.js} +1 -1
- package/dist/web/assets/{tab-store-DZbiYk7y.js → tab-store-Jvy1eZGM.js} +1 -1
- package/dist/web/assets/terminal-tab-BxljmYb7.js +1 -0
- package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
- package/dist/web/assets/{use-monaco-theme-OY18iXNi.js → use-monaco-theme-kjiAwvOp.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-B2SLgECS.js → vendor-mermaid-CylkVm4U.js} +3 -3
- package/dist/web/index.html +13 -13
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +29 -5
- package/docs/project-changelog.md +31 -1
- package/docs/system-architecture.md +106 -1
- package/package.json +1 -1
- package/packages/ext-git-graph/src/extension.ts +11 -4
- package/packages/ext-git-graph/src/webview-html.ts +25 -11
- package/src/cli/commands/jira-cmd.ts +92 -0
- package/src/cli/commands/jira-watcher-cmd.ts +149 -0
- package/src/index.ts +3 -0
- package/src/server/index.ts +19 -0
- package/src/server/routes/files.ts +15 -0
- package/src/server/routes/fs-browse.ts +40 -1
- package/src/server/routes/jira-config-routes.ts +74 -0
- package/src/server/routes/jira-watcher-routes.ts +316 -0
- package/src/server/routes/jira.ts +7 -0
- package/src/server/ws/chat.ts +21 -0
- package/src/services/db.service.ts +65 -1
- package/src/services/extension-host-worker.ts +3 -2
- package/src/services/extension.service.ts +4 -2
- package/src/services/file.service.ts +42 -0
- package/src/services/jira-api-client.ts +216 -0
- package/src/services/jira-config.service.ts +83 -0
- package/src/services/jira-debug-session.service.ts +240 -0
- package/src/services/jira-watcher-db.service.ts +195 -0
- package/src/services/jira-watcher.service.ts +159 -0
- package/src/services/notification.service.ts +6 -0
- package/src/services/supervisor-state.ts +13 -1
- package/src/services/supervisor.ts +4 -3
- package/src/types/jira.ts +128 -0
- package/src/web/app.tsx +15 -12
- package/src/web/components/chat/chat-tab.tsx +32 -1
- package/src/web/components/chat/message-input.tsx +56 -5
- package/src/web/components/explorer/file-tree.tsx +9 -0
- package/src/web/components/extensions/extension-webview.tsx +31 -13
- package/src/web/components/jira/jira-config-form.tsx +109 -0
- package/src/web/components/jira/jira-debug-prompt-dialog.tsx +58 -0
- package/src/web/components/jira/jira-filter-builder.tsx +197 -0
- package/src/web/components/jira/jira-panel.tsx +201 -0
- package/src/web/components/jira/jira-results-panel.tsx +184 -0
- package/src/web/components/jira/jira-settings-section.tsx +58 -0
- package/src/web/components/jira/jira-status-badge.tsx +18 -0
- package/src/web/components/jira/jira-ticket-card.tsx +144 -0
- package/src/web/components/jira/jira-ticket-detail.tsx +153 -0
- package/src/web/components/jira/jira-watcher-form.tsx +154 -0
- package/src/web/components/jira/jira-watcher-list.tsx +98 -0
- package/src/web/components/layout/mobile-drawer.tsx +18 -5
- package/src/web/components/layout/sidebar.tsx +20 -3
- package/src/web/components/settings/settings-tab.tsx +20 -3
- package/src/web/components/shared/markdown-code-block.tsx +5 -3
- package/src/web/components/ui/file-browser-picker.tsx +88 -1
- package/src/web/hooks/use-chat.ts +6 -0
- package/src/web/lib/report-bug.ts +3 -2
- package/src/web/lib/ws-client.ts +14 -6
- package/src/web/stores/jira-store.ts +198 -0
- package/src/web/stores/settings-store.ts +24 -5
- package/src/web/styles/globals.css +7 -0
- package/vite.config.ts +5 -66
- package/bun.lock +0 -2062
- package/bunfig.toml +0 -2
- package/dist/web/assets/ai-settings-section-LMO_cfIW.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-CUZIB1Vq.js +0 -1
- package/dist/web/assets/chat-tab-By7krQ3s.js +0 -10
- package/dist/web/assets/code-editor-BoKL57Co.js +0 -8
- package/dist/web/assets/extension-webview-Dvk_61ON.js +0 -3
- package/dist/web/assets/gitGraph-HDMCJU4V-CtOMUphQ.js +0 -1
- package/dist/web/assets/index-DPnjO2FY.css +0 -2
- package/dist/web/assets/index-EgCQVN13.js +0 -26
- package/dist/web/assets/info-3K5VOQVL-BCrPCWGY.js +0 -1
- package/dist/web/assets/keybindings-store-C7No6mtl.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-D_OqB-zi.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-WUHpLNJz.js +0 -1
- package/dist/web/assets/port-forwarding-tab-CUgwDn_5.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-HQIIecVM.js +0 -1
- package/dist/web/assets/settings-store-B470PCWf.js +0 -2
- package/dist/web/assets/settings-tab-BGvgK51L.js +0 -1
- package/dist/web/assets/square-nsMa3iMk.js +0 -1
- package/dist/web/assets/terminal-tab-CUyHmiHH.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-0wLgUUTz.js +0 -1
- /package/dist/web/assets/{api-client-o_6TmLGC.js → api-client-C3tXCh0r.js} +0 -0
- /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-BAa56Nnn.js} +0 -0
- /package/dist/web/assets/{dist-im4ynINo.js → dist-On3hz9_g.js} +0 -0
- /package/dist/web/assets/{katex-CKoArbIw.js → katex-Bbu770d9.js} +0 -0
- /package/dist/web/assets/{lib-D_kRA9p6.js → lib-BqkcKGFq.js} +0 -0
- /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
- /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-D3acAhav.js} +0 -0
- /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
- /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
- /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-ejLe7-tK.js → vendor-xterm-B9BUAFKA.js} +0 -0
|
@@ -7,7 +7,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
7
7
|
import { Input } from "@/components/ui/input";
|
|
8
8
|
import { api } from "@/lib/api-client";
|
|
9
9
|
import {
|
|
10
|
-
Folder, File, Database, Home, Monitor, FileText,
|
|
10
|
+
Folder, File, Database, Home, Monitor, FileText, FolderPlus, Trash2,
|
|
11
11
|
Download, ChevronRight, ArrowLeft, Search, Loader2, Clock, Eye, EyeOff,
|
|
12
12
|
} from "lucide-react";
|
|
13
13
|
import { cn } from "@/lib/utils";
|
|
@@ -107,6 +107,10 @@ export function FileBrowserPicker({
|
|
|
107
107
|
const [pathInput, setPathInput] = useState("");
|
|
108
108
|
const [showHidden, setShowHidden] = useState(false);
|
|
109
109
|
const [recentPaths, setRecentPaths] = useState<string[]>([]);
|
|
110
|
+
const [newFolderName, setNewFolderName] = useState<string | null>(null);
|
|
111
|
+
const [creatingFolder, setCreatingFolder] = useState(false);
|
|
112
|
+
const [newFolderError, setNewFolderError] = useState<string | null>(null);
|
|
113
|
+
const newFolderInputRef = useRef<HTMLInputElement>(null);
|
|
110
114
|
const listRef = useRef<HTMLDivElement>(null);
|
|
111
115
|
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
|
112
116
|
|
|
@@ -185,6 +189,40 @@ export function FileBrowserPicker({
|
|
|
185
189
|
onSelect(selected);
|
|
186
190
|
};
|
|
187
191
|
|
|
192
|
+
const handleCreateFolder = async () => {
|
|
193
|
+
if (!newFolderName?.trim() || !current) return;
|
|
194
|
+
const folderPath = `${current}/${newFolderName.trim()}`;
|
|
195
|
+
setCreatingFolder(true);
|
|
196
|
+
setNewFolderError(null);
|
|
197
|
+
try {
|
|
198
|
+
await api.post("/api/fs/mkdir", { path: folderPath });
|
|
199
|
+
setNewFolderName(null);
|
|
200
|
+
setNewFolderError(null);
|
|
201
|
+
await fetchDir(current, showHidden);
|
|
202
|
+
setSelected(folderPath);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
setNewFolderError((e as Error).message || "Failed to create folder");
|
|
205
|
+
} finally {
|
|
206
|
+
setCreatingFolder(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const handleDeleteSelected = async () => {
|
|
211
|
+
if (!selected) return;
|
|
212
|
+
const entry = entries.find((e) => e.path === selected);
|
|
213
|
+
if (!entry || entry.type !== "directory") return;
|
|
214
|
+
if (!window.confirm(`Delete folder "${entry.name}"? This cannot be undone.`)) return;
|
|
215
|
+
try {
|
|
216
|
+
await api.del("/api/fs/rmdir", { path: selected });
|
|
217
|
+
setSelected(null);
|
|
218
|
+
await fetchDir(current, showHidden);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
setError((e as Error).message || "Failed to delete folder");
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const selectedIsFolder = selected ? entries.some((e) => e.path === selected && e.type === "directory") : false;
|
|
225
|
+
|
|
188
226
|
// Filter entries by search + accept
|
|
189
227
|
const visible = entries.filter((e) => {
|
|
190
228
|
if (search && !e.name.toLowerCase().includes(search.toLowerCase())) return false;
|
|
@@ -283,6 +321,32 @@ export function FileBrowserPicker({
|
|
|
283
321
|
</div>
|
|
284
322
|
) : (
|
|
285
323
|
<div ref={listRef} className="py-1">
|
|
324
|
+
{newFolderName != null && (
|
|
325
|
+
<>
|
|
326
|
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-primary/5 border-b border-border">
|
|
327
|
+
<FolderPlus className="size-4 text-primary shrink-0" />
|
|
328
|
+
<Input
|
|
329
|
+
ref={newFolderInputRef}
|
|
330
|
+
value={newFolderName}
|
|
331
|
+
onChange={(e) => setNewFolderName(e.target.value)}
|
|
332
|
+
onKeyDown={(e) => {
|
|
333
|
+
if (e.key === "Enter") handleCreateFolder();
|
|
334
|
+
if (e.key === "Escape") setNewFolderName(null);
|
|
335
|
+
}}
|
|
336
|
+
placeholder="Folder name"
|
|
337
|
+
className="h-6 text-xs flex-1"
|
|
338
|
+
disabled={creatingFolder}
|
|
339
|
+
autoFocus
|
|
340
|
+
/>
|
|
341
|
+
{creatingFolder && <Loader2 className="size-3.5 animate-spin text-primary shrink-0" />}
|
|
342
|
+
</div>
|
|
343
|
+
{newFolderError && (
|
|
344
|
+
<div className="px-3 py-1 text-[11px] text-destructive bg-destructive/5 border-b border-border">
|
|
345
|
+
{newFolderError}
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
</>
|
|
349
|
+
)}
|
|
286
350
|
{visible.map((entry) => {
|
|
287
351
|
const selectable = isSelectable(entry);
|
|
288
352
|
return (
|
|
@@ -321,6 +385,29 @@ export function FileBrowserPicker({
|
|
|
321
385
|
|
|
322
386
|
{/* Footer */}
|
|
323
387
|
<div className="flex items-center gap-2 px-3 py-2 border-t border-border shrink-0">
|
|
388
|
+
<Button
|
|
389
|
+
variant="ghost"
|
|
390
|
+
size="icon"
|
|
391
|
+
className="size-7 shrink-0"
|
|
392
|
+
onClick={() => {
|
|
393
|
+
setNewFolderName("");
|
|
394
|
+
setNewFolderError(null);
|
|
395
|
+
setTimeout(() => newFolderInputRef.current?.focus(), 50);
|
|
396
|
+
}}
|
|
397
|
+
title="New Folder"
|
|
398
|
+
>
|
|
399
|
+
<FolderPlus className="size-3.5" />
|
|
400
|
+
</Button>
|
|
401
|
+
<Button
|
|
402
|
+
variant="ghost"
|
|
403
|
+
size="icon"
|
|
404
|
+
className="size-7 shrink-0 text-destructive/70 hover:text-destructive disabled:opacity-30"
|
|
405
|
+
onClick={handleDeleteSelected}
|
|
406
|
+
disabled={!selectedIsFolder}
|
|
407
|
+
title="Delete selected folder"
|
|
408
|
+
>
|
|
409
|
+
<Trash2 className="size-3.5" />
|
|
410
|
+
</Button>
|
|
324
411
|
<Button
|
|
325
412
|
variant="ghost"
|
|
326
413
|
size="icon"
|
|
@@ -396,6 +396,12 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
396
396
|
// Ignore keepalive pings
|
|
397
397
|
if ((data as any).type === "ping") return;
|
|
398
398
|
|
|
399
|
+
// Dispatch global Jira events so components can listen via window events
|
|
400
|
+
if (typeof (data as any).type === "string" && (data as any).type.startsWith("jira:")) {
|
|
401
|
+
window.dispatchEvent(new CustomEvent((data as any).type, { detail: data }));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
399
405
|
// Handle title updates from SDK summary
|
|
400
406
|
if ((data as any).type === "title_updated") {
|
|
401
407
|
setSessionTitle((data as any).title ?? null);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { api, projectUrl } from "./api-client";
|
|
1
|
+
import { api, projectUrl, getAuthToken } from "./api-client";
|
|
2
2
|
|
|
3
3
|
const REPO = "hienlh/ppm";
|
|
4
4
|
|
|
@@ -9,7 +9,8 @@ export async function buildBugReport(
|
|
|
9
9
|
): Promise<string> {
|
|
10
10
|
let serverLogs = "(could not fetch)";
|
|
11
11
|
try {
|
|
12
|
-
const
|
|
12
|
+
const token = getAuthToken();
|
|
13
|
+
const res = await fetch("/api/logs/recent", token ? { headers: { Authorization: `Bearer ${token}` } } : {});
|
|
13
14
|
const json = await res.json();
|
|
14
15
|
if (json.ok) serverLogs = json.data.logs || "(empty)";
|
|
15
16
|
} catch {}
|
package/src/web/lib/ws-client.ts
CHANGED
|
@@ -33,9 +33,16 @@ export class WsClient {
|
|
|
33
33
|
this.cleanup();
|
|
34
34
|
|
|
35
35
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
let fullUrl: string;
|
|
37
|
+
if (this.url.startsWith("ws")) {
|
|
38
|
+
fullUrl = this.url;
|
|
39
|
+
} else if (import.meta.env.DEV && this.url.startsWith("/ws/")) {
|
|
40
|
+
// In dev mode, connect directly to the backend server (port 8081) to
|
|
41
|
+
// bypass Vite's dev proxy which has unreliable WebSocket upgrade handling.
|
|
42
|
+
fullUrl = `ws://${window.location.hostname}:8081${this.url}`;
|
|
43
|
+
} else {
|
|
44
|
+
fullUrl = `${protocol}//${window.location.host}${this.url}`;
|
|
45
|
+
}
|
|
39
46
|
|
|
40
47
|
this.ws = new WebSocket(fullUrl);
|
|
41
48
|
|
|
@@ -90,11 +97,12 @@ export class WsClient {
|
|
|
90
97
|
send(data: string | ArrayBuffer): void {
|
|
91
98
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
92
99
|
this.ws.send(data);
|
|
93
|
-
} else if (this.
|
|
94
|
-
|
|
100
|
+
} else if (!this.intentionalClose) {
|
|
101
|
+
// Queue message — will be flushed when WS (re)connects
|
|
102
|
+
console.warn(`[ws] WS not open (readyState=${this.ws?.readyState ?? "no-ws"}) — queuing message`);
|
|
95
103
|
this.pendingMessages.push(data);
|
|
96
104
|
} else {
|
|
97
|
-
console.warn(`[ws] message dropped —
|
|
105
|
+
console.warn(`[ws] message dropped — WS intentionally closed`);
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { api } from "@/lib/api-client";
|
|
3
|
+
import type {
|
|
4
|
+
JiraConfig, JiraWatcher, JiraWatchResult, JiraWatcherMode, JiraIssue,
|
|
5
|
+
} from "../../../src/types/jira";
|
|
6
|
+
|
|
7
|
+
export interface ProjectWithId {
|
|
8
|
+
id: number;
|
|
9
|
+
name: string;
|
|
10
|
+
path: string;
|
|
11
|
+
color?: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface JiraStore {
|
|
15
|
+
// Projects (with DB ids)
|
|
16
|
+
projectsWithIds: ProjectWithId[];
|
|
17
|
+
loadProjectsWithIds: () => Promise<void>;
|
|
18
|
+
|
|
19
|
+
// Config
|
|
20
|
+
configs: JiraConfig[];
|
|
21
|
+
selectedProjectId: number | null;
|
|
22
|
+
loadConfigs: () => Promise<void>;
|
|
23
|
+
saveConfig: (projectId: number, data: { baseUrl: string; email: string; token: string }) => Promise<void>;
|
|
24
|
+
deleteConfig: (projectId: number) => Promise<void>;
|
|
25
|
+
testConnection: (projectId: number) => Promise<boolean>;
|
|
26
|
+
setSelectedProjectId: (id: number | null) => void;
|
|
27
|
+
|
|
28
|
+
// Watchers
|
|
29
|
+
watchers: JiraWatcher[];
|
|
30
|
+
loadWatchers: (configId: number) => Promise<void>;
|
|
31
|
+
createWatcher: (data: { configId: number; name: string; jql: string; promptTemplate?: string; intervalMs?: number; mode?: JiraWatcherMode }) => Promise<void>;
|
|
32
|
+
updateWatcher: (id: number, data: Partial<{ name: string; jql: string; promptTemplate: string | null; intervalMs: number; enabled: boolean; mode: JiraWatcherMode }>) => Promise<void>;
|
|
33
|
+
deleteWatcher: (id: number) => Promise<void>;
|
|
34
|
+
toggleWatcher: (id: number, enabled: boolean) => Promise<void>;
|
|
35
|
+
pullWatcher: (id: number) => Promise<{ newIssues: number }>;
|
|
36
|
+
testJql: (configId: number, jql: string) => Promise<{ issues: JiraIssue[]; total: number }>;
|
|
37
|
+
|
|
38
|
+
// Results
|
|
39
|
+
results: JiraWatchResult[];
|
|
40
|
+
loadResults: (watcherId?: number, status?: string, limit?: number, offset?: number) => Promise<void>;
|
|
41
|
+
softDeleteResult: (id: number) => Promise<void>;
|
|
42
|
+
|
|
43
|
+
// Debug + Unread
|
|
44
|
+
startDebug: (resultId: number, prompt?: string) => Promise<void>;
|
|
45
|
+
resumeDebug: (resultId: number) => Promise<void>;
|
|
46
|
+
cancelDebug: (resultId: number) => Promise<void>;
|
|
47
|
+
markRead: (resultId: number) => Promise<void>;
|
|
48
|
+
unreadCount: number;
|
|
49
|
+
loadUnreadCount: () => Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const useJiraStore = create<JiraStore>((set, get) => ({
|
|
53
|
+
projectsWithIds: [],
|
|
54
|
+
configs: [],
|
|
55
|
+
selectedProjectId: null,
|
|
56
|
+
watchers: [],
|
|
57
|
+
results: [],
|
|
58
|
+
unreadCount: 0,
|
|
59
|
+
|
|
60
|
+
setSelectedProjectId: (id) => set({ selectedProjectId: id }),
|
|
61
|
+
|
|
62
|
+
loadProjectsWithIds: async () => {
|
|
63
|
+
const rows = await api.get<ProjectWithId[]>("/api/jira/config/projects");
|
|
64
|
+
set({ projectsWithIds: Array.isArray(rows) ? rows : [] });
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
loadConfigs: async () => {
|
|
68
|
+
const configs = await api.get<JiraConfig[]>("/api/jira/config");
|
|
69
|
+
set({ configs });
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
saveConfig: async (projectId, data) => {
|
|
73
|
+
await api.put(`/api/jira/config/${projectId}`, data);
|
|
74
|
+
await get().loadConfigs();
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
deleteConfig: async (projectId) => {
|
|
78
|
+
await api.del(`/api/jira/config/${projectId}`);
|
|
79
|
+
set((s) => ({ configs: s.configs.filter((c) => c.projectId !== projectId), watchers: [] }));
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
testConnection: async (projectId) => {
|
|
83
|
+
const res = await api.post<{ connected: boolean }>(`/api/jira/config/${projectId}/test`);
|
|
84
|
+
return res.connected;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
loadWatchers: async (configId) => {
|
|
88
|
+
const watchers = await api.get<JiraWatcher[]>(`/api/jira/watchers?configId=${configId}`);
|
|
89
|
+
set({ watchers });
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
createWatcher: async (data) => {
|
|
93
|
+
await api.post("/api/jira/watchers", data);
|
|
94
|
+
if (data.configId) await get().loadWatchers(data.configId);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
updateWatcher: async (id, data) => {
|
|
98
|
+
await api.put(`/api/jira/watchers/${id}`, data);
|
|
99
|
+
// Refresh — find configId from current watchers
|
|
100
|
+
const w = get().watchers.find((w) => w.id === id);
|
|
101
|
+
if (w) await get().loadWatchers(w.jiraConfigId);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
deleteWatcher: async (id) => {
|
|
105
|
+
const w = get().watchers.find((w) => w.id === id);
|
|
106
|
+
await api.del(`/api/jira/watchers/${id}`);
|
|
107
|
+
if (w) await get().loadWatchers(w.jiraConfigId);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
toggleWatcher: async (id, enabled) => {
|
|
111
|
+
// Optimistic update
|
|
112
|
+
set((s) => ({ watchers: s.watchers.map((w) => w.id === id ? { ...w, enabled } : w) }));
|
|
113
|
+
try {
|
|
114
|
+
await api.put(`/api/jira/watchers/${id}`, { enabled });
|
|
115
|
+
} catch {
|
|
116
|
+
set((s) => ({ watchers: s.watchers.map((w) => w.id === id ? { ...w, enabled: !enabled } : w) }));
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
pullWatcher: async (id) => {
|
|
121
|
+
const result = await api.post<{ newIssues: number }>(`/api/jira/watchers/${id}/pull`);
|
|
122
|
+
// Refresh results so the UI shows newly pulled tickets
|
|
123
|
+
await get().loadResults();
|
|
124
|
+
return result;
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
testJql: async (configId, jql) => {
|
|
128
|
+
return await api.post<{ issues: JiraIssue[]; total: number }>("/api/jira/watchers/test-jql", { configId, jql });
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
loadResults: async (watcherId, status, limit = 50, offset = 0) => {
|
|
132
|
+
const params = new URLSearchParams();
|
|
133
|
+
if (watcherId !== undefined) params.set("watcherId", String(watcherId));
|
|
134
|
+
if (status) params.set("status", status);
|
|
135
|
+
params.set("limit", String(limit));
|
|
136
|
+
params.set("offset", String(offset));
|
|
137
|
+
const results = await api.get<JiraWatchResult[]>(`/api/jira/results?${params}`);
|
|
138
|
+
set(offset > 0 ? (s) => ({ results: [...s.results, ...results] }) : { results });
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
softDeleteResult: async (id) => {
|
|
142
|
+
set((s) => ({ results: s.results.filter((r) => r.id !== id) }));
|
|
143
|
+
try { await api.del(`/api/jira/results/${id}`); } catch {}
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
startDebug: async (resultId, prompt) => {
|
|
147
|
+
// Optimistic update before API call
|
|
148
|
+
const prev = get().results.find((r) => r.id === resultId)?.status;
|
|
149
|
+
set((s) => ({
|
|
150
|
+
results: s.results.map((r) => r.id === resultId ? { ...r, status: "queued" as const } : r),
|
|
151
|
+
}));
|
|
152
|
+
try {
|
|
153
|
+
await api.post(`/api/jira/results/${resultId}/debug`, prompt ? { prompt } : {});
|
|
154
|
+
} catch {
|
|
155
|
+
// Rollback on failure
|
|
156
|
+
set((s) => ({
|
|
157
|
+
results: s.results.map((r) => r.id === resultId ? { ...r, status: (prev ?? "pending") as any } : r),
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
resumeDebug: async (resultId) => {
|
|
163
|
+
const prev = get().results.find((r) => r.id === resultId)?.status;
|
|
164
|
+
set((s) => ({
|
|
165
|
+
results: s.results.map((r) => r.id === resultId ? { ...r, status: "queued" as const } : r),
|
|
166
|
+
}));
|
|
167
|
+
try {
|
|
168
|
+
await api.post(`/api/jira/results/${resultId}/resume`);
|
|
169
|
+
} catch {
|
|
170
|
+
set((s) => ({
|
|
171
|
+
results: s.results.map((r) => r.id === resultId ? { ...r, status: (prev ?? "failed") as any } : r),
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
cancelDebug: async (resultId) => {
|
|
177
|
+
try {
|
|
178
|
+
await api.post(`/api/jira/results/${resultId}/cancel`);
|
|
179
|
+
await get().loadResults();
|
|
180
|
+
} catch {}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
markRead: async (resultId) => {
|
|
184
|
+
// Optimistic update
|
|
185
|
+
set((s) => ({
|
|
186
|
+
results: s.results.map((r) => r.id === resultId ? { ...r, readAt: new Date().toISOString() } : r),
|
|
187
|
+
unreadCount: Math.max(0, s.unreadCount - 1),
|
|
188
|
+
}));
|
|
189
|
+
try { await api.patch(`/api/jira/results/${resultId}/read`); } catch {}
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
loadUnreadCount: async () => {
|
|
193
|
+
try {
|
|
194
|
+
const res = await api.get<{ count: number }>("/api/jira/results/unread-count");
|
|
195
|
+
set({ unreadCount: res.count });
|
|
196
|
+
} catch {}
|
|
197
|
+
},
|
|
198
|
+
}));
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { create } from "zustand";
|
|
2
|
+
import { getAuthToken } from "@/lib/api-client";
|
|
2
3
|
|
|
3
4
|
export type Theme = "light" | "dark" | "system";
|
|
4
5
|
export type GitStatusViewMode = "flat" | "tree";
|
|
5
|
-
export type SidebarActiveTab = "explorer" | "git" | "settings" | "database" | "search" | `ext:${string}`;
|
|
6
|
+
export type SidebarActiveTab = "explorer" | "git" | "settings" | "database" | "search" | "jira" | `ext:${string}`;
|
|
6
7
|
|
|
7
8
|
const STORAGE_KEY = "ppm-settings";
|
|
8
9
|
|
|
@@ -13,9 +14,11 @@ interface SettingsState {
|
|
|
13
14
|
gitStatusViewMode: GitStatusViewMode;
|
|
14
15
|
wordWrap: boolean;
|
|
15
16
|
sidebarActiveTab: SidebarActiveTab;
|
|
17
|
+
jiraEnabled: boolean;
|
|
16
18
|
deviceName: string | null;
|
|
17
19
|
version: string | null;
|
|
18
20
|
setTheme: (theme: Theme) => void;
|
|
21
|
+
setJiraEnabled: (enabled: boolean) => void;
|
|
19
22
|
setDeviceName: (name: string) => Promise<void>;
|
|
20
23
|
toggleSidebar: () => void;
|
|
21
24
|
setSidebarWidth: (width: number) => void;
|
|
@@ -32,6 +35,7 @@ interface PersistedSettings {
|
|
|
32
35
|
gitStatusViewMode?: GitStatusViewMode;
|
|
33
36
|
wordWrap?: boolean;
|
|
34
37
|
sidebarActiveTab?: SidebarActiveTab;
|
|
38
|
+
jiraEnabled?: boolean;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
function loadPersistedSettings(): PersistedSettings {
|
|
@@ -46,7 +50,7 @@ function loadPersistedSettings(): PersistedSettings {
|
|
|
46
50
|
|
|
47
51
|
function isValidSidebarTab(tab: unknown): tab is SidebarActiveTab {
|
|
48
52
|
if (typeof tab !== "string") return false;
|
|
49
|
-
return ["explorer", "git", "settings", "database", "search"].includes(tab) || tab.startsWith("ext:");
|
|
53
|
+
return ["explorer", "git", "settings", "database", "search", "jira"].includes(tab) || tab.startsWith("ext:");
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
function persistSettings(update: Partial<PersistedSettings>) {
|
|
@@ -85,6 +89,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|
|
85
89
|
gitStatusViewMode: _initial.gitStatusViewMode === "flat" ? "flat" : "tree",
|
|
86
90
|
wordWrap: _initial.wordWrap ?? false,
|
|
87
91
|
sidebarActiveTab: isValidSidebarTab(_initial.sidebarActiveTab) ? _initial.sidebarActiveTab : "explorer",
|
|
92
|
+
jiraEnabled: _initial.jiraEnabled ?? false,
|
|
88
93
|
deviceName: null,
|
|
89
94
|
version: null,
|
|
90
95
|
|
|
@@ -93,9 +98,10 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|
|
93
98
|
applyThemeClass(theme);
|
|
94
99
|
set({ theme });
|
|
95
100
|
// Save to server (fire-and-forget)
|
|
101
|
+
const token = getAuthToken();
|
|
96
102
|
fetch("/api/settings/theme", {
|
|
97
103
|
method: "PUT",
|
|
98
|
-
headers: { "Content-Type": "application/json" },
|
|
104
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
99
105
|
body: JSON.stringify({ theme }),
|
|
100
106
|
}).catch(() => {});
|
|
101
107
|
},
|
|
@@ -114,6 +120,17 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|
|
114
120
|
} catch {}
|
|
115
121
|
},
|
|
116
122
|
|
|
123
|
+
setJiraEnabled: (enabled) => {
|
|
124
|
+
persistSettings({ jiraEnabled: enabled });
|
|
125
|
+
set({ jiraEnabled: enabled });
|
|
126
|
+
// If disabling and currently on jira tab, switch to explorer
|
|
127
|
+
if (!enabled && get().sidebarActiveTab === "jira") {
|
|
128
|
+
const tab: SidebarActiveTab = "explorer";
|
|
129
|
+
persistSettings({ sidebarActiveTab: tab });
|
|
130
|
+
set({ sidebarActiveTab: tab });
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
117
134
|
toggleSidebar: () => {
|
|
118
135
|
const next = !get().sidebarCollapsed;
|
|
119
136
|
persistSettings({ sidebarCollapsed: next });
|
|
@@ -144,9 +161,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|
|
144
161
|
|
|
145
162
|
fetchServerInfo: async () => {
|
|
146
163
|
try {
|
|
164
|
+
const token = getAuthToken();
|
|
165
|
+
const authInit = token ? { headers: { Authorization: `Bearer ${token}` } } : {};
|
|
147
166
|
const [infoRes, themeRes] = await Promise.all([
|
|
148
|
-
fetch("/api/info"),
|
|
149
|
-
fetch("/api/settings/theme"),
|
|
167
|
+
fetch("/api/info", authInit),
|
|
168
|
+
fetch("/api/settings/theme", authInit),
|
|
150
169
|
]);
|
|
151
170
|
const infoJson = await infoRes.json();
|
|
152
171
|
if (infoJson.ok) {
|
|
@@ -192,6 +192,13 @@ html, body {
|
|
|
192
192
|
padding: 0;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/* Light mode: dark code blocks so github-dark-dimmed syntax colors stay readable */
|
|
196
|
+
.light .markdown-content pre {
|
|
197
|
+
background: #22272e;
|
|
198
|
+
color: #adbac7;
|
|
199
|
+
border-color: #373e47;
|
|
200
|
+
}
|
|
201
|
+
|
|
195
202
|
.markdown-content :not(pre) > code {
|
|
196
203
|
background: var(--color-background);
|
|
197
204
|
padding: 0.125rem 0.25rem;
|
package/vite.config.ts
CHANGED
|
@@ -1,77 +1,12 @@
|
|
|
1
|
-
import { defineConfig
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
2
|
import react from "@vitejs/plugin-react";
|
|
3
3
|
import tailwindcss from "@tailwindcss/vite";
|
|
4
4
|
import { VitePWA } from "vite-plugin-pwa";
|
|
5
5
|
import monacoEditorPlugin from "vite-plugin-monaco-editor";
|
|
6
6
|
import { resolve } from "path";
|
|
7
|
-
import { createConnection } from "net";
|
|
8
|
-
import type { IncomingMessage } from "http";
|
|
9
|
-
import type { Duplex } from "stream";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Custom WebSocket proxy plugin.
|
|
13
|
-
*
|
|
14
|
-
* Replaces http-proxy's built-in WS proxy (which relies on socket.pipe())
|
|
15
|
-
* because Bun on Windows doesn't correctly pipe client→server data when the
|
|
16
|
-
* connection arrives through a Cloudflare tunnel. Using explicit 'data'
|
|
17
|
-
* event handlers instead of .pipe() fixes the issue.
|
|
18
|
-
*/
|
|
19
|
-
function wsProxy(targetPort: number): Plugin {
|
|
20
|
-
return {
|
|
21
|
-
name: "ws-proxy",
|
|
22
|
-
configureServer(server) {
|
|
23
|
-
server.httpServer?.on(
|
|
24
|
-
"upgrade",
|
|
25
|
-
(req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
26
|
-
const url = req.url ?? "";
|
|
27
|
-
if (!url.startsWith("/ws/")) return;
|
|
28
|
-
|
|
29
|
-
const target = createConnection(
|
|
30
|
-
{ port: targetPort, host: "localhost" },
|
|
31
|
-
() => {
|
|
32
|
-
const headerLines = Object.entries(req.headers)
|
|
33
|
-
.filter(([, v]) => v != null)
|
|
34
|
-
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`)
|
|
35
|
-
.join("\r\n");
|
|
36
|
-
target.write(
|
|
37
|
-
`GET ${url} HTTP/${req.httpVersion}\r\n${headerLines}\r\n\r\n`,
|
|
38
|
-
);
|
|
39
|
-
if (head && head.length > 0) target.write(head);
|
|
40
|
-
|
|
41
|
-
socket.on("data", (chunk: Buffer) => {
|
|
42
|
-
if (!target.destroyed) target.write(chunk);
|
|
43
|
-
});
|
|
44
|
-
target.on("data", (chunk: Buffer) => {
|
|
45
|
-
if (!socket.destroyed) socket.write(chunk);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
socket.on("close", () => {
|
|
49
|
-
if (!target.destroyed) target.destroy();
|
|
50
|
-
});
|
|
51
|
-
target.on("close", () => {
|
|
52
|
-
if (!socket.destroyed) socket.destroy();
|
|
53
|
-
});
|
|
54
|
-
socket.on("error", () => {
|
|
55
|
-
if (!target.destroyed) target.destroy();
|
|
56
|
-
});
|
|
57
|
-
target.on("error", () => {
|
|
58
|
-
if (!socket.destroyed) socket.destroy();
|
|
59
|
-
});
|
|
60
|
-
},
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
target.on("error", () => {
|
|
64
|
-
if (!socket.destroyed) socket.destroy();
|
|
65
|
-
});
|
|
66
|
-
},
|
|
67
|
-
);
|
|
68
|
-
},
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
7
|
|
|
72
8
|
export default defineConfig({
|
|
73
9
|
plugins: [
|
|
74
|
-
wsProxy(8081),
|
|
75
10
|
react(),
|
|
76
11
|
tailwindcss(),
|
|
77
12
|
((monacoEditorPlugin as unknown as { default?: (opts: object) => object }).default ?? (monacoEditorPlugin as unknown as (opts: object) => object))({
|
|
@@ -134,6 +69,10 @@ export default defineConfig({
|
|
|
134
69
|
allowedHosts: true,
|
|
135
70
|
proxy: {
|
|
136
71
|
"/api": "http://localhost:8081",
|
|
72
|
+
"/ws": {
|
|
73
|
+
target: "http://localhost:8081",
|
|
74
|
+
ws: true,
|
|
75
|
+
},
|
|
137
76
|
},
|
|
138
77
|
},
|
|
139
78
|
});
|