@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,128 @@
1
+ // ── DB row types (snake_case, matches SQLite columns) ─────────────────
2
+
3
+ export interface JiraConfigRow {
4
+ id: number;
5
+ project_id: number;
6
+ base_url: string;
7
+ email: string;
8
+ api_token_encrypted: string;
9
+ created_at: string;
10
+ }
11
+
12
+ export interface JiraWatcherRow {
13
+ id: number;
14
+ jira_config_id: number;
15
+ name: string;
16
+ jql: string;
17
+ prompt_template: string | null;
18
+ enabled: number; // 0 | 1
19
+ mode: string; // "debug" | "notify"
20
+ interval_ms: number;
21
+ last_polled_at: string | null;
22
+ created_at: string;
23
+ }
24
+
25
+ export interface JiraWatchResultRow {
26
+ id: number;
27
+ watcher_id: number | null;
28
+ issue_key: string;
29
+ issue_summary: string | null;
30
+ issue_updated: string | null;
31
+ session_id: string | null;
32
+ status: JiraResultStatus;
33
+ ai_summary: string | null;
34
+ source: string; // "watcher" | "manual"
35
+ deleted: number; // 0 | 1
36
+ read_at: string | null;
37
+ triggered_by: string; // "auto" | "manual"
38
+ created_at: string;
39
+ }
40
+
41
+ export type JiraResultStatus = "pending" | "queued" | "running" | "done" | "failed";
42
+ export type JiraWatcherMode = "debug" | "notify";
43
+
44
+ // ── API response types (camelCase for frontend) ───────────────────────
45
+
46
+ export interface JiraConfig {
47
+ id: number;
48
+ projectId: number;
49
+ baseUrl: string;
50
+ email: string;
51
+ hasToken: boolean; // never expose actual token
52
+ createdAt: string;
53
+ }
54
+
55
+ export interface JiraWatcher {
56
+ id: number;
57
+ jiraConfigId: number;
58
+ name: string;
59
+ jql: string;
60
+ promptTemplate: string | null;
61
+ enabled: boolean;
62
+ mode: JiraWatcherMode;
63
+ intervalMs: number;
64
+ lastPolledAt: string | null;
65
+ createdAt: string;
66
+ }
67
+
68
+ export interface JiraWatchResult {
69
+ id: number;
70
+ watcherId: number | null;
71
+ issueKey: string;
72
+ issueSummary: string | null;
73
+ issueUpdated: string | null;
74
+ sessionId: string | null;
75
+ status: JiraResultStatus;
76
+ aiSummary: string | null;
77
+ source: string;
78
+ readAt: string | null;
79
+ triggeredBy: string;
80
+ createdAt: string;
81
+ }
82
+
83
+ // ── Jira Cloud API response shapes (subset we use) ────────────────────
84
+
85
+ export interface JiraIssue {
86
+ key: string;
87
+ id: string;
88
+ fields: {
89
+ summary: string;
90
+ description: string | null;
91
+ status: { name: string; id: string };
92
+ priority: { name: string; id: string } | null;
93
+ assignee: { accountId: string; displayName: string; emailAddress?: string } | null;
94
+ updated: string;
95
+ created: string;
96
+ };
97
+ }
98
+
99
+ export interface JiraSearchResponse {
100
+ issues: JiraIssue[];
101
+ total: number;
102
+ maxResults: number;
103
+ nextPageToken?: string;
104
+ }
105
+
106
+ export interface JiraTransition {
107
+ id: string;
108
+ name: string;
109
+ to: { name: string };
110
+ }
111
+
112
+ // ── Credentials (internal, decrypted for API calls) ───────────────────
113
+
114
+ export interface JiraCredentials {
115
+ baseUrl: string;
116
+ email: string;
117
+ token: string; // plaintext (decrypted)
118
+ }
119
+
120
+ // ── Rate limit tracking ───────────────────────────────────────────────
121
+
122
+ export interface JiraRateLimitState {
123
+ remaining: number | null;
124
+ limit: number | null;
125
+ resetAt: string | null;
126
+ backingOff: boolean;
127
+ pausedUntil: number | null; // epoch ms
128
+ }
package/src/web/app.tsx CHANGED
@@ -178,19 +178,22 @@ export function App() {
178
178
 
179
179
  useProjectStore.getState().setActiveProject(target);
180
180
 
181
+ // Switch panel layout to target project BEFORE opening URL tabs.
182
+ // Without this, autoOpenFromUrl creates tabs in the __global__ layout
183
+ // which get lost when the switchProject effect fires after render.
184
+ useTabStore.getState().switchProject(target.name);
185
+
181
186
  // Auto-open target tab from URL (type-based)
182
- queueMicrotask(() => {
183
- if (urlState.tabType) {
184
- autoOpenFromUrl(urlState.tabType, urlState.tabIdentifier, target!.name);
185
- }
186
- // Legacy: ?openChat= query param
187
- if (urlState.openChat) {
188
- autoOpenFromUrl("chat", urlState.openChat, target!.name);
189
- const url = new URL(window.location.href);
190
- url.searchParams.delete("openChat");
191
- window.history.replaceState(null, "", url.pathname);
192
- }
193
- });
187
+ if (urlState.tabType) {
188
+ autoOpenFromUrl(urlState.tabType, urlState.tabIdentifier, target!.name);
189
+ }
190
+ // Legacy: ?openChat= query param
191
+ if (urlState.openChat) {
192
+ autoOpenFromUrl("chat", urlState.openChat, target!.name);
193
+ const url = new URL(window.location.href);
194
+ url.searchParams.delete("openChat");
195
+ window.history.replaceState(null, "", url.pathname);
196
+ }
194
197
  });
