@hienlh/ppm 0.10.4 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
- package/dist/web/assets/{api-settings-CoKe_BdR.js → api-settings-2eTz4SgY.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-BRW4VwMk.js +1 -0
- package/dist/web/assets/chat-tab-CbguR_l0.js +10 -0
- package/dist/web/assets/code-editor-DbZP0Dnj.js +8 -0
- package/dist/web/assets/{conflict-editor-HvxI1A29.js → conflict-editor-BzrH1UpC.js} +3 -3
- package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
- package/dist/web/assets/{database-viewer-BgCXPc4e.js → database-viewer-CqMOv2Sg.js} +2 -2
- package/dist/web/assets/{diff-viewer-blzXAJHd.js → diff-viewer-B6a2oYYn.js} +1 -1
- package/dist/web/assets/{esm-K1XIK4vc.js → esm-B99v94EE.js} +1 -1
- package/dist/web/assets/{extension-store-3yZYn07W.js → extension-store-CkyOvGbF.js} +1 -1
- package/dist/web/assets/extension-webview-CZr_fvOm.js +3 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
- package/dist/web/assets/index-C68PuiOm.js +26 -0
- package/dist/web/assets/index-iZHWllzQ.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-ySD5z855.js +1 -0
- package/dist/web/assets/{keybindings-store-D2N-Tq4N.js → keybindings-store-CpP5_miA.js} +1 -1
- package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
- package/dist/web/assets/{markdown-renderer-Hcj-59AX.js → markdown-renderer-BhNYbXCp.js} +3 -3
- package/dist/web/assets/packet-RMMSAZCW-CLxaXgIf.js +1 -0
- package/dist/web/assets/pie-UPGHQEXC-C9wPZfkn.js +1 -0
- package/dist/web/assets/port-forwarding-tab-Dw9MUu5a.js +1 -0
- package/dist/web/assets/{postgres-viewer-BEUI1N1X.js → postgres-viewer-YKyNjTLp.js} +3 -3
- package/dist/web/assets/{project-store-Ciq-cK1O.js → project-store-CczGNZyf.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
- package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
- package/dist/web/assets/settings-tab-2tdZuQIn.js +1 -0
- package/dist/web/assets/{sql-query-editor-DZ9xskL8.js → sql-query-editor-CVEi0jLM.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-sQs615K6.js → sqlite-viewer-Fx9qDD4-.js} +1 -1
- package/dist/web/assets/{tab-store-DZbiYk7y.js → tab-store-Jvy1eZGM.js} +1 -1
- package/dist/web/assets/terminal-tab-BxljmYb7.js +1 -0
- package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
- package/dist/web/assets/{use-monaco-theme-OY18iXNi.js → use-monaco-theme-kjiAwvOp.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-B2SLgECS.js → vendor-mermaid-CylkVm4U.js} +3 -3
- package/dist/web/index.html +13 -13
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +29 -5
- package/docs/project-changelog.md +31 -1
- package/docs/system-architecture.md +106 -1
- package/package.json +1 -1
- package/packages/ext-git-graph/src/extension.ts +11 -4
- package/packages/ext-git-graph/src/webview-html.ts +25 -11
- package/src/cli/commands/jira-cmd.ts +92 -0
- package/src/cli/commands/jira-watcher-cmd.ts +149 -0
- package/src/index.ts +3 -0
- package/src/server/index.ts +19 -0
- package/src/server/routes/files.ts +15 -0
- package/src/server/routes/fs-browse.ts +40 -1
- package/src/server/routes/jira-config-routes.ts +74 -0
- package/src/server/routes/jira-watcher-routes.ts +316 -0
- package/src/server/routes/jira.ts +7 -0
- package/src/server/ws/chat.ts +21 -0
- package/src/services/db.service.ts +65 -1
- package/src/services/extension-host-worker.ts +3 -2
- package/src/services/extension.service.ts +4 -2
- package/src/services/file.service.ts +42 -0
- package/src/services/jira-api-client.ts +216 -0
- package/src/services/jira-config.service.ts +83 -0
- package/src/services/jira-debug-session.service.ts +240 -0
- package/src/services/jira-watcher-db.service.ts +195 -0
- package/src/services/jira-watcher.service.ts +159 -0
- package/src/services/notification.service.ts +6 -0
- package/src/services/supervisor-state.ts +13 -1
- package/src/services/supervisor.ts +4 -3
- package/src/types/jira.ts +128 -0
- package/src/web/app.tsx +15 -12
- package/src/web/components/chat/chat-tab.tsx +32 -1
- package/src/web/components/chat/message-input.tsx +56 -5
- package/src/web/components/explorer/file-tree.tsx +9 -0
- package/src/web/components/extensions/extension-webview.tsx +31 -13
- package/src/web/components/jira/jira-config-form.tsx +109 -0
- package/src/web/components/jira/jira-debug-prompt-dialog.tsx +58 -0
- package/src/web/components/jira/jira-filter-builder.tsx +197 -0
- package/src/web/components/jira/jira-panel.tsx +201 -0
- package/src/web/components/jira/jira-results-panel.tsx +184 -0
- package/src/web/components/jira/jira-settings-section.tsx +58 -0
- package/src/web/components/jira/jira-status-badge.tsx +18 -0
- package/src/web/components/jira/jira-ticket-card.tsx +144 -0
- package/src/web/components/jira/jira-ticket-detail.tsx +153 -0
- package/src/web/components/jira/jira-watcher-form.tsx +154 -0
- package/src/web/components/jira/jira-watcher-list.tsx +98 -0
- package/src/web/components/layout/mobile-drawer.tsx +18 -5
- package/src/web/components/layout/sidebar.tsx +20 -3
- package/src/web/components/settings/settings-tab.tsx +20 -3
- package/src/web/components/shared/markdown-code-block.tsx +5 -3
- package/src/web/components/ui/file-browser-picker.tsx +88 -1
- package/src/web/hooks/use-chat.ts +6 -0
- package/src/web/lib/report-bug.ts +3 -2
- package/src/web/lib/ws-client.ts +14 -6
- package/src/web/stores/jira-store.ts +198 -0
- package/src/web/stores/settings-store.ts +24 -5
- package/src/web/styles/globals.css +7 -0
- package/vite.config.ts +5 -66
- package/bun.lock +0 -2062
- package/bunfig.toml +0 -2
- package/dist/web/assets/ai-settings-section-LMO_cfIW.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-CUZIB1Vq.js +0 -1
- package/dist/web/assets/chat-tab-By7krQ3s.js +0 -10
- package/dist/web/assets/code-editor-BoKL57Co.js +0 -8
- package/dist/web/assets/extension-webview-Dvk_61ON.js +0 -3
- package/dist/web/assets/gitGraph-HDMCJU4V-CtOMUphQ.js +0 -1
- package/dist/web/assets/index-DPnjO2FY.css +0 -2
- package/dist/web/assets/index-EgCQVN13.js +0 -26
- package/dist/web/assets/info-3K5VOQVL-BCrPCWGY.js +0 -1
- package/dist/web/assets/keybindings-store-C7No6mtl.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-D_OqB-zi.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-WUHpLNJz.js +0 -1
- package/dist/web/assets/port-forwarding-tab-CUgwDn_5.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-HQIIecVM.js +0 -1
- package/dist/web/assets/settings-store-B470PCWf.js +0 -2
- package/dist/web/assets/settings-tab-BGvgK51L.js +0 -1
- package/dist/web/assets/square-nsMa3iMk.js +0 -1
- package/dist/web/assets/terminal-tab-CUyHmiHH.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-0wLgUUTz.js +0 -1
- /package/dist/web/assets/{api-client-o_6TmLGC.js → api-client-C3tXCh0r.js} +0 -0
- /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-BAa56Nnn.js} +0 -0
- /package/dist/web/assets/{dist-im4ynINo.js → dist-On3hz9_g.js} +0 -0
- /package/dist/web/assets/{katex-CKoArbIw.js → katex-Bbu770d9.js} +0 -0
- /package/dist/web/assets/{lib-D_kRA9p6.js → lib-BqkcKGFq.js} +0 -0
- /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
- /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-D3acAhav.js} +0 -0
- /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
- /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
- /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-ejLe7-tK.js → vendor-xterm-B9BUAFKA.js} +0 -0
|
@@ -40,6 +40,12 @@ interface MessageInputProps {
|
|
|
40
40
|
fileSelected?: FileNode | null;
|
|
41
41
|
/** External files added via drag-drop on parent */
|
|
42
42
|
externalFiles?: File[] | null;
|
|
43
|
+
/** External paths from file tree drag or disambiguation */
|
|
44
|
+
externalPaths?: string[] | null;
|
|
45
|
+
/** Callback when external paths have been consumed (inserted into textarea) */
|
|
46
|
+
onExternalPathsConsumed?: () => void;
|
|
47
|
+
/** Callback when OS-dropped files resolve to multiple matches (disambiguation needed) */
|
|
48
|
+
onDisambiguate?: (matches: FileNode[]) => void;
|
|
43
49
|
/** Pre-fill input value (e.g. from command palette "Ask AI") */
|
|
44
50
|
initialValue?: string;
|
|
45
51
|
/** Auto-focus textarea on mount */
|
|
@@ -67,6 +73,9 @@ export const MessageInput = memo(function MessageInput({
|
|
|
67
73
|
onFileItemsLoaded,
|
|
68
74
|
fileSelected,
|
|
69
75
|
externalFiles,
|
|
76
|
+
externalPaths,
|
|
77
|
+
onExternalPathsConsumed,
|
|
78
|
+
onDisambiguate,
|
|
70
79
|
initialValue,
|
|
71
80
|
autoFocus,
|
|
72
81
|
permissionMode,
|
|
@@ -291,6 +300,17 @@ export const MessageInput = memo(function MessageInput({
|
|
|
291
300
|
processFiles(externalFiles);
|
|
292
301
|
}, [externalFiles]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
293
302
|
|
|
303
|
+
// Handle external paths from file tree drag or disambiguation
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (!externalPaths || externalPaths.length === 0) return;
|
|
306
|
+
const pathRefs = externalPaths.map((p) => `@${p}`).join(" ");
|
|
307
|
+
const cur = valueRef.current;
|
|
308
|
+
const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
|
|
309
|
+
writeTextareas(cur + sep + pathRefs + " ");
|
|
310
|
+
getVisibleTextarea()?.focus();
|
|
311
|
+
onExternalPathsConsumed?.();
|
|
312
|
+
}, [externalPaths]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
313
|
+
|
|
294
314
|
/** Upload a single file to the server, return server path */
|
|
295
315
|
const uploadFile = useCallback(
|
|
296
316
|
async (file: File): Promise<string | null> => {
|
|
@@ -318,12 +338,34 @@ export const MessageInput = memo(function MessageInput({
|
|
|
318
338
|
[projectName],
|
|
319
339
|
);
|
|
320
340
|
|
|
321
|
-
/** Process dropped/pasted/selected files */
|
|
341
|
+
/** Process dropped/pasted/selected files — resolves paths via server when possible */
|
|
322
342
|
const processFiles = useCallback(
|
|
323
|
-
(files: File[]) => {
|
|
343
|
+
async (files: File[]) => {
|
|
324
344
|
for (const file of files) {
|
|
345
|
+
// Step 1: Try server-side filename resolution for all files
|
|
346
|
+
if (projectName) {
|
|
347
|
+
try {
|
|
348
|
+
const data = await api.get<{ matches: FileNode[] }>(
|
|
349
|
+
`${projectUrl(projectName)}/files/resolve?name=${encodeURIComponent(file.name)}`,
|
|
350
|
+
);
|
|
351
|
+
if (data.matches.length === 1) {
|
|
352
|
+
const cur = valueRef.current;
|
|
353
|
+
const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
|
|
354
|
+
writeTextareas(cur + sep + `@${data.matches[0]!.path} `);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (data.matches.length > 1) {
|
|
358
|
+
onDisambiguate?.(data.matches);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
// 0 matches → fall through to existing behavior
|
|
362
|
+
} catch {
|
|
363
|
+
// Resolve failed → fall through
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Step 2: Fallback — upload supported files, insert name for unsupported
|
|
325
368
|
if (!isSupportedFile(file)) {
|
|
326
|
-
// Unsupported → insert file name as text
|
|
327
369
|
const cur = valueRef.current;
|
|
328
370
|
const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
|
|
329
371
|
writeTextareas(cur + sep + file.name);
|
|
@@ -358,7 +400,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
358
400
|
}
|
|
359
401
|
(mobileTextareaRef.current ?? textareaRef.current)?.focus();
|
|
360
402
|
},
|
|
361
|
-
[uploadFile, writeTextareas],
|
|
403
|
+
[uploadFile, writeTextareas, projectName, onDisambiguate],
|
|
362
404
|
);
|
|
363
405
|
|
|
364
406
|
const removeAttachment = useCallback((id: string) => {
|
|
@@ -566,10 +608,19 @@ export const MessageInput = memo(function MessageInput({
|
|
|
566
608
|
const handleDrop = useCallback(
|
|
567
609
|
(e: DragEvent<HTMLTextAreaElement>) => {
|
|
568
610
|
e.preventDefault();
|
|
611
|
+
// Check for internal file tree drag first
|
|
612
|
+
const ppmPath = e.dataTransfer.getData("application/x-ppm-path");
|
|
613
|
+
if (ppmPath) {
|
|
614
|
+
const cur = valueRef.current;
|
|
615
|
+
const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
|
|
616
|
+
writeTextareas(cur + sep + `@${ppmPath} `);
|
|
617
|
+
getVisibleTextarea()?.focus();
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
569
620
|
const files = Array.from(e.dataTransfer.files);
|
|
570
621
|
if (files.length > 0) processFiles(files);
|
|
571
622
|
},
|
|
572
|
-
[processFiles],
|
|
623
|
+
[processFiles, writeTextareas, getVisibleTextarea],
|
|
573
624
|
);
|
|
574
625
|
|
|
575
626
|
const handleDragOver = useCallback((e: DragEvent<HTMLTextAreaElement>) => {
|
|
@@ -89,6 +89,13 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
|
|
|
89
89
|
onFileOpen?.();
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
function handleDragStart(e: React.DragEvent) {
|
|
93
|
+
const pathValue = isDir ? `${node.path}/` : node.path;
|
|
94
|
+
e.dataTransfer.setData("application/x-ppm-path", pathValue);
|
|
95
|
+
e.dataTransfer.setData("text/plain", node.name);
|
|
96
|
+
e.dataTransfer.effectAllowed = "copy";
|
|
97
|
+
}
|
|
98
|
+
|
|
92
99
|
const Icon = isDir
|
|
93
100
|
? isExpanded
|
|
94
101
|
? FolderOpen
|
|
@@ -107,6 +114,8 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
|
|
|
107
114
|
<ContextMenu>
|
|
108
115
|
<ContextMenuTrigger asChild>
|
|
109
116
|
<button
|
|
117
|
+
draggable
|
|
118
|
+
onDragStart={handleDragStart}
|
|
110
119
|
onClick={handleClick}
|
|
111
120
|
className={cn(
|
|
112
121
|
"flex items-center w-full gap-1.5 px-2 py-1 rounded-sm text-sm",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useRef, useEffect, useState, useCallback } from "react";
|
|
2
2
|
import { useExtensionStore } from "@/stores/extension-store";
|
|
3
3
|
import { useTabStore } from "@/stores/tab-store";
|
|
4
|
+
import { getAuthToken } from "@/lib/api-client";
|
|
4
5
|
import { Loader2 } from "lucide-react";
|
|
5
6
|
|
|
6
7
|
/** Inject acquireVsCodeApi() shim so extension webviews can postMessage to parent */
|
|
@@ -33,6 +34,8 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
|
|
|
33
34
|
// Prefer currentProject (reflects URL/active project) over stale tab metadata
|
|
34
35
|
const projectName = currentProject || (metadata?.projectName as string | undefined) || undefined;
|
|
35
36
|
const [timedOut, setTimedOut] = useState(false);
|
|
37
|
+
// Track whether extensions are activated (contributions received from WS)
|
|
38
|
+
const extensionsReady = useExtensionStore((s) => s.contributions !== null);
|
|
36
39
|
|
|
37
40
|
// Match panel: prefer panelId (exact), fallback to viewType match (reload recovery)
|
|
38
41
|
const panel = useExtensionStore((s) => {
|
|
@@ -58,11 +61,11 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
|
|
|
58
61
|
const prevProjectRef = useRef<string | null>(null);
|
|
59
62
|
|
|
60
63
|
// On reload: resolve project path and dispatch command once.
|
|
61
|
-
//
|
|
64
|
+
// Wait for extensions to be activated (contributions received) before dispatching.
|
|
62
65
|
// Skip if project-sync effect already dispatched for this project
|
|
63
66
|
// (panel is briefly undefined during dispose→recreate transition).
|
|
64
67
|
useEffect(() => {
|
|
65
|
-
if (panel || !viewType) return;
|
|
68
|
+
if (panel || !viewType || !extensionsReady) return;
|
|
66
69
|
// If we already dispatched for this project (via project-sync effect),
|
|
67
70
|
// don't dispatch again — the panel is just temporarily missing.
|
|
68
71
|
if (projectName && projectName === prevProjectRef.current) return;
|
|
@@ -74,7 +77,8 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
|
|
|
74
77
|
let args: unknown[] = [];
|
|
75
78
|
if (projectName) {
|
|
76
79
|
try {
|
|
77
|
-
const
|
|
80
|
+
const token = getAuthToken();
|
|
81
|
+
const res = await fetch("/api/projects", token ? { headers: { Authorization: `Bearer ${token}` } } : {});
|
|
78
82
|
const json = await res.json() as { ok: boolean; data?: { name: string; path: string }[] };
|
|
79
83
|
const match = json.data?.find((p) => p.name === projectName);
|
|
80
84
|
if (match) args = [match.path];
|
|
@@ -86,10 +90,9 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
|
|
|
86
90
|
}));
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}, [panel, viewType, projectName]);
|
|
93
|
+
dispatch();
|
|
94
|
+
return () => { cancelled = true; };
|
|
95
|
+
}, [panel, viewType, projectName, extensionsReady]);
|
|
93
96
|
|
|
94
97
|
// When panel exists, ensure correct project is loaded.
|
|
95
98
|
// On mount: dispatch command so extension can reload if project differs.
|
|
@@ -103,7 +106,8 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
|
|
|
103
106
|
const command = viewType.includes(".") ? viewType : `${viewType}.view`;
|
|
104
107
|
(async () => {
|
|
105
108
|
try {
|
|
106
|
-
const
|
|
109
|
+
const token = getAuthToken();
|
|
110
|
+
const res = await fetch("/api/projects", token ? { headers: { Authorization: `Bearer ${token}` } } : {});
|
|
107
111
|
const json = await res.json() as { ok: boolean; data?: { name: string; path: string }[] };
|
|
108
112
|
const match = json.data?.find((p) => p.name === projectName);
|
|
109
113
|
if (match) {
|
|
@@ -135,7 +139,8 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
|
|
|
135
139
|
const command = viewType.includes(".") ? viewType : `${viewType}.view`;
|
|
136
140
|
(async () => {
|
|
137
141
|
try {
|
|
138
|
-
const
|
|
142
|
+
const token = getAuthToken();
|
|
143
|
+
const res = await fetch("/api/projects", token ? { headers: { Authorization: `Bearer ${token}` } } : {});
|
|
139
144
|
const json = await res.json() as { ok: boolean; data?: { name: string; path: string }[] };
|
|
140
145
|
const match = json.data?.find((p) => p.name === projectName);
|
|
141
146
|
const args = match ? [match.path] : [];
|
|
@@ -161,12 +166,25 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
|
|
|
161
166
|
};
|
|
162
167
|
}, []);
|
|
163
168
|
|
|
164
|
-
//
|
|
169
|
+
// Auto-retry: if panel doesn't appear after extensions are ready,
|
|
170
|
+
// re-dispatch the command every 2s (up to 3 times) before showing error.
|
|
171
|
+
// This handles transient WS instability during initial page load where the
|
|
172
|
+
// first command dispatch may be lost due to connection cycling.
|
|
165
173
|
useEffect(() => {
|
|
166
174
|
if (panel) { setTimedOut(false); return; }
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
175
|
+
if (!extensionsReady || !viewType) return;
|
|
176
|
+
let retries = 0;
|
|
177
|
+
const id = setInterval(() => {
|
|
178
|
+
retries++;
|
|
179
|
+
if (retries > 3) {
|
|
180
|
+
clearInterval(id);
|
|
181
|
+
setTimedOut(true);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
handleRetry();
|
|
185
|
+
}, 2_000);
|
|
186
|
+
return () => clearInterval(id);
|
|
187
|
+
}, [panel, extensionsReady, viewType, handleRetry]);
|
|
170
188
|
|
|
171
189
|
// Listen for postMessage from iframe → forward to extension via WS bridge
|
|
172
190
|
useEffect(() => {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useState, useEffect, type FormEvent } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Input } from "@/components/ui/input";
|
|
4
|
+
import { useJiraStore } from "@/stores/jira-store";
|
|
5
|
+
import { CheckCircle, AlertCircle, Loader2, Trash2 } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
projectId: number;
|
|
9
|
+
existing?: { baseUrl: string; email: string; hasToken: boolean } | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function JiraConfigForm({ projectId, existing }: Props) {
|
|
13
|
+
const { saveConfig, deleteConfig, testConnection } = useJiraStore();
|
|
14
|
+
const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? "");
|
|
15
|
+
const [email, setEmail] = useState(existing?.email ?? "");
|
|
16
|
+
const [token, setToken] = useState("");
|
|
17
|
+
|
|
18
|
+
// Sync state when existing config loads or changes
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (existing) {
|
|
21
|
+
setBaseUrl(existing.baseUrl);
|
|
22
|
+
setEmail(existing.email);
|
|
23
|
+
}
|
|
24
|
+
}, [existing?.baseUrl, existing?.email]);
|
|
25
|
+
const [saving, setSaving] = useState(false);
|
|
26
|
+
const [testing, setTesting] = useState(false);
|
|
27
|
+
const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null);
|
|
28
|
+
const [testError, setTestError] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
const handleSave = async (e: FormEvent) => {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
if (!baseUrl || !email || (!token && !existing?.hasToken)) return;
|
|
33
|
+
setSaving(true);
|
|
34
|
+
try {
|
|
35
|
+
await saveConfig(projectId, { baseUrl, email, ...(token ? { token } : {}) });
|
|
36
|
+
setToken("");
|
|
37
|
+
} catch {}
|
|
38
|
+
setSaving(false);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const handleTest = async () => {
|
|
42
|
+
setTesting(true);
|
|
43
|
+
setTestResult(null);
|
|
44
|
+
setTestError(null);
|
|
45
|
+
try {
|
|
46
|
+
const ok = await testConnection(projectId);
|
|
47
|
+
setTestResult(ok ? "ok" : "fail");
|
|
48
|
+
} catch (e: any) {
|
|
49
|
+
setTestResult("fail");
|
|
50
|
+
setTestError(e?.message ?? "Connection failed");
|
|
51
|
+
}
|
|
52
|
+
setTesting(false);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<form onSubmit={handleSave} className="space-y-3">
|
|
57
|
+
<div>
|
|
58
|
+
<label className="text-xs text-muted-foreground">Base URL</label>
|
|
59
|
+
<Input
|
|
60
|
+
value={baseUrl}
|
|
61
|
+
onChange={(e) => setBaseUrl(e.target.value)}
|
|
62
|
+
placeholder="https://mysite.atlassian.net"
|
|
63
|
+
className="h-9"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
<div>
|
|
67
|
+
<label className="text-xs text-muted-foreground">Email</label>
|
|
68
|
+
<Input
|
|
69
|
+
value={email}
|
|
70
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
71
|
+
placeholder="you@company.com"
|
|
72
|
+
className="h-9"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
<div>
|
|
76
|
+
<label className="text-xs text-muted-foreground">
|
|
77
|
+
API Token {existing?.hasToken && <span className="text-green-500">(saved)</span>}
|
|
78
|
+
</label>
|
|
79
|
+
<Input
|
|
80
|
+
type="password"
|
|
81
|
+
value={token}
|
|
82
|
+
onChange={(e) => setToken(e.target.value)}
|
|
83
|
+
placeholder={existing?.hasToken ? "Enter new token to replace" : "Your Jira API token"}
|
|
84
|
+
className="h-9"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
88
|
+
<Button type="submit" size="sm" disabled={saving} className="min-w-[44px] min-h-[44px]">
|
|
89
|
+
{saving ? <Loader2 className="size-4 animate-spin" /> : "Save"}
|
|
90
|
+
</Button>
|
|
91
|
+
{existing && (
|
|
92
|
+
<>
|
|
93
|
+
<Button type="button" size="sm" variant="outline" onClick={handleTest} disabled={testing} className="min-h-[44px]">
|
|
94
|
+
{testing ? <Loader2 className="size-4 animate-spin" /> : "Test Connection"}
|
|
95
|
+
</Button>
|
|
96
|
+
<Button type="button" size="sm" variant="destructive" onClick={() => deleteConfig(projectId)} className="min-h-[44px]">
|
|
97
|
+
<Trash2 className="size-4" />
|
|
98
|
+
</Button>
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
101
|
+
{testResult === "ok" && <CheckCircle className="size-4 text-green-500" />}
|
|
102
|
+
{testResult === "fail" && <AlertCircle className="size-4 text-red-500" />}
|
|
103
|
+
</div>
|
|
104
|
+
{testError && (
|
|
105
|
+
<p className="text-xs text-red-500 break-all">{testError}</p>
|
|
106
|
+
)}
|
|
107
|
+
</form>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -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
|
+
}
|