@hienlh/ppm 0.2.2 → 0.2.5
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 +10 -0
- package/dist/web/assets/api-client-B_eCZViO.js +1 -0
- package/dist/web/assets/chat-tab-FOn2nq1x.js +6 -0
- package/dist/web/assets/{code-editor-BgiyQO-M.js → code-editor-R0uEZQ-h.js} +1 -1
- package/dist/web/assets/{diff-viewer-8_asmBRZ.js → diff-viewer-DDQ2Z0sz.js} +1 -1
- package/dist/web/assets/{git-graph-BiyTIbCz.js → git-graph-ugBsFNaz.js} +1 -1
- package/dist/web/assets/{git-status-panel-BifyO31N.js → git-status-panel-UMKtdAxp.js} +1 -1
- package/dist/web/assets/index-CGDMk8DE.css +2 -0
- package/dist/web/assets/index-Dmu22zQo.js +12 -0
- package/dist/web/assets/project-list-D38uQSpC.js +1 -0
- package/dist/web/assets/{settings-tab-Cn5Ja0_J.js → settings-tab-BpyCSbii.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +4 -4
- package/src/server/index.ts +38 -11
- package/src/server/routes/chat.ts +13 -18
- package/src/server/routes/projects.ts +12 -0
- package/src/server/routes/static.ts +2 -2
- package/src/services/claude-usage.service.ts +93 -74
- package/src/services/project.service.ts +43 -0
- package/src/types/chat.ts +0 -2
- package/src/web/components/chat/chat-tab.tsx +24 -7
- package/src/web/components/chat/usage-badge.tsx +23 -23
- package/src/web/components/layout/mobile-drawer.tsx +19 -1
- package/src/web/components/layout/sidebar.tsx +15 -4
- package/src/web/components/projects/project-list.tsx +153 -4
- package/src/web/hooks/use-chat.ts +30 -41
- package/src/web/hooks/use-usage.ts +65 -0
- package/src/web/lib/api-client.ts +9 -0
- package/src/web/lib/report-bug.ts +33 -0
- package/dist/web/assets/api-client-BgVufYKf.js +0 -1
- package/dist/web/assets/chat-tab-C4ovA2w4.js +0 -6
- package/dist/web/assets/index-DILaVO6p.css +0 -2
- package/dist/web/assets/index-DasstYgw.js +0 -11
- package/dist/web/assets/project-list-C7L3hZct.js +0 -1
|
@@ -40,6 +40,49 @@ class ProjectService {
|
|
|
40
40
|
return entry;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Update a project's name and/or path */
|
|
44
|
+
update(
|
|
45
|
+
currentName: string,
|
|
46
|
+
updates: { name?: string; path?: string },
|
|
47
|
+
): ProjectConfig {
|
|
48
|
+
const projects = configService.get("projects");
|
|
49
|
+
const idx = projects.findIndex((p) => p.name === currentName);
|
|
50
|
+
if (idx === -1) {
|
|
51
|
+
throw new Error(`Project not found: ${currentName}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const current = projects[idx]!;
|
|
55
|
+
const newName = updates.name?.trim() || current.name;
|
|
56
|
+
const newPath = updates.path ? resolve(updates.path) : current.path;
|
|
57
|
+
|
|
58
|
+
// Validate new path exists
|
|
59
|
+
if (updates.path && !existsSync(newPath)) {
|
|
60
|
+
throw new Error(`Path does not exist: ${newPath}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check name uniqueness (skip self)
|
|
64
|
+
if (
|
|
65
|
+
newName !== currentName &&
|
|
66
|
+
projects.some((p) => p.name === newName)
|
|
67
|
+
) {
|
|
68
|
+
throw new Error(`Project "${newName}" already exists`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check path uniqueness (skip self)
|
|
72
|
+
if (
|
|
73
|
+
newPath !== current.path &&
|
|
74
|
+
projects.some((p, i) => i !== idx && resolve(p.path) === newPath)
|
|
75
|
+
) {
|
|
76
|
+
throw new Error(`Path "${newPath}" already registered`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const updated: ProjectConfig = { path: newPath, name: newName };
|
|
80
|
+
projects[idx] = updated;
|
|
81
|
+
configService.set("projects", projects);
|
|
82
|
+
configService.save();
|
|
83
|
+
return updated;
|
|
84
|
+
}
|
|
85
|
+
|
|
43
86
|
/** Remove a project by name or path */
|
|
44
87
|
remove(nameOrPath: string): void {
|
|
45
88
|
const projects = configService.get("projects");
|
package/src/types/chat.ts
CHANGED
|
@@ -41,12 +41,10 @@ export interface SessionInfo {
|
|
|
41
41
|
|
|
42
42
|
export interface LimitBucket {
|
|
43
43
|
utilization: number;
|
|
44
|
-
budgetPace: number;
|
|
45
44
|
resetsAt: string;
|
|
46
45
|
resetsInMinutes: number | null;
|
|
47
46
|
resetsInHours: number | null;
|
|
48
47
|
windowHours: number;
|
|
49
|
-
status: string;
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
export interface UsageInfo {
|
|
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect, type DragEvent } from "react"
|
|
|
2
2
|
import { Upload } from "lucide-react";
|
|
3
3
|
import { api, projectUrl } from "@/lib/api-client";
|
|
4
4
|
import { useChat } from "@/hooks/use-chat";
|
|
5
|
+
import { useUsage } from "@/hooks/use-usage";
|
|
5
6
|
import { useTabStore } from "@/stores/tab-store";
|
|
6
7
|
import { useProjectStore } from "@/stores/project-store";
|
|
7
8
|
import { MessageList } from "./message-list";
|
|
@@ -49,6 +50,10 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
49
50
|
const activeProject = useProjectStore((s) => s.activeProject);
|
|
50
51
|
const updateTab = useTabStore((s) => s.updateTab);
|
|
51
52
|
|
|
53
|
+
// Usage runs independently — auto-refreshes on interval
|
|
54
|
+
const { usageInfo, usageLoading, lastUpdatedAt, refreshUsage, mergeUsage } =
|
|
55
|
+
useUsage(activeProject?.name ?? "", providerId);
|
|
56
|
+
|
|
52
57
|
// Persist sessionId and providerId to tab metadata so reload restores the session
|
|
53
58
|
useEffect(() => {
|
|
54
59
|
if (!tabId || !sessionId) return;
|
|
@@ -62,14 +67,13 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
62
67
|
messagesLoading,
|
|
63
68
|
isStreaming,
|
|
64
69
|
pendingApproval,
|
|
65
|
-
usageInfo,
|
|
66
|
-
usageLoading,
|
|
67
70
|
sendMessage,
|
|
68
71
|
respondToApproval,
|
|
69
72
|
cancelStreaming,
|
|
70
|
-
|
|
73
|
+
reconnect,
|
|
74
|
+
refetchMessages,
|
|
71
75
|
isConnected,
|
|
72
|
-
} = useChat(sessionId, providerId, activeProject?.name ?? "");
|
|
76
|
+
} = useChat(sessionId, providerId, activeProject?.name ?? "", { onUsageEvent: mergeUsage });
|
|
73
77
|
|
|
74
78
|
const handleNewSession = useCallback(() => {
|
|
75
79
|
const projectName = activeProject?.name ?? null;
|
|
@@ -248,11 +252,23 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
248
252
|
<div className="flex items-center gap-2">
|
|
249
253
|
<UsageBadge
|
|
250
254
|
usage={usageInfo}
|
|
255
|
+
loading={usageLoading}
|
|
251
256
|
onClick={() => setShowUsageDetail((v) => !v)}
|
|
252
257
|
/>
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
<button
|
|
259
|
+
onClick={() => {
|
|
260
|
+
if (!isConnected) reconnect();
|
|
261
|
+
refetchMessages();
|
|
262
|
+
}}
|
|
263
|
+
className="group relative size-4 flex items-center justify-center rounded-full hover:bg-surface-hover transition-colors"
|
|
264
|
+
title={isConnected ? "Connected — click to refetch messages" : "Disconnected — click to reconnect"}
|
|
265
|
+
>
|
|
266
|
+
<span
|
|
267
|
+
className={`size-2 rounded-full transition-colors ${
|
|
268
|
+
isConnected ? "bg-green-500" : "bg-red-500 animate-pulse"
|
|
269
|
+
}`}
|
|
270
|
+
/>
|
|
271
|
+
</button>
|
|
256
272
|
</div>
|
|
257
273
|
</div>
|
|
258
274
|
|
|
@@ -263,6 +279,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
263
279
|
onClose={() => setShowUsageDetail(false)}
|
|
264
280
|
onReload={refreshUsage}
|
|
265
281
|
loading={usageLoading}
|
|
282
|
+
lastUpdatedAt={lastUpdatedAt}
|
|
266
283
|
/>
|
|
267
284
|
|
|
268
285
|
{/* Pickers (in-flow, above input — only one visible at a time) */}
|
|
@@ -3,6 +3,7 @@ import type { UsageInfo, LimitBucket } from "../../../types/chat";
|
|
|
3
3
|
|
|
4
4
|
interface UsageBadgeProps {
|
|
5
5
|
usage: UsageInfo;
|
|
6
|
+
loading?: boolean;
|
|
6
7
|
onClick?: () => void;
|
|
7
8
|
}
|
|
8
9
|
|
|
@@ -18,7 +19,7 @@ function barColor(pct: number): string {
|
|
|
18
19
|
return "bg-green-500";
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export function UsageBadge({ usage, onClick }: UsageBadgeProps) {
|
|
22
|
+
export function UsageBadge({ usage, loading, onClick }: UsageBadgeProps) {
|
|
22
23
|
const fiveHourPct = usage.fiveHour != null ? Math.round(usage.fiveHour * 100) : null;
|
|
23
24
|
const sevenDayPct = usage.sevenDay != null ? Math.round(usage.sevenDay * 100) : null;
|
|
24
25
|
|
|
@@ -34,7 +35,7 @@ export function UsageBadge({ usage, onClick }: UsageBadgeProps) {
|
|
|
34
35
|
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium tabular-nums transition-colors hover:bg-surface-hover ${colorClass}`}
|
|
35
36
|
title="Click for usage details"
|
|
36
37
|
>
|
|
37
|
-
<Activity className="size-3" />
|
|
38
|
+
{loading ? <RefreshCw className="size-3 animate-spin" /> : <Activity className="size-3" />}
|
|
38
39
|
<span>5h:{fiveHourLabel}</span>
|
|
39
40
|
<span className="text-text-subtle">·</span>
|
|
40
41
|
<span>Wk:{sevenDayLabel}</span>
|
|
@@ -50,6 +51,7 @@ interface UsageDetailPanelProps {
|
|
|
50
51
|
onClose: () => void;
|
|
51
52
|
onReload?: () => void;
|
|
52
53
|
loading?: boolean;
|
|
54
|
+
lastUpdatedAt?: number | null;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
function formatResetTime(bucket?: LimitBucket): string | null {
|
|
@@ -74,34 +76,18 @@ function formatResetTime(bucket?: LimitBucket): string | null {
|
|
|
74
76
|
return `${m}m`;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
function statusLabel(status?: string): { text: string; color: string } | null {
|
|
78
|
-
if (!status) return null;
|
|
79
|
-
switch (status) {
|
|
80
|
-
case "ahead_of_pace": return { text: "Ahead of pace", color: "text-green-500" };
|
|
81
|
-
case "behind_pace": return { text: "Behind pace", color: "text-amber-500" };
|
|
82
|
-
case "on_pace": return { text: "On pace", color: "text-text-subtle" };
|
|
83
|
-
default: return { text: status.replace(/_/g, " "), color: "text-text-subtle" };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
79
|
function BucketRow({ label, bucket }: { label: string; bucket?: LimitBucket }) {
|
|
88
80
|
if (!bucket) return null;
|
|
89
81
|
const pct = Math.round(bucket.utilization * 100);
|
|
90
82
|
const reset = formatResetTime(bucket);
|
|
91
|
-
const status = statusLabel(bucket.status);
|
|
92
83
|
|
|
93
84
|
return (
|
|
94
85
|
<div className="space-y-1">
|
|
95
86
|
<div className="flex items-center justify-between">
|
|
96
87
|
<span className="text-xs font-medium text-text-primary">{label}</span>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)}
|
|
101
|
-
{reset && (
|
|
102
|
-
<span className="text-[10px] text-text-subtle">↻ {reset}</span>
|
|
103
|
-
)}
|
|
104
|
-
</div>
|
|
88
|
+
{reset && (
|
|
89
|
+
<span className="text-[10px] text-text-subtle">↻ {reset}</span>
|
|
90
|
+
)}
|
|
105
91
|
</div>
|
|
106
92
|
<div className="flex items-center gap-2">
|
|
107
93
|
<div className="flex-1 h-2 rounded-full bg-border overflow-hidden">
|
|
@@ -118,7 +104,16 @@ function BucketRow({ label, bucket }: { label: string; bucket?: LimitBucket }) {
|
|
|
118
104
|
);
|
|
119
105
|
}
|
|
120
106
|
|
|
121
|
-
|
|
107
|
+
function formatLastUpdated(ts: number | null | undefined): string | null {
|
|
108
|
+
if (!ts) return null;
|
|
109
|
+
const secs = Math.round((Date.now() - ts) / 1000);
|
|
110
|
+
if (secs < 5) return "just now";
|
|
111
|
+
if (secs < 60) return `${secs}s ago`;
|
|
112
|
+
const mins = Math.floor(secs / 60);
|
|
113
|
+
return `${mins}m ago`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, lastUpdatedAt }: UsageDetailPanelProps) {
|
|
122
117
|
if (!visible) return null;
|
|
123
118
|
|
|
124
119
|
const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
|
|
@@ -127,7 +122,12 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading }:
|
|
|
127
122
|
return (
|
|
128
123
|
<div className="border-b border-border bg-surface px-3 py-2.5 space-y-2.5">
|
|
129
124
|
<div className="flex items-center justify-between">
|
|
130
|
-
<
|
|
125
|
+
<div className="flex items-center gap-2">
|
|
126
|
+
<span className="text-xs font-semibold text-text-primary">Usage Limits</span>
|
|
127
|
+
{lastUpdatedAt && (
|
|
128
|
+
<span className="text-[10px] text-text-subtle">{formatLastUpdated(lastUpdatedAt)}</span>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
131
|
<div className="flex items-center gap-1">
|
|
132
132
|
{onReload && (
|
|
133
133
|
<button
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo } from "react";
|
|
1
|
+
import { useState, useMemo, useCallback } from "react";
|
|
2
2
|
import {
|
|
3
3
|
FolderOpen,
|
|
4
4
|
Terminal,
|
|
@@ -13,12 +13,15 @@ import {
|
|
|
13
13
|
Check,
|
|
14
14
|
Plus,
|
|
15
15
|
Search,
|
|
16
|
+
Bug,
|
|
16
17
|
} from "lucide-react";
|
|
17
18
|
import { useProjectStore, sortByRecent } from "@/stores/project-store";
|
|
18
19
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
20
|
+
import { useSettingsStore } from "@/stores/settings-store";
|
|
19
21
|
import { cn } from "@/lib/utils";
|
|
20
22
|
import { Separator } from "@/components/ui/separator";
|
|
21
23
|
import { FileTree } from "@/components/explorer/file-tree";
|
|
24
|
+
import { openBugReport } from "@/lib/report-bug";
|
|
22
25
|
|
|
23
26
|
interface MobileDrawerProps {
|
|
24
27
|
isOpen: boolean;
|
|
@@ -50,6 +53,7 @@ const MAX_VISIBLE_MOBILE = 5;
|
|
|
50
53
|
export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
|
51
54
|
const { projects, activeProject, setActiveProject } = useProjectStore();
|
|
52
55
|
const openTab = useTabStore((s) => s.openTab);
|
|
56
|
+
const version = useSettingsStore((s) => s.version);
|
|
53
57
|
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
|
54
58
|
const [query, setQuery] = useState("");
|
|
55
59
|
|
|
@@ -87,6 +91,8 @@ export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
|
|
87
91
|
setQuery("");
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
const handleReportBug = useCallback(() => openBugReport(version), [version]);
|
|
95
|
+
|
|
90
96
|
return (
|
|
91
97
|
<div
|
|
92
98
|
className={cn(
|
|
@@ -231,6 +237,18 @@ export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
|
|
231
237
|
</div>
|
|
232
238
|
)}
|
|
233
239
|
</div>
|
|
240
|
+
|
|
241
|
+
{/* Report Bug + Version */}
|
|
242
|
+
<div className="flex items-center justify-between px-4 py-2 border-t border-border">
|
|
243
|
+
{version && <span className="text-[10px] text-text-subtle">v{version}</span>}
|
|
244
|
+
<button
|
|
245
|
+
onClick={handleReportBug}
|
|
246
|
+
className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-secondary transition-colors"
|
|
247
|
+
>
|
|
248
|
+
<Bug className="size-3" />
|
|
249
|
+
<span>Report Bug</span>
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
234
252
|
</div>
|
|
235
253
|
</div>
|
|
236
254
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useState, useMemo } from "react";
|
|
2
|
-
import { FolderOpen, ChevronDown, Check, Plus, Search } from "lucide-react";
|
|
1
|
+
import { useState, useMemo, useCallback } from "react";
|
|
2
|
+
import { FolderOpen, ChevronDown, Check, Plus, Search, Bug } from "lucide-react";
|
|
3
3
|
import { useProjectStore, sortByRecent } from "@/stores/project-store";
|
|
4
4
|
import { useTabStore } from "@/stores/tab-store";
|
|
5
5
|
import { FileTree } from "@/components/explorer/file-tree";
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "@/components/ui/dropdown-menu";
|
|
12
12
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
13
13
|
import { cn } from "@/lib/utils";
|
|
14
|
+
import { openBugReport } from "@/lib/report-bug";
|
|
14
15
|
|
|
15
16
|
/** Max projects shown before needing to search (desktop) */
|
|
16
17
|
const MAX_VISIBLE = 8;
|
|
@@ -39,6 +40,8 @@ export function Sidebar() {
|
|
|
39
40
|
openTab({ type: "projects", title: "Projects", projectId: null, closable: true });
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
const handleReportBug = useCallback(() => openBugReport(version), [version]);
|
|
44
|
+
|
|
42
45
|
return (
|
|
43
46
|
<aside className="hidden md:flex flex-col w-[280px] min-w-[280px] bg-background border-r border-border overflow-hidden">
|
|
44
47
|
{/* Logo + project dropdown — same height as tab bar */}
|
|
@@ -130,10 +133,18 @@ export function Sidebar() {
|
|
|
130
133
|
</div>
|
|
131
134
|
)}
|
|
132
135
|
|
|
133
|
-
{/* Version footer */}
|
|
136
|
+
{/* Version footer + Report Bug */}
|
|
134
137
|
{version && (
|
|
135
|
-
<div className="px-3 py-1.5 border-t border-border shrink-0">
|
|
138
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border shrink-0">
|
|
136
139
|
<span className="text-[10px] text-text-subtle">v{version}</span>
|
|
140
|
+
<button
|
|
141
|
+
onClick={handleReportBug}
|
|
142
|
+
title="Report a bug"
|
|
143
|
+
className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-secondary transition-colors"
|
|
144
|
+
>
|
|
145
|
+
<Bug className="size-3" />
|
|
146
|
+
<span>Report Bug</span>
|
|
147
|
+
</button>
|
|
137
148
|
</div>
|
|
138
149
|
)}
|
|
139
150
|
</aside>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState, useCallback } from "react";
|
|
2
|
-
import { FolderOpen, GitBranch, Circle, Plus } from "lucide-react";
|
|
2
|
+
import { FolderOpen, GitBranch, Circle, Plus, Pencil, Trash2 } from "lucide-react";
|
|
3
3
|
import { useProjectStore } from "@/stores/project-store";
|
|
4
4
|
import { useTabStore } from "@/stores/tab-store";
|
|
5
5
|
import { api } from "@/lib/api-client";
|
|
@@ -10,29 +10,43 @@ import {
|
|
|
10
10
|
DialogHeader,
|
|
11
11
|
DialogTitle,
|
|
12
12
|
DialogFooter,
|
|
13
|
+
DialogDescription,
|
|
13
14
|
} from "@/components/ui/dialog";
|
|
14
15
|
import { Input } from "@/components/ui/input";
|
|
15
16
|
import { Button } from "@/components/ui/button";
|
|
16
17
|
import { DirSuggest } from "./dir-suggest";
|
|
18
|
+
import type { ProjectInfo } from "@/stores/project-store";
|
|
17
19
|
|
|
18
20
|
export function ProjectList() {
|
|
19
21
|
const { projects, activeProject, setActiveProject, fetchProjects, loading, error } =
|
|
20
22
|
useProjectStore();
|
|
21
23
|
const openTab = useTabStore((s) => s.openTab);
|
|
24
|
+
|
|
25
|
+
// Add dialog state
|
|
22
26
|
const [showAdd, setShowAdd] = useState(false);
|
|
23
27
|
const [addPath, setAddPath] = useState("");
|
|
24
28
|
const [addName, setAddName] = useState("");
|
|
25
29
|
const [addError, setAddError] = useState("");
|
|
26
30
|
|
|
31
|
+
// Edit dialog state
|
|
32
|
+
const [editTarget, setEditTarget] = useState<ProjectInfo | null>(null);
|
|
33
|
+
const [editName, setEditName] = useState("");
|
|
34
|
+
const [editPath, setEditPath] = useState("");
|
|
35
|
+
const [editError, setEditError] = useState("");
|
|
36
|
+
|
|
37
|
+
// Delete dialog state
|
|
38
|
+
const [deleteTarget, setDeleteTarget] = useState<ProjectInfo | null>(null);
|
|
39
|
+
const [deleteError, setDeleteError] = useState("");
|
|
40
|
+
|
|
27
41
|
useEffect(() => {
|
|
28
42
|
fetchProjects();
|
|
29
43
|
}, [fetchProjects]);
|
|
30
44
|
|
|
31
|
-
function handleClick(project:
|
|
45
|
+
function handleClick(project: ProjectInfo) {
|
|
32
46
|
setActiveProject(project);
|
|
33
47
|
}
|
|
34
48
|
|
|
35
|
-
function handleOpen(project:
|
|
49
|
+
function handleOpen(project: ProjectInfo) {
|
|
36
50
|
setActiveProject(project);
|
|
37
51
|
openTab({
|
|
38
52
|
type: "terminal",
|
|
@@ -60,6 +74,60 @@ export function ProjectList() {
|
|
|
60
74
|
}
|
|
61
75
|
}, [addPath, addName, fetchProjects]);
|
|
62
76
|
|
|
77
|
+
function openEdit(project: ProjectInfo, e: React.MouseEvent) {
|
|
78
|
+
e.stopPropagation();
|
|
79
|
+
setEditTarget(project);
|
|
80
|
+
setEditName(project.name);
|
|
81
|
+
setEditPath(project.path);
|
|
82
|
+
setEditError("");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handleEditProject = useCallback(async () => {
|
|
86
|
+
if (!editTarget || !editName.trim()) return;
|
|
87
|
+
setEditError("");
|
|
88
|
+
try {
|
|
89
|
+
await api.patch(`/api/projects/${encodeURIComponent(editTarget.name)}`, {
|
|
90
|
+
name: editName.trim(),
|
|
91
|
+
path: editPath.trim() || undefined,
|
|
92
|
+
});
|
|
93
|
+
await fetchProjects();
|
|
94
|
+
// Update active project if it was the one edited
|
|
95
|
+
if (activeProject?.name === editTarget.name) {
|
|
96
|
+
const updated = useProjectStore.getState().projects
|
|
97
|
+
.find((p) => p.name === editName.trim());
|
|
98
|
+
if (updated) setActiveProject(updated);
|
|
99
|
+
}
|
|
100
|
+
setEditTarget(null);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
setEditError(e instanceof Error ? e.message : "Failed to update project");
|
|
103
|
+
}
|
|
104
|
+
}, [editTarget, editName, editPath, fetchProjects, activeProject, setActiveProject]);
|
|
105
|
+
|
|
106
|
+
function openDelete(project: ProjectInfo, e: React.MouseEvent) {
|
|
107
|
+
e.stopPropagation();
|
|
108
|
+
setDeleteTarget(project);
|
|
109
|
+
setDeleteError("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const handleDeleteProject = useCallback(async () => {
|
|
113
|
+
if (!deleteTarget) return;
|
|
114
|
+
setDeleteError("");
|
|
115
|
+
try {
|
|
116
|
+
await api.del(`/api/projects/${encodeURIComponent(deleteTarget.name)}`);
|
|
117
|
+
// Clear active project if it was deleted
|
|
118
|
+
if (activeProject?.name === deleteTarget.name) {
|
|
119
|
+
const remaining = projects.filter((p) => p.name !== deleteTarget.name);
|
|
120
|
+
if (remaining.length > 0) {
|
|
121
|
+
setActiveProject(remaining[0]!);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await fetchProjects();
|
|
125
|
+
setDeleteTarget(null);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
setDeleteError(e instanceof Error ? e.message : "Failed to delete project");
|
|
128
|
+
}
|
|
129
|
+
}, [deleteTarget, activeProject, projects, fetchProjects, setActiveProject]);
|
|
130
|
+
|
|
63
131
|
if (error) {
|
|
64
132
|
return (
|
|
65
133
|
<div className="flex items-center justify-center h-full p-4">
|
|
@@ -104,13 +172,31 @@ export function ProjectList() {
|
|
|
104
172
|
onClick={() => handleClick(project)}
|
|
105
173
|
onDoubleClick={() => handleOpen(project)}
|
|
106
174
|
className={cn(
|
|
107
|
-
"text-left p-4 rounded-lg border transition-colors",
|
|
175
|
+
"group text-left p-4 rounded-lg border transition-colors relative",
|
|
108
176
|
"min-h-[44px]",
|
|
109
177
|
activeProject?.name === project.name
|
|
110
178
|
? "bg-surface border-primary"
|
|
111
179
|
: "bg-surface border-border hover:border-text-subtle",
|
|
112
180
|
)}
|
|
113
181
|
>
|
|
182
|
+
{/* Action buttons */}
|
|
183
|
+
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
184
|
+
<button
|
|
185
|
+
onClick={(e) => openEdit(project, e)}
|
|
186
|
+
className="p-1.5 rounded-md hover:bg-surface-elevated text-text-subtle hover:text-text-primary transition-colors"
|
|
187
|
+
title="Edit project"
|
|
188
|
+
>
|
|
189
|
+
<Pencil className="size-3.5" />
|
|
190
|
+
</button>
|
|
191
|
+
<button
|
|
192
|
+
onClick={(e) => openDelete(project, e)}
|
|
193
|
+
className="p-1.5 rounded-md hover:bg-error/10 text-text-subtle hover:text-error transition-colors"
|
|
194
|
+
title="Remove project"
|
|
195
|
+
>
|
|
196
|
+
<Trash2 className="size-3.5" />
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
114
200
|
<div className="flex items-start gap-3">
|
|
115
201
|
<FolderOpen className="size-5 text-primary shrink-0 mt-0.5" />
|
|
116
202
|
<div className="flex-1 min-w-0 space-y-1">
|
|
@@ -182,6 +268,69 @@ export function ProjectList() {
|
|
|
182
268
|
</DialogFooter>
|
|
183
269
|
</DialogContent>
|
|
184
270
|
</Dialog>
|
|
271
|
+
|
|
272
|
+
{/* Edit Project Dialog */}
|
|
273
|
+
<Dialog open={!!editTarget} onOpenChange={(open) => !open && setEditTarget(null)}>
|
|
274
|
+
<DialogContent>
|
|
275
|
+
<DialogHeader>
|
|
276
|
+
<DialogTitle>Edit Project</DialogTitle>
|
|
277
|
+
</DialogHeader>
|
|
278
|
+
<div className="space-y-3">
|
|
279
|
+
<div>
|
|
280
|
+
<label className="text-sm text-text-secondary">Name</label>
|
|
281
|
+
<Input
|
|
282
|
+
value={editName}
|
|
283
|
+
onChange={(e) => setEditName(e.target.value)}
|
|
284
|
+
onKeyDown={(e) => e.key === "Enter" && handleEditProject()}
|
|
285
|
+
autoFocus
|
|
286
|
+
/>
|
|
287
|
+
</div>
|
|
288
|
+
<div>
|
|
289
|
+
<label className="text-sm text-text-secondary">Path</label>
|
|
290
|
+
<DirSuggest
|
|
291
|
+
value={editPath}
|
|
292
|
+
onChange={setEditPath}
|
|
293
|
+
placeholder="/home/user/my-project"
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
{editError && (
|
|
297
|
+
<p className="text-sm text-error">{editError}</p>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
<DialogFooter>
|
|
301
|
+
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
|
302
|
+
Cancel
|
|
303
|
+
</Button>
|
|
304
|
+
<Button onClick={handleEditProject} disabled={!editName.trim()}>
|
|
305
|
+
Save
|
|
306
|
+
</Button>
|
|
307
|
+
</DialogFooter>
|
|
308
|
+
</DialogContent>
|
|
309
|
+
</Dialog>
|
|
310
|
+
|
|
311
|
+
{/* Delete Confirmation Dialog */}
|
|
312
|
+
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
313
|
+
<DialogContent>
|
|
314
|
+
<DialogHeader>
|
|
315
|
+
<DialogTitle>Remove Project</DialogTitle>
|
|
316
|
+
<DialogDescription>
|
|
317
|
+
Remove <strong>{deleteTarget?.name}</strong> from PPM? This only unregisters
|
|
318
|
+
it — project files on disk are not affected.
|
|
319
|
+
</DialogDescription>
|
|
320
|
+
</DialogHeader>
|
|
321
|
+
{deleteError && (
|
|
322
|
+
<p className="text-sm text-error">{deleteError}</p>
|
|
323
|
+
)}
|
|
324
|
+
<DialogFooter>
|
|
325
|
+
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
|
326
|
+
Cancel
|
|
327
|
+
</Button>
|
|
328
|
+
<Button variant="destructive" onClick={handleDeleteProject}>
|
|
329
|
+
Remove
|
|
330
|
+
</Button>
|
|
331
|
+
</DialogFooter>
|
|
332
|
+
</DialogContent>
|
|
333
|
+
</Dialog>
|
|
185
334
|
</div>
|
|
186
335
|
);
|
|
187
336
|
}
|