@atercates/claude-deck 0.2.4 → 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/app/api/sessions/[id]/fork/route.ts +0 -1
- package/app/api/sessions/[id]/route.ts +0 -5
- package/app/api/sessions/[id]/summarize/route.ts +2 -3
- package/app/api/sessions/route.ts +2 -11
- package/app/page.tsx +4 -45
- package/components/ClaudeProjects/ClaudeProjectCard.tsx +19 -31
- package/components/ClaudeProjects/ClaudeSessionCard.tsx +20 -31
- package/components/NewSessionDialog/AdvancedSettings.tsx +3 -12
- package/components/NewSessionDialog/NewSessionDialog.types.ts +0 -10
- package/components/NewSessionDialog/ProjectSelector.tsx +2 -7
- package/components/NewSessionDialog/hooks/useNewSessionForm.ts +3 -36
- package/components/NewSessionDialog/index.tsx +0 -7
- package/components/Projects/index.ts +0 -1
- package/components/SessionList/hooks/useSessionListMutations.ts +0 -35
- package/components/views/DesktopView.tsx +1 -6
- package/components/views/MobileView.tsx +6 -1
- package/components/views/types.ts +1 -1
- package/data/sessions/index.ts +0 -1
- package/data/sessions/queries.ts +1 -27
- package/hooks/useSessions.ts +0 -12
- package/lib/db/queries.ts +4 -64
- package/lib/db/types.ts +0 -8
- package/lib/orchestration.ts +10 -15
- package/lib/providers/registry.ts +2 -56
- package/lib/providers.ts +19 -100
- package/lib/status-monitor.ts +40 -15
- package/package.json +1 -1
- package/server.ts +1 -1
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/components/NewSessionDialog/AgentSelector.tsx +0 -37
- package/components/Projects/ProjectCard.tsx +0 -276
- package/components/TmuxSessions.tsx +0 -132
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- package/hooks/useGroups.ts +0 -37
- package/hooks/useKeybarVisibility.ts +0 -42
- package/lib/claude/process-manager.ts +0 -278
package/lib/status-monitor.ts
CHANGED
|
@@ -29,8 +29,11 @@ const TICK_INTERVAL_MS = 3000;
|
|
|
29
29
|
const SESSION_NAME_CACHE_TTL = 10_000;
|
|
30
30
|
const UUID_PATTERN = getManagedSessionPattern();
|
|
31
31
|
|
|
32
|
-
// Cache for session
|
|
33
|
-
const
|
|
32
|
+
// Cache for session metadata (name + cwd from SDK)
|
|
33
|
+
const sessionMetaCache = new Map<
|
|
34
|
+
string,
|
|
35
|
+
{ name: string; cwd: string | null; cachedAt: number }
|
|
36
|
+
>();
|
|
34
37
|
|
|
35
38
|
// --- Types ---
|
|
36
39
|
|
|
@@ -45,6 +48,7 @@ interface StateFile {
|
|
|
45
48
|
|
|
46
49
|
export interface SessionStatusSnapshot {
|
|
47
50
|
sessionName: string;
|
|
51
|
+
cwd: string | null;
|
|
48
52
|
status: SessionStatus;
|
|
49
53
|
lastLine: string;
|
|
50
54
|
waitingContext?: string;
|
|
@@ -103,28 +107,35 @@ async function listTmuxSessions(): Promise<Map<string, string>> {
|
|
|
103
107
|
}
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
// --- Session
|
|
110
|
+
// --- Session metadata resolution ---
|
|
107
111
|
|
|
108
|
-
async function
|
|
109
|
-
|
|
112
|
+
async function resolveSessionMeta(
|
|
113
|
+
sessionId: string
|
|
114
|
+
): Promise<{ name: string; cwd: string | null }> {
|
|
115
|
+
const cached = sessionMetaCache.get(sessionId);
|
|
110
116
|
if (cached && Date.now() - cached.cachedAt < SESSION_NAME_CACHE_TTL) {
|
|
111
|
-
return cached.name;
|
|
117
|
+
return { name: cached.name, cwd: cached.cwd };
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
try {
|
|
115
121
|
const info = await getSessionInfo(sessionId);
|
|
116
122
|
if (info) {
|
|
117
123
|
const name = info.customTitle || info.summary || sessionId.slice(0, 8);
|
|
118
|
-
|
|
119
|
-
|
|
124
|
+
const cwd = info.cwd || null;
|
|
125
|
+
sessionMetaCache.set(sessionId, { name, cwd, cachedAt: Date.now() });
|
|
126
|
+
return { name, cwd };
|
|
120
127
|
}
|
|
121
128
|
} catch {
|
|
122
129
|
// SDK lookup failed — use short ID
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
const fallback = sessionId.slice(0, 8);
|
|
126
|
-
|
|
127
|
-
|
|
133
|
+
sessionMetaCache.set(sessionId, {
|
|
134
|
+
name: fallback,
|
|
135
|
+
cwd: null,
|
|
136
|
+
cachedAt: Date.now(),
|
|
137
|
+
});
|
|
138
|
+
return { name: fallback, cwd: null };
|
|
128
139
|
}
|
|
129
140
|
|
|
130
141
|
// --- Snapshot building ---
|
|
@@ -137,17 +148,18 @@ async function buildSnapshot(
|
|
|
137
148
|
|
|
138
149
|
const entries = await Promise.all(
|
|
139
150
|
[...tmuxSessions.entries()].map(async ([sessionId, tmuxName]) => {
|
|
140
|
-
const
|
|
141
|
-
return { sessionId, tmuxName,
|
|
151
|
+
const meta = await resolveSessionMeta(sessionId);
|
|
152
|
+
return { sessionId, tmuxName, ...meta };
|
|
142
153
|
})
|
|
143
154
|
);
|
|
144
155
|
|
|
145
|
-
for (const { sessionId, tmuxName,
|
|
156
|
+
for (const { sessionId, tmuxName, name, cwd } of entries) {
|
|
146
157
|
const agentType = getProviderIdFromSessionName(tmuxName) || "claude";
|
|
147
158
|
const state = stateFiles.get(sessionId);
|
|
148
159
|
|
|
149
160
|
snap[sessionId] = {
|
|
150
|
-
sessionName:
|
|
161
|
+
sessionName: name,
|
|
162
|
+
cwd,
|
|
151
163
|
status: state?.status || "idle",
|
|
152
164
|
lastLine: state?.lastLine || "",
|
|
153
165
|
...(state?.status === "waiting" && state.waitingContext
|
|
@@ -158,6 +170,19 @@ async function buildSnapshot(
|
|
|
158
170
|
};
|
|
159
171
|
}
|
|
160
172
|
|
|
173
|
+
// Filter out hidden sessions
|
|
174
|
+
try {
|
|
175
|
+
const db = getDb();
|
|
176
|
+
const hiddenRows = db
|
|
177
|
+
.prepare("SELECT item_id FROM hidden_items WHERE item_type = 'session'")
|
|
178
|
+
.all() as { item_id: string }[];
|
|
179
|
+
for (const { item_id } of hiddenRows) {
|
|
180
|
+
delete snap[item_id];
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// DB errors shouldn't break the monitor
|
|
184
|
+
}
|
|
185
|
+
|
|
161
186
|
return snap;
|
|
162
187
|
}
|
|
163
188
|
|
|
@@ -250,7 +275,7 @@ export function onStateFileChange(): void {
|
|
|
250
275
|
}
|
|
251
276
|
|
|
252
277
|
export function invalidateSessionName(sessionId: string): void {
|
|
253
|
-
|
|
278
|
+
sessionMetaCache.delete(sessionId);
|
|
254
279
|
}
|
|
255
280
|
|
|
256
281
|
export function startStatusMonitor(): void {
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "./lib/auth";
|
|
16
16
|
|
|
17
17
|
const dev = process.env.NODE_ENV !== "production";
|
|
18
|
-
const hostname = "0.0.0.0";
|
|
18
|
+
const hostname = process.env.HOST || (dev ? "localhost" : "0.0.0.0");
|
|
19
19
|
|
|
20
20
|
// Support: npm run dev -- -p 3012
|
|
21
21
|
const pFlagIndex = process.argv.indexOf("-p");
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import { NextResponse } from "next/server";
|
|
2
|
-
import { queries, type Group } from "@/lib/db";
|
|
3
|
-
|
|
4
|
-
// GET /api/groups/[...path] - Get a single group
|
|
5
|
-
export async function GET(
|
|
6
|
-
request: Request,
|
|
7
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
8
|
-
) {
|
|
9
|
-
const { path: pathParts } = await params;
|
|
10
|
-
const path = pathParts.join("/");
|
|
11
|
-
|
|
12
|
-
try {
|
|
13
|
-
const group = await queries.getGroup(path) as Group | undefined;
|
|
14
|
-
|
|
15
|
-
if (!group) {
|
|
16
|
-
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return NextResponse.json({
|
|
20
|
-
group: { ...group, expanded: Boolean(group.expanded) },
|
|
21
|
-
});
|
|
22
|
-
} catch (error) {
|
|
23
|
-
console.error("Error fetching group:", error);
|
|
24
|
-
return NextResponse.json(
|
|
25
|
-
{ error: "Failed to fetch group" },
|
|
26
|
-
{ status: 500 }
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// PATCH /api/groups/[...path] - Update group (name, expanded, order)
|
|
32
|
-
export async function PATCH(
|
|
33
|
-
request: Request,
|
|
34
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
35
|
-
) {
|
|
36
|
-
const { path: pathParts } = await params;
|
|
37
|
-
const path = pathParts.join("/");
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
const body = await request.json();
|
|
41
|
-
const { name, expanded, sort_order } = body;
|
|
42
|
-
|
|
43
|
-
// Check if group exists
|
|
44
|
-
const group = await queries.getGroup(path) as Group | undefined;
|
|
45
|
-
if (!group) {
|
|
46
|
-
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Protect default group from being renamed
|
|
50
|
-
if (path === "sessions" && name !== undefined && name !== "Sessions") {
|
|
51
|
-
return NextResponse.json(
|
|
52
|
-
{ error: "Cannot rename the default group" },
|
|
53
|
-
{ status: 400 }
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Update name
|
|
58
|
-
if (name !== undefined) {
|
|
59
|
-
await queries.updateGroupName(name, path);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Update expanded state
|
|
63
|
-
if (expanded !== undefined) {
|
|
64
|
-
await queries.updateGroupExpanded(!!expanded, path);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Update sort order
|
|
68
|
-
if (sort_order !== undefined) {
|
|
69
|
-
await queries.updateGroupOrder(sort_order, path);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const updatedGroup = await queries.getGroup(path) as Group;
|
|
73
|
-
return NextResponse.json({
|
|
74
|
-
group: { ...updatedGroup, expanded: Boolean(updatedGroup.expanded) },
|
|
75
|
-
});
|
|
76
|
-
} catch (error) {
|
|
77
|
-
console.error("Error updating group:", error);
|
|
78
|
-
return NextResponse.json(
|
|
79
|
-
{ error: "Failed to update group" },
|
|
80
|
-
{ status: 500 }
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// DELETE /api/groups/[...path] - Delete group (moves sessions to parent or default)
|
|
86
|
-
export async function DELETE(
|
|
87
|
-
request: Request,
|
|
88
|
-
{ params }: { params: Promise<{ path: string[] }> }
|
|
89
|
-
) {
|
|
90
|
-
const { path: pathParts } = await params;
|
|
91
|
-
const path = pathParts.join("/");
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
// Protect default group
|
|
95
|
-
if (path === "sessions") {
|
|
96
|
-
return NextResponse.json(
|
|
97
|
-
{ error: "Cannot delete the default group" },
|
|
98
|
-
{ status: 400 }
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Check if group exists
|
|
103
|
-
const group = await queries.getGroup(path) as Group | undefined;
|
|
104
|
-
if (!group) {
|
|
105
|
-
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Find parent group or use default
|
|
109
|
-
const pathParts2 = path.split("/");
|
|
110
|
-
pathParts2.pop();
|
|
111
|
-
const parentPath =
|
|
112
|
-
pathParts2.length > 0 ? pathParts2.join("/") : "sessions";
|
|
113
|
-
|
|
114
|
-
// Move all sessions in this group to parent
|
|
115
|
-
await queries.moveSessionsToGroup(parentPath, path);
|
|
116
|
-
|
|
117
|
-
// Also move sessions from any subgroups
|
|
118
|
-
const allGroups = await queries.getAllGroups() as Group[];
|
|
119
|
-
const subgroups = allGroups.filter((g) => g.path.startsWith(path + "/"));
|
|
120
|
-
for (const subgroup of subgroups) {
|
|
121
|
-
await queries.moveSessionsToGroup(parentPath, subgroup.path);
|
|
122
|
-
await queries.deleteGroup(subgroup.path);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Delete the group
|
|
126
|
-
await queries.deleteGroup(path);
|
|
127
|
-
|
|
128
|
-
return NextResponse.json({ success: true, movedTo: parentPath });
|
|
129
|
-
} catch (error) {
|
|
130
|
-
console.error("Error deleting group:", error);
|
|
131
|
-
return NextResponse.json(
|
|
132
|
-
{ error: "Failed to delete group" },
|
|
133
|
-
{ status: 500 }
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
}
|
package/app/api/groups/route.ts
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { NextResponse } from "next/server";
|
|
2
|
-
import { queries, type Group } from "@/lib/db";
|
|
3
|
-
|
|
4
|
-
// GET /api/groups - List all groups
|
|
5
|
-
export async function GET() {
|
|
6
|
-
try {
|
|
7
|
-
const groups = await queries.getAllGroups() as Group[];
|
|
8
|
-
|
|
9
|
-
// Convert expanded from 0/1 to boolean
|
|
10
|
-
const formattedGroups = groups.map((g) => ({
|
|
11
|
-
...g,
|
|
12
|
-
expanded: Boolean(g.expanded),
|
|
13
|
-
}));
|
|
14
|
-
|
|
15
|
-
return NextResponse.json({ groups: formattedGroups });
|
|
16
|
-
} catch (error) {
|
|
17
|
-
console.error("Error fetching groups:", error);
|
|
18
|
-
return NextResponse.json(
|
|
19
|
-
{ error: "Failed to fetch groups" },
|
|
20
|
-
{ status: 500 }
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// POST /api/groups - Create a new group
|
|
26
|
-
export async function POST(request: Request) {
|
|
27
|
-
try {
|
|
28
|
-
const body = await request.json();
|
|
29
|
-
const { name, parentPath } = body;
|
|
30
|
-
|
|
31
|
-
if (!name || typeof name !== "string") {
|
|
32
|
-
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Sanitize name to create path
|
|
36
|
-
const sanitizedName = name
|
|
37
|
-
.toLowerCase()
|
|
38
|
-
.replace(/[^a-z0-9-]/g, "-")
|
|
39
|
-
.replace(/-+/g, "-")
|
|
40
|
-
.replace(/^-|-$/g, "");
|
|
41
|
-
|
|
42
|
-
if (!sanitizedName) {
|
|
43
|
-
return NextResponse.json(
|
|
44
|
-
{ error: "Invalid group name" },
|
|
45
|
-
{ status: 400 }
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Build full path
|
|
50
|
-
const path = parentPath ? `${parentPath}/${sanitizedName}` : sanitizedName;
|
|
51
|
-
|
|
52
|
-
// Check if group already exists
|
|
53
|
-
const existing = await queries.getGroup(path) as Group | undefined;
|
|
54
|
-
if (existing) {
|
|
55
|
-
return NextResponse.json(
|
|
56
|
-
{ error: "Group already exists", group: existing },
|
|
57
|
-
{ status: 409 }
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// If parent path specified, ensure parent exists
|
|
62
|
-
if (parentPath) {
|
|
63
|
-
const parent = await queries.getGroup(parentPath) as Group | undefined;
|
|
64
|
-
if (!parent) {
|
|
65
|
-
return NextResponse.json(
|
|
66
|
-
{ error: "Parent group does not exist" },
|
|
67
|
-
{ status: 400 }
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Get max sort order for new group
|
|
73
|
-
const groups = await queries.getAllGroups() as Group[];
|
|
74
|
-
const maxOrder = groups.reduce((max, g) => Math.max(max, g.sort_order), 0);
|
|
75
|
-
|
|
76
|
-
// Create the group
|
|
77
|
-
await queries.createGroup(path, name, maxOrder + 1);
|
|
78
|
-
|
|
79
|
-
const newGroup = await queries.getGroup(path) as Group;
|
|
80
|
-
return NextResponse.json(
|
|
81
|
-
{
|
|
82
|
-
group: { ...newGroup, expanded: Boolean(newGroup.expanded) },
|
|
83
|
-
},
|
|
84
|
-
{ status: 201 }
|
|
85
|
-
);
|
|
86
|
-
} catch (error) {
|
|
87
|
-
console.error("Error creating group:", error);
|
|
88
|
-
return NextResponse.json(
|
|
89
|
-
{ error: "Failed to create group" },
|
|
90
|
-
{ status: 500 }
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Select,
|
|
3
|
-
SelectContent,
|
|
4
|
-
SelectItem,
|
|
5
|
-
SelectTrigger,
|
|
6
|
-
SelectValue,
|
|
7
|
-
} from "@/components/ui/select";
|
|
8
|
-
import type { AgentType } from "@/lib/providers";
|
|
9
|
-
import { AGENT_OPTIONS } from "./NewSessionDialog.types";
|
|
10
|
-
|
|
11
|
-
interface AgentSelectorProps {
|
|
12
|
-
value: AgentType;
|
|
13
|
-
onChange: (value: AgentType) => void;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function AgentSelector({ value, onChange }: AgentSelectorProps) {
|
|
17
|
-
return (
|
|
18
|
-
<div className="space-y-2">
|
|
19
|
-
<label className="text-sm font-medium">Agent</label>
|
|
20
|
-
<Select value={value} onValueChange={(v) => onChange(v as AgentType)}>
|
|
21
|
-
<SelectTrigger>
|
|
22
|
-
<SelectValue />
|
|
23
|
-
</SelectTrigger>
|
|
24
|
-
<SelectContent>
|
|
25
|
-
{AGENT_OPTIONS.map((option) => (
|
|
26
|
-
<SelectItem key={option.value} value={option.value}>
|
|
27
|
-
<span className="font-medium">{option.label}</span>
|
|
28
|
-
<span className="text-muted-foreground ml-2 text-xs">
|
|
29
|
-
{option.description}
|
|
30
|
-
</span>
|
|
31
|
-
</SelectItem>
|
|
32
|
-
))}
|
|
33
|
-
</SelectContent>
|
|
34
|
-
</Select>
|
|
35
|
-
</div>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useRef, useEffect } from "react";
|
|
4
|
-
import { cn } from "@/lib/utils";
|
|
5
|
-
import {
|
|
6
|
-
ChevronRight,
|
|
7
|
-
ChevronDown,
|
|
8
|
-
MoreHorizontal,
|
|
9
|
-
Settings,
|
|
10
|
-
Plus,
|
|
11
|
-
Server,
|
|
12
|
-
Trash2,
|
|
13
|
-
Pencil,
|
|
14
|
-
FolderOpen,
|
|
15
|
-
Terminal,
|
|
16
|
-
} from "lucide-react";
|
|
17
|
-
import { Button } from "@/components/ui/button";
|
|
18
|
-
import {
|
|
19
|
-
DropdownMenu,
|
|
20
|
-
DropdownMenuContent,
|
|
21
|
-
DropdownMenuItem,
|
|
22
|
-
DropdownMenuSeparator,
|
|
23
|
-
DropdownMenuTrigger,
|
|
24
|
-
} from "@/components/ui/dropdown-menu";
|
|
25
|
-
import {
|
|
26
|
-
ContextMenu,
|
|
27
|
-
ContextMenuContent,
|
|
28
|
-
ContextMenuItem,
|
|
29
|
-
ContextMenuSeparator,
|
|
30
|
-
ContextMenuTrigger,
|
|
31
|
-
} from "@/components/ui/context-menu";
|
|
32
|
-
import {
|
|
33
|
-
Tooltip,
|
|
34
|
-
TooltipContent,
|
|
35
|
-
TooltipTrigger,
|
|
36
|
-
} from "@/components/ui/tooltip";
|
|
37
|
-
import type { Project, DevServer } from "@/lib/db";
|
|
38
|
-
|
|
39
|
-
interface ProjectCardProps {
|
|
40
|
-
project: Project;
|
|
41
|
-
sessionCount: number;
|
|
42
|
-
runningDevServers?: DevServer[];
|
|
43
|
-
onClick?: () => void;
|
|
44
|
-
onToggleExpanded?: (expanded: boolean) => void;
|
|
45
|
-
onEdit?: () => void;
|
|
46
|
-
onNewSession?: () => void;
|
|
47
|
-
onOpenTerminal?: () => void;
|
|
48
|
-
onStartDevServer?: () => void;
|
|
49
|
-
onOpenInEditor?: () => void;
|
|
50
|
-
onDelete?: () => void;
|
|
51
|
-
onRename?: (newName: string) => void;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function ProjectCard({
|
|
55
|
-
project,
|
|
56
|
-
sessionCount,
|
|
57
|
-
runningDevServers = [],
|
|
58
|
-
onClick,
|
|
59
|
-
onToggleExpanded,
|
|
60
|
-
onEdit,
|
|
61
|
-
onNewSession,
|
|
62
|
-
onOpenTerminal,
|
|
63
|
-
onStartDevServer,
|
|
64
|
-
onOpenInEditor,
|
|
65
|
-
onDelete,
|
|
66
|
-
onRename,
|
|
67
|
-
}: ProjectCardProps) {
|
|
68
|
-
const [isEditing, setIsEditing] = useState(false);
|
|
69
|
-
const [editName, setEditName] = useState(project.name);
|
|
70
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
71
|
-
const justStartedEditingRef = useRef(false);
|
|
72
|
-
|
|
73
|
-
const hasRunningServers = runningDevServers.length > 0;
|
|
74
|
-
// Uncategorized can have New Session, Open Terminal, and Rename, but not Edit/Delete/DevServer
|
|
75
|
-
const hasActions = project.is_uncategorized
|
|
76
|
-
? onNewSession || onOpenTerminal || onRename
|
|
77
|
-
: onEdit ||
|
|
78
|
-
onNewSession ||
|
|
79
|
-
onOpenTerminal ||
|
|
80
|
-
onStartDevServer ||
|
|
81
|
-
onDelete ||
|
|
82
|
-
onRename;
|
|
83
|
-
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
if (isEditing && inputRef.current) {
|
|
86
|
-
const input = inputRef.current;
|
|
87
|
-
// Mark that we just started editing to ignore immediate blur
|
|
88
|
-
justStartedEditingRef.current = true;
|
|
89
|
-
// Small timeout to ensure input is fully mounted
|
|
90
|
-
setTimeout(() => {
|
|
91
|
-
input.focus();
|
|
92
|
-
input.select();
|
|
93
|
-
// Clear the flag after focus is established
|
|
94
|
-
setTimeout(() => {
|
|
95
|
-
justStartedEditingRef.current = false;
|
|
96
|
-
}, 100);
|
|
97
|
-
}, 0);
|
|
98
|
-
}
|
|
99
|
-
}, [isEditing]);
|
|
100
|
-
|
|
101
|
-
const handleRename = () => {
|
|
102
|
-
// Ignore blur events that happen immediately after starting to edit
|
|
103
|
-
if (justStartedEditingRef.current) return;
|
|
104
|
-
|
|
105
|
-
if (editName.trim() && editName !== project.name && onRename) {
|
|
106
|
-
onRename(editName.trim());
|
|
107
|
-
}
|
|
108
|
-
setIsEditing(false);
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const handleClick = (_e: React.MouseEvent) => {
|
|
112
|
-
if (isEditing) return;
|
|
113
|
-
onClick?.();
|
|
114
|
-
onToggleExpanded?.(!project.expanded);
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const renderMenuItems = (isContextMenu: boolean) => {
|
|
118
|
-
const MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem;
|
|
119
|
-
const MenuSeparator = isContextMenu
|
|
120
|
-
? ContextMenuSeparator
|
|
121
|
-
: DropdownMenuSeparator;
|
|
122
|
-
|
|
123
|
-
return (
|
|
124
|
-
<>
|
|
125
|
-
{onNewSession && (
|
|
126
|
-
<MenuItem onClick={() => onNewSession()}>
|
|
127
|
-
<Plus className="mr-2 h-3 w-3" />
|
|
128
|
-
New session
|
|
129
|
-
</MenuItem>
|
|
130
|
-
)}
|
|
131
|
-
{onOpenTerminal && (
|
|
132
|
-
<MenuItem onClick={() => onOpenTerminal()}>
|
|
133
|
-
<Terminal className="mr-2 h-3 w-3" />
|
|
134
|
-
Open terminal
|
|
135
|
-
</MenuItem>
|
|
136
|
-
)}
|
|
137
|
-
{onEdit && (
|
|
138
|
-
<MenuItem onClick={() => onEdit()}>
|
|
139
|
-
<Settings className="mr-2 h-3 w-3" />
|
|
140
|
-
Project settings
|
|
141
|
-
</MenuItem>
|
|
142
|
-
)}
|
|
143
|
-
{onRename && (
|
|
144
|
-
<MenuItem onClick={() => setIsEditing(true)}>
|
|
145
|
-
<Pencil className="mr-2 h-3 w-3" />
|
|
146
|
-
Rename
|
|
147
|
-
</MenuItem>
|
|
148
|
-
)}
|
|
149
|
-
{onOpenInEditor && (
|
|
150
|
-
<MenuItem onClick={() => onOpenInEditor()}>
|
|
151
|
-
<FolderOpen className="mr-2 h-3 w-3" />
|
|
152
|
-
Open in editor
|
|
153
|
-
</MenuItem>
|
|
154
|
-
)}
|
|
155
|
-
{onStartDevServer && (
|
|
156
|
-
<>
|
|
157
|
-
<MenuSeparator />
|
|
158
|
-
<MenuItem onClick={() => onStartDevServer()}>
|
|
159
|
-
<Server className="mr-2 h-3 w-3" />
|
|
160
|
-
Start dev server
|
|
161
|
-
</MenuItem>
|
|
162
|
-
</>
|
|
163
|
-
)}
|
|
164
|
-
{onDelete && (
|
|
165
|
-
<>
|
|
166
|
-
<MenuSeparator />
|
|
167
|
-
<MenuItem
|
|
168
|
-
onClick={() => onDelete()}
|
|
169
|
-
className="text-red-500 focus:text-red-500"
|
|
170
|
-
>
|
|
171
|
-
<Trash2 className="mr-2 h-3 w-3" />
|
|
172
|
-
Delete project
|
|
173
|
-
</MenuItem>
|
|
174
|
-
</>
|
|
175
|
-
)}
|
|
176
|
-
</>
|
|
177
|
-
);
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const cardContent = (
|
|
181
|
-
<div
|
|
182
|
-
onClick={handleClick}
|
|
183
|
-
className={cn(
|
|
184
|
-
"group flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5",
|
|
185
|
-
"min-h-[36px] md:min-h-[28px]",
|
|
186
|
-
"hover:bg-accent/50"
|
|
187
|
-
)}
|
|
188
|
-
>
|
|
189
|
-
{/* Expand/collapse toggle */}
|
|
190
|
-
<button className="flex-shrink-0 p-0.5">
|
|
191
|
-
{project.expanded ? (
|
|
192
|
-
<ChevronDown className="text-muted-foreground h-4 w-4" />
|
|
193
|
-
) : (
|
|
194
|
-
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
|
195
|
-
)}
|
|
196
|
-
</button>
|
|
197
|
-
|
|
198
|
-
{/* Project name */}
|
|
199
|
-
{isEditing ? (
|
|
200
|
-
<input
|
|
201
|
-
ref={inputRef}
|
|
202
|
-
type="text"
|
|
203
|
-
value={editName}
|
|
204
|
-
onChange={(e) => setEditName(e.target.value)}
|
|
205
|
-
onBlur={handleRename}
|
|
206
|
-
onKeyDown={(e) => {
|
|
207
|
-
if (e.key === "Enter") handleRename();
|
|
208
|
-
if (e.key === "Escape") {
|
|
209
|
-
setEditName(project.name);
|
|
210
|
-
setIsEditing(false);
|
|
211
|
-
}
|
|
212
|
-
}}
|
|
213
|
-
onClick={(e) => e.stopPropagation()}
|
|
214
|
-
className="border-primary min-w-0 flex-1 border-b bg-transparent text-sm font-medium outline-none"
|
|
215
|
-
/>
|
|
216
|
-
) : (
|
|
217
|
-
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
|
218
|
-
{project.name}
|
|
219
|
-
</span>
|
|
220
|
-
)}
|
|
221
|
-
|
|
222
|
-
{/* Running servers indicator */}
|
|
223
|
-
{hasRunningServers && (
|
|
224
|
-
<Tooltip>
|
|
225
|
-
<TooltipTrigger asChild>
|
|
226
|
-
<div className="flex flex-shrink-0 items-center gap-1 text-green-500">
|
|
227
|
-
<Server className="h-3 w-3" />
|
|
228
|
-
<span className="text-xs">{runningDevServers.length}</span>
|
|
229
|
-
</div>
|
|
230
|
-
</TooltipTrigger>
|
|
231
|
-
<TooltipContent>
|
|
232
|
-
<p>
|
|
233
|
-
{runningDevServers.length} dev server
|
|
234
|
-
{runningDevServers.length > 1 ? "s" : ""} running
|
|
235
|
-
</p>
|
|
236
|
-
</TooltipContent>
|
|
237
|
-
</Tooltip>
|
|
238
|
-
)}
|
|
239
|
-
|
|
240
|
-
{/* Session count */}
|
|
241
|
-
<span className="text-muted-foreground flex-shrink-0 text-xs">
|
|
242
|
-
{sessionCount}
|
|
243
|
-
</span>
|
|
244
|
-
|
|
245
|
-
{/* Actions menu */}
|
|
246
|
-
{hasActions && (
|
|
247
|
-
<DropdownMenu>
|
|
248
|
-
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
|
249
|
-
<Button
|
|
250
|
-
variant="ghost"
|
|
251
|
-
size="icon-sm"
|
|
252
|
-
className="h-7 w-7 flex-shrink-0 opacity-100 md:h-6 md:w-6 md:opacity-0 md:group-hover:opacity-100"
|
|
253
|
-
>
|
|
254
|
-
<MoreHorizontal className="h-4 w-4" />
|
|
255
|
-
</Button>
|
|
256
|
-
</DropdownMenuTrigger>
|
|
257
|
-
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
|
258
|
-
{renderMenuItems(false)}
|
|
259
|
-
</DropdownMenuContent>
|
|
260
|
-
</DropdownMenu>
|
|
261
|
-
)}
|
|
262
|
-
</div>
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
// Wrap with context menu if actions are available
|
|
266
|
-
if (hasActions) {
|
|
267
|
-
return (
|
|
268
|
-
<ContextMenu>
|
|
269
|
-
<ContextMenuTrigger asChild>{cardContent}</ContextMenuTrigger>
|
|
270
|
-
<ContextMenuContent>{renderMenuItems(true)}</ContextMenuContent>
|
|
271
|
-
</ContextMenu>
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return cardContent;
|
|
276
|
-
}
|