@hienlh/ppm 0.10.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
  3. package/dist/web/assets/{api-settings-CoKe_BdR.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-HvxI1A29.js → conflict-editor-BzrH1UpC.js} +3 -3
  8. package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
  9. package/dist/web/assets/{database-viewer-BgCXPc4e.js → database-viewer-CqMOv2Sg.js} +2 -2
  10. package/dist/web/assets/{diff-viewer-blzXAJHd.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/{keybindings-store-D2N-Tq4N.js → keybindings-store-CpP5_miA.js} +1 -1
  19. package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
  20. package/dist/web/assets/{markdown-renderer-Hcj-59AX.js → markdown-renderer-BhNYbXCp.js} +3 -3
  21. package/dist/web/assets/packet-RMMSAZCW-CLxaXgIf.js +1 -0
  22. package/dist/web/assets/pie-UPGHQEXC-C9wPZfkn.js +1 -0
  23. package/dist/web/assets/port-forwarding-tab-Dw9MUu5a.js +1 -0
  24. package/dist/web/assets/{postgres-viewer-BEUI1N1X.js → postgres-viewer-YKyNjTLp.js} +3 -3
  25. package/dist/web/assets/{project-store-Ciq-cK1O.js → project-store-CczGNZyf.js} +1 -1
  26. package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
  27. package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
  28. package/dist/web/assets/settings-tab-2tdZuQIn.js +1 -0
  29. package/dist/web/assets/{sql-query-editor-DZ9xskL8.js → sql-query-editor-CVEi0jLM.js} +1 -1
  30. package/dist/web/assets/{sqlite-viewer-sQs615K6.js → sqlite-viewer-Fx9qDD4-.js} +1 -1
  31. package/dist/web/assets/{tab-store-DZbiYk7y.js → tab-store-Jvy1eZGM.js} +1 -1
  32. package/dist/web/assets/terminal-tab-BxljmYb7.js +1 -0
  33. package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
  34. package/dist/web/assets/{use-monaco-theme-OY18iXNi.js → use-monaco-theme-kjiAwvOp.js} +1 -1
  35. package/dist/web/assets/{vendor-mermaid-B2SLgECS.js → vendor-mermaid-CylkVm4U.js} +3 -3
  36. package/dist/web/index.html +13 -13
  37. package/dist/web/sw.js +1 -1
  38. package/docs/codebase-summary.md +29 -5
  39. package/docs/project-changelog.md +31 -1
  40. package/docs/system-architecture.md +106 -1
  41. package/package.json +1 -1
  42. package/packages/ext-git-graph/src/extension.ts +11 -4
  43. package/packages/ext-git-graph/src/webview-html.ts +25 -11
  44. package/src/cli/commands/jira-cmd.ts +92 -0
  45. package/src/cli/commands/jira-watcher-cmd.ts +149 -0
  46. package/src/index.ts +3 -0
  47. package/src/server/index.ts +19 -0
  48. package/src/server/routes/files.ts +15 -0
  49. package/src/server/routes/fs-browse.ts +40 -1
  50. package/src/server/routes/jira-config-routes.ts +74 -0
  51. package/src/server/routes/jira-watcher-routes.ts +316 -0
  52. package/src/server/routes/jira.ts +7 -0
  53. package/src/server/ws/chat.ts +21 -0
  54. package/src/services/db.service.ts +65 -1
  55. package/src/services/extension-host-worker.ts +3 -2
  56. package/src/services/extension.service.ts +4 -2
  57. package/src/services/file.service.ts +42 -0
  58. package/src/services/jira-api-client.ts +216 -0
  59. package/src/services/jira-config.service.ts +83 -0
  60. package/src/services/jira-debug-session.service.ts +240 -0
  61. package/src/services/jira-watcher-db.service.ts +195 -0
  62. package/src/services/jira-watcher.service.ts +159 -0
  63. package/src/services/notification.service.ts +6 -0
  64. package/src/services/supervisor-state.ts +13 -1
  65. package/src/services/supervisor.ts +4 -3
  66. package/src/types/jira.ts +128 -0
  67. package/src/web/app.tsx +15 -12
  68. package/src/web/components/chat/chat-tab.tsx +32 -1
  69. package/src/web/components/chat/message-input.tsx +56 -5
  70. package/src/web/components/explorer/file-tree.tsx +9 -0
  71. package/src/web/components/extensions/extension-webview.tsx +31 -13
  72. package/src/web/components/jira/jira-config-form.tsx +109 -0
  73. package/src/web/components/jira/jira-debug-prompt-dialog.tsx +58 -0
  74. package/src/web/components/jira/jira-filter-builder.tsx +197 -0
  75. package/src/web/components/jira/jira-panel.tsx +201 -0
  76. package/src/web/components/jira/jira-results-panel.tsx +184 -0
  77. package/src/web/components/jira/jira-settings-section.tsx +58 -0
  78. package/src/web/components/jira/jira-status-badge.tsx +18 -0
  79. package/src/web/components/jira/jira-ticket-card.tsx +144 -0
  80. package/src/web/components/jira/jira-ticket-detail.tsx +153 -0
  81. package/src/web/components/jira/jira-watcher-form.tsx +154 -0
  82. package/src/web/components/jira/jira-watcher-list.tsx +98 -0
  83. package/src/web/components/layout/mobile-drawer.tsx +18 -5
  84. package/src/web/components/layout/sidebar.tsx +20 -3
  85. package/src/web/components/settings/settings-tab.tsx +20 -3
  86. package/src/web/components/shared/markdown-code-block.tsx +5 -3
  87. package/src/web/components/ui/file-browser-picker.tsx +88 -1
  88. package/src/web/hooks/use-chat.ts +6 -0
  89. package/src/web/lib/report-bug.ts +3 -2
  90. package/src/web/lib/ws-client.ts +14 -6
  91. package/src/web/stores/jira-store.ts +198 -0
  92. package/src/web/stores/settings-store.ts +24 -5
  93. package/src/web/styles/globals.css +7 -0
  94. package/vite.config.ts +5 -66
  95. package/bun.lock +0 -2062
  96. package/bunfig.toml +0 -2
  97. package/dist/web/assets/ai-settings-section-LMO_cfIW.js +0 -1
  98. package/dist/web/assets/architecture-PBZL5I3N-CUZIB1Vq.js +0 -1
  99. package/dist/web/assets/chat-tab-By7krQ3s.js +0 -10
  100. package/dist/web/assets/code-editor-BoKL57Co.js +0 -8
  101. package/dist/web/assets/extension-webview-Dvk_61ON.js +0 -3
  102. package/dist/web/assets/gitGraph-HDMCJU4V-CtOMUphQ.js +0 -1
  103. package/dist/web/assets/index-DPnjO2FY.css +0 -2
  104. package/dist/web/assets/index-EgCQVN13.js +0 -26
  105. package/dist/web/assets/info-3K5VOQVL-BCrPCWGY.js +0 -1
  106. package/dist/web/assets/keybindings-store-C7No6mtl.js +0 -1
  107. package/dist/web/assets/packet-RMMSAZCW-D_OqB-zi.js +0 -1
  108. package/dist/web/assets/pie-UPGHQEXC-WUHpLNJz.js +0 -1
  109. package/dist/web/assets/port-forwarding-tab-CUgwDn_5.js +0 -1
  110. package/dist/web/assets/radar-KQ55EAFF-HQIIecVM.js +0 -1
  111. package/dist/web/assets/settings-store-B470PCWf.js +0 -2
  112. package/dist/web/assets/settings-tab-BGvgK51L.js +0 -1
  113. package/dist/web/assets/square-nsMa3iMk.js +0 -1
  114. package/dist/web/assets/terminal-tab-CUyHmiHH.js +0 -1
  115. package/dist/web/assets/treemap-KZPCXAKY-0wLgUUTz.js +0 -1
  116. /package/dist/web/assets/{api-client-o_6TmLGC.js → api-client-C3tXCh0r.js} +0 -0
  117. /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-BAa56Nnn.js} +0 -0
  118. /package/dist/web/assets/{dist-im4ynINo.js → dist-On3hz9_g.js} +0 -0
  119. /package/dist/web/assets/{katex-CKoArbIw.js → katex-Bbu770d9.js} +0 -0
  120. /package/dist/web/assets/{lib-D_kRA9p6.js → lib-BqkcKGFq.js} +0 -0
  121. /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
  122. /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-D3acAhav.js} +0 -0
  123. /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
  124. /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
  125. /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
  126. /package/dist/web/assets/{vendor-xterm-ejLe7-tK.js → vendor-xterm-B9BUAFKA.js} +0 -0
