@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
@@ -1,6 +1,6 @@
1
- import { useState, useCallback, useEffect } from "react";
1
+ import { useState, useCallback, useEffect, useMemo } from "react";
2
2
  import {
3
- X, Bug, FolderOpen, GitBranch, Settings, Database,
3
+ X, Bug as BugIcon, FolderOpen, GitBranch, Settings, Database,
4
4
  } from "lucide-react";
5
5
  import { useShallow } from "zustand/react/shallow";
6
6
  import { useProjectStore } from "@/stores/project-store";
@@ -9,12 +9,13 @@ import { FileTree } from "@/components/explorer/file-tree";
9
9
  import { GitStatusPanel } from "@/components/git/git-status-panel";
10
10
  import { SettingsTab } from "@/components/settings/settings-tab";
11
11
  import { DatabaseSidebar } from "@/components/database/database-sidebar";
12
+ import { JiraPanel } from "@/components/jira/jira-panel";
12
13
  import { openBugReportPopup } from "@/lib/report-bug";
13
14
  import { cn } from "@/lib/utils";
14
15
 
15
- type DrawerTab = "explorer" | "git" | "settings" | "database";
16
+ type DrawerTab = "explorer" | "git" | "settings" | "database" | "jira";
16
17
 
17
- const TABS: { id: DrawerTab; label: string; icon: React.ElementType }[] = [
18
+ const BASE_TABS: { id: DrawerTab; label: string; icon: React.ElementType }[] = [
18
19
  { id: "explorer", label: "Explorer", icon: FolderOpen },
19
20
  { id: "git", label: "Git", icon: GitBranch },
20
21
  { id: "database", label: "Database", icon: Database },
@@ -31,8 +32,17 @@ interface MobileDrawerProps {
31
32
  export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps) {
32
33
  const { activeProject } = useProjectStore(useShallow((s) => ({ activeProject: s.activeProject })));
33
34
  const version = useSettingsStore((s) => s.version);
35
+ const jiraEnabled = useSettingsStore((s) => s.jiraEnabled);
34
36
  const [activeTab, setActiveTab] = useState<DrawerTab>(initialTab ?? "explorer");
35
37
 
38
+ const TABS = useMemo(() => {
39
+ if (!jiraEnabled) return BASE_TABS;
40
+ const tabs = [...BASE_TABS];
41
+ const settingsIdx = tabs.findIndex((t) => t.id === "settings");
42
+ tabs.splice(settingsIdx, 0, { id: "jira", label: "Jira", icon: BugIcon });
43
+ return tabs;
44
+ }, [jiraEnabled]);
45
+
36
46
  // Sync when initialTab changes (e.g. settings button opens drawer)
37
47
  useEffect(() => {
38
48
  if (initialTab) setActiveTab(initialTab);
@@ -92,6 +102,9 @@ export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps)
92
102
  {activeTab === "database" && (
93
103
  <DatabaseSidebar />
94
104
  )}
105
+ {activeTab === "jira" && (
106
+ <JiraPanel />
107
+ )}
95
108
  {activeTab === "settings" && (
96
109
  <SettingsTab />
97
110
  )}
@@ -126,7 +139,7 @@ export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps)
126
139
  onClick={handleReportBug}
127
140
  className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-secondary transition-colors"
128
141
  >
129
- <Bug className="size-3" />
142
+ <BugIcon className="size-3" />
130
143
  <span>Report Bug</span>
131
144
  </button>
132
145
  </div>
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef, useMemo, memo } from "react";
2
- import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, Settings, Database, Search, Puzzle } from "lucide-react";
2
+ import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, Settings, Database, Search, Puzzle, Bug } from "lucide-react";
3
3
  import { useShallow } from "zustand/react/shallow";
4
4
  import { useProjectStore } from "@/stores/project-store";
5
5
  import { useSettingsStore, type SidebarActiveTab } from "@/stores/settings-store";
@@ -10,7 +10,9 @@ import { SettingsTab } from "@/components/settings/settings-tab";
10
10
  import { DatabaseSidebar } from "@/components/database/database-sidebar";
11
11
  import { SearchPanel } from "@/components/explorer/search-panel";
