@hienlh/ppm 0.10.5 → 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 +29 -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-CbguR_l0.js +10 -0
- package/dist/web/assets/code-editor-DbZP0Dnj.js +8 -0
- package/dist/web/assets/{conflict-editor-Bxq4QiW1.js → conflict-editor-BzrH1UpC.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-CqMOv2Sg.js} +2 -2
- package/dist/web/assets/{diff-viewer-x7kjfVYW.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/{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-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-YkljtDWX.js → postgres-viewer-YKyNjTLp.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-2tdZuQIn.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-Fx9qDD4-.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-BxljmYb7.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 +9 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Input } from "@/components/ui/input";
|
|
4
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
5
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
6
|
+
import { api } from "@/lib/api-client";
|
|
7
|
+
import { ExternalLink, Loader2 } from "lucide-react";
|
|
8
|
+
import type { JiraIssue, JiraTransition } from "../../../../src/types/jira";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onOpenChange: (open: boolean) => void;
|
|
13
|
+
configId: number;
|
|
14
|
+
issueKey: string;
|
|
15
|
+
baseUrl?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function JiraTicketDetail({ open, onOpenChange, configId, issueKey, baseUrl }: Props) {
|
|
19
|
+
const [issue, setIssue] = useState<JiraIssue | null>(null);
|
|
20
|
+
const [transitions, setTransitions] = useState<JiraTransition[]>([]);
|
|
21
|
+
const [loading, setLoading] = useState(false);
|
|
22
|
+
const [saving, setSaving] = useState(false);
|
|
23
|
+
const [editSummary, setEditSummary] = useState("");
|
|
24
|
+
const [editing, setEditing] = useState(false);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!open || !issueKey) return;
|
|
28
|
+
setLoading(true);
|
|
29
|
+
Promise.all([
|
|
30
|
+
api.get<JiraIssue>(`/api/jira/ticket/${configId}/${issueKey}`),
|
|
31
|
+
api.get<JiraTransition[]>(`/api/jira/ticket/${configId}/${issueKey}/transitions`),
|
|
32
|
+
]).then(([iss, trans]) => {
|
|
33
|
+
setIssue(iss);
|
|
34
|
+
setEditSummary(iss.fields.summary);
|
|
35
|
+
setTransitions(trans);
|
|
36
|
+
}).catch(() => {}).finally(() => setLoading(false));
|
|
37
|
+
}, [open, issueKey, configId]);
|
|
38
|
+
|
|
39
|
+
const handleSaveSummary = async () => {
|
|
40
|
+
if (!issue || editSummary === issue.fields.summary) { setEditing(false); return; }
|
|
41
|
+
setSaving(true);
|
|
42
|
+
try {
|
|
43
|
+
await api.put(`/api/jira/ticket/${configId}/${issueKey}`, { fields: { summary: editSummary } });
|
|
44
|
+
setIssue({ ...issue, fields: { ...issue.fields, summary: editSummary } });
|
|
45
|
+
setEditing(false);
|
|
46
|
+
} catch {}
|
|
47
|
+
setSaving(false);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleTransition = async (transitionId: string) => {
|
|
51
|
+
setSaving(true);
|
|
52
|
+
try {
|
|
53
|
+
await api.post(`/api/jira/ticket/${configId}/${issueKey}/transition`, { transitionId });
|
|
54
|
+
// Refresh issue
|
|
55
|
+
const iss = await api.get<JiraIssue>(`/api/jira/ticket/${configId}/${issueKey}`);
|
|
56
|
+
setIssue(iss);
|
|
57
|
+
} catch {}
|
|
58
|
+
setSaving(false);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
63
|
+
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
|
64
|
+
<DialogHeader>
|
|
65
|
+
<DialogTitle className="flex items-center gap-2">
|
|
66
|
+
{issueKey}
|
|
67
|
+
{baseUrl && (
|
|
68
|
+
<a href={`${baseUrl}/browse/${issueKey}`} target="_blank" rel="noopener noreferrer"
|
|
69
|
+
className="text-muted-foreground hover:text-foreground">
|
|
70
|
+
<ExternalLink className="size-4" />
|
|
71
|
+
</a>
|
|
72
|
+
)}
|
|
73
|
+
</DialogTitle>
|
|
74
|
+
</DialogHeader>
|
|
75
|
+
|
|
76
|
+
{loading ? (
|
|
77
|
+
<div className="flex justify-center py-8"><Loader2 className="size-6 animate-spin" /></div>
|
|
78
|
+
) : issue ? (
|
|
79
|
+
<div className="space-y-3 text-sm">
|
|
80
|
+
{/* Summary */}
|
|
81
|
+
<div>
|
|
82
|
+
<label className="text-xs text-muted-foreground">Summary</label>
|
|
83
|
+
{editing ? (
|
|
84
|
+
<div className="flex gap-1">
|
|
85
|
+
<Input value={editSummary} onChange={(e) => setEditSummary(e.target.value)} className="h-8 text-sm" />
|
|
86
|
+
<Button size="sm" onClick={handleSaveSummary} disabled={saving} className="h-8">
|
|
87
|
+
{saving ? <Loader2 className="size-3 animate-spin" /> : "Save"}
|
|
88
|
+
</Button>
|
|
89
|
+
</div>
|
|
90
|
+
) : (
|
|
91
|
+
<p className="cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5" onClick={() => setEditing(true)}>
|
|
92
|
+
{issue.fields.summary}
|
|
93
|
+
</p>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Status + transition */}
|
|
98
|
+
<div className="flex gap-4">
|
|
99
|
+
<div className="flex-1">
|
|
100
|
+
<label className="text-xs text-muted-foreground">Status</label>
|
|
101
|
+
<p className="font-medium">{issue.fields.status.name}</p>
|
|
102
|
+
</div>
|
|
103
|
+
{transitions.length > 0 && (
|
|
104
|
+
<div className="flex-1">
|
|
105
|
+
<label className="text-xs text-muted-foreground">Transition to</label>
|
|
106
|
+
<Select onValueChange={handleTransition} disabled={saving}>
|
|
107
|
+
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Change status..." /></SelectTrigger>
|
|
108
|
+
<SelectContent>
|
|
109
|
+
{transitions.map((t) => (
|
|
110
|
+
<SelectItem key={t.id} value={t.id}>{t.name} → {t.to.name}</SelectItem>
|
|
111
|
+
))}
|
|
112
|
+
</SelectContent>
|
|
113
|
+
</Select>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* Priority & Assignee */}
|
|
119
|
+
<div className="flex gap-4">
|
|
120
|
+
<div className="flex-1">
|
|
121
|
+
<label className="text-xs text-muted-foreground">Priority</label>
|
|
122
|
+
<p>{issue.fields.priority?.name ?? "None"}</p>
|
|
123
|
+
</div>
|
|
124
|
+
<div className="flex-1">
|
|
125
|
+
<label className="text-xs text-muted-foreground">Assignee</label>
|
|
126
|
+
<p>{issue.fields.assignee?.displayName ?? "Unassigned"}</p>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Description */}
|
|
131
|
+
{issue.fields.description && (
|
|
132
|
+
<div>
|
|
133
|
+
<label className="text-xs text-muted-foreground">Description</label>
|
|
134
|
+
<p className="text-xs text-muted-foreground whitespace-pre-wrap bg-muted/30 rounded p-2 max-h-40 overflow-y-auto">
|
|
135
|
+
{typeof issue.fields.description === "string"
|
|
136
|
+
? issue.fields.description
|
|
137
|
+
: JSON.stringify(issue.fields.description, null, 2)}
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{/* Timestamps */}
|
|
143
|
+
<div className="text-xs text-muted-foreground">
|
|
144
|
+
Updated: {new Date(issue.fields.updated).toLocaleString()}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
) : (
|
|
148
|
+
<p className="text-sm text-muted-foreground text-center py-4">Failed to load issue.</p>
|
|
149
|
+
)}
|
|
150
|
+
</DialogContent>
|
|
151
|
+
</Dialog>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Input } from "@/components/ui/input";
|
|
4
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
5
|
+
import { useJiraStore } from "@/stores/jira-store";
|
|
6
|
+
import { JiraFilterBuilder } from "./jira-filter-builder";
|
|
7
|
+
import { Loader2, Search } from "lucide-react";
|
|
8
|
+
import type { JiraWatcher, JiraWatcherMode, JiraIssue } from "../../../../src/types/jira";
|
|
9
|
+
|
|
10
|
+
const INTERVALS = [
|
|
11
|
+
{ label: "30s", value: 30000 }, { label: "1m", value: 60000 },
|
|
12
|
+
{ label: "2m", value: 120000 }, { label: "5m", value: 300000 },
|
|
13
|
+
{ label: "10m", value: 600000 }, { label: "30m", value: 1800000 },
|
|
14
|
+
{ label: "1h", value: 3600000 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
configId: number;
|
|
19
|
+
existing?: JiraWatcher;
|
|
20
|
+
onDone: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function JiraWatcherForm({ configId, existing, onDone }: Props) {
|
|
24
|
+
const { createWatcher, updateWatcher, testJql } = useJiraStore();
|
|
25
|
+
const [name, setName] = useState(existing?.name ?? "");
|
|
26
|
+
const [jql, setJql] = useState(existing?.jql ?? "");
|
|
27
|
+
const [intervalMs, setIntervalMs] = useState(existing?.intervalMs ?? 120000);
|
|
28
|
+
const [mode, setMode] = useState<JiraWatcherMode>(existing?.mode ?? "debug");
|
|
29
|
+
const [prompt, setPrompt] = useState(existing?.promptTemplate ?? "");
|
|
30
|
+
const [saving, setSaving] = useState(false);
|
|
31
|
+
|
|
32
|
+
// Test JQL state
|
|
33
|
+
const [testing, setTesting] = useState(false);
|
|
34
|
+
const [testResults, setTestResults] = useState<JiraIssue[] | null>(null);
|
|
35
|
+
const [testTotal, setTestTotal] = useState(0);
|
|
36
|
+
const [testError, setTestError] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
const isEdit = !!existing;
|
|
39
|
+
|
|
40
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
if (!name || !jql) return;
|
|
43
|
+
setSaving(true);
|
|
44
|
+
try {
|
|
45
|
+
if (isEdit) {
|
|
46
|
+
await updateWatcher(existing.id, {
|
|
47
|
+
name, jql, intervalMs, mode,
|
|
48
|
+
promptTemplate: prompt || null,
|
|
49
|
+
});
|
|
50
|
+
} else {
|
|
51
|
+
await createWatcher({ configId, name, jql, intervalMs, mode, promptTemplate: prompt || undefined });
|
|
52
|
+
}
|
|
53
|
+
onDone();
|
|
54
|
+
} catch {}
|
|
55
|
+
setSaving(false);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleTestJql = async () => {
|
|
59
|
+
if (!jql) return;
|
|
60
|
+
setTesting(true);
|
|
61
|
+
setTestError(null);
|
|
62
|
+
setTestResults(null);
|
|
63
|
+
try {
|
|
64
|
+
const res = await testJql(configId, jql);
|
|
65
|
+
setTestResults(res.issues);
|
|
66
|
+
setTestTotal(res.total);
|
|
67
|
+
} catch (e: any) {
|
|
68
|
+
setTestError(e.message ?? "Test failed");
|
|
69
|
+
}
|
|
70
|
+
setTesting(false);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<form onSubmit={handleSubmit} className="space-y-3 min-w-0">
|
|
75
|
+
<div>
|
|
76
|
+
<label className="text-xs text-muted-foreground">Name</label>
|
|
77
|
+
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Bug watcher" className="h-9" />
|
|
78
|
+
</div>
|
|
79
|
+
<JiraFilterBuilder value={jql} onChange={setJql} configId={configId} />
|
|
80
|
+
|
|
81
|
+
{/* Test JQL button */}
|
|
82
|
+
<Button
|
|
83
|
+
type="button" size="sm" variant="outline"
|
|
84
|
+
className="w-full min-h-[44px]"
|
|
85
|
+
disabled={!jql || testing}
|
|
86
|
+
onClick={handleTestJql}
|
|
87
|
+
>
|
|
88
|
+
{testing ? <Loader2 className="size-4 animate-spin mr-1.5" /> : <Search className="size-4 mr-1.5" />}
|
|
89
|
+
Test Filter
|
|
90
|
+
</Button>
|
|
91
|
+
|
|
92
|
+
{/* Test results preview */}
|
|
93
|
+
{testError && (
|
|
94
|
+
<p className="text-xs text-destructive bg-destructive/10 rounded-md px-3 py-2">{testError}</p>
|
|
95
|
+
)}
|
|
96
|
+
{testResults && (
|
|
97
|
+
<div className="border rounded-md max-h-48 overflow-hidden">
|
|
98
|
+
<div className="px-3 py-1.5 border-b bg-muted/50 text-xs text-muted-foreground font-medium">
|
|
99
|
+
{testTotal} ticket{testTotal !== 1 ? "s" : ""} found
|
|
100
|
+
{testTotal > testResults.length && ` (showing ${testResults.length})`}
|
|
101
|
+
</div>
|
|
102
|
+
<div className="overflow-y-auto max-h-[calc(12rem-30px)]">
|
|
103
|
+
{testResults.length === 0 ? (
|
|
104
|
+
<p className="text-xs text-muted-foreground text-center py-4">No tickets match this filter.</p>
|
|
105
|
+
) : (
|
|
106
|
+
testResults.map((issue) => (
|
|
107
|
+
<div key={issue.key} className="flex items-center gap-2 px-3 py-1.5 border-b last:border-b-0 text-xs min-w-0">
|
|
108
|
+
<span className="font-mono font-medium shrink-0">{issue.key}</span>
|
|
109
|
+
<span className="truncate text-muted-foreground flex-1 min-w-0">{issue.fields.summary}</span>
|
|
110
|
+
<span className="shrink-0 text-[10px] text-muted-foreground px-1.5 py-0.5 bg-muted rounded whitespace-nowrap">
|
|
111
|
+
{issue.fields.status.name}
|
|
112
|
+
</span>
|
|
113
|
+
</div>
|
|
114
|
+
))
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
<div className="flex gap-2">
|
|
121
|
+
<div className="flex-1">
|
|
122
|
+
<label className="text-xs text-muted-foreground">Interval</label>
|
|
123
|
+
<Select value={String(intervalMs)} onValueChange={(v) => setIntervalMs(Number(v))}>
|
|
124
|
+
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
125
|
+
<SelectContent>
|
|
126
|
+
{INTERVALS.map((i) => <SelectItem key={i.value} value={String(i.value)}>{i.label}</SelectItem>)}
|
|
127
|
+
</SelectContent>
|
|
128
|
+
</Select>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="flex-1">
|
|
131
|
+
<label className="text-xs text-muted-foreground">Mode</label>
|
|
132
|
+
<Select value={mode} onValueChange={(v) => setMode(v as JiraWatcherMode)}>
|
|
133
|
+
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
134
|
+
<SelectContent>
|
|
135
|
+
<SelectItem value="debug">Debug + Notify</SelectItem>
|
|
136
|
+
<SelectItem value="notify">Notify only</SelectItem>
|
|
137
|
+
</SelectContent>
|
|
138
|
+
</Select>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
<div>
|
|
142
|
+
<label className="text-xs text-muted-foreground">Prompt template (optional)</label>
|
|
143
|
+
<textarea
|
|
144
|
+
value={prompt} onChange={(e) => setPrompt(e.target.value)}
|
|
145
|
+
className="w-full h-16 rounded-md border border-input bg-background px-3 py-2 text-xs font-mono resize-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
146
|
+
placeholder="Debug Jira issue {issue_key}: {summary}"
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
<Button type="submit" size="sm" disabled={saving || !name || !jql} className="min-h-[44px] w-full">
|
|
150
|
+
{saving ? <Loader2 className="size-4 animate-spin" /> : isEdit ? "Save Changes" : "Create Watcher"}
|
|
151
|
+
</Button>
|
|
152
|
+
</form>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Switch } from "@/components/ui/switch";
|
|
4
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
5
|
+
import { useJiraStore } from "@/stores/jira-store";
|
|
6
|
+
import { JiraWatcherForm } from "./jira-watcher-form";
|
|
7
|
+
import { Plus, Trash2, Play, Loader2, Pencil } from "lucide-react";
|
|
8
|
+
import { toast } from "sonner";
|
|
9
|
+
import type { JiraWatcher } from "../../../../src/types/jira";
|
|
10
|
+
|
|
11
|
+
interface Props { configId: number }
|
|
12
|
+
|
|
13
|
+
export function JiraWatcherList({ configId }: Props) {
|
|
14
|
+
const { watchers, deleteWatcher, toggleWatcher, pullWatcher } = useJiraStore();
|
|
15
|
+
const [addOpen, setAddOpen] = useState(false);
|
|
16
|
+
const [editingWatcher, setEditingWatcher] = useState<JiraWatcher | null>(null);
|
|
17
|
+
const [pulling, setPulling] = useState<number | null>(null);
|
|
18
|
+
|
|
19
|
+
const handlePull = async (id: number) => {
|
|
20
|
+
setPulling(id);
|
|
21
|
+
try {
|
|
22
|
+
const res = await pullWatcher(id);
|
|
23
|
+
toast.success(`Pulled ${res.newIssues} new issue${res.newIssues !== 1 ? "s" : ""}`);
|
|
24
|
+
} catch (e: any) {
|
|
25
|
+
toast.error(e.message ?? "Pull failed");
|
|
26
|
+
}
|
|
27
|
+
setPulling(null);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="space-y-2">
|
|
32
|
+
<div className="flex items-center justify-between">
|
|
33
|
+
<h4 className="text-sm font-medium">Watchers</h4>
|
|
34
|
+
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
|
35
|
+
<DialogTrigger asChild>
|
|
36
|
+
<Button size="sm" variant="outline" className="min-h-[44px]">
|
|
37
|
+
<Plus className="size-4 mr-1" /> Add
|
|
38
|
+
</Button>
|
|
39
|
+
</DialogTrigger>
|
|
40
|
+
<DialogContent className="max-w-md overflow-hidden">
|
|
41
|
+
<DialogHeader><DialogTitle>New Watcher</DialogTitle></DialogHeader>
|
|
42
|
+
<JiraWatcherForm configId={configId} onDone={() => setAddOpen(false)} />
|
|
43
|
+
</DialogContent>
|
|
44
|
+
</Dialog>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{watchers.length === 0 && (
|
|
48
|
+
<p className="text-sm text-muted-foreground py-4 text-center">No watchers yet.</p>
|
|
49
|
+
)}
|
|
50
|
+
|
|
51
|
+
{watchers.map((w) => (
|
|
52
|
+
<div key={w.id} className="flex items-center gap-2 p-2 rounded-md border text-sm">
|
|
53
|
+
<Switch
|
|
54
|
+
checked={w.enabled}
|
|
55
|
+
onCheckedChange={(val) => toggleWatcher(w.id, val)}
|
|
56
|
+
className="shrink-0"
|
|
57
|
+
/>
|
|
58
|
+
<div className="flex-1 min-w-0">
|
|
59
|
+
<div className="font-medium truncate">{w.name}</div>
|
|
60
|
+
<div className="text-xs text-muted-foreground truncate font-mono">{w.jql}</div>
|
|
61
|
+
</div>
|
|
62
|
+
<span className="text-xs text-muted-foreground shrink-0">
|
|
63
|
+
{w.mode === "notify" ? "notify" : "debug"} · {formatInterval(w.intervalMs)}
|
|
64
|
+
</span>
|
|
65
|
+
<Button size="icon" variant="ghost" className="size-8" onClick={() => setEditingWatcher(w)}>
|
|
66
|
+
<Pencil className="size-3.5" />
|
|
67
|
+
</Button>
|
|
68
|
+
<Button size="icon" variant="ghost" className="size-8" onClick={() => handlePull(w.id)} disabled={pulling === w.id}>
|
|
69
|
+
{pulling === w.id ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
|
|
70
|
+
</Button>
|
|
71
|
+
<Button size="icon" variant="ghost" className="size-8 text-destructive" onClick={() => deleteWatcher(w.id)}>
|
|
72
|
+
<Trash2 className="size-3.5" />
|
|
73
|
+
</Button>
|
|
74
|
+
</div>
|
|
75
|
+
))}
|
|
76
|
+
|
|
77
|
+
{/* Edit dialog */}
|
|
78
|
+
<Dialog open={!!editingWatcher} onOpenChange={(open) => { if (!open) setEditingWatcher(null); }}>
|
|
79
|
+
<DialogContent className="max-w-md overflow-hidden">
|
|
80
|
+
<DialogHeader><DialogTitle>Edit Watcher</DialogTitle></DialogHeader>
|
|
81
|
+
{editingWatcher && (
|
|
82
|
+
<JiraWatcherForm
|
|
83
|
+
configId={configId}
|
|
84
|
+
existing={editingWatcher}
|
|
85
|
+
onDone={() => setEditingWatcher(null)}
|
|
86
|
+
/>
|
|
87
|
+
)}
|
|
88
|
+
</DialogContent>
|
|
89
|
+
</Dialog>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatInterval(ms: number): string {
|
|
95
|
+
if (ms >= 3600000) return `${ms / 3600000}h`;
|
|
96
|
+
if (ms >= 60000) return `${ms / 60000}m`;
|
|
97
|
+
return `${ms / 1000}s`;
|
|
98
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { useState, useCallback, useEffect } from "react";
|
|
1
|
+
import { useState, useCallback, useEffect, useMemo } from "react";
|
|
2
2
|
import {
|
|
3
|
-
X, Bug, FolderOpen, GitBranch, Settings, Database,
|
|
3
|
+
X, Bug as BugIcon, FolderOpen, GitBranch, Settings, Database,
|
|
4
4
|
} from "lucide-react";
|
|
5
5
|
import { useShallow } from "zustand/react/shallow";
|
|
6
6
|
import { useProjectStore } from "@/stores/project-store";
|
|
@@ -9,12 +9,13 @@ import { FileTree } from "@/components/explorer/file-tree";
|
|
|
9
9
|
import { GitStatusPanel } from "@/components/git/git-status-panel";
|
|
10
10
|
import { SettingsTab } from "@/components/settings/settings-tab";
|
|
11
11
|
import { DatabaseSidebar } from "@/components/database/database-sidebar";
|
|
12
|
+
import { JiraPanel } from "@/components/jira/jira-panel";
|
|
12
13
|
import { openBugReportPopup } from "@/lib/report-bug";
|
|
13
14
|
import { cn } from "@/lib/utils";
|
|
14
15
|
|
|
15
|
-
type DrawerTab = "explorer" | "git" | "settings" | "database";
|
|
16
|
+
type DrawerTab = "explorer" | "git" | "settings" | "database" | "jira";
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
+
const BASE_TABS: { id: DrawerTab; label: string; icon: React.ElementType }[] = [
|
|
18
19
|
{ id: "explorer", label: "Explorer", icon: FolderOpen },
|
|
19
20
|
{ id: "git", label: "Git", icon: GitBranch },
|
|
20
21
|
{ id: "database", label: "Database", icon: Database },
|
|
@@ -31,8 +32,17 @@ interface MobileDrawerProps {
|
|
|
31
32
|
export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps) {
|
|
32
33
|
const { activeProject } = useProjectStore(useShallow((s) => ({ activeProject: s.activeProject })));
|
|
33
34
|
const version = useSettingsStore((s) => s.version);
|
|
35
|
+
const jiraEnabled = useSettingsStore((s) => s.jiraEnabled);
|
|
34
36
|
const [activeTab, setActiveTab] = useState<DrawerTab>(initialTab ?? "explorer");
|
|
35
37
|
|
|
38
|
+
const TABS = useMemo(() => {
|
|
39
|
+
if (!jiraEnabled) return BASE_TABS;
|
|
40
|
+
const tabs = [...BASE_TABS];
|
|
41
|
+
const settingsIdx = tabs.findIndex((t) => t.id === "settings");
|
|
42
|
+
tabs.splice(settingsIdx, 0, { id: "jira", label: "Jira", icon: BugIcon });
|
|
43
|
+
return tabs;
|
|
44
|
+
}, [jiraEnabled]);
|
|
45
|
+
|
|
36
46
|
// Sync when initialTab changes (e.g. settings button opens drawer)
|
|
37
47
|
useEffect(() => {
|
|
38
48
|
if (initialTab) setActiveTab(initialTab);
|
|
@@ -92,6 +102,9 @@ export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps)
|
|
|
92
102
|
{activeTab === "database" && (
|
|
93
103
|
<DatabaseSidebar />
|
|
94
104
|
)}
|
|
105
|
+
{activeTab === "jira" && (
|
|
106
|
+
<JiraPanel />
|
|
107
|
+
)}
|
|
95
108
|
{activeTab === "settings" && (
|
|
96
109
|
<SettingsTab />
|
|
97
110
|
)}
|
|
@@ -126,7 +139,7 @@ export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps)
|
|
|
126
139
|
onClick={handleReportBug}
|
|
127
140
|
className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-secondary transition-colors"
|
|
128
141
|
>
|
|
129
|
-
<
|
|
142
|
+
<BugIcon className="size-3" />
|
|
130
143
|
<span>Report Bug</span>
|
|
131
144
|
</button>
|
|
132
145
|
</div>
|