@hienlh/ppm 0.10.5 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +35 -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-DYf6U6UF.js +10 -0
  6. package/dist/web/assets/code-editor-BPxBeu0S.js +8 -0
  7. package/dist/web/assets/{conflict-editor-Bxq4QiW1.js → conflict-editor-BCkYHDUy.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-CCe8qa1Q.js} +2 -2
  10. package/dist/web/assets/{diff-viewer-x7kjfVYW.js → diff-viewer-DIjzWvaG.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-HY8XueLo.js +3 -0
  14. package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
  15. package/dist/web/assets/index-DpRxWGjM.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-BQV0AIm5.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-DPmTpfFX.js +1 -0
  25. package/dist/web/assets/{postgres-viewer-YkljtDWX.js → postgres-viewer-BUSNt_7x.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-DHBG5O0C.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-B7WnFN29.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-1K4ijyNe.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 +70 -19
  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>) => {
@@ -11,6 +11,9 @@ import {
11
11
  ChevronDown,
12
12
  Download,
13
13
  Loader2,
14
+ FilePlus,
15
+ FolderPlus,
16
+ RefreshCw,
14
17
  } from "lucide-react";
15
18
  import { useShallow } from "zustand/react/shallow";
16
19
  import { useFileStore, type FileNode } from "@/stores/file-store";
@@ -28,6 +31,9 @@ import {
28
31
  import { FileActions } from "./file-actions";
29
32
  import { downloadFile, downloadFolder } from "@/lib/file-download";
30
33
 
34
+ /** Synthetic root node for creating files/folders at project root */
35
+ const ROOT_NODE: FileNode = { name: "", path: "", type: "directory" };
36
+
31
37
  const FILE_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
32
38
  ts: FileCode,
33
39
  tsx: FileCode,
@@ -89,6 +95,13 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
89
95
  onFileOpen?.();
90
96
  }
91
97
 
98
+ function handleDragStart(e: React.DragEvent) {
99
+ const pathValue = isDir ? `${node.path}/` : node.path;
100
+ e.dataTransfer.setData("application/x-ppm-path", pathValue);
101
+ e.dataTransfer.setData("text/plain", node.name);
102
+ e.dataTransfer.effectAllowed = "copy";
103
+ }
104
+
92
105
  const Icon = isDir
93
106
  ? isExpanded
94
107
  ? FolderOpen
@@ -107,6 +120,8 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
107
120
  <ContextMenu>
108
121
  <ContextMenuTrigger asChild>
109
122
  <button
123
+ draggable
124
+ onDragStart={handleDragStart}
110
125
  onClick={handleClick}
111
126
  className={cn(
112
127
  "flex items-center w-full gap-1.5 px-2 py-1 rounded-sm text-sm",
@@ -292,25 +307,61 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
292
307
  return a.name.localeCompare(b.name);
293
308
  });
294
309
 
310
+ const toolbarBtnClass = "p-1 rounded-sm text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors";
311
+
295
312
  return (
296
- <>
297
- <ScrollArea className="flex-1">
298
- <div className="py-1">
299
- {sorted.map((node) => (
300
- <TreeNode
301
- key={node.path}
302
- node={node}
303
- depth={0}
304
- projectName={activeProject.name}
305
- onAction={handleAction}
306
- onFileOpen={onFileOpen}
307
- />
308
- ))}
309
- {sorted.length === 0 && (
310
- <p className="p-3 text-xs text-text-subtle">Empty project.</p>
311
- )}
312
- </div>
313
- </ScrollArea>
313
+ <div className="flex flex-col h-full">
314
+ {/* Toolbar */}
315
+ <div className="flex items-center gap-0.5 px-2 h-8 border-b border-border shrink-0">
316
+ <button onClick={() => handleAction("new-file", ROOT_NODE)} title="New File" className={toolbarBtnClass}>
317
+ <FilePlus className="size-3.5" />
318
+ </button>
319
+ <button onClick={() => handleAction("new-folder", ROOT_NODE)} title="New Folder" className={toolbarBtnClass}>
320
+ <FolderPlus className="size-3.5" />
321
+ </button>
322
+ <div className="flex-1" />
323
+ <button onClick={loadTree} title="Refresh" className={toolbarBtnClass}>
324
+ <RefreshCw className="size-3.5" />
325
+ </button>
326
+ </div>
327
+
328
+ {/* File tree with blank-area context menu */}
329
+ <ContextMenu>
330
+ <ContextMenuTrigger asChild>
331
+ <ScrollArea className="flex-1">
332
+ <div className="py-1">
333
+ {sorted.map((node) => (
334
+ <TreeNode
335
+ key={node.path}
336
+ node={node}
337
+ depth={0}
338
+ projectName={activeProject.name}
339
+ onAction={handleAction}
340
+ onFileOpen={onFileOpen}
341
+ />
342
+ ))}
343
+ {sorted.length === 0 && (
344
+ <p className="p-3 text-xs text-text-subtle">Empty project.</p>
345
+ )}
346
+ </div>
347
+ </ScrollArea>
348
+ </ContextMenuTrigger>
349
+ <ContextMenuContent>
350
+ <ContextMenuItem onClick={() => handleAction("new-file", ROOT_NODE)}>
351
+ <FilePlus className="size-3.5 mr-2" />
352
+ New File
353
+ </ContextMenuItem>
354
+ <ContextMenuItem onClick={() => handleAction("new-folder", ROOT_NODE)}>
355
+ <FolderPlus className="size-3.5 mr-2" />
356
+ New Folder
357
+ </ContextMenuItem>
358
+ <ContextMenuSeparator />
359
+ <ContextMenuItem onClick={loadTree}>
360
+ <RefreshCw className="size-3.5 mr-2" />
361
+ Refresh
362
+ </ContextMenuItem>
363
+ </ContextMenuContent>
364
+ </ContextMenu>
314
365
 
315
366
  {actionState && (
316
367
  <FileActions
@@ -321,6 +372,6 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
321
372
  onRefresh={loadTree}
322
373
  />
323
374
  )}
324
- </>
375
+ </div>
325
376
  );
326
377
  }
@@ -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
+ }