12
12
  import { ExtensionTreeView } from "@/components/extensions/extension-tree-view";
13
+ import { JiraPanel } from "@/components/jira/jira-panel";
13
14
  import { useGitStatusStore, useGitChangesPoller } from "@/stores/git-status-store";
15
+ import { useJiraStore } from "@/stores/jira-store";
14
16
  import { cn } from "@/lib/utils";
15
17
 
16
18
  const BUILTIN_TABS: { id: SidebarActiveTab; label: string; icon: React.ElementType }[] = [
@@ -67,15 +69,22 @@ export const Sidebar = memo(function Sidebar() {
67
69
  const setSidebarWidth = useSettingsStore((s) => s.setSidebarWidth);
68
70
  const sidebarActiveTab = useSettingsStore((s) => s.sidebarActiveTab);
69
71
  const setSidebarActiveTab = useSettingsStore((s) => s.setSidebarActiveTab);
72
+ const jiraEnabled = useSettingsStore((s) => s.jiraEnabled);
70
73
  const contributions = useExtensionStore((s) => s.contributions);
71
74
  const gitChangesCount = useGitStatusStore((s) =>
72
75
  activeProject?.name ? (s.counts.get(activeProject.name) ?? 0) : 0,
73
76
  );
77
+ const jiraUnreadCount = useJiraStore((s) => s.unreadCount);
74
78
  useGitChangesPoller(activeProject?.name, sidebarActiveTab === "git");
75
79
 
76
- // Build tabs list: built-in + extension-contributed sidebar views
80
+ // Build tabs list: built-in + jira (conditional) + extension-contributed sidebar views
77
81
  const TABS = useMemo(() => {
78
82
  const tabs: { id: SidebarActiveTab; label: string; icon: React.ElementType }[] = [...BUILTIN_TABS];
83
+ if (jiraEnabled) {
84
+ // Insert Jira before Settings
85
+ const settingsIdx = tabs.findIndex((t) => t.id === "settings");
86
+ tabs.splice(settingsIdx, 0, { id: "jira", label: "Jira", icon: Bug });
87
+ }
79
88
  if (contributions?.views) {
80
89
  const sidebarViews = contributions.views["sidebar"] ?? contributions.views["explorer"] ?? [];
81
90
  for (const view of sidebarViews) {
@@ -83,7 +92,7 @@ export const Sidebar = memo(function Sidebar() {
83
92
  }
84
93
  }
85
94
  return tabs;
86
- }, [contributions]);
95
+ }, [contributions, jiraEnabled]);
87
96
 
88
97
  if (sidebarCollapsed) {
89
98
  return (
@@ -126,6 +135,11 @@ export const Sidebar = memo(function Sidebar() {
126
135
  {gitChangesCount > 99 ? "99+" : gitChangesCount}
127
136
  </span>
128
137
  )}
138
+ {tab.id === "jira" && jiraUnreadCount > 0 && (
139
+ <span className="absolute top-1 right-1 min-w-[16px] h-4 px-1 flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[10px] font-medium leading-none">
140
+ {jiraUnreadCount > 99 ? "99+" : jiraUnreadCount}
141
+ </span>
142
+ )}
129
143
  </button>
130
144
  );
131
145
  })}
@@ -158,6 +172,9 @@ export const Sidebar = memo(function Sidebar() {
158
172
  {sidebarActiveTab === "database" && (
159
173
  <DatabaseSidebar />
160
174
  )}
175
+ {sidebarActiveTab === "jira" && (
176
+ <JiraPanel />
177
+ )}
161
178
  {sidebarActiveTab === "settings" && (
162
179
  <SettingsTab />
163
180
  )}
@@ -1,10 +1,11 @@
1
1
  import { useState, useCallback, useRef } from "react";
2
2
  import {
3
3
  Moon, Sun, Monitor, Bell, BellOff, Check, ChevronRight, ArrowLeft,
4
- Bot, BellRing, Keyboard, Globe, Plug, Puzzle,
4
+ Bot, BellRing, Keyboard, Globe, Plug, Puzzle, Bug,
5
5
  } from "lucide-react";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Input } from "@/components/ui/input";
8
+ import { Switch } from "@/components/ui/switch";
8
9
  import { ScrollArea } from "@/components/ui/scroll-area";
9
10
  import { Separator } from "@/components/ui/separator";
10
11
  import { useShallow } from "zustand/react/shallow";
@@ -29,12 +30,13 @@ const pushSupported = "PushManager" in window && "serviceWorker" in navigator;
29
30
  const isIosNonPwa = /iPhone|iPad/.test(navigator.userAgent) &&
30
31
  !window.matchMedia("(display-mode: standalone)").matches;
31
32
 
32
- type SettingsCategory = "ai" | "notifications" | "clawbot" | "proxy" | "shortcuts" | "mcp" | "extensions";
33
+ type SettingsCategory = "ai" | "notifications" | "clawbot" | "jira" | "proxy" | "shortcuts" | "mcp" | "extensions";
33
34
 
34
35
  const CATEGORIES: { value: SettingsCategory; label: string; subtitle: string; icon: React.ElementType }[] = [
35
36
  { value: "ai", label: "AI Provider", subtitle: "Model, execution mode, limits", icon: Bot },
36
37
  { value: "notifications", label: "Notifications", subtitle: "Push & Telegram alerts", icon: BellRing },
37
38
  { value: "clawbot", label: "PPMBot", subtitle: "Telegram AI bot", icon: Bot },
39
+ // Jira is now a toggle, not a full settings category
38
40
  { value: "proxy", label: "API Proxy", subtitle: "Expose accounts as Anthropic API", icon: Globe },
39
41
  { value: "shortcuts", label: "Keyboard Shortcuts", subtitle: "Customize key bindings", icon: Keyboard },
40
42
  { value: "mcp", label: "MCP Servers", subtitle: "Model Context Protocol tools", icon: Plug },
@@ -42,7 +44,7 @@ const CATEGORIES: { value: SettingsCategory; label: string; subtitle: string; ic
42
44
  ];
43
45
 
44
46
  export function SettingsTab() {
45
- const { theme, setTheme, deviceName, setDeviceName, version } = useSettingsStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, deviceName: s.deviceName, setDeviceName: s.setDeviceName, version: s.version })));
47
+ const { theme, setTheme, deviceName, setDeviceName, version, jiraEnabled, setJiraEnabled } = useSettingsStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, deviceName: s.deviceName, setDeviceName: s.setDeviceName, version: s.version, jiraEnabled: s.jiraEnabled, setJiraEnabled: s.setJiraEnabled })));
46
48
  const { permission, isSubscribed, loading, error: pushError, subscribe, unsubscribe } = usePushNotification();
