@hienlh/ppm 0.10.5 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -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-CbguR_l0.js +10 -0
- package/dist/web/assets/code-editor-DbZP0Dnj.js +8 -0
- package/dist/web/assets/{conflict-editor-Bxq4QiW1.js → conflict-editor-BzrH1UpC.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-CqMOv2Sg.js} +2 -2
- package/dist/web/assets/{diff-viewer-x7kjfVYW.js → diff-viewer-B6a2oYYn.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-CZr_fvOm.js +3 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
- package/dist/web/assets/index-C68PuiOm.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-BhNYbXCp.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-Dw9MUu5a.js +1 -0
- package/dist/web/assets/{postgres-viewer-YkljtDWX.js → postgres-viewer-YKyNjTLp.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-2tdZuQIn.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-Fx9qDD4-.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-BxljmYb7.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 +9 -0
- 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
|
@@ -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
|
-
<
|
|
45
|
-
{
|
|
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
|
-
</
|
|
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);
|
package/src/web/lib/ws-client.ts
CHANGED
|
@@ -33,9 +33,16 @@ export class WsClient {
|
|
|
33
33
|
this.cleanup();
|
|
34
34
|
|
|
35
35
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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;
|