@@ -7,7 +7,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
7
7
  import { Input } from "@/components/ui/input";
8
8
  import { api } from "@/lib/api-client";
9
9
  import {
10
- Folder, File, Database, Home, Monitor, FileText,
10
+ Folder, File, Database, Home, Monitor, FileText, FolderPlus, Trash2,
11
11
  Download, ChevronRight, ArrowLeft, Search, Loader2, Clock, Eye, EyeOff,
12
12
  } from "lucide-react";
13
13
  import { cn } from "@/lib/utils";
@@ -107,6 +107,10 @@ export function FileBrowserPicker({
107
107
  const [pathInput, setPathInput] = useState("");
108
108
  const [showHidden, setShowHidden] = useState(false);
109
109
  const [recentPaths, setRecentPaths] = useState<string[]>([]);
110
+ const [newFolderName, setNewFolderName] = useState<string | null>(null);
111
+ const [creatingFolder, setCreatingFolder] = useState(false);
112
+ const [newFolderError, setNewFolderError] = useState<string | null>(null);
113
+ const newFolderInputRef = useRef<HTMLInputElement>(null);
110
114
  const listRef = useRef<HTMLDivElement>(null);
111
115
  const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
112
116
 
@@ -185,6 +189,40 @@ export function FileBrowserPicker({
185
189
  onSelect(selected);
186
190
  };
187
191
 
192
+ const handleCreateFolder = async () => {
193
+ if (!newFolderName?.trim() || !current) return;
194
+ const folderPath = `${current}/${newFolderName.trim()}`;
195
+ setCreatingFolder(true);
196
+ setNewFolderError(null);
197
+ try {
198
+ await api.post("/api/fs/mkdir", { path: folderPath });
199
+ setNewFolderName(null);
200
+ setNewFolderError(null);
201
+ await fetchDir(current, showHidden);
202
+ setSelected(folderPath);
203
+ } catch (e) {
204
+ setNewFolderError((e as Error).message || "Failed to create folder");
205
+ } finally {
206
+ setCreatingFolder(false);
207
+ }
208
+ };
209
+
210
+ const handleDeleteSelected = async () => {
211
+ if (!selected) return;
212
+ const entry = entries.find((e) => e.path === selected);
213
+ if (!entry || entry.type !== "directory") return;
214
+ if (!window.confirm(`Delete folder "${entry.name}"? This cannot be undone.`)) return;
215
+ try {
216
+ await api.del("/api/fs/rmdir", { path: selected });
217
+ setSelected(null);
218
+ await fetchDir(current, showHidden);
219
+ } catch (e) {
220
+ setError((e as Error).message || "Failed to delete folder");
221
+ }
222
+ };
223
+
224
+ const selectedIsFolder = selected ? entries.some((e) => e.path === selected && e.type === "directory") : false;
225
+
188
226
  // Filter entries by search + accept
189
227
  const visible = entries.filter((e) => {
190
228
  if (search && !e.name.toLowerCase().includes(search.toLowerCase())) return false;
@@ -283,6 +321,32 @@ export function FileBrowserPicker({
283
321
  </div>
284
322
  ) : (
285
323
  <div ref={listRef} className="py-1">
324
+ {newFolderName != null && (
325
+ <>
326
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-primary/5 border-b border-border">
327
+ <FolderPlus className="size-4 text-primary shrink-0" />
328
+ <Input
329
+ ref={newFolderInputRef}
330
+ value={newFolderName}
331
+ onChange={(e) => setNewFolderName(e.target.value)}
332
+ onKeyDown={(e) => {
333
+ if (e.key === "Enter") handleCreateFolder();
334
+ if (e.key === "Escape") setNewFolderName(null);
335
+ }}
336
+ placeholder="Folder name"
337
+ className="h-6 text-xs flex-1"
338
+ disabled={creatingFolder}
339
+ autoFocus
340
+ />
341
+ {creatingFolder && <Loader2 className="size-3.5 animate-spin text-primary shrink-0" />}
342
+ </div>
343
+ {newFolderError && (
344
+ <div className="px-3 py-1 text-[11px] text-destructive bg-destructive/5 border-b border-border">
345
+ {newFolderError}
346
+ </div>
347
+ )}
348
+ </>
349
+ )}
286
350
  {visible.map((entry) => {
287
351
  const selectable = isSelectable(entry);
288
352
  return (
@@ -321,6 +385,29 @@ export function FileBrowserPicker({
321
385
 
322
386
  {/* Footer */}
323
387
  <div className="flex items-center gap-2 px-3 py-2 border-t border-border shrink-0">
388
+ <Button
389
+ variant="ghost"
390
+ size="icon"
391
+ className="size-7 shrink-0"
392
+ onClick={() => {
393
+ setNewFolderName("");
394
+ setNewFolderError(null);
395
+ setTimeout(() => newFolderInputRef.current?.focus(), 50);
396
+ }}
397
+ title="New Folder"
398
+ >
399
+ <FolderPlus className="size-3.5" />
400
+ </Button>
401
+ <Button
402
+ variant="ghost"
403
+ size="icon"
404
+ className="size-7 shrink-0 text-destructive/70 hover:text-destructive disabled:opacity-30"
405
+ onClick={handleDeleteSelected}
406
+ disabled={!selectedIsFolder}
407
+ title="Delete selected folder"
408
+ >
409
+ <Trash2 className="size-3.5" />
410
+ </Button>
324
411
  <Button
325
412
  variant="ghost"
326
413
  size="icon"
@@ -396,6 +396,12 @@ export function useChat(sessionId: string | null, providerId = "claude", project
396
396
  // Ignore keepalive pings
397
397
  if ((data as any).type === "ping") return;
398
398
 
399
+ // Dispatch global Jira events so components can listen via window events
400
+ if (typeof (data as any).type === "string" && (data as any).type.startsWith("jira:")) {
401
+ window.dispatchEvent(new CustomEvent((data as any).type, { detail: data }));
402
+ return;
403
+ }
404
+
399
405
  // Handle title updates from SDK summary
400
406
  if ((data as any).type === "title_updated") {
401
407
  setSessionTitle((data as any).title ?? null);
@@ -1,4 +1,4 @@
1
- import { api, projectUrl } from "./api-client";
1
+ import { api, projectUrl, getAuthToken } from "./api-client";
2
2
 
3
3
  const REPO = "hienlh/ppm";
4
4
 
@@ -9,7 +9,8 @@ export async function buildBugReport(
9
9
  ): Promise<string> {
10
10
  let serverLogs = "(could not fetch)";
11
11
  try {
12
- const res = await fetch("/api/logs/recent");
12
+ const token = getAuthToken();
13
+ const res = await fetch("/api/logs/recent", token ? { headers: { Authorization: `Bearer ${token}` } } : {});
13
14
  const json = await res.json();
14
15
  if (json.ok) serverLogs = json.data.logs || "(empty)";
15
16
  } catch {}
@@ -33,9 +33,16 @@ export class WsClient {
33
33
  this.cleanup();
34
34
 
35
35
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
36
- const fullUrl = this.url.startsWith("ws")
37
- ? this.url
38
- : `${protocol}//${window.location.host}${this.url}`;
36
+ let fullUrl: string;
37
+ if (this.url.startsWith("ws")) {
38
+ fullUrl = this.url;
39
+ } else if (import.meta.env.DEV && this.url.startsWith("/ws/")) {
40
+ // In dev mode, connect directly to the backend server (port 8081) to
41
+ // bypass Vite's dev proxy which has unreliable WebSocket upgrade handling.
42
+ fullUrl = `ws://${window.location.hostname}:8081${this.url}`;
43
+ } else {
44
+ fullUrl = `${protocol}//${window.location.host}${this.url}`;
45
+ }
39
46
 
40
47
  this.ws = new WebSocket(fullUrl);
41
48
 
@@ -90,11 +97,12 @@ export class WsClient {
90
97
  send(data: string | ArrayBuffer): void {
91
98
  if (this.ws?.readyState === WebSocket.OPEN) {
92
99
  this.ws.send(data);
93
- } else if (this.ws?.readyState === WebSocket.CONNECTING) {
94
- console.warn("[ws] WS still CONNECTING queuing message");
100
+ } else if (!this.intentionalClose) {
101
+ // Queue messagewill be flushed when WS (re)connects
102
+ console.warn(`[ws] WS not open (readyState=${this.ws?.readyState ?? "no-ws"}) — queuing message`);
95
103
  this.pendingMessages.push(data);
96
104
  } else {
97
- console.warn(`[ws] message dropped — readyState=${this.ws?.readyState ?? "no-ws"}`);
105
+ console.warn(`[ws] message dropped — WS intentionally closed`);
98
106
  }
99
107
  }
100
108
 
@@ -0,0 +1,198 @@
1
+ import { create } from "zustand";
2
+ import { api } from "@/lib/api-client";
3
+ import type {
4
+ JiraConfig, JiraWatcher, JiraWatchResult, JiraWatcherMode, JiraIssue,
5
+ } from "../../../src/types/jira";
6
+
7
+ export interface ProjectWithId {
8
+ id: number;
9
+ name: string;
10
+ path: string;
11
+ color?: string | null;
12
+ }
13
+
14
+ interface JiraStore {
15
+ // Projects (with DB ids)
16
+ projectsWithIds: ProjectWithId[];
17
+ loadProjectsWithIds: () => Promise<void>;
18
+
19
+ // Config
20
+ configs: JiraConfig[];
21
+ selectedProjectId: number | null;
22
+ loadConfigs: () => Promise<void>;
23
+ saveConfig: (projectId: number, data: { baseUrl: string; email: string; token: string }) => Promise<void>;
24
+ deleteConfig: (projectId: number) => Promise<void>;
25
+ testConnection: (projectId: number) => Promise<boolean>;
26
+ setSelectedProjectId: (id: number | null) => void;
27
+
28
+ // Watchers
29
+ watchers: JiraWatcher[];
30
+ loadWatchers: (configId: number) => Promise<void>;
31
+ createWatcher: (data: { configId: number; name: string; jql: string; promptTemplate?: string; intervalMs?: number; mode?: JiraWatcherMode }) => Promise<void>;
32
+ updateWatcher: (id: number, data: Partial<{ name: string; jql: string; promptTemplate: string | null; intervalMs: number; enabled: boolean; mode: JiraWatcherMode }>) => Promise<void>;
33
+ deleteWatcher: (id: number) => Promise<void>;
34
+ toggleWatcher: (id: number, enabled: boolean) => Promise<void>;
35
+ pullWatcher: (id: number) => Promise<{ newIssues: number }>;
36
+ testJql: (configId: number, jql: string) => Promise<{ issues: JiraIssue[]; total: number }>;
37
+
38
+ // Results
39
+ results: JiraWatchResult[];
40
+ loadResults: (watcherId?: number, status?: string, limit?: number, offset?: number) => Promise<void>;
41
+ softDeleteResult: (id: number) => Promise<void>;
42
+
43
+ // Debug + Unread
44
+ startDebug: (resultId: number, prompt?: string) => Promise<void>;
45
+ resumeDebug: (resultId: number) => Promise<void>;
46
+ cancelDebug: (resultId: number) => Promise<void>;
47
+ markRead: (resultId: number) => Promise<void>;
48
+ unreadCount: number;
49
+ loadUnreadCount: () => Promise<void>;
50
+ }
51
+
52
+ export const useJiraStore = create<JiraStore>((set, get) => ({
53
+ projectsWithIds: [],
54
+ configs: [],
55
+ selectedProjectId: null,
56
+ watchers: [],
57
+ results: [],
58
+ unreadCount: 0,
59
+
60
+ setSelectedProjectId: (id) => set({ selectedProjectId: id }),
61
+
62
+ loadProjectsWithIds: async () => {
63
+ const rows = await api.get<ProjectWithId[]>("/api/jira/config/projects");
64
+ set({ projectsWithIds: Array.isArray(rows) ? rows : [] });
65
+ },
66
+
67
+ loadConfigs: async () => {
68
+ const configs = await api.get<JiraConfig[]>("/api/jira/config");
69
+ set({ configs });
70
+ },
71
+
72
+ saveConfig: async (projectId, data) => {
73
+ await api.put(`/api/jira/config/${projectId}`, data);
74
+ await get().loadConfigs();
75
+ },
76
+
77
+ deleteConfig: async (projectId) => {
78
+ await api.del(`/api/jira/config/${projectId}`);
79
+ set((s) => ({ configs: s.configs.filter((c) => c.projectId !== projectId), watchers: [] }));
80
+ },
81
+
82
+ testConnection: async (projectId) => {
83
+ const res = await api.post<{ connected: boolean }>(`/api/jira/config/${projectId}/test`);
84
+ return res.connected;
85
+ },
86
+
87
+ loadWatchers: async (configId) => {
88
+ const watchers = await api.get<JiraWatcher[]>(`/api/jira/watchers?configId=${configId}`);
89
+ set({ watchers });
90
+ },
91
+
92
+ createWatcher: async (data) => {
93
+ await api.post("/api/jira/watchers", data);
94
+ if (data.configId) await get().loadWatchers(data.configId);
95
+ },
96
+
97
+ updateWatcher: async (id, data) => {
98
+ await api.put(`/api/jira/watchers/${id}`, data);
99
+ // Refresh — find configId from current watchers
100
+ const w = get().watchers.find((w) => w.id === id);
101
+ if (w) await get().loadWatchers(w.jiraConfigId);
102
+ },
103
+
104
+ deleteWatcher: async (id) => {
105
+ const w = get().watchers.find((w) => w.id === id);
106
+ await api.del(`/api/jira/watchers/${id}`);
107
+ if (w) await get().loadWatchers(w.jiraConfigId);
108
+ },
109
+
110
+ toggleWatcher: async (id, enabled) => {
111
+ // Optimistic update
112
+ set((s) => ({ watchers: s.watchers.map((w) => w.id === id ? { ...w, enabled } : w) }));
113
+ try {
114
+ await api.put(`/api/jira/watchers/${id}`, { enabled });
115
+ } catch {
116
+ set((s) => ({ watchers: s.watchers.map((w) => w.id === id ? { ...w, enabled: !enabled } : w) }));
117
+ }
118
+ },
119
+
120
+ pullWatcher: async (id) => {
121
+ const result = await api.post<{ newIssues: number }>(`/api/jira/watchers/${id}/pull`);
122
+ // Refresh results so the UI shows newly pulled tickets
123
+ await get().loadResults();
124
+ return result;
125
+ },
126
+
127
+ testJql: async (configId, jql) => {
128
+ return await api.post<{ issues: JiraIssue[]; total: number }>("/api/jira/watchers/test-jql", { configId, jql });
129
+ },
130
+
131
+ loadResults: async (watcherId, status, limit = 50, offset = 0) => {
132
+ const params = new URLSearchParams();
133
+ if (watcherId !== undefined) params.set("watcherId", String(watcherId));
134
+ if (status) params.set("status", status);
135
+ params.set("limit", String(limit));
136
+ params.set("offset", String(offset));
137
+ const results = await api.get<JiraWatchResult[]>(`/api/jira/results?${params}`);
138
+ set(offset > 0 ? (s) => ({ results: [...s.results, ...results] }) : { results });
139
+ },
140
+
141
+ softDeleteResult: async (id) => {
142
+ set((s) => ({ results: s.results.filter((r) => r.id !== id) }));
143
+ try { await api.del(`/api/jira/results/${id}`); } catch {}
144
+ },
145
+
146
+ startDebug: async (resultId, prompt) => {
147
+ // Optimistic update before API call
148
+ const prev = get().results.find((r) => r.id === resultId)?.status;
149
+ set((s) => ({
150
+ results: s.results.map((r) => r.id === resultId ? { ...r, status: "queued" as const } : r),
151
+ }));
152
+ try {
153
+ await api.post(`/api/jira/results/${resultId}/debug`, prompt ? { prompt } : {});
154
+ } catch {
155
+ // Rollback on failure
156
+ set((s) => ({
157
+ results: s.results.map((r) => r.id === resultId ? { ...r, status: (prev ?? "pending") as any } : r),
158
+ }));
159
+ }
160
+ },
161
+
162
+ resumeDebug: async (resultId) => {
163
+ const prev = get().results.find((r) => r.id === resultId)?.status;
164
+ set((s) => ({
165
+ results: s.results.map((r) => r.id === resultId ? { ...r, status: "queued" as const } : r),
166
+ }));
167
+ try {
168
+ await api.post(`/api/jira/results/${resultId}/resume`);
169
+ } catch {
170
+ set((s) => ({
171
+ results: s.results.map((r) => r.id === resultId ? { ...r, status: (prev ?? "failed") as any } : r),
172
+ }));
173
+ }
174
+ },
175
+
176
+ cancelDebug: async (resultId) => {
177
+ try {
178
+ await api.post(`/api/jira/results/${resultId}/cancel`);
179
+ await get().loadResults();
180
+ } catch {}
181
+ },
182
+
183
+ markRead: async (resultId) => {
184
+ // Optimistic update
185
+ set((s) => ({
186
+ results: s.results.map((r) => r.id === resultId ? { ...r, readAt: new Date().toISOString() } : r),
187
+ unreadCount: Math.max(0, s.unreadCount - 1),
188
+ }));
189
+ try { await api.patch(`/api/jira/results/${resultId}/read`); } catch {}
190
+ },
191
+
192
+ loadUnreadCount: async () => {
193
+ try {
194
+ const res = await api.get<{ count: number }>("/api/jira/results/unread-count");
195
+ set({ unreadCount: res.count });
196
+ } catch {}
197
+ },
198
+ }));
@@ -1,8 +1,9 @@
1
1
  import { create } from "zustand";
2
+ import { getAuthToken } from "@/lib/api-client";
2
3
 
3
4
  export type Theme = "light" | "dark" | "system";
4
5
  export type GitStatusViewMode = "flat" | "tree";
5
- export type SidebarActiveTab = "explorer" | "git" | "settings" | "database" | "search" | `ext:${string}`;
6
+ export type SidebarActiveTab = "explorer" | "git" | "settings" | "database" | "search" | "jira" | `ext:${string}`;
6
7
 
7
8
  const STORAGE_KEY = "ppm-settings";
8
9
 
@@ -13,9 +14,11 @@ interface SettingsState {
13
14
  gitStatusViewMode: GitStatusViewMode;
14
15
  wordWrap: boolean;
15
16
  sidebarActiveTab: SidebarActiveTab;
17
+ jiraEnabled: boolean;
16
18
  deviceName: string | null;
17
19
  version: string | null;
18
20
  setTheme: (theme: Theme) => void;
21
+ setJiraEnabled: (enabled: boolean) => void;
19
22
  setDeviceName: (name: string) => Promise<void>;
20
23
  toggleSidebar: () => void;
21
24
  setSidebarWidth: (width: number) => void;
@@ -32,6 +35,7 @@ interface PersistedSettings {
32
35
  gitStatusViewMode?: GitStatusViewMode;
33
36
  wordWrap?: boolean;
34
37
  sidebarActiveTab?: SidebarActiveTab;
38
+ jiraEnabled?: boolean;
35
39
  }
36
40
 
37
41
  function loadPersistedSettings(): PersistedSettings {
@@ -46,7 +50,7 @@ function loadPersistedSettings(): PersistedSettings {
46
50
 
47
51
  function isValidSidebarTab(tab: unknown): tab is SidebarActiveTab {
48
52
  if (typeof tab !== "string") return false;
49
- return ["explorer", "git", "settings", "database", "search"].includes(tab) || tab.startsWith("ext:");
53
+ return ["explorer", "git", "settings", "database", "search", "jira"].includes(tab) || tab.startsWith("ext:");
50
54
  }
51
55
 
52
56
  function persistSettings(update: Partial<PersistedSettings>) {
@@ -85,6 +89,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
85
89
  gitStatusViewMode: _initial.gitStatusViewMode === "flat" ? "flat" : "tree",
86
90
  wordWrap: _initial.wordWrap ?? false,
87
91
  sidebarActiveTab: isValidSidebarTab(_initial.sidebarActiveTab) ? _initial.sidebarActiveTab : "explorer",
92
+ jiraEnabled: _initial.jiraEnabled ?? false,
88
93
  deviceName: null,
89
94
  version: null,
90
95
 
@@ -93,9 +98,10 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
93
98
  applyThemeClass(theme);
94
99
  set({ theme });
95
100
  // Save to server (fire-and-forget)
101
+ const token = getAuthToken();
96
102
  fetch("/api/settings/theme", {
97
103
  method: "PUT",
98
- headers: { "Content-Type": "application/json" },
104
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
99
105
  body: JSON.stringify({ theme }),
100
106
  }).catch(() => {});
101
107
  },
@@ -114,6 +120,17 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
114
120
  } catch {}
115
121
  },
116
122
 
123
+ setJiraEnabled: (enabled) => {
124
+ persistSettings({ jiraEnabled: enabled });
125
+ set({ jiraEnabled: enabled });
126
+ // If disabling and currently on jira tab, switch to explorer
127
+ if (!enabled && get().sidebarActiveTab === "jira") {
128
+ const tab: SidebarActiveTab = "explorer";
129
+ persistSettings({ sidebarActiveTab: tab });
130
+ set({ sidebarActiveTab: tab });
131
+ }
132
+ },
133
+
117
134
  toggleSidebar: () => {
118
135
  const next = !get().sidebarCollapsed;
119
136
  persistSettings({ sidebarCollapsed: next });
@@ -144,9 +161,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
144
161
 
145
162
  fetchServerInfo: async () => {
146
163
  try {
164
+ const token = getAuthToken();
165
+ const authInit = token ? { headers: { Authorization: `Bearer ${token}` } } : {};
147
166
  const [infoRes, themeRes] = await Promise.all([
148
- fetch("/api/info"),
149
- fetch("/api/settings/theme"),
167
+ fetch("/api/info", authInit),
168
+ fetch("/api/settings/theme", authInit),
150
169
  ]);
151
170
  const infoJson = await infoRes.json();
152
171
  if (infoJson.ok) {
@@ -192,6 +192,13 @@ html, body {
192
192
  padding: 0;
193
193
  }
194
194
 
195
+ /* Light mode: dark code blocks so github-dark-dimmed syntax colors stay readable */
196
+ .light .markdown-content pre {
197
+ background: #22272e;
198
+ color: #adbac7;
199
+ border-color: #373e47;
200
+ }
201
+
195
202
  .markdown-content :not(pre) > code {
196
203
  background: var(--color-background);
197
204
  padding: 0.125rem 0.25rem;
package/vite.config.ts CHANGED
@@ -1,77 +1,12 @@
1
- import { defineConfig, type Plugin } from "vite";
1
+ import { defineConfig } from "vite";
2
2
  import react from "@vitejs/plugin-react";
3
3
  import tailwindcss from "@tailwindcss/vite";
4
4
  import { VitePWA } from "vite-plugin-pwa";
5
5
  import monacoEditorPlugin from "vite-plugin-monaco-editor";
6
6
  import { resolve } from "path";
7
- import { createConnection } from "net";
8
- import type { IncomingMessage } from "http";
9
- import type { Duplex } from "stream";
10
-
11
- /**
12
- * Custom WebSocket proxy plugin.
13
- *
14
- * Replaces http-proxy's built-in WS proxy (which relies on socket.pipe())
15
- * because Bun on Windows doesn't correctly pipe client→server data when the
16
- * connection arrives through a Cloudflare tunnel. Using explicit 'data'
17
- * event handlers instead of .pipe() fixes the issue.
18
- */
19
- function wsProxy(targetPort: number): Plugin {
20
- return {
21
- name: "ws-proxy",
22
- configureServer(server) {
23
- server.httpServer?.on(
24
- "upgrade",
25
- (req: IncomingMessage, socket: Duplex, head: Buffer) => {
26
- const url = req.url ?? "";
27
- if (!url.startsWith("/ws/")) return;
28
-
29
- const target = createConnection(
30
- { port: targetPort, host: "localhost" },
31
- () => {
32
- const headerLines = Object.entries(req.headers)
33
- .filter(([, v]) => v != null)
34
- .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`)
35
- .join("\r\n");
36
- target.write(
37
- `GET ${url} HTTP/${req.httpVersion}\r\n${headerLines}\r\n\r\n`,
38
- );
39
- if (head && head.length > 0) target.write(head);
40
-
41
- socket.on("data", (chunk: Buffer) => {
42
- if (!target.destroyed) target.write(chunk);
43
- });
44
- target.on("data", (chunk: Buffer) => {
45
- if (!socket.destroyed) socket.write(chunk);
46
- });
47
-
48
- socket.on("close", () => {
49
- if (!target.destroyed) target.destroy();
50
- });
51
- target.on("close", () => {
52
- if (!socket.destroyed) socket.destroy();
53
- });
54
- socket.on("error", () => {
55
- if (!target.destroyed) target.destroy();
56
- });
57
- target.on("error", () => {
58
- if (!socket.destroyed) socket.destroy();
59
- });
60
- },
61
- );
62
-
63
- target.on("error", () => {
64
- if (!socket.destroyed) socket.destroy();
65
- });
66
- },
67
- );
68
- },
69
- };
70
- }
71
7
 
72
8
  export default defineConfig({
73
9
  plugins: [
74
- wsProxy(8081),
75
10
  react(),
76
11
  tailwindcss(),
77
12
  ((monacoEditorPlugin as unknown as { default?: (opts: object) => object }).default ?? (monacoEditorPlugin as unknown as (opts: object) => object))({
@@ -134,6 +69,10 @@ export default defineConfig({
134
69
  allowedHosts: true,
135
70
  proxy: {
136
71
  "/api": "http://localhost:8081",
72
+ "/ws": {
73
+ target: "http://localhost:8081",
74
+ ws: true,
75
+ },
137
76
  },
138
77
  },
139
78
  });