195
198
  }, [authState, fetchProjects]);
196
199
 
@@ -57,6 +57,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
57
57
  // Drag-and-drop state
58
58
  const [isDragging, setIsDragging] = useState(false);
59
59
  const [externalFiles, setExternalFiles] = useState<File[] | null>(null);
60
+ const [externalPaths, setExternalPaths] = useState<string[] | null>(null);
61
+ const [disambiguateItems, setDisambiguateItems] = useState<FileNode[] | null>(null);
60
62
  const dragCounterRef = useRef(0);
61
63
 
62
64
  // Use tab's own project, not global activeProject (keep-alive: hidden tabs must not react to switches)
@@ -285,6 +287,16 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
285
287
  setSlashFilter("");
286
288
  }, []);
287
289
 
290
+ // --- Disambiguation picker handler (OS drag resolve with multiple matches) ---
291
+ const handleDisambiguate = useCallback((matches: FileNode[]) => {
292
+ setDisambiguateItems(matches);
293
+ }, []);
294
+
295
+ const handleDisambiguateSelect = useCallback((item: FileNode) => {
296
+ setExternalPaths([item.path]);
297
+ setDisambiguateItems(null);
298
+ }, []);
299
+
288
300
  // --- File picker handlers ---
289
301
  const handleFileStateChange = useCallback((visible: boolean, filter: string) => {
290
302
  setShowFilePicker(visible);
@@ -307,7 +319,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
307
319
  const handleDragEnter = useCallback((e: DragEvent) => {
308
320
  e.preventDefault();
309
321
  dragCounterRef.current++;
310
- if (e.dataTransfer.types.includes("Files")) {
322
+ if (e.dataTransfer.types.includes("application/x-ppm-path") || e.dataTransfer.types.includes("Files")) {
311
323
  setIsDragging(true);
312
324
  }
313
325
  }, []);
@@ -329,6 +341,13 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
329
341
  dragCounterRef.current = 0;
330
342
  setIsDragging(false);
331
343
 
344
+ // Check for internal file tree drag (custom MIME) first
345
+ const ppmPath = e.dataTransfer.getData("application/x-ppm-path");
346
+ if (ppmPath) {
347
+ setExternalPaths([ppmPath]);
348
+ return;
349
+ }
350
+
332
351
  const files = Array.from(e.dataTransfer.files);
333
352
  if (files.length > 0) {
334
353
  setExternalFiles(files);
@@ -422,6 +441,15 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
422
441
  onClose={handleFileClose}
423
442
  visible={showFilePicker}
424
443
  />
444
+ {disambiguateItems && (
445
+ <FilePicker
446
+ items={disambiguateItems}
447
+ filter=""
448
+ onSelect={handleDisambiguateSelect}
449
+ onClose={() => setDisambiguateItems(null)}
450
+ visible={true}
451
+ />
452
+ )}
425
453
 
426
454
  {/* Input */}
427
455
  <MessageInput
@@ -438,6 +466,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
438
466
  onFileItemsLoaded={setFileItems}
439
467
  fileSelected={fileSelected}
440
468
  externalFiles={externalFiles}
469
+ externalPaths={externalPaths}
470
+ onExternalPathsConsumed={() => setExternalPaths(null)}
471
+ onDisambiguate={handleDisambiguate}
441
472
  permissionMode={permissionMode}
442
473
  onModeChange={setPermissionMode}
443
474
  providerId={providerId}
@@ -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",
@@ -34,6 +34,8 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
34
34
  // Prefer currentProject (reflects URL/active project) over stale tab metadata
35
35
  const projectName = currentProject || (metadata?.projectName as string | undefined) || undefined;
36
36
  const [timedOut, setTimedOut] = useState(false);
37
+ // Track whether extensions are activated (contributions received from WS)
38
+ const extensionsReady = useExtensionStore((s) => s.contributions !== null);
37
39
 
38
40
  // Match panel: prefer panelId (exact), fallback to viewType match (reload recovery)
39
41
  const panel = useExtensionStore((s) => {
@@ -59,11 +61,11 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
59
61
  const prevProjectRef = useRef<string | null>(null);
60
62
 
61
63
  // On reload: resolve project path and dispatch command once.
62
- // No retry if it fails, user closes tab and reopens to retry.
64
+ // Wait for extensions to be activated (contributions received) before dispatching.
63
65
  // Skip if project-sync effect already dispatched for this project
64
66
  // (panel is briefly undefined during dispose→recreate transition).
65
67
  useEffect(() => {
66
- if (panel || !viewType) return;
68
+ if (panel || !viewType || !extensionsReady) return;
67
69
  // If we already dispatched for this project (via project-sync effect),
68
70
  // don't dispatch again — the panel is just temporarily missing.
69
71
  if (projectName && projectName === prevProjectRef.current) return;
@@ -88,10 +90,9 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
88
90
  }));
89
91
  }
90
92
 
91
- // Short delay to let WS connect after page load
92
- const timer = setTimeout(dispatch, 500);
93
- return () => { cancelled = true; clearTimeout(timer); };
94
- }, [panel, viewType, projectName]);
93
+ dispatch();
94
+ return () => { cancelled = true; };
95
+ }, [panel, viewType, projectName, extensionsReady]);
95
96
 
96
97
  // When panel exists, ensure correct project is loaded.
97
98
  // On mount: dispatch command so extension can reload if project differs.
@@ -165,12 +166,25 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
165
166
  };
166
167
  }, []);
167
168
 
168
- // Timeout: if panel doesn't appear within 5s, show error
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.
169
173
  useEffect(() => {
170
174
  if (panel) { setTimedOut(false); return; }
171
- const timer = setTimeout(() => setTimedOut(true), 5_000);
172
- return () => clearTimeout(timer);
173
- }, [panel]);
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]);
174
188
 
175
189
  // Listen for postMessage from iframe → forward to extension via WS bridge
176
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
+ }