47
49
  const [activeCategory, setActiveCategory] = useState<SettingsCategory | null>(null);
48
50
  const [nameInput, setNameInput] = useState(deviceName ?? "");
@@ -90,6 +92,7 @@ export function SettingsTab() {
90
92
  {activeCategory === "ai" && <AISettingsSection compact />}
91
93
  {activeCategory === "notifications" && <NotificationsContent isSubscribed={isSubscribed} loading={loading} permission={permission} pushError={pushError} subscribe={subscribe} unsubscribe={unsubscribe} />}
92
94
  {activeCategory === "clawbot" && <PPMBotSettingsSection />}
95
+ {/* Jira is now a sidebar tab with a toggle below */}
93
96
  {activeCategory === "proxy" && <ProxySettingsSection />}
94
97
  {activeCategory === "shortcuts" && <KeyboardShortcutsSection />}
95
98
  {activeCategory === "mcp" && <McpSettingsSection />}
@@ -163,6 +166,20 @@ export function SettingsTab() {
163
166
  </div>
164
167
  </section>
165
168
 
169
+ {/* Jira toggle */}
170
+ <section className="space-y-1">
171
+ <div className="flex items-center justify-between">
172
+ <div className="flex items-center gap-2">
173
+ <Bug className="size-4 text-muted-foreground" />
174
+ <div>
175
+ <p className="text-xs font-medium">Jira Watcher</p>
176
+ <p className="text-[11px] text-muted-foreground">Auto-debug Jira tickets</p>
177
+ </div>
178
+ </div>
179
+ <Switch checked={jiraEnabled} onCheckedChange={setJiraEnabled} />
180
+ </div>
181
+ </section>
182
+
166
183
  <Separator />
167
184
 
168
185
  {/* Category navigation list */}
@@ -41,8 +41,10 @@ export function MdPre({ children, node, ...rest }: any) {
41
41
  const isBash = /^(bash|sh|shell|zsh)$/.test(lang || "") || (!lang && text.startsWith("$"));
42
42
 
43
43
  return (
44
- <pre {...rest} className={`relative group ${rest.className || ""}`}>
45
- {children}
44
+ <div className="relative group">
45
+ <pre {...rest}>
46
+ {children}
47
+ </pre>
46
48
  {codeActions && (
47
49
  <div className="code-actions absolute top-1 right-1 flex gap-1">
48
50
  <ActionBtn title="Copy" icon={<CopyIcon />} activeIcon={<CheckIcon />} onClick={() => navigator.clipboard.writeText(text)} />
@@ -58,7 +60,7 @@ export function MdPre({ children, node, ...rest }: any) {
58
60
  )}
59
61
  </div>
60
62
  )}
61
- </pre>
63
+ </div>
62
64
  );
63
65
  }
64
66
 
@@ -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);
@@ -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
 
