@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.
Files changed (122) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
  3. package/dist/web/assets/{api-settings-C__hxGX2.js → api-settings-2eTz4SgY.js} +1 -1
  4. package/dist/web/assets/architecture-PBZL5I3N-BRW4VwMk.js +1 -0
  5. package/dist/web/assets/chat-tab-CbguR_l0.js +10 -0
  6. package/dist/web/assets/code-editor-DbZP0Dnj.js +8 -0
  7. package/dist/web/assets/{conflict-editor-Bxq4QiW1.js → conflict-editor-BzrH1UpC.js} +1 -1
  8. package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
  9. package/dist/web/assets/{database-viewer-CvQc1PZH.js → database-viewer-CqMOv2Sg.js} +2 -2
  10. package/dist/web/assets/{diff-viewer-x7kjfVYW.js → diff-viewer-B6a2oYYn.js} +1 -1
  11. package/dist/web/assets/{esm-K1XIK4vc.js → esm-B99v94EE.js} +1 -1
  12. package/dist/web/assets/{extension-store-3yZYn07W.js → extension-store-CkyOvGbF.js} +1 -1
  13. package/dist/web/assets/extension-webview-CZr_fvOm.js +3 -0
  14. package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
  15. package/dist/web/assets/index-C68PuiOm.js +26 -0
  16. package/dist/web/assets/index-iZHWllzQ.css +2 -0
  17. package/dist/web/assets/info-3K5VOQVL-ySD5z855.js +1 -0
  18. package/dist/web/assets/{input-ClhO__YM.js → input-CHRMley8.js} +1 -1
  19. package/dist/web/assets/{keybindings-store-C9KsBH7z.js → keybindings-store-CpP5_miA.js} +1 -1
  20. package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
  21. package/dist/web/assets/{markdown-renderer-CKmmrUuy.js → markdown-renderer-BhNYbXCp.js} +3 -3
  22. package/dist/web/assets/packet-RMMSAZCW-CLxaXgIf.js +1 -0
  23. package/dist/web/assets/pie-UPGHQEXC-C9wPZfkn.js +1 -0
  24. package/dist/web/assets/port-forwarding-tab-Dw9MUu5a.js +1 -0
  25. package/dist/web/assets/{postgres-viewer-YkljtDWX.js → postgres-viewer-YKyNjTLp.js} +3 -3
  26. package/dist/web/assets/{project-store-BYmQ0fDC.js → project-store-CczGNZyf.js} +1 -1
  27. package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
  28. package/dist/web/assets/{scroll-area-DW7L4Gnc.js → scroll-area-DwWF9FpN.js} +1 -1
  29. package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
  30. package/dist/web/assets/settings-tab-2tdZuQIn.js +1 -0
  31. package/dist/web/assets/{sql-query-editor-CM_qEhaX.js → sql-query-editor-CVEi0jLM.js} +1 -1
  32. package/dist/web/assets/{sqlite-viewer-f6ZJHIzh.js → sqlite-viewer-Fx9qDD4-.js} +1 -1
  33. package/dist/web/assets/{tab-store-B3M9hjho.js → tab-store-Jvy1eZGM.js} +1 -1
  34. package/dist/web/assets/{terminal-tab-CVdfvDSK.js → terminal-tab-BxljmYb7.js} +1 -1
  35. package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
  36. package/dist/web/assets/{use-monaco-theme-CvV5vy_F.js → use-monaco-theme-kjiAwvOp.js} +1 -1
  37. package/dist/web/assets/{vendor-mermaid-CwOSbfhN.js → vendor-mermaid-CylkVm4U.js} +3 -3
  38. package/dist/web/index.html +16 -16
  39. package/dist/web/sw.js +1 -1
  40. package/docs/codebase-summary.md +29 -5
  41. package/docs/project-changelog.md +31 -1
  42. package/docs/system-architecture.md +106 -1
  43. package/package.json +1 -1
  44. package/packages/ext-git-graph/src/webview-html.ts +8 -7
  45. package/src/cli/commands/jira-cmd.ts +92 -0
  46. package/src/cli/commands/jira-watcher-cmd.ts +149 -0
  47. package/src/index.ts +3 -0
  48. package/src/server/index.ts +19 -0
  49. package/src/server/routes/files.ts +15 -0
  50. package/src/server/routes/fs-browse.ts +40 -1
  51. package/src/server/routes/jira-config-routes.ts +74 -0
  52. package/src/server/routes/jira-watcher-routes.ts +316 -0
  53. package/src/server/routes/jira.ts +7 -0
  54. package/src/server/ws/chat.ts +21 -0
  55. package/src/services/db.service.ts +65 -1
  56. package/src/services/file.service.ts +42 -0
  57. package/src/services/jira-api-client.ts +216 -0
  58. package/src/services/jira-config.service.ts +83 -0
  59. package/src/services/jira-debug-session.service.ts +240 -0
  60. package/src/services/jira-watcher-db.service.ts +195 -0
  61. package/src/services/jira-watcher.service.ts +159 -0
  62. package/src/services/notification.service.ts +6 -0
  63. package/src/types/jira.ts +128 -0
  64. package/src/web/app.tsx +15 -12
  65. package/src/web/components/chat/chat-tab.tsx +32 -1
  66. package/src/web/components/chat/message-input.tsx +56 -5
  67. package/src/web/components/explorer/file-tree.tsx +9 -0
  68. package/src/web/components/extensions/extension-webview.tsx +24 -10
  69. package/src/web/components/jira/jira-config-form.tsx +109 -0
  70. package/src/web/components/jira/jira-debug-prompt-dialog.tsx +58 -0
  71. package/src/web/components/jira/jira-filter-builder.tsx +197 -0
  72. package/src/web/components/jira/jira-panel.tsx +201 -0
  73. package/src/web/components/jira/jira-results-panel.tsx +184 -0
  74. package/src/web/components/jira/jira-settings-section.tsx +58 -0
  75. package/src/web/components/jira/jira-status-badge.tsx +18 -0
  76. package/src/web/components/jira/jira-ticket-card.tsx +144 -0
  77. package/src/web/components/jira/jira-ticket-detail.tsx +153 -0
  78. package/src/web/components/jira/jira-watcher-form.tsx +154 -0
  79. package/src/web/components/jira/jira-watcher-list.tsx +98 -0
  80. package/src/web/components/layout/mobile-drawer.tsx +18 -5
  81. package/src/web/components/layout/sidebar.tsx +20 -3
  82. package/src/web/components/settings/settings-tab.tsx +20 -3
  83. package/src/web/components/shared/markdown-code-block.tsx +5 -3
  84. package/src/web/components/ui/file-browser-picker.tsx +88 -1
  85. package/src/web/hooks/use-chat.ts +6 -0
  86. package/src/web/lib/ws-client.ts +10 -3
  87. package/src/web/stores/jira-store.ts +198 -0
  88. package/src/web/stores/settings-store.ts +17 -2
  89. package/src/web/styles/globals.css +7 -0
  90. package/vite.config.ts +5 -66
  91. package/bun.lock +0 -2062
  92. package/bunfig.toml +0 -2
  93. package/dist/web/assets/ai-settings-section-D2rONDPd.js +0 -1
  94. package/dist/web/assets/architecture-PBZL5I3N-DmL1WyG-.js +0 -1
  95. package/dist/web/assets/chat-tab-Dki1pz84.js +0 -10
  96. package/dist/web/assets/code-editor-D3AAT8nI.js +0 -8
  97. package/dist/web/assets/extension-webview-BFd0USXC.js +0 -3
  98. package/dist/web/assets/gitGraph-HDMCJU4V-D8vKfkjC.js +0 -1
  99. package/dist/web/assets/index-DPnjO2FY.css +0 -2
  100. package/dist/web/assets/index-DuEUN2Eg.js +0 -26
  101. package/dist/web/assets/info-3K5VOQVL-VG29MIoT.js +0 -1
  102. package/dist/web/assets/keybindings-store-BkZjvU9J.js +0 -1
  103. package/dist/web/assets/packet-RMMSAZCW-Bl_WpvPc.js +0 -1
  104. package/dist/web/assets/pie-UPGHQEXC-BVpLpAIy.js +0 -1
  105. package/dist/web/assets/port-forwarding-tab-BUH9aImG.js +0 -1
  106. package/dist/web/assets/radar-KQ55EAFF-CJGco43I.js +0 -1
  107. package/dist/web/assets/settings-store-D9CflsKU.js +0 -2
  108. package/dist/web/assets/settings-tab-DfPjX9uY.js +0 -1
  109. package/dist/web/assets/square-nsMa3iMk.js +0 -1
  110. package/dist/web/assets/treemap-KZPCXAKY-BsOrObtE.js +0 -1
  111. /package/dist/web/assets/{api-client-Bn-Pi9k5.js → api-client-C3tXCh0r.js} +0 -0
  112. /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-BAa56Nnn.js} +0 -0
  113. /package/dist/web/assets/{dist-im4ynINo.js → dist-On3hz9_g.js} +0 -0
  114. /package/dist/web/assets/{katex-CKoArbIw.js → katex-Bbu770d9.js} +0 -0
  115. /package/dist/web/assets/{lib-D_kRA9p6.js → lib-BqkcKGFq.js} +0 -0
  116. /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
  117. /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-D3acAhav.js} +0 -0
  118. /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
  119. /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
  120. /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
  121. /package/dist/web/assets/{utils-CTg5uAYR.js → utils-ChWX7pZv.js} +0 -0
  122. /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 TABS: { id: DrawerTab; label: string; icon: React.ElementType }[] = [
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
- <Bug className="size-3" />
142
+ <BugIcon className="size-3" />
130
143
  <span>Report Bug</span>
131
144
  </button>
132
145
  </div>