@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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
4
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
5
|
+
import { useJiraStore } from "@/stores/jira-store";
|
|
6
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
7
|
+
import { JiraTicketCard } from "./jira-ticket-card";
|
|
8
|
+
import { JiraWatcherList } from "./jira-watcher-list";
|
|
9
|
+
import { JiraConfigForm } from "./jira-config-form";
|
|
10
|
+
import { JiraDebugPromptDialog } from "./jira-debug-prompt-dialog";
|
|
11
|
+
import { ArrowLeft, Settings2, Plus, ListFilter, RefreshCw, Loader2 } from "lucide-react";
|
|
12
|
+
import { toast } from "sonner";
|
|
13
|
+
import type { JiraWatchResult } from "../../../../src/types/jira";
|
|
14
|
+
|
|
15
|
+
type SubView = "tickets" | "watchers" | "credentials";
|
|
16
|
+
|
|
17
|
+
export function JiraPanel() {
|
|
18
|
+
const activeProject = useProjectStore((s) => s.activeProject);
|
|
19
|
+
const openTab = useTabStore((s) => s.openTab);
|
|
20
|
+
const {
|
|
21
|
+
configs, watchers, results, loadConfigs, loadWatchers, loadResults,
|
|
22
|
+
loadProjectsWithIds, projectsWithIds, softDeleteResult, resumeDebug, cancelDebug,
|
|
23
|
+
markRead, unreadCount, loadUnreadCount,
|
|
24
|
+
} = useJiraStore();
|
|
25
|
+
|
|
26
|
+
const [subView, setSubView] = useState<SubView>("tickets");
|
|
27
|
+
const [loading, setLoading] = useState(false);
|
|
28
|
+
const [debugTarget, setDebugTarget] = useState<JiraWatchResult | null>(null);
|
|
29
|
+
|
|
30
|
+
// Resolve current project → config
|
|
31
|
+
const projectEntry = projectsWithIds.find((p) => p.name === activeProject?.name);
|
|
32
|
+
const config = configs.find((c) => c.projectId === projectEntry?.id);
|
|
33
|
+
|
|
34
|
+
// Load data on mount and when project changes
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
loadConfigs();
|
|
37
|
+
loadProjectsWithIds();
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (config) loadWatchers(config.id);
|
|
42
|
+
}, [config?.id]);
|
|
43
|
+
|
|
44
|
+
const refresh = useCallback(async () => {
|
|
45
|
+
setLoading(true);
|
|
46
|
+
try { await loadResults(); } catch {}
|
|
47
|
+
setLoading(false);
|
|
48
|
+
}, [loadResults]);
|
|
49
|
+
|
|
50
|
+
useEffect(() => { refresh(); loadUnreadCount(); }, [config?.id]);
|
|
51
|
+
|
|
52
|
+
// WS events for live status updates
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const handler = (e: Event) => {
|
|
55
|
+
const detail = (e as CustomEvent).detail;
|
|
56
|
+
if (!detail) return;
|
|
57
|
+
refresh();
|
|
58
|
+
if (detail.status === "done") {
|
|
59
|
+
loadUnreadCount();
|
|
60
|
+
toast.success(`Debug complete: ${detail.issueKey}`, {
|
|
61
|
+
action: detail.sessionId ? {
|
|
62
|
+
label: "View",
|
|
63
|
+
onClick: () => openTab({
|
|
64
|
+
type: "chat",
|
|
65
|
+
title: `[Jira] ${detail.issueKey}`,
|
|
66
|
+
projectId: activeProject?.name ?? null,
|
|
67
|
+
metadata: { projectName: activeProject?.name, sessionId: detail.sessionId },
|
|
68
|
+
closable: true,
|
|
69
|
+
}),
|
|
70
|
+
} : undefined,
|
|
71
|
+
});
|
|
72
|
+
} else if (detail.status === "failed") {
|
|
73
|
+
toast.error(`Debug failed: ${detail.issueKey}`);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
window.addEventListener("jira:status_change", handler);
|
|
77
|
+
return () => window.removeEventListener("jira:status_change", handler);
|
|
78
|
+
}, [refresh, loadUnreadCount]);
|
|
79
|
+
|
|
80
|
+
const openSession = useCallback((r: JiraWatchResult) => {
|
|
81
|
+
if (!r.sessionId) return;
|
|
82
|
+
if (r.status === "done" && !r.readAt) markRead(r.id);
|
|
83
|
+
openTab({
|
|
84
|
+
type: "chat",
|
|
85
|
+
title: `[Jira] ${r.issueKey}`,
|
|
86
|
+
projectId: activeProject?.name ?? null,
|
|
87
|
+
metadata: { projectName: activeProject?.name, sessionId: r.sessionId },
|
|
88
|
+
closable: true,
|
|
89
|
+
});
|
|
90
|
+
}, [openTab, markRead, activeProject]);
|
|
91
|
+
|
|
92
|
+
const handleRowClick = (r: JiraWatchResult) => {
|
|
93
|
+
if (r.sessionId) openSession(r);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// No project selected
|
|
97
|
+
if (!activeProject) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="flex items-center justify-center h-32 p-4">
|
|
100
|
+
<p className="text-xs text-muted-foreground text-center">Select a project to use Jira</p>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Sub-views: watchers / credentials
|
|
106
|
+
if (subView !== "tickets") {
|
|
107
|
+
const title = subView === "watchers" ? "Watchers" : "Jira Credentials";
|
|
108
|
+
return (
|
|
109
|
+
<div className="h-full flex flex-col">
|
|
110
|
+
<div className="shrink-0 px-2 py-2 flex items-center gap-1.5 border-b border-border/50">
|
|
111
|
+
<Button size="icon" variant="ghost" className="size-7" onClick={() => setSubView("tickets")}>
|
|
112
|
+
<ArrowLeft className="size-4" />
|
|
113
|
+
</Button>
|
|
114
|
+
<h2 className="text-sm font-semibold truncate">{title}</h2>
|
|
115
|
+
</div>
|
|
116
|
+
<ScrollArea className="flex-1 min-h-0">
|
|
117
|
+
<div className="p-3">
|
|
118
|
+
{subView === "watchers" ? (
|
|
119
|
+
config ? <JiraWatcherList configId={config.id} /> : <p className="text-xs text-muted-foreground text-center py-4">Configure Jira credentials first.</p>
|
|
120
|
+
) : (
|
|
121
|
+
projectEntry ? <JiraConfigForm projectId={projectEntry.id} existing={config ? { baseUrl: config.baseUrl, email: config.email, hasToken: config.hasToken } : null} /> : <p className="text-xs text-muted-foreground text-center py-4">Project not found in database.</p>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
</ScrollArea>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Default: ticket list
|
|
130
|
+
return (
|
|
131
|
+
<div className="h-full flex flex-col">
|
|
132
|
+
{/* Header */}
|
|
133
|
+
<div className="shrink-0 px-3 py-2 flex items-center gap-1.5 border-b border-border/50">
|
|
134
|
+
<h2 className="text-sm font-semibold flex-1 truncate">Jira</h2>
|
|
135
|
+
{unreadCount > 0 && (
|
|
136
|
+
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-bold">
|
|
137
|
+
{unreadCount}
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
<Button size="icon" variant="ghost" className="size-7" onClick={refresh} disabled={loading} title="Refresh">
|
|
141
|
+
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
|
142
|
+
</Button>
|
|
143
|
+
<Button size="icon" variant="ghost" className="size-7" onClick={() => setSubView("watchers")} title="Watchers">
|
|
144
|
+
<ListFilter className="size-3.5" />
|
|
145
|
+
</Button>
|
|
146
|
+
<Button size="icon" variant="ghost" className="size-7" onClick={() => setSubView("credentials")} title="Credentials">
|
|
147
|
+
<Settings2 className="size-3.5" />
|
|
148
|
+
</Button>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Ticket list */}
|
|
152
|
+
<ScrollArea className="flex-1 min-h-0">
|
|
153
|
+
<div className="p-1.5 space-y-1.5">
|
|
154
|
+
{!config ? (
|
|
155
|
+
<EmptyState
|
|
156
|
+
message="No Jira credentials configured"
|
|
157
|
+
action="Set up Jira"
|
|
158
|
+
onAction={() => setSubView("credentials")}
|
|
159
|
+
/>
|
|
160
|
+
) : results.length === 0 && watchers.length === 0 ? (
|
|
161
|
+
<EmptyState
|
|
162
|
+
message="No watchers yet"
|
|
163
|
+
action="Add Watcher"
|
|
164
|
+
onAction={() => setSubView("watchers")}
|
|
165
|
+
/>
|
|
166
|
+
) : results.length === 0 ? (
|
|
167
|
+
<EmptyState message="No tickets yet. Watchers will pick up new issues." />
|
|
168
|
+
) : (
|
|
169
|
+
results.map((r) => (
|
|
170
|
+
<JiraTicketCard
|
|
171
|
+
key={r.id}
|
|
172
|
+
result={r}
|
|
173
|
+
onDebug={setDebugTarget}
|
|
174
|
+
onResume={(r) => resumeDebug(r.id)}
|
|
175
|
+
onCancel={(r) => cancelDebug(r.id)}
|
|
176
|
+
onOpenSession={openSession}
|
|
177
|
+
onDelete={softDeleteResult}
|
|
178
|
+
onClick={handleRowClick}
|
|
179
|
+
/>
|
|
180
|
+
))
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</ScrollArea>
|
|
184
|
+
|
|
185
|
+
<JiraDebugPromptDialog result={debugTarget} onClose={() => setDebugTarget(null)} />
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function EmptyState({ message, action, onAction }: { message: string; action?: string; onAction?: () => void }) {
|
|
191
|
+
return (
|
|
192
|
+
<div className="flex flex-col items-center justify-center py-12 gap-3">
|
|
193
|
+
<p className="text-xs text-muted-foreground text-center">{message}</p>
|
|
194
|
+
{action && onAction && (
|
|
195
|
+
<Button size="sm" variant="outline" className="min-h-[44px]" onClick={onAction}>
|
|
196
|
+
<Plus className="size-4 mr-1.5" /> {action}
|
|
197
|
+
</Button>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
4
|
+
import { useJiraStore } from "@/stores/jira-store";
|
|
5
|
+
import { JiraStatusBadge } from "./jira-status-badge";
|
|
6
|
+
import { JiraTicketDetail } from "./jira-ticket-detail";
|
|
7
|
+
import { JiraDebugPromptDialog } from "./jira-debug-prompt-dialog";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
import { RefreshCw, Trash2, Loader2, Play } from "lucide-react";
|
|
10
|
+
import { toast } from "sonner";
|
|
11
|
+
import type { JiraWatchResult, JiraResultStatus } from "../../../../src/types/jira";
|
|
12
|
+
|
|
13
|
+
export function JiraResultsPanel() {
|
|
14
|
+
const {
|
|
15
|
+
results, watchers, configs, loadResults, loadConfigs,
|
|
16
|
+
softDeleteResult, markRead, unreadCount, loadUnreadCount,
|
|
17
|
+
} = useJiraStore();
|
|
18
|
+
const [filterWatcher, setFilterWatcher] = useState<string>("all");
|
|
19
|
+
const [filterStatus, setFilterStatus] = useState<string>("all");
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
const [ticketOpen, setTicketOpen] = useState(false);
|
|
22
|
+
const [selectedIssue, setSelectedIssue] = useState<{ configId: number; issueKey: string } | null>(null);
|
|
23
|
+
const [debugTarget, setDebugTarget] = useState<JiraWatchResult | null>(null);
|
|
24
|
+
|
|
25
|
+
const refresh = useCallback(async () => {
|
|
26
|
+
setLoading(true);
|
|
27
|
+
try {
|
|
28
|
+
await loadResults(
|
|
29
|
+
filterWatcher !== "all" ? Number(filterWatcher) : undefined,
|
|
30
|
+
filterStatus !== "all" ? filterStatus : undefined,
|
|
31
|
+
);
|
|
32
|
+
} catch {}
|
|
33
|
+
setLoading(false);
|
|
34
|
+
}, [filterWatcher, filterStatus, loadResults]);
|
|
35
|
+
|
|
36
|
+
useEffect(() => { loadConfigs(); loadUnreadCount(); refresh(); }, []);
|
|
37
|
+
useEffect(() => { refresh(); }, [filterWatcher, filterStatus]);
|
|
38
|
+
|
|
39
|
+
// Listen for jira:status_change WS events dispatched as CustomEvents
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const handler = (e: Event) => {
|
|
42
|
+
const detail = (e as CustomEvent).detail;
|
|
43
|
+
if (!detail) return;
|
|
44
|
+
refresh();
|
|
45
|
+
if (detail.status === "done") {
|
|
46
|
+
loadUnreadCount();
|
|
47
|
+
toast.success(`Debug complete: ${detail.issueKey}`, {
|
|
48
|
+
action: detail.sessionId ? {
|
|
49
|
+
label: "View",
|
|
50
|
+
onClick: () => window.dispatchEvent(
|
|
51
|
+
new CustomEvent("ppm:open-session", { detail: { sessionId: detail.sessionId } }),
|
|
52
|
+
),
|
|
53
|
+
} : undefined,
|
|
54
|
+
});
|
|
55
|
+
} else if (detail.status === "failed") {
|
|
56
|
+
toast.error(`Debug failed: ${detail.issueKey}`);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
window.addEventListener("jira:status_change", handler);
|
|
60
|
+
return () => window.removeEventListener("jira:status_change", handler);
|
|
61
|
+
}, [refresh, loadUnreadCount]);
|
|
62
|
+
|
|
63
|
+
const handleRowClick = (r: JiraWatchResult) => {
|
|
64
|
+
// Mark as read if done + unread
|
|
65
|
+
if (r.status === "done" && !r.readAt) markRead(r.id);
|
|
66
|
+
|
|
67
|
+
if (r.status === "done" && r.sessionId) {
|
|
68
|
+
window.dispatchEvent(new CustomEvent("ppm:open-session", { detail: { sessionId: r.sessionId } }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const watcher = watchers.find((w) => w.id === r.watcherId);
|
|
72
|
+
const config = configs.find((c) => c.id === watcher?.jiraConfigId);
|
|
73
|
+
if (config) {
|
|
74
|
+
setSelectedIssue({ configId: config.id, issueKey: r.issueKey });
|
|
75
|
+
setTicketOpen(true);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const selectedConfig = selectedIssue
|
|
80
|
+
? configs.find((c) => c.id === selectedIssue.configId)
|
|
81
|
+
: null;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="h-full flex flex-col">
|
|
85
|
+
{/* Filter bar */}
|
|
86
|
+
<div className="shrink-0 px-3 py-2 flex items-center gap-2 border-b">
|
|
87
|
+
<Select value={filterWatcher} onValueChange={setFilterWatcher}>
|
|
88
|
+
<SelectTrigger className="h-8 w-32 text-xs"><SelectValue placeholder="Watcher" /></SelectTrigger>
|
|
89
|
+
<SelectContent>
|
|
90
|
+
<SelectItem value="all">All watchers</SelectItem>
|
|
91
|
+
{watchers.map((w) => <SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>)}
|
|
92
|
+
</SelectContent>
|
|
93
|
+
</Select>
|
|
94
|
+
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
|
95
|
+
<SelectTrigger className="h-8 w-28 text-xs"><SelectValue placeholder="Status" /></SelectTrigger>
|
|
96
|
+
<SelectContent>
|
|
97
|
+
<SelectItem value="all">All</SelectItem>
|
|
98
|
+
<SelectItem value="pending">Pending</SelectItem>
|
|
99
|
+
<SelectItem value="queued">Queued</SelectItem>
|
|
100
|
+
<SelectItem value="running">Running</SelectItem>
|
|
101
|
+
<SelectItem value="done">Done</SelectItem>
|
|
102
|
+
<SelectItem value="failed">Failed</SelectItem>
|
|
103
|
+
</SelectContent>
|
|
104
|
+
</Select>
|
|
105
|
+
{unreadCount > 0 && (
|
|
106
|
+
<span className="inline-flex items-center justify-center size-5 rounded-full bg-primary text-primary-foreground text-[10px] font-bold">
|
|
107
|
+
{unreadCount}
|
|
108
|
+
</span>
|
|
109
|
+
)}
|
|
110
|
+
<Button size="icon" variant="ghost" className="size-8 ml-auto" onClick={refresh} disabled={loading}>
|
|
111
|
+
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
|
112
|
+
</Button>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Results list */}
|
|
116
|
+
<div className="flex-1 overflow-y-auto">
|
|
117
|
+
{results.length === 0 ? (
|
|
118
|
+
<p className="text-sm text-muted-foreground text-center py-8">
|
|
119
|
+
No results yet. Configure a watcher to start.
|
|
120
|
+
</p>
|
|
121
|
+
) : (
|
|
122
|
+
results.map((r) => (
|
|
123
|
+
<div
|
|
124
|
+
key={r.id}
|
|
125
|
+
className={cn(
|
|
126
|
+
"flex items-center gap-2 px-3 py-2 border-b hover:bg-muted/50 cursor-pointer",
|
|
127
|
+
r.status === "done" && !r.readAt && "bg-primary/5 font-medium border-l-2 border-l-primary",
|
|
128
|
+
)}
|
|
129
|
+
onClick={() => handleRowClick(r)}
|
|
130
|
+
>
|
|
131
|
+
<JiraStatusBadge status={r.status as JiraResultStatus} />
|
|
132
|
+
<span className="text-xs font-mono font-medium shrink-0">{r.issueKey}</span>
|
|
133
|
+
<span className="text-xs text-muted-foreground truncate flex-1">
|
|
134
|
+
{r.issueSummary ?? ""}
|
|
135
|
+
</span>
|
|
136
|
+
{r.status === "queued" && <Loader2 className="size-3.5 text-muted-foreground" />}
|
|
137
|
+
{r.status === "running" && <Loader2 className="size-3.5 animate-spin text-primary" />}
|
|
138
|
+
{r.status === "pending" && (
|
|
139
|
+
<Button size="icon" variant="ghost" className="size-8 shrink-0"
|
|
140
|
+
onClick={(e) => { e.stopPropagation(); setDebugTarget(r); }}>
|
|
141
|
+
<Play className="size-3.5 text-primary" />
|
|
142
|
+
</Button>
|
|
143
|
+
)}
|
|
144
|
+
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
145
|
+
{timeAgo(r.createdAt)}
|
|
146
|
+
</span>
|
|
147
|
+
<Button
|
|
148
|
+
size="icon" variant="ghost"
|
|
149
|
+
className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
|
|
150
|
+
onClick={(e) => { e.stopPropagation(); softDeleteResult(r.id); }}
|
|
151
|
+
>
|
|
152
|
+
<Trash2 className="size-3" />
|
|
153
|
+
</Button>
|
|
154
|
+
</div>
|
|
155
|
+
))
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Debug prompt dialog */}
|
|
160
|
+
<JiraDebugPromptDialog result={debugTarget} onClose={() => setDebugTarget(null)} />
|
|
161
|
+
|
|
162
|
+
{/* Ticket detail dialog */}
|
|
163
|
+
{selectedIssue && (
|
|
164
|
+
<JiraTicketDetail
|
|
165
|
+
open={ticketOpen}
|
|
166
|
+
onOpenChange={setTicketOpen}
|
|
167
|
+
configId={selectedIssue.configId}
|
|
168
|
+
issueKey={selectedIssue.issueKey}
|
|
169
|
+
baseUrl={selectedConfig?.baseUrl}
|
|
170
|
+
/>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function timeAgo(dateStr: string): string {
|
|
177
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
178
|
+
const mins = Math.floor(diff / 60000);
|
|
179
|
+
if (mins < 1) return "now";
|
|
180
|
+
if (mins < 60) return `${mins}m`;
|
|
181
|
+
const hrs = Math.floor(mins / 60);
|
|
182
|
+
if (hrs < 24) return `${hrs}h`;
|
|
183
|
+
return `${Math.floor(hrs / 24)}d`;
|
|
184
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
3
|
+
import { Separator } from "@/components/ui/separator";
|
|
4
|
+
import { useJiraStore } from "@/stores/jira-store";
|
|
5
|
+
import { JiraConfigForm } from "./jira-config-form";
|
|
6
|
+
import { JiraWatcherList } from "./jira-watcher-list";
|
|
7
|
+
import { JiraResultsPanel } from "./jira-results-panel";
|
|
8
|
+
|
|
9
|
+
export function JiraSettingsSection() {
|
|
10
|
+
const {
|
|
11
|
+
configs, selectedProjectId, setSelectedProjectId,
|
|
12
|
+
loadConfigs, loadWatchers, projectsWithIds, loadProjectsWithIds,
|
|
13
|
+
} = useJiraStore();
|
|
14
|
+
|
|
15
|
+
useEffect(() => { loadConfigs(); loadProjectsWithIds(); }, []);
|
|
16
|
+
|
|
17
|
+
const selectedConfig = configs.find((c) => c.projectId === selectedProjectId);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (selectedConfig) loadWatchers(selectedConfig.id);
|
|
21
|
+
}, [selectedConfig?.id]);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="space-y-4">
|
|
25
|
+
<div>
|
|
26
|
+
<label className="text-xs text-muted-foreground">Project</label>
|
|
27
|
+
<Select
|
|
28
|
+
value={selectedProjectId ? String(selectedProjectId) : ""}
|
|
29
|
+
onValueChange={(v) => setSelectedProjectId(Number(v))}
|
|
30
|
+
>
|
|
31
|
+
<SelectTrigger className="h-9"><SelectValue placeholder="Select project..." /></SelectTrigger>
|
|
32
|
+
<SelectContent>
|
|
33
|
+
{projectsWithIds.map((p) => (
|
|
34
|
+
<SelectItem key={p.id} value={String(p.id)}>{p.name}</SelectItem>
|
|
35
|
+
))}
|
|
36
|
+
</SelectContent>
|
|
37
|
+
</Select>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
{selectedProjectId && (
|
|
41
|
+
<>
|
|
42
|
+
<JiraConfigForm
|
|
43
|
+
projectId={selectedProjectId}
|
|
44
|
+
existing={selectedConfig ? { baseUrl: selectedConfig.baseUrl, email: selectedConfig.email, hasToken: selectedConfig.hasToken } : null}
|
|
45
|
+
/>
|
|
46
|
+
{selectedConfig && (
|
|
47
|
+
<>
|
|
48
|
+
<Separator />
|
|
49
|
+
<JiraWatcherList configId={selectedConfig.id} />
|
|
50
|
+
<Separator />
|
|
51
|
+
<JiraResultsPanel />
|
|
52
|
+
</>
|
|
53
|
+
)}
|
|
54
|
+
</>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Badge } from "@/components/ui/badge";
|
|
2
|
+
import type { JiraResultStatus } from "../../../../src/types/jira";
|
|
3
|
+
|
|
4
|
+
const STATUS_STYLES: Record<JiraResultStatus, string> = {
|
|
5
|
+
pending: "bg-yellow-500/15 text-yellow-600 border-yellow-500/30",
|
|
6
|
+
queued: "bg-orange-500/15 text-orange-600 border-orange-500/30",
|
|
7
|
+
running: "bg-blue-500/15 text-blue-600 border-blue-500/30",
|
|
8
|
+
done: "bg-green-500/15 text-green-600 border-green-500/30",
|
|
9
|
+
failed: "bg-red-500/15 text-red-600 border-red-500/30",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function JiraStatusBadge({ status }: { status: JiraResultStatus }) {
|
|
13
|
+
return (
|
|
14
|
+
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${STATUS_STYLES[status] ?? ""}`}>
|
|
15
|
+
{status}
|
|
16
|
+
</Badge>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Play, RotateCcw, Square, ExternalLink, Trash2, Loader2, MoreHorizontal } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
|
4
|
+
} from "@/components/ui/dropdown-menu";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import type { JiraWatchResult, JiraResultStatus } from "../../../../src/types/jira";
|
|
7
|
+
|
|
8
|
+
/** Status dot color */
|
|
9
|
+
const DOT_COLORS: Record<JiraResultStatus, string> = {
|
|
10
|
+
pending: "bg-yellow-500",
|
|
11
|
+
queued: "bg-orange-500",
|
|
12
|
+
running: "bg-blue-500 animate-pulse",
|
|
13
|
+
done: "bg-green-500",
|
|
14
|
+
failed: "bg-red-500",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Relative time helper */
|
|
18
|
+
function timeAgo(dateStr: string): string {
|
|
19
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
20
|
+
const mins = Math.floor(diff / 60000);
|
|
21
|
+
if (mins < 1) return "now";
|
|
22
|
+
if (mins < 60) return `${mins}m`;
|
|
23
|
+
const hrs = Math.floor(mins / 60);
|
|
24
|
+
if (hrs < 24) return `${hrs}h`;
|
|
25
|
+
const days = Math.floor(hrs / 24);
|
|
26
|
+
return `${days}d`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
result: JiraWatchResult;
|
|
31
|
+
onDebug: (r: JiraWatchResult) => void;
|
|
32
|
+
onResume: (r: JiraWatchResult) => void;
|
|
33
|
+
onCancel: (r: JiraWatchResult) => void;
|
|
34
|
+
onOpenSession: (r: JiraWatchResult) => void;
|
|
35
|
+
onDelete: (id: number) => void;
|
|
36
|
+
onClick: (r: JiraWatchResult) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function JiraTicketCard({ result, onDebug, onResume, onCancel, onOpenSession, onDelete, onClick }: Props) {
|
|
40
|
+
const r = result;
|
|
41
|
+
const isUnread = r.status === "done" && !r.readAt;
|
|
42
|
+
const hasSession = !!r.sessionId;
|
|
43
|
+
const canResume = r.status === "failed" && hasSession;
|
|
44
|
+
const canDebug = r.status === "pending" || (r.status === "failed" && !hasSession);
|
|
45
|
+
const canCancel = r.status === "queued" || r.status === "running";
|
|
46
|
+
const status = r.status as JiraResultStatus;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className={cn(
|
|
51
|
+
"group rounded bg-card shadow-sm hover:shadow-md hover:bg-accent/50 hover:-translate-y-px cursor-pointer transition-all duration-150",
|
|
52
|
+
"px-2.5 py-2 space-y-1",
|
|
53
|
+
isUnread && "ring-1 ring-primary/30",
|
|
54
|
+
)}
|
|
55
|
+
onClick={() => onClick(r)}
|
|
56
|
+
>
|
|
57
|
+
{/* Line 1: issue key + summary + time */}
|
|
58
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
59
|
+
<span className="text-xs font-mono font-semibold text-foreground shrink-0">{r.issueKey}</span>
|
|
60
|
+
{isUnread && <span className="size-1.5 rounded-full bg-primary shrink-0" />}
|
|
61
|
+
<span className="text-xs text-muted-foreground truncate flex-1 min-w-0">
|
|
62
|
+
{r.issueSummary || "No summary"}
|
|
63
|
+
</span>
|
|
64
|
+
<span className="text-[10px] text-muted-foreground/60 tabular-nums shrink-0">
|
|
65
|
+
{timeAgo(r.createdAt)}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{/* Line 2: status dot + label + action buttons */}
|
|
70
|
+
<div className="flex items-center justify-between min-w-0">
|
|
71
|
+
<span className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
72
|
+
<span className={cn("size-1.5 rounded-full shrink-0", DOT_COLORS[status])} />
|
|
73
|
+
{status}
|
|
74
|
+
</span>
|
|
75
|
+
|
|
76
|
+
<div
|
|
77
|
+
className="flex items-center gap-0.5 shrink-0"
|
|
78
|
+
onClick={(e) => e.stopPropagation()}
|
|
79
|
+
>
|
|
80
|
+
{canResume && (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
className="flex items-center gap-1 h-6 px-1.5 rounded text-[10px] font-medium text-primary hover:bg-primary/10 active:scale-95 transition-colors"
|
|
84
|
+
onClick={() => onResume(r)}
|
|
85
|
+
title="Resume debug session"
|
|
86
|
+
>
|
|
87
|
+
<RotateCcw className="size-3" />
|
|
88
|
+
<span>Resume</span>
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
91
|
+
{canDebug && (
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
className="flex items-center justify-center size-6 rounded text-muted-foreground hover:text-primary active:scale-95 transition-colors"
|
|
95
|
+
onClick={() => onDebug(r)}
|
|
96
|
+
title="Debug"
|
|
97
|
+
>
|
|
98
|
+
<Play className="size-3" />
|
|
99
|
+
</button>
|
|
100
|
+
)}
|
|
101
|
+
{canCancel && (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className="flex items-center justify-center size-6 rounded text-muted-foreground hover:text-destructive active:scale-95 transition-colors"
|
|
105
|
+
onClick={() => onCancel(r)}
|
|
106
|
+
title="Stop debug"
|
|
107
|
+
>
|
|
108
|
+
<Square className="size-3" />
|
|
109
|
+
</button>
|
|
110
|
+
)}
|
|
111
|
+
{r.status === "running" && (
|
|
112
|
+
<Loader2 className="size-3 animate-spin text-primary" />
|
|
113
|
+
)}
|
|
114
|
+
{hasSession && (
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
className="flex items-center gap-1 h-6 px-1.5 rounded text-[10px] font-medium text-primary hover:bg-primary/10 active:scale-95 transition-colors"
|
|
118
|
+
onClick={() => onOpenSession(r)}
|
|
119
|
+
title="Open session"
|
|
120
|
+
>
|
|
121
|
+
<ExternalLink className="size-3" />
|
|
122
|
+
<span>Open</span>
|
|
123
|
+
</button>
|
|
124
|
+
)}
|
|
125
|
+
<DropdownMenu>
|
|
126
|
+
<DropdownMenuTrigger asChild>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className="flex items-center justify-center size-6 rounded text-muted-foreground hover:text-foreground active:scale-95 transition-colors"
|
|
130
|
+
>
|
|
131
|
+
<MoreHorizontal className="size-3" />
|
|
132
|
+
</button>
|
|
133
|
+
</DropdownMenuTrigger>
|
|
134
|
+
<DropdownMenuContent align="end">
|
|
135
|
+
<DropdownMenuItem className="text-destructive" onClick={() => onDelete(r.id)}>
|
|
136
|
+
<Trash2 className="size-3.5 mr-2" /> Delete
|
|
137
|
+
</DropdownMenuItem>
|
|
138
|
+
</DropdownMenuContent>
|
|
139
|
+
</DropdownMenu>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|