@hienlh/ppm 0.9.9 → 0.9.11
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 +15 -0
- package/dist/web/assets/{browser-tab-CpltAQ9R.js → browser-tab-CWkYQN8G.js} +1 -1
- package/dist/web/assets/chat-tab-BVf4q-TX.js +8 -0
- package/dist/web/assets/code-editor-r5T7wq0I.js +2 -0
- package/dist/web/assets/{database-viewer-DUDwfC9o.js → database-viewer-DIKCOXIJ.js} +1 -1
- package/dist/web/assets/{diff-viewer-C8wC1442.js → diff-viewer-Cs2knua9.js} +1 -1
- package/dist/web/assets/{extension-webview-jl1C2HGm.js → extension-webview-BbDesnaR.js} +1 -1
- package/dist/web/assets/{git-graph-BgpRKeIW.js → git-graph-BWQm8I8V.js} +1 -1
- package/dist/web/assets/index-D-NC2Rnz.css +2 -0
- package/dist/web/assets/index-E0N-qark.js +37 -0
- package/dist/web/assets/keybindings-store-DXucgQSx.js +1 -0
- package/dist/web/assets/{markdown-renderer-fLOZi3vK.js → markdown-renderer-DgeFkhU6.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DhEmO5Pd.js → postgres-viewer-hQ47YDO5.js} +1 -1
- package/dist/web/assets/{settings-tab-B0JPmP_y.js → settings-tab-BZ_CLZDj.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-4YwpO6X6.js → sqlite-viewer-B8vs7svK.js} +1 -1
- package/dist/web/assets/{terminal-tab-Dc1b9QaT.js → terminal-tab-DjE8lCXj.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-CN0etsaO.js → use-monaco-theme-BNtfLGmz.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/project-changelog.md +8 -0
- package/docs/project-roadmap.md +2 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/server/index.ts +4 -0
- package/src/server/routes/git.ts +56 -0
- package/src/server/routes/teams.ts +40 -0
- package/src/server/ws/chat.ts +42 -0
- package/src/server/ws/team-inbox-watcher.ts +181 -0
- package/src/services/git.service.ts +99 -0
- package/src/types/chat.ts +4 -1
- package/src/types/git.ts +21 -0
- package/src/types/team.ts +33 -0
- package/src/web/components/chat/chat-tab.tsx +6 -0
- package/src/web/components/chat/message-input.tsx +70 -1
- package/src/web/components/chat/team-activity-popover.tsx +202 -0
- package/src/web/components/git/create-worktree-dialog.tsx +232 -0
- package/src/web/components/git/git-status-panel.tsx +13 -0
- package/src/web/components/git/git-worktree-panel.tsx +306 -0
- package/src/web/components/layout/draggable-tab.tsx +7 -1
- package/src/web/components/layout/editor-panel.tsx +1 -1
- package/src/web/components/layout/split-drop-overlay.tsx +10 -5
- package/src/web/components/layout/tab-bar.tsx +6 -0
- package/src/web/components/settings/ai-settings-section.tsx +83 -1
- package/src/web/hooks/use-chat.ts +96 -1
- package/src/web/hooks/use-media-query.ts +18 -0
- package/src/web/hooks/use-tab-drag.ts +1 -1
- package/src/web/hooks/use-touch-tab-drag.ts +190 -0
- package/dist/web/assets/chat-tab-BDvg70wQ.js +0 -8
- package/dist/web/assets/code-editor-CQXdf1v_.js +0 -2
- package/dist/web/assets/index-BPTNjFbl.js +0 -37
- package/dist/web/assets/index-D3XyoeN3.css +0 -2
- package/dist/web/assets/keybindings-store-BTCdVItE.js +0 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import { RefreshCw, X } from "lucide-react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { api } from "@/lib/api-client";
|
|
5
|
+
import type { TeamMessageItem } from "@/hooks/use-chat";
|
|
6
|
+
|
|
7
|
+
interface TeamActivityPopoverProps {
|
|
8
|
+
teamNames: string[];
|
|
9
|
+
messages: TeamMessageItem[];
|
|
10
|
+
open: boolean;
|
|
11
|
+
onOpenChange: (open: boolean) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
15
|
+
active: "bg-green-500",
|
|
16
|
+
idle: "bg-yellow-500",
|
|
17
|
+
shutdown: "bg-zinc-400",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
|
|
21
|
+
task_assignment: { label: "task", className: "bg-blue-500/20 text-blue-400" },
|
|
22
|
+
idle_notification: { label: "idle", className: "bg-yellow-500/20 text-yellow-400" },
|
|
23
|
+
completion: { label: "done", className: "bg-green-500/20 text-green-400" },
|
|
24
|
+
shutdown_request: { label: "shutdown", className: "bg-red-500/20 text-red-400" },
|
|
25
|
+
shutdown_approved: { label: "shutdown ✓", className: "bg-zinc-500/20 text-zinc-400" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function TeamActivityPopover({ teamNames, messages, open, onOpenChange }: TeamActivityPopoverProps) {
|
|
29
|
+
const [selectedTeam, setSelectedTeam] = useState(teamNames[0] ?? "");
|
|
30
|
+
const [members, setMembers] = useState<any[]>([]);
|
|
31
|
+
const [loading, setLoading] = useState(false);
|
|
32
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
|
|
35
|
+
// Sync selected team when teamNames changes
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (teamNames.length > 0 && !teamNames.includes(selectedTeam)) {
|
|
38
|
+
setSelectedTeam(teamNames[0]!);
|
|
39
|
+
}
|
|
40
|
+
}, [teamNames, selectedTeam]);
|
|
41
|
+
|
|
42
|
+
const fetchTeamDetail = useCallback(async (name: string) => {
|
|
43
|
+
setLoading(true);
|
|
44
|
+
try {
|
|
45
|
+
const detail = await api.get<any>(`/api/teams/${encodeURIComponent(name)}`);
|
|
46
|
+
setMembers(detail?.members ?? []);
|
|
47
|
+
} catch { setMembers([]); }
|
|
48
|
+
setLoading(false);
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
// Fetch members on mount and tab switch
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (open && selectedTeam) fetchTeamDetail(selectedTeam);
|
|
54
|
+
}, [open, selectedTeam, fetchTeamDetail]);
|
|
55
|
+
|
|
56
|
+
// Auto-scroll messages
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
59
|
+
}, [messages.length]);
|
|
60
|
+
|
|
61
|
+
// Close on outside click
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!open) return;
|
|
64
|
+
const handler = (e: MouseEvent) => {
|
|
65
|
+
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
|
66
|
+
onOpenChange(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
document.addEventListener("mousedown", handler);
|
|
70
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
71
|
+
}, [open, onOpenChange]);
|
|
72
|
+
|
|
73
|
+
if (!open) return null;
|
|
74
|
+
|
|
75
|
+
// Filter messages relevant to selected team (all for now — teams share the same messages array)
|
|
76
|
+
const teamMessages = messages;
|
|
77
|
+
const displayMessages = teamMessages.slice(-200);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
ref={panelRef}
|
|
82
|
+
className="absolute bottom-full left-0 mb-2 w-80 md:w-96 bg-surface border border-border rounded-lg shadow-lg z-50 overflow-hidden"
|
|
83
|
+
>
|
|
84
|
+
{/* Header with tabs */}
|
|
85
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface-elevated">
|
|
86
|
+
<div className="flex items-center gap-1 overflow-x-auto min-w-0">
|
|
87
|
+
{teamNames.map((name) => (
|
|
88
|
+
<button
|
|
89
|
+
key={name}
|
|
90
|
+
onClick={() => setSelectedTeam(name)}
|
|
91
|
+
className={cn(
|
|
92
|
+
"px-2 py-0.5 text-[11px] rounded-md whitespace-nowrap transition-colors",
|
|
93
|
+
selectedTeam === name
|
|
94
|
+
? "bg-primary/10 text-primary font-medium"
|
|
95
|
+
: "text-text-subtle hover:text-text-primary"
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
{name}
|
|
99
|
+
</button>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
103
|
+
<button
|
|
104
|
+
onClick={() => selectedTeam && fetchTeamDetail(selectedTeam)}
|
|
105
|
+
className="text-text-subtle hover:text-foreground p-1"
|
|
106
|
+
aria-label="Refresh"
|
|
107
|
+
>
|
|
108
|
+
<RefreshCw className={cn("size-3", loading && "animate-spin")} />
|
|
109
|
+
</button>
|
|
110
|
+
<button
|
|
111
|
+
onClick={() => onOpenChange(false)}
|
|
112
|
+
className="text-text-subtle hover:text-foreground p-1"
|
|
113
|
+
aria-label="Close"
|
|
114
|
+
>
|
|
115
|
+
<X className="size-3" />
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{/* Members */}
|
|
121
|
+
{members.length > 0 && (
|
|
122
|
+
<div className="px-3 py-2 border-b border-border">
|
|
123
|
+
<div className="text-[10px] text-text-subtle uppercase tracking-wider mb-1">Members</div>
|
|
124
|
+
<div className="space-y-1">
|
|
125
|
+
{members.map((m: any) => (
|
|
126
|
+
<div key={m.name} className="flex items-center gap-2 text-xs">
|
|
127
|
+
<span className={cn("size-1.5 rounded-full shrink-0", STATUS_COLORS[m.status] ?? "bg-zinc-400")} />
|
|
128
|
+
<span className="font-medium truncate">{m.name}</span>
|
|
129
|
+
{m.model && m.model !== "unknown" && (
|
|
130
|
+
<span className="text-text-subtle text-[10px]">({m.model})</span>
|
|
131
|
+
)}
|
|
132
|
+
<span className="ml-auto text-text-subtle text-[10px]">{m.status}</span>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{/* Messages */}
|
|
140
|
+
<div className="max-h-64 overflow-y-auto px-3 py-2">
|
|
141
|
+
{displayMessages.length === 0 ? (
|
|
142
|
+
<p className="text-xs text-text-subtle text-center py-4">No messages yet</p>
|
|
143
|
+
) : (
|
|
144
|
+
<div className="space-y-2">
|
|
145
|
+
{displayMessages.map((msg, i) => {
|
|
146
|
+
const badge = msg.parsedType ? TYPE_BADGES[msg.parsedType] : null;
|
|
147
|
+
const time = formatTime(msg.timestamp);
|
|
148
|
+
return (
|
|
149
|
+
<div key={`${msg.timestamp}-${i}`} className="text-xs">
|
|
150
|
+
<div className="flex items-center gap-1 text-text-subtle">
|
|
151
|
+
<span className="font-medium" style={safeColor(msg.color)}>
|
|
152
|
+
{msg.from}
|
|
153
|
+
</span>
|
|
154
|
+
<span>→</span>
|
|
155
|
+
<span>{msg.to}</span>
|
|
156
|
+
<span className="ml-auto text-[10px]">{time}</span>
|
|
157
|
+
</div>
|
|
158
|
+
<div className="mt-0.5 text-foreground/90 break-words">
|
|
159
|
+
{badge && (
|
|
160
|
+
<span className={cn("inline-block px-1 py-0 rounded text-[9px] mr-1", badge.className)}>
|
|
161
|
+
{badge.label}
|
|
162
|
+
</span>
|
|
163
|
+
)}
|
|
164
|
+
{msg.summary ?? truncateText(msg.text)}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
})}
|
|
169
|
+
<div ref={messagesEndRef} />
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function formatTime(timestamp: string): string {
|
|
178
|
+
try {
|
|
179
|
+
const d = new Date(timestamp);
|
|
180
|
+
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
181
|
+
} catch { return ""; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Sanitize color value to prevent CSS injection */
|
|
185
|
+
function safeColor(color?: string): React.CSSProperties | undefined {
|
|
186
|
+
if (!color) return undefined;
|
|
187
|
+
// Only allow hex colors and named CSS colors (no url(), expression(), etc.)
|
|
188
|
+
if (/^#[0-9a-fA-F]{3,8}$/.test(color) || /^[a-zA-Z]{3,20}$/.test(color)) {
|
|
189
|
+
return { color };
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function truncateText(text: string, max = 120): string {
|
|
195
|
+
if (!text) return "";
|
|
196
|
+
// Try to parse JSON for structured messages
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(text);
|
|
199
|
+
return parsed.summary ?? parsed.text ?? text.slice(0, max);
|
|
200
|
+
} catch {}
|
|
201
|
+
return text.length > max ? text.slice(0, max) + "..." : text;
|
|
202
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Loader2, X } from "lucide-react";
|
|
3
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from "@/components/ui/dialog";
|
|
12
|
+
import { Input } from "@/components/ui/input";
|
|
13
|
+
import { Label } from "@/components/ui/label";
|
|
14
|
+
import type { GitBranch } from "../../../types/git";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
16
|
+
|
|
17
|
+
interface CreateWorktreeDialogProps {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onOpenChange: (open: boolean) => void;
|
|
20
|
+
projectName: string;
|
|
21
|
+
onCreated: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type BranchMode = "existing" | "new";
|
|
25
|
+
|
|
26
|
+
/** Mobile bottom sheet + desktop dialog for creating a git worktree. */
|
|
27
|
+
export function CreateWorktreeDialog({
|
|
28
|
+
open,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
projectName,
|
|
31
|
+
onCreated,
|
|
32
|
+
}: CreateWorktreeDialogProps) {
|
|
33
|
+
const [worktreePath, setWorktreePath] = useState("");
|
|
34
|
+
const [branchMode, setBranchMode] = useState<BranchMode>("new");
|
|
35
|
+
const [newBranch, setNewBranch] = useState("");
|
|
36
|
+
const [existingBranch, setExistingBranch] = useState("");
|
|
37
|
+
const [branches, setBranches] = useState<string[]>([]);
|
|
38
|
+
const [loading, setLoading] = useState(false);
|
|
39
|
+
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
|
|
41
|
+
// Fetch available branches when dialog opens
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!open || !projectName) return;
|
|
44
|
+
api
|
|
45
|
+
.get<GitBranch[]>(`${projectUrl(projectName)}/git/branches`)
|
|
46
|
+
.then((data) => {
|
|
47
|
+
const local = data.filter((b) => !b.remote).map((b) => b.name);
|
|
48
|
+
setBranches(local);
|
|
49
|
+
if (local.length > 0 && !existingBranch) {
|
|
50
|
+
setExistingBranch(local[0]!);
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
.catch(() => setBranches([]));
|
|
54
|
+
}, [open, projectName]);
|
|
55
|
+
|
|
56
|
+
function reset() {
|
|
57
|
+
setWorktreePath("");
|
|
58
|
+
setBranchMode("new");
|
|
59
|
+
setNewBranch("");
|
|
60
|
+
setError(null);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function handleClose() {
|
|
64
|
+
reset();
|
|
65
|
+
onOpenChange(false);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleSubmit() {
|
|
69
|
+
if (!worktreePath.trim()) {
|
|
70
|
+
setError("Worktree path is required");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const selectedBranch = branchMode === "existing" ? existingBranch : undefined;
|
|
74
|
+
const selectedNewBranch = branchMode === "new" && newBranch.trim() ? newBranch.trim() : undefined;
|
|
75
|
+
|
|
76
|
+
setLoading(true);
|
|
77
|
+
setError(null);
|
|
78
|
+
try {
|
|
79
|
+
await api.post(`${projectUrl(projectName)}/git/worktree/add`, {
|
|
80
|
+
path: worktreePath.trim(),
|
|
81
|
+
branch: selectedBranch,
|
|
82
|
+
newBranch: selectedNewBranch,
|
|
83
|
+
});
|
|
84
|
+
onCreated();
|
|
85
|
+
handleClose();
|
|
86
|
+
} catch (e) {
|
|
87
|
+
setError(e instanceof Error ? e.message : "Failed to create worktree");
|
|
88
|
+
} finally {
|
|
89
|
+
setLoading(false);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const formContent = (
|
|
94
|
+
<div className="space-y-4">
|
|
95
|
+
{error && (
|
|
96
|
+
<p className="text-xs text-destructive bg-destructive/10 px-3 py-2 rounded-md">{error}</p>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
<div className="space-y-1.5">
|
|
100
|
+
<Label htmlFor="wt-path">Worktree Path</Label>
|
|
101
|
+
<Input
|
|
102
|
+
id="wt-path"
|
|
103
|
+
placeholder="../my-feature"
|
|
104
|
+
value={worktreePath}
|
|
105
|
+
onChange={(e) => setWorktreePath(e.target.value)}
|
|
106
|
+
autoFocus
|
|
107
|
+
className="w-full"
|
|
108
|
+
/>
|
|
109
|
+
<p className="text-xs text-muted-foreground">
|
|
110
|
+
Relative or absolute path for the new worktree directory
|
|
111
|
+
</p>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div className="space-y-2">
|
|
115
|
+
<Label>Branch</Label>
|
|
116
|
+
<div className="flex gap-3">
|
|
117
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
118
|
+
<input
|
|
119
|
+
type="radio"
|
|
120
|
+
checked={branchMode === "new"}
|
|
121
|
+
onChange={() => setBranchMode("new")}
|
|
122
|
+
className="accent-primary"
|
|
123
|
+
/>
|
|
124
|
+
<span className="text-sm">Create new branch</span>
|
|
125
|
+
</label>
|
|
126
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
127
|
+
<input
|
|
128
|
+
type="radio"
|
|
129
|
+
checked={branchMode === "existing"}
|
|
130
|
+
onChange={() => setBranchMode("existing")}
|
|
131
|
+
className="accent-primary"
|
|
132
|
+
/>
|
|
133
|
+
<span className="text-sm">Use existing branch</span>
|
|
134
|
+
</label>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{branchMode === "new" ? (
|
|
138
|
+
<Input
|
|
139
|
+
placeholder="feature/my-branch"
|
|
140
|
+
value={newBranch}
|
|
141
|
+
onChange={(e) => setNewBranch(e.target.value)}
|
|
142
|
+
className="w-full"
|
|
143
|
+
/>
|
|
144
|
+
) : (
|
|
145
|
+
<select
|
|
146
|
+
value={existingBranch}
|
|
147
|
+
onChange={(e) => setExistingBranch(e.target.value)}
|
|
148
|
+
className="w-full h-9 px-3 rounded-md border border-input bg-transparent text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
149
|
+
>
|
|
150
|
+
{branches.length === 0 && (
|
|
151
|
+
<option value="">No branches available</option>
|
|
152
|
+
)}
|
|
153
|
+
{branches.map((b) => (
|
|
154
|
+
<option key={b} value={b}>
|
|
155
|
+
{b}
|
|
156
|
+
</option>
|
|
157
|
+
))}
|
|
158
|
+
</select>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const footer = (
|
|
165
|
+
<div className="flex gap-2 justify-end pt-2">
|
|
166
|
+
<Button variant="outline" size="sm" onClick={handleClose} disabled={loading}>
|
|
167
|
+
Cancel
|
|
168
|
+
</Button>
|
|
169
|
+
<Button
|
|
170
|
+
size="sm"
|
|
171
|
+
onClick={handleSubmit}
|
|
172
|
+
disabled={loading || !worktreePath.trim()}
|
|
173
|
+
>
|
|
174
|
+
{loading ? <Loader2 className="size-3 animate-spin mr-1" /> : null}
|
|
175
|
+
Create Worktree
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<>
|
|
182
|
+
{/* Desktop: centered dialog */}
|
|
183
|
+
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
|
|
184
|
+
<DialogContent className="hidden md:grid sm:max-w-md">
|
|
185
|
+
<DialogHeader>
|
|
186
|
+
<DialogTitle>Add Worktree</DialogTitle>
|
|
187
|
+
</DialogHeader>
|
|
188
|
+
{formContent}
|
|
189
|
+
<DialogFooter>{footer}</DialogFooter>
|
|
190
|
+
</DialogContent>
|
|
191
|
+
</Dialog>
|
|
192
|
+
|
|
193
|
+
{/* Mobile: bottom sheet */}
|
|
194
|
+
{open && (
|
|
195
|
+
<div className="md:hidden">
|
|
196
|
+
{/* Backdrop */}
|
|
197
|
+
<div
|
|
198
|
+
className="fixed inset-0 z-50 bg-black/50"
|
|
199
|
+
onClick={handleClose}
|
|
200
|
+
/>
|
|
201
|
+
{/* Sheet */}
|
|
202
|
+
<div
|
|
203
|
+
className={cn(
|
|
204
|
+
"fixed bottom-0 left-0 right-0 z-50 bg-background rounded-t-2xl border-t border-border shadow-2xl",
|
|
205
|
+
"animate-in slide-in-from-bottom-2 duration-200",
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
208
|
+
{/* Drag handle */}
|
|
209
|
+
<div className="flex justify-center pt-3 pb-1">
|
|
210
|
+
<div className="w-10 h-1 rounded-full bg-border" />
|
|
211
|
+
</div>
|
|
212
|
+
{/* Header */}
|
|
213
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
|
214
|
+
<span className="text-sm font-semibold">Add Worktree</span>
|
|
215
|
+
<button
|
|
216
|
+
onClick={handleClose}
|
|
217
|
+
className="flex items-center justify-center size-7 rounded-md hover:bg-surface-elevated transition-colors"
|
|
218
|
+
>
|
|
219
|
+
<X className="size-4" />
|
|
220
|
+
</button>
|
|
221
|
+
</div>
|
|
222
|
+
{/* Body */}
|
|
223
|
+
<div className="px-4 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
|
224
|
+
{formContent}
|
|
225
|
+
{footer}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -17,6 +17,8 @@ import { api, projectUrl } from "@/lib/api-client";
|
|
|
17
17
|
import { basename } from "@/lib/utils";
|
|
18
18
|
import { useTabStore } from "@/stores/tab-store";
|
|
19
19
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
20
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
21
|
+
import { GitWorktreePanel } from "./git-worktree-panel";
|
|
20
22
|
import { Button } from "@/components/ui/button";
|
|
21
23
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
22
24
|
import {
|
|
@@ -117,6 +119,9 @@ export function GitStatusPanel({ metadata, tabId, onNavigate }: GitStatusPanelPr
|
|
|
117
119
|
const { openTab } = useTabStore();
|
|
118
120
|
const viewMode = useSettingsStore((s) => s.gitStatusViewMode);
|
|
119
121
|
const setViewMode = useSettingsStore((s) => s.setGitStatusViewMode);
|
|
122
|
+
const activeProjectPath = useProjectStore((s) =>
|
|
123
|
+
s.projects.find((p) => p.name === projectName)?.path,
|
|
124
|
+
);
|
|
120
125
|
|
|
121
126
|
const fetchStatus = useCallback(async () => {
|
|
122
127
|
if (!projectName) return;
|
|
@@ -335,6 +340,14 @@ export function GitStatusPanel({ metadata, tabId, onNavigate }: GitStatusPanelPr
|
|
|
335
340
|
</div>
|
|
336
341
|
)}
|
|
337
342
|
|
|
343
|
+
{/* Worktrees collapsible section */}
|
|
344
|
+
{projectName && (
|
|
345
|
+
<GitWorktreePanel
|
|
346
|
+
projectName={projectName}
|
|
347
|
+
projectPath={activeProjectPath}
|
|
348
|
+
/>
|
|
349
|
+
)}
|
|
350
|
+
|
|
338
351
|
<ScrollArea className="flex-1 overflow-hidden">
|
|
339
352
|
<div className="p-1.5 space-y-2 overflow-hidden">
|
|
340
353
|
{/* Staged Changes */}
|