@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.
- package/CHANGELOG.md +35 -0
- package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
- package/dist/web/assets/{api-settings-C__hxGX2.js → api-settings-2eTz4SgY.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-BRW4VwMk.js +1 -0
- package/dist/web/assets/chat-tab-DYf6U6UF.js +10 -0
- package/dist/web/assets/code-editor-BPxBeu0S.js +8 -0
- package/dist/web/assets/{conflict-editor-Bxq4QiW1.js → conflict-editor-BCkYHDUy.js} +1 -1
- package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
- package/dist/web/assets/{database-viewer-CvQc1PZH.js → database-viewer-CCe8qa1Q.js} +2 -2
- package/dist/web/assets/{diff-viewer-x7kjfVYW.js → diff-viewer-DIjzWvaG.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-HY8XueLo.js +3 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
- package/dist/web/assets/index-DpRxWGjM.js +26 -0
- package/dist/web/assets/index-iZHWllzQ.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-ySD5z855.js +1 -0
- package/dist/web/assets/{input-ClhO__YM.js → input-CHRMley8.js} +1 -1
- package/dist/web/assets/{keybindings-store-C9KsBH7z.js → keybindings-store-CpP5_miA.js} +1 -1
- package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
- package/dist/web/assets/{markdown-renderer-CKmmrUuy.js → markdown-renderer-BQV0AIm5.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-DPmTpfFX.js +1 -0
- package/dist/web/assets/{postgres-viewer-YkljtDWX.js → postgres-viewer-BUSNt_7x.js} +3 -3
- package/dist/web/assets/{project-store-BYmQ0fDC.js → project-store-CczGNZyf.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
- package/dist/web/assets/{scroll-area-DW7L4Gnc.js → scroll-area-DwWF9FpN.js} +1 -1
- package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
- package/dist/web/assets/settings-tab-DHBG5O0C.js +1 -0
- package/dist/web/assets/{sql-query-editor-CM_qEhaX.js → sql-query-editor-CVEi0jLM.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-f6ZJHIzh.js → sqlite-viewer-B7WnFN29.js} +1 -1
- package/dist/web/assets/{tab-store-B3M9hjho.js → tab-store-Jvy1eZGM.js} +1 -1
- package/dist/web/assets/{terminal-tab-CVdfvDSK.js → terminal-tab-1K4ijyNe.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
- package/dist/web/assets/{use-monaco-theme-CvV5vy_F.js → use-monaco-theme-kjiAwvOp.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-CwOSbfhN.js → vendor-mermaid-CylkVm4U.js} +3 -3
- package/dist/web/index.html +16 -16
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +29 -5
- package/docs/project-changelog.md +31 -1
- package/docs/system-architecture.md +106 -1
- package/package.json +1 -1
- package/packages/ext-git-graph/src/webview-html.ts +8 -7
- package/src/cli/commands/jira-cmd.ts +92 -0
- package/src/cli/commands/jira-watcher-cmd.ts +149 -0
- package/src/index.ts +3 -0
- package/src/server/index.ts +19 -0
- package/src/server/routes/files.ts +15 -0
- package/src/server/routes/fs-browse.ts +40 -1
- package/src/server/routes/jira-config-routes.ts +74 -0
- package/src/server/routes/jira-watcher-routes.ts +316 -0
- package/src/server/routes/jira.ts +7 -0
- package/src/server/ws/chat.ts +21 -0
- package/src/services/db.service.ts +65 -1
- package/src/services/file.service.ts +42 -0
- package/src/services/jira-api-client.ts +216 -0
- package/src/services/jira-config.service.ts +83 -0
- package/src/services/jira-debug-session.service.ts +240 -0
- package/src/services/jira-watcher-db.service.ts +195 -0
- package/src/services/jira-watcher.service.ts +159 -0
- package/src/services/notification.service.ts +6 -0
- package/src/types/jira.ts +128 -0
- package/src/web/app.tsx +15 -12
- package/src/web/components/chat/chat-tab.tsx +32 -1
- package/src/web/components/chat/message-input.tsx +56 -5
- package/src/web/components/explorer/file-tree.tsx +70 -19
- package/src/web/components/extensions/extension-webview.tsx +24 -10
- package/src/web/components/jira/jira-config-form.tsx +109 -0
- package/src/web/components/jira/jira-debug-prompt-dialog.tsx +58 -0
- package/src/web/components/jira/jira-filter-builder.tsx +197 -0
- package/src/web/components/jira/jira-panel.tsx +201 -0
- package/src/web/components/jira/jira-results-panel.tsx +184 -0
- package/src/web/components/jira/jira-settings-section.tsx +58 -0
- package/src/web/components/jira/jira-status-badge.tsx +18 -0
- package/src/web/components/jira/jira-ticket-card.tsx +144 -0
- package/src/web/components/jira/jira-ticket-detail.tsx +153 -0
- package/src/web/components/jira/jira-watcher-form.tsx +154 -0
- package/src/web/components/jira/jira-watcher-list.tsx +98 -0
- package/src/web/components/layout/mobile-drawer.tsx +18 -5
- package/src/web/components/layout/sidebar.tsx +20 -3
- package/src/web/components/settings/settings-tab.tsx +20 -3
- package/src/web/components/shared/markdown-code-block.tsx +5 -3
- package/src/web/components/ui/file-browser-picker.tsx +88 -1
- package/src/web/hooks/use-chat.ts +6 -0
- package/src/web/lib/ws-client.ts +10 -3
- package/src/web/stores/jira-store.ts +198 -0
- package/src/web/stores/settings-store.ts +17 -2
- package/src/web/styles/globals.css +7 -0
- package/vite.config.ts +5 -66
- package/bun.lock +0 -2062
- package/bunfig.toml +0 -2
- package/dist/web/assets/ai-settings-section-D2rONDPd.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-DmL1WyG-.js +0 -1
- package/dist/web/assets/chat-tab-Dki1pz84.js +0 -10
- package/dist/web/assets/code-editor-D3AAT8nI.js +0 -8
- package/dist/web/assets/extension-webview-BFd0USXC.js +0 -3
- package/dist/web/assets/gitGraph-HDMCJU4V-D8vKfkjC.js +0 -1
- package/dist/web/assets/index-DPnjO2FY.css +0 -2
- package/dist/web/assets/index-DuEUN2Eg.js +0 -26
- package/dist/web/assets/info-3K5VOQVL-VG29MIoT.js +0 -1
- package/dist/web/assets/keybindings-store-BkZjvU9J.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-Bl_WpvPc.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-BVpLpAIy.js +0 -1
- package/dist/web/assets/port-forwarding-tab-BUH9aImG.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-CJGco43I.js +0 -1
- package/dist/web/assets/settings-store-D9CflsKU.js +0 -2
- package/dist/web/assets/settings-tab-DfPjX9uY.js +0 -1
- package/dist/web/assets/square-nsMa3iMk.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-BsOrObtE.js +0 -1
- /package/dist/web/assets/{api-client-Bn-Pi9k5.js → api-client-C3tXCh0r.js} +0 -0
- /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-BAa56Nnn.js} +0 -0
- /package/dist/web/assets/{dist-im4ynINo.js → dist-On3hz9_g.js} +0 -0
- /package/dist/web/assets/{katex-CKoArbIw.js → katex-Bbu770d9.js} +0 -0
- /package/dist/web/assets/{lib-D_kRA9p6.js → lib-BqkcKGFq.js} +0 -0
- /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
- /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-D3acAhav.js} +0 -0
- /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
- /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
- /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
- /package/dist/web/assets/{utils-CTg5uAYR.js → utils-ChWX7pZv.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-ejLe7-tK.js → vendor-xterm-B9BUAFKA.js} +0 -0
|
@@ -0,0 +1,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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
}
|