@hienlh/ppm 0.10.5 → 0.11.1
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 +35 -0
- package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
- package/dist/web/assets/{api-settings-C__hxGX2.js → api-settings-2eTz4SgY.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-BRW4VwMk.js +1 -0
- package/dist/web/assets/chat-tab-DYf6U6UF.js +10 -0
- package/dist/web/assets/code-editor-BPxBeu0S.js +8 -0
- package/dist/web/assets/{conflict-editor-Bxq4QiW1.js → conflict-editor-BCkYHDUy.js} +1 -1
- package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
- package/dist/web/assets/{database-viewer-CvQc1PZH.js → database-viewer-CCe8qa1Q.js} +2 -2
- package/dist/web/assets/{diff-viewer-x7kjfVYW.js → diff-viewer-DIjzWvaG.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-HY8XueLo.js +3 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
- package/dist/web/assets/index-DpRxWGjM.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/{input-ClhO__YM.js → input-CHRMley8.js} +1 -1
- package/dist/web/assets/{keybindings-store-C9KsBH7z.js → keybindings-store-CpP5_miA.js} +1 -1
- package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
- package/dist/web/assets/{markdown-renderer-CKmmrUuy.js → markdown-renderer-BQV0AIm5.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-DPmTpfFX.js +1 -0
- package/dist/web/assets/{postgres-viewer-YkljtDWX.js → postgres-viewer-BUSNt_7x.js} +3 -3
- package/dist/web/assets/{project-store-BYmQ0fDC.js → project-store-CczGNZyf.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
- package/dist/web/assets/{scroll-area-DW7L4Gnc.js → scroll-area-DwWF9FpN.js} +1 -1
- package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
- package/dist/web/assets/settings-tab-DHBG5O0C.js +1 -0
- package/dist/web/assets/{sql-query-editor-CM_qEhaX.js → sql-query-editor-CVEi0jLM.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-f6ZJHIzh.js → sqlite-viewer-B7WnFN29.js} +1 -1
- package/dist/web/assets/{tab-store-B3M9hjho.js → tab-store-Jvy1eZGM.js} +1 -1
- package/dist/web/assets/{terminal-tab-CVdfvDSK.js → terminal-tab-1K4ijyNe.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
- package/dist/web/assets/{use-monaco-theme-CvV5vy_F.js → use-monaco-theme-kjiAwvOp.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-CwOSbfhN.js → vendor-mermaid-CylkVm4U.js} +3 -3
- package/dist/web/index.html +16 -16
- 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/webview-html.ts +8 -7
- 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/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/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 +70 -19
- package/src/web/components/extensions/extension-webview.tsx +24 -10
- 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/ws-client.ts +10 -3
- package/src/web/stores/jira-store.ts +198 -0
- package/src/web/stores/settings-store.ts +17 -2
- 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-D2rONDPd.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-DmL1WyG-.js +0 -1
- package/dist/web/assets/chat-tab-Dki1pz84.js +0 -10
- package/dist/web/assets/code-editor-D3AAT8nI.js +0 -8
- package/dist/web/assets/extension-webview-BFd0USXC.js +0 -3
- package/dist/web/assets/gitGraph-HDMCJU4V-D8vKfkjC.js +0 -1
- package/dist/web/assets/index-DPnjO2FY.css +0 -2
- package/dist/web/assets/index-DuEUN2Eg.js +0 -26
- package/dist/web/assets/info-3K5VOQVL-VG29MIoT.js +0 -1
- package/dist/web/assets/keybindings-store-BkZjvU9J.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-Bl_WpvPc.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-BVpLpAIy.js +0 -1
- package/dist/web/assets/port-forwarding-tab-BUH9aImG.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-CJGco43I.js +0 -1
- package/dist/web/assets/settings-store-D9CflsKU.js +0 -2
- package/dist/web/assets/settings-tab-DfPjX9uY.js +0 -1
- package/dist/web/assets/square-nsMa3iMk.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-BsOrObtE.js +0 -1
- /package/dist/web/assets/{api-client-Bn-Pi9k5.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/{utils-CTg5uAYR.js → utils-ChWX7pZv.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-ejLe7-tK.js → vendor-xterm-B9BUAFKA.js} +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
4
|
+
import { useJiraStore } from "@/stores/jira-store";
|
|
5
|
+
import type { JiraWatchResult } from "../../../../src/types/jira";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
result: JiraWatchResult | null;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function JiraDebugPromptDialog({ result, onClose }: Props) {
|
|
13
|
+
const { watchers, startDebug } = useJiraStore();
|
|
14
|
+
const [prompt, setPrompt] = useState("");
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!result) return;
|
|
18
|
+
const watcher = watchers.find((w) => w.id === result.watcherId);
|
|
19
|
+
const template = watcher?.promptTemplate
|
|
20
|
+
?? `Debug Jira issue {issue_key}: {summary}`;
|
|
21
|
+
setPrompt(
|
|
22
|
+
template
|
|
23
|
+
.replace(/\{issue_key\}/g, result.issueKey)
|
|
24
|
+
.replace(/\{summary\}/g, result.issueSummary ?? ""),
|
|
25
|
+
);
|
|
26
|
+
}, [result, watchers]);
|
|
27
|
+
|
|
28
|
+
const handleStart = async () => {
|
|
29
|
+
if (!result) return;
|
|
30
|
+
try {
|
|
31
|
+
await startDebug(result.id, prompt);
|
|
32
|
+
} catch {}
|
|
33
|
+
onClose();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Dialog open={!!result} onOpenChange={(open) => { if (!open) onClose(); }}>
|
|
38
|
+
<DialogContent className="max-w-md">
|
|
39
|
+
<DialogHeader>
|
|
40
|
+
<DialogTitle>Start Debug: {result?.issueKey}</DialogTitle>
|
|
41
|
+
</DialogHeader>
|
|
42
|
+
<p className="text-sm text-muted-foreground">{result?.issueSummary}</p>
|
|
43
|
+
<div>
|
|
44
|
+
<label className="text-xs text-muted-foreground">Debug Prompt</label>
|
|
45
|
+
<textarea
|
|
46
|
+
value={prompt}
|
|
47
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
48
|
+
className="w-full h-24 rounded-md border border-input bg-background px-3 py-2 text-xs font-mono resize-none mt-1"
|
|
49
|
+
placeholder="Debug Jira issue {issue_key}: {summary}"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
<Button size="sm" className="w-full min-h-[44px]" onClick={handleStart}>
|
|
53
|
+
Start Debug Session
|
|
54
|
+
</Button>
|
|
55
|
+
</DialogContent>
|
|
56
|
+
</Dialog>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
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 { X, Loader2 } from "lucide-react";
|
|
5
|
+
import { api } from "@/lib/api-client";
|
|
6
|
+
|
|
7
|
+
interface FilterState {
|
|
8
|
+
project: string[];
|
|
9
|
+
issueType: string[];
|
|
10
|
+
priority: string[];
|
|
11
|
+
status: string[];
|
|
12
|
+
assignee: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function filtersToJql(filters: FilterState): string {
|
|
16
|
+
const clauses: string[] = [];
|
|
17
|
+
if (filters.project.length) clauses.push(`project IN (${filters.project.join(", ")})`);
|
|
18
|
+
if (filters.issueType.length) clauses.push(`issuetype IN (${filters.issueType.map((v) => `"${v}"`).join(", ")})`);
|
|
19
|
+
if (filters.priority.length) clauses.push(`priority IN (${filters.priority.map((v) => `"${v}"`).join(", ")})`);
|
|
20
|
+
if (filters.status.length) clauses.push(`status IN (${filters.status.map((v) => `"${v}"`).join(", ")})`);
|
|
21
|
+
if (filters.assignee.length) clauses.push(`assignee IN (${filters.assignee.map((v) => `"${v}"`).join(", ")})`);
|
|
22
|
+
return (clauses.join(" AND ") || "ORDER BY updated DESC") + (clauses.length ? " ORDER BY updated DESC" : "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const EMPTY_FILTERS: FilterState = { project: [], issueType: [], priority: [], status: [], assignee: [] };
|
|
26
|
+
|
|
27
|
+
interface FieldOption { id?: string; key?: string; name: string }
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
value: string;
|
|
31
|
+
onChange: (jql: string) => void;
|
|
32
|
+
configId: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function JiraFilterBuilder({ value, onChange, configId }: Props) {
|
|
36
|
+
const [mode, setMode] = useState<"builder" | "raw">(value ? "raw" : "builder");
|
|
37
|
+
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTERS);
|
|
38
|
+
const [rawJql, setRawJql] = useState(value);
|
|
39
|
+
|
|
40
|
+
// Metadata options fetched from Jira API
|
|
41
|
+
const [projects, setProjects] = useState<FieldOption[]>([]);
|
|
42
|
+
const [issueTypes, setIssueTypes] = useState<FieldOption[]>([]);
|
|
43
|
+
const [priorities, setPriorities] = useState<FieldOption[]>([]);
|
|
44
|
+
const [statuses, setStatuses] = useState<FieldOption[]>([]);
|
|
45
|
+
const [assignees, setAssignees] = useState<FieldOption[]>([]);
|
|
46
|
+
const [loading, setLoading] = useState(false);
|
|
47
|
+
|
|
48
|
+
// Fetch metadata when configId changes
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!configId) return;
|
|
51
|
+
setLoading(true);
|
|
52
|
+
Promise.all([
|
|
53
|
+
api.get<FieldOption[]>(`/api/jira/metadata/${configId}/projects`).catch(() => []),
|
|
54
|
+
api.get<FieldOption[]>(`/api/jira/metadata/${configId}/issuetype`).catch(() => []),
|
|
55
|
+
api.get<FieldOption[]>(`/api/jira/metadata/${configId}/priority`).catch(() => []),
|
|
56
|
+
api.get<FieldOption[]>(`/api/jira/metadata/${configId}/status`).catch(() => []),
|
|
57
|
+
api.get<Array<{ accountId: string; displayName: string }>>(`/api/jira/metadata/${configId}/assignees`).catch(() => []),
|
|
58
|
+
]).then(([p, it, pr, st, as_]) => {
|
|
59
|
+
setProjects(p);
|
|
60
|
+
setIssueTypes(it);
|
|
61
|
+
setPriorities(pr);
|
|
62
|
+
setStatuses(st);
|
|
63
|
+
setAssignees(as_.map((u) => ({ id: u.accountId, name: u.displayName })));
|
|
64
|
+
}).finally(() => setLoading(false));
|
|
65
|
+
}, [configId]);
|
|
66
|
+
|
|
67
|
+
// Sync builder → JQL
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (mode === "builder") {
|
|
70
|
+
const jql = filtersToJql(filters);
|
|
71
|
+
onChange(jql);
|
|
72
|
+
}
|
|
73
|
+
}, [filters, mode]);
|
|
74
|
+
|
|
75
|
+
const handleRawChange = useCallback((val: string) => {
|
|
76
|
+
setRawJql(val);
|
|
77
|
+
onChange(val);
|
|
78
|
+
}, [onChange]);
|
|
79
|
+
|
|
80
|
+
const addValue = (field: keyof FilterState, val: string) => {
|
|
81
|
+
if (!val || filters[field].includes(val)) return;
|
|
82
|
+
setFilters((f) => ({ ...f, [field]: [...f[field], val] }));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const removeValue = (field: keyof FilterState, val: string) => {
|
|
86
|
+
setFilters((f) => ({ ...f, [field]: f[field].filter((v) => v !== val) }));
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="space-y-2 min-w-0">
|
|
91
|
+
<div className="flex items-center gap-2">
|
|
92
|
+
<Button
|
|
93
|
+
type="button" size="sm" variant={mode === "builder" ? "default" : "outline"}
|
|
94
|
+
onClick={() => setMode("builder")} className="min-h-[44px] text-xs"
|
|
95
|
+
>Builder</Button>
|
|
96
|
+
<Button
|
|
97
|
+
type="button" size="sm" variant={mode === "raw" ? "default" : "outline"}
|
|
98
|
+
onClick={() => setMode("raw")} className="min-h-[44px] text-xs"
|
|
99
|
+
>Raw JQL</Button>
|
|
100
|
+
{loading && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{mode === "raw" ? (
|
|
104
|
+
<textarea
|
|
105
|
+
value={rawJql}
|
|
106
|
+
onChange={(e) => handleRawChange(e.target.value)}
|
|
107
|
+
className="w-full h-20 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
108
|
+
placeholder='e.g. project = MYPROJ AND status = "In Progress"'
|
|
109
|
+
/>
|
|
110
|
+
) : (
|
|
111
|
+
<div className="space-y-2">
|
|
112
|
+
<FilterField
|
|
113
|
+
label="Project" field="project" filters={filters}
|
|
114
|
+
onAdd={addValue} onRemove={removeValue}
|
|
115
|
+
options={projects.map((p) => ({ value: p.key ?? p.name, label: `${p.key ?? p.name} — ${p.name}` }))}
|
|
116
|
+
placeholder="Select project..."
|
|
117
|
+
/>
|
|
118
|
+
<FilterField
|
|
119
|
+
label="Issue Type" field="issueType" filters={filters}
|
|
120
|
+
onAdd={addValue} onRemove={removeValue}
|
|
121
|
+
options={issueTypes.map((t) => ({ value: t.name, label: t.name }))}
|
|
122
|
+
placeholder="Select issue type..."
|
|
123
|
+
/>
|
|
124
|
+
<FilterField
|
|
125
|
+
label="Priority" field="priority" filters={filters}
|
|
126
|
+
onAdd={addValue} onRemove={removeValue}
|
|
127
|
+
options={priorities.map((p) => ({ value: p.name, label: p.name }))}
|
|
128
|
+
placeholder="Select priority..."
|
|
129
|
+
/>
|
|
130
|
+
<FilterField
|
|
131
|
+
label="Status" field="status" filters={filters}
|
|
132
|
+
onAdd={addValue} onRemove={removeValue}
|
|
133
|
+
options={statuses.map((s) => ({ value: s.name, label: s.name }))}
|
|
134
|
+
placeholder="Select status..."
|
|
135
|
+
/>
|
|
136
|
+
<FilterField
|
|
137
|
+
label="Assignee" field="assignee" filters={filters}
|
|
138
|
+
onAdd={addValue} onRemove={removeValue}
|
|
139
|
+
options={assignees.map((a) => ({ value: a.id ?? a.name, label: a.name }))}
|
|
140
|
+
placeholder="Select assignee..."
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{/* JQL preview */}
|
|
146
|
+
<div className="text-xs text-muted-foreground bg-muted/50 rounded px-2 py-1 font-mono break-all">
|
|
147
|
+
{mode === "builder" ? filtersToJql(filters) : rawJql || "(empty)"}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function FilterField({ label, field, filters, onAdd, onRemove, options, placeholder }: {
|
|
154
|
+
label: string; field: keyof FilterState; filters: FilterState;
|
|
155
|
+
onAdd: (f: keyof FilterState, v: string) => void;
|
|
156
|
+
onRemove: (f: keyof FilterState, v: string) => void;
|
|
157
|
+
options: Array<{ value: string; label: string }>;
|
|
158
|
+
placeholder: string;
|
|
159
|
+
}) {
|
|
160
|
+
// Filter out already-selected options
|
|
161
|
+
const available = options.filter((o) => !filters[field].includes(o.value));
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div>
|
|
165
|
+
<label className="text-xs text-muted-foreground">{label}</label>
|
|
166
|
+
<div className="flex items-center gap-1 flex-wrap">
|
|
167
|
+
{filters[field].map((v) => {
|
|
168
|
+
const displayLabel = options.find((o) => o.value === v)?.label ?? v;
|
|
169
|
+
return (
|
|
170
|
+
<span key={v} className="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full bg-primary/10 text-xs">
|
|
171
|
+
{displayLabel}
|
|
172
|
+
<button type="button" onClick={() => onRemove(field, v)} className="hover:text-destructive">
|
|
173
|
+
<X className="size-3" />
|
|
174
|
+
</button>
|
|
175
|
+
</span>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
{available.length > 0 ? (
|
|
179
|
+
<Select onValueChange={(v) => onAdd(field, v)}>
|
|
180
|
+
<SelectTrigger className="h-7 w-auto min-w-[120px] text-xs">
|
|
181
|
+
<SelectValue placeholder={placeholder} />
|
|
182
|
+
</SelectTrigger>
|
|
183
|
+
<SelectContent>
|
|
184
|
+
{available.map((o) => (
|
|
185
|
+
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
186
|
+
))}
|
|
187
|
+
</SelectContent>
|
|
188
|
+
</Select>
|
|
189
|
+
) : options.length > 0 ? (
|
|
190
|
+
<span className="text-xs text-muted-foreground italic">All selected</span>
|
|
191
|
+
) : (
|
|
192
|
+
<span className="text-xs text-muted-foreground italic">Loading...</span>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -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
|
+
}
|