@@ -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
+ }));
@@ -3,7 +3,7 @@ import { getAuthToken } from "@/lib/api-client";
3
3
 
4
4
  export type Theme = "light" | "dark" | "system";
5
5
  export type GitStatusViewMode = "flat" | "tree";
6
- export type SidebarActiveTab = "explorer" | "git" | "settings" | "database" | "search" | `ext:${string}`;
6
+ export type SidebarActiveTab = "explorer" | "git" | "settings" | "database" | "search" | "jira" | `ext:${string}`;
7
7
 
8
8
  const STORAGE_KEY = "ppm-settings";
9
9
 
@@ -14,9 +14,11 @@ interface SettingsState {
14
14
  gitStatusViewMode: GitStatusViewMode;
15
15
  wordWrap: boolean;
16
16
  sidebarActiveTab: SidebarActiveTab;
17
+ jiraEnabled: boolean;
17
18
  deviceName: string | null;
18
19
  version: string | null;
19
20
  setTheme: (theme: Theme) => void;
21
+ setJiraEnabled: (enabled: boolean) => void;
20
22
  setDeviceName: (name: string) => Promise<void>;
21
23
  toggleSidebar: () => void;
22
24
  setSidebarWidth: (width: number) => void;
@@ -33,6 +35,7 @@ interface PersistedSettings {
33
35
  gitStatusViewMode?: GitStatusViewMode;
34
36
  wordWrap?: boolean;
35
37
  sidebarActiveTab?: SidebarActiveTab;
38
+ jiraEnabled?: boolean;
36
39
  }
37
40
 
38
41
  function loadPersistedSettings(): PersistedSettings {
@@ -47,7 +50,7 @@ function loadPersistedSettings(): PersistedSettings {
47
50
 
48
51
  function isValidSidebarTab(tab: unknown): tab is SidebarActiveTab {
49
52
  if (typeof tab !== "string") return false;
50
- return ["explorer", "git", "settings", "database", "search"].includes(tab) || tab.startsWith("ext:");
53
+ return ["explorer", "git", "settings", "database", "search", "jira"].includes(tab) || tab.startsWith("ext:");
51
54
  }
52
55
 
53
56
  function persistSettings(update: Partial<PersistedSettings>) {
@@ -86,6 +89,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
86
89
  gitStatusViewMode: _initial.gitStatusViewMode === "flat" ? "flat" : "tree",
87
90
  wordWrap: _initial.wordWrap ?? false,
88
91
  sidebarActiveTab: isValidSidebarTab(_initial.sidebarActiveTab) ? _initial.sidebarActiveTab : "explorer",
92
+ jiraEnabled: _initial.jiraEnabled ?? false,
89
93
  deviceName: null,
90
94
  version: null,
91
95
 
@@ -116,6 +120,17 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
116
120
  } catch {}
117
121
  },
118
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
+
119
134
  toggleSidebar: () => {
120
135
  const next = !get().sidebarCollapsed;
121
136
  persistSettings({ sidebarCollapsed: next });
@@ -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;