@hienlh/ppm 0.13.20 → 0.13.21
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 +7 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-settings-section-DR5BueEL.js → ai-settings-section-DN4egS8e.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-CZBayZMd.js +1 -0
- package/dist/web/assets/{audio-preview-DwyrUe-V.js → audio-preview-Bit1BkEv.js} +1 -1
- package/dist/web/assets/chat-tab-LuR2CwiB.js +12 -0
- package/dist/web/assets/code-editor-DES3rcVN.js +8 -0
- package/dist/web/assets/{conflict-editor-C8vTvS9w.js → conflict-editor-upKOD9uO.js} +1 -1
- package/dist/web/assets/{csv-preview-Bo-N3GHl.js → csv-preview-BIfojSWd.js} +1 -1
- package/dist/web/assets/{data-grid-overlay-editor-DqcDQ9st.js → data-grid-overlay-editor-DZIqEOsz.js} +1 -1
- package/dist/web/assets/{database-viewer-_RTlPC26.js → database-viewer-N6OCfZs9.js} +1 -1
- package/dist/web/assets/{diff-viewer-P2Dc__bQ.js → diff-viewer-B1JmhayU.js} +1 -1
- package/dist/web/assets/{esm-Dvc8oJly.js → esm-UZtw2QcY.js} +1 -1
- package/dist/web/assets/{extension-webview-CHqtkQBd.js → extension-webview-BHHiMswb.js} +2 -2
- package/dist/web/assets/gitGraph-HDMCJU4V-CboO1wK8.js +1 -0
- package/dist/web/assets/{glide-data-grid-9TPVejSQ.js → glide-data-grid-DBN29kPX.js} +6 -6
- package/dist/web/assets/{image-preview--nh-wHgF.js → image-preview-XYXkVEGO.js} +1 -1
- package/dist/web/assets/index-C5sLGvFC.css +2 -0
- package/dist/web/assets/{index-xpTdWKsA.js → index-EaYSB9U9.js} +13 -13
- package/dist/web/assets/info-3K5VOQVL-D_qKNgUf.js +1 -0
- package/dist/web/assets/keybindings-store-fGywATlN.js +1 -0
- package/dist/web/assets/{markdown-renderer-Bsow9WVr.js → markdown-renderer-DSFZBOpD.js} +3 -3
- package/dist/web/assets/notification-store-Dz9dmEg3.js +1 -0
- package/dist/web/assets/{number-overlay-editor-XTjjEXtk.js → number-overlay-editor-CewUR5pB.js} +1 -1
- package/dist/web/assets/packet-RMMSAZCW-XtGc2GdX.js +1 -0
- package/dist/web/assets/{pdf-preview-BqntOcNA.js → pdf-preview-Bz2JkLQ6.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-DNZ5YtCW.js +1 -0
- package/dist/web/assets/{port-forwarding-tab-WWRLWcTB.js → port-forwarding-tab-s0cGnGgx.js} +1 -1
- package/dist/web/assets/{postgres-viewer-g7-3kOzD.js → postgres-viewer-DwELE9sG.js} +3 -3
- package/dist/web/assets/radar-KQ55EAFF-uCGpAvZE.js +1 -0
- package/dist/web/assets/{settings-store-D2MtC9tm.js → settings-store-CVrIYYCB.js} +2 -2
- package/dist/web/assets/settings-tab-D6zXU5c_.js +1 -0
- package/dist/web/assets/{sql-query-editor-CultKZsI.js → sql-query-editor-CMPsQprT.js} +1 -1
- package/dist/web/assets/sqlite-viewer-BL0Z_xor.js +1 -0
- package/dist/web/assets/terminal-tab-CqSN73E-.js +1 -0
- package/dist/web/assets/treemap-KZPCXAKY-DQvivjBa.js +1 -0
- package/dist/web/assets/{use-monaco-theme-CugUkORI.js → use-monaco-theme-BePWbY58.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-CPtQ2zua.js → vendor-mermaid-Cl50p6TB.js} +2 -2
- package/dist/web/assets/{video-preview-C4PxtiOc.js → video-preview-Y5NIrm_u.js} +1 -1
- package/dist/web/index.html +6 -6
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +13 -1
- package/src/server/routes/chat.ts +73 -3
- package/src/server/routes/database.ts +11 -2
- package/src/server/ws/chat.ts +12 -3
- package/src/services/autostart-generator.ts +2 -2
- package/src/services/autostart-register.ts +6 -3
- package/src/services/db.service.ts +41 -1
- package/src/services/supervisor.ts +20 -7
- package/src/web/app.tsx +8 -0
- package/src/web/components/chat/chat-history-bar.tsx +43 -13
- package/src/web/components/chat/chat-tab.tsx +3 -0
- package/src/web/components/chat/session-list-panel.tsx +15 -8
- package/src/web/components/chat/session-picker.tsx +33 -5
- package/src/web/components/database/connection-list.tsx +55 -205
- package/src/web/components/database/connection-row.tsx +104 -0
- package/src/web/components/database/database-sidebar.tsx +1 -1
- package/src/web/components/database/schema-table-tree.tsx +98 -0
- package/src/web/components/database/use-connections.ts +9 -6
- package/src/web/hooks/use-chat.ts +9 -2
- package/src/web/hooks/use-debounced-value.ts +10 -0
- package/src/web/stores/notification-store.ts +42 -0
- package/dist/web/assets/architecture-PBZL5I3N-7JKY4P1L.js +0 -1
- package/dist/web/assets/chat-tab-DqS9Qk3O.js +0 -12
- package/dist/web/assets/code-editor-DwdeigGe.js +0 -8
- package/dist/web/assets/gitGraph-HDMCJU4V-Daf9rhiF.js +0 -1
- package/dist/web/assets/index-nC9UURj4.css +0 -2
- package/dist/web/assets/info-3K5VOQVL-gn0pjNiT.js +0 -1
- package/dist/web/assets/keybindings-store-C0XkvJcm.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-Csaeizjc.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-DatkjxTH.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-BnGB20hR.js +0 -1
- package/dist/web/assets/settings-tab-DO3s244B.js +0 -1
- package/dist/web/assets/sqlite-viewer-BtYh66b0.js +0 -1
- package/dist/web/assets/terminal-tab-C25rc_34.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-CgEYv38e.js +0 -1
- /package/dist/web/assets/{api-settings-DowGyuVy.js → api-settings-DnHv6JgF.js} +0 -0
- /package/dist/web/assets/{data-grid-types-DqqspyVw.js → data-grid-types-BISkUXAY.js} +0 -0
- /package/dist/web/assets/{dist-_jZs3YZC.js → dist-B1I_4Jtc.js} +0 -0
- /package/dist/web/assets/{dist-D1SZxtVS.js → dist-CcDNqGjt.js} +0 -0
- /package/dist/web/assets/{katex-DzXRfQ_m.js → katex-Bqvo_ZG0.js} +0 -0
- /package/dist/web/assets/{lib-Dub8DlCJ.js → lib-Bu71-TFS.js} +0 -0
- /package/dist/web/assets/{use-blob-url-DGY5qKiT.js → use-blob-url-QX-XajU8.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-Dyfw49hJ.js → vendor-xterm-K3_Xwigj.js} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
-
import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff, Trash2, Users, Bot, Tags } from "lucide-react";
|
|
2
|
+
import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff, Trash2, Users, Bot, Tags, CalendarX2 } from "lucide-react";
|
|
3
3
|
import { Activity } from "lucide-react";
|
|
4
4
|
import { api, projectUrl } from "@/lib/api-client";
|
|
5
5
|
import { useTabStore } from "@/stores/tab-store";
|
|
@@ -11,6 +11,7 @@ import { UsageDetailPanel } from "./usage-badge";
|
|
|
11
11
|
import { TeamActivityPanel } from "./team-activity-panel";
|
|
12
12
|
import { ProviderBadge } from "./provider-selector";
|
|
13
13
|
import { formatRelativeDate } from "@/lib/format-date";
|
|
14
|
+
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|
14
15
|
import type { SessionInfo, SessionListResponse, ProjectTag } from "../../../types/chat";
|
|
15
16
|
import type { UsageInfo } from "../../../types/chat";
|
|
16
17
|
import type { TeamMessageItem } from "@/hooks/use-chat";
|
|
@@ -99,6 +100,7 @@ export function ChatHistoryBar({
|
|
|
99
100
|
const hasUnread = useNotificationStore((s) => sessionId ? s.notifications.has(sessionId) : false);
|
|
100
101
|
const clearForSession = useNotificationStore((s) => s.clearForSession);
|
|
101
102
|
const [searchQuery, setSearchQuery] = useState("");
|
|
103
|
+
const debouncedSearch = useDebouncedValue(searchQuery, 300);
|
|
102
104
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
103
105
|
const [editingTitle, setEditingTitle] = useState("");
|
|
104
106
|
const [hasMore, setHasMore] = useState(false);
|
|
@@ -115,11 +117,13 @@ export function ChatHistoryBar({
|
|
|
115
117
|
setActivePanel((prev) => prev === panel ? null : panel);
|
|
116
118
|
};
|
|
117
119
|
|
|
118
|
-
const load = useCallback(async () => {
|
|
120
|
+
const load = useCallback(async (query?: string) => {
|
|
119
121
|
if (!projectName) return;
|
|
120
122
|
setLoading(true);
|
|
121
123
|
try {
|
|
122
|
-
const
|
|
124
|
+
const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: "0" });
|
|
125
|
+
if (query) params.set("q", query);
|
|
126
|
+
const data = await api.get<SessionListResponse>(`${projectUrl(projectName)}/chat/sessions?${params}`);
|
|
123
127
|
setSessions(data.sessions);
|
|
124
128
|
setHasMore(data.hasMore);
|
|
125
129
|
} catch {
|
|
@@ -135,7 +139,9 @@ export function ChatHistoryBar({
|
|
|
135
139
|
try {
|
|
136
140
|
// Offset by count of non-pinned sessions (pinned are injected separately by backend)
|
|
137
141
|
const unpinnedCount = sessions.filter((s) => !s.pinned).length;
|
|
138
|
-
const
|
|
142
|
+
const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(unpinnedCount) });
|
|
143
|
+
if (debouncedSearch) params.set("q", debouncedSearch);
|
|
144
|
+
const data = await api.get<SessionListResponse>(`${projectUrl(projectName)}/chat/sessions?${params}`);
|
|
139
145
|
setSessions((prev) => {
|
|
140
146
|
const existingIds = new Set(prev.map((s) => s.id));
|
|
141
147
|
const newSessions = data.sessions.filter((s) => !existingIds.has(s.id));
|
|
@@ -147,13 +153,18 @@ export function ChatHistoryBar({
|
|
|
147
153
|
} finally {
|
|
148
154
|
setLoadingMore(false);
|
|
149
155
|
}
|
|
150
|
-
}, [projectName, loadingMore, hasMore, sessions]);
|
|
156
|
+
}, [projectName, loadingMore, hasMore, sessions, debouncedSearch]);
|
|
151
157
|
|
|
152
158
|
// Load sessions when history panel opens
|
|
153
159
|
useEffect(() => {
|
|
154
160
|
if (activePanel === "history" && sessions.length === 0) load();
|
|
155
161
|
}, [activePanel]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
156
162
|
|
|
163
|
+
// Re-fetch when debounced search query changes (server-side search)
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (activePanel === "history") load(debouncedSearch || undefined);
|
|
166
|
+
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
167
|
+
|
|
157
168
|
// Fetch tags
|
|
158
169
|
const loadTags = useCallback(async () => {
|
|
159
170
|
if (!projectName) return;
|
|
@@ -242,6 +253,20 @@ export function ChatHistoryBar({
|
|
|
242
253
|
loadTags(); // Refetch counts from API for accuracy
|
|
243
254
|
}, [loadTags]);
|
|
244
255
|
|
|
256
|
+
const bulkDelete = useCallback(async () => {
|
|
257
|
+
if (!projectName) return;
|
|
258
|
+
const days = window.prompt("Delete sessions older than how many days? (pinned sessions are kept)", "30");
|
|
259
|
+
if (!days) return;
|
|
260
|
+
const num = parseInt(days, 10);
|
|
261
|
+
if (!num || num < 1) return;
|
|
262
|
+
if (!window.confirm(`Delete all unpinned sessions older than ${num} days? This cannot be undone.`)) return;
|
|
263
|
+
setLoading(true);
|
|
264
|
+
try {
|
|
265
|
+
await api.del(`${projectUrl(projectName)}/chat/sessions?olderThanDays=${num}`);
|
|
266
|
+
load(debouncedSearch || undefined);
|
|
267
|
+
} catch { /* silent */ }
|
|
268
|
+
}, [projectName, load, debouncedSearch]);
|
|
269
|
+
|
|
245
270
|
// Keyboard shortcuts: 1-9 to assign tags to current session
|
|
246
271
|
useEffect(() => {
|
|
247
272
|
if (activePanel !== "history") return;
|
|
@@ -260,12 +285,10 @@ export function ChatHistoryBar({
|
|
|
260
285
|
return () => window.removeEventListener("keydown", handler);
|
|
261
286
|
}, [activePanel, projectTags, sessionId, projectName, handleTagChanged]);
|
|
262
287
|
|
|
263
|
-
// Filter
|
|
264
|
-
const filteredSessions =
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return true;
|
|
268
|
-
});
|
|
288
|
+
// Filter by tag client-side (search is now server-side via ?q=)
|
|
289
|
+
const filteredSessions = selectedTagId !== null
|
|
290
|
+
? sessions.filter((s) => s.tag?.id === selectedTagId)
|
|
291
|
+
: sessions;
|
|
269
292
|
|
|
270
293
|
// Usage badge display — only meaningful for Claude (SDK) provider
|
|
271
294
|
const isClaudeProvider = !providerId || providerId === "claude";
|
|
@@ -399,7 +422,14 @@ export function ChatHistoryBar({
|
|
|
399
422
|
className="flex-1 bg-transparent text-[11px] text-text-primary outline-none placeholder:text-text-subtle"
|
|
400
423
|
/>
|
|
401
424
|
<button
|
|
402
|
-
onClick={
|
|
425
|
+
onClick={bulkDelete}
|
|
426
|
+
className="p-0.5 rounded text-text-subtle hover:text-red-400 transition-colors"
|
|
427
|
+
title="Delete old sessions..."
|
|
428
|
+
>
|
|
429
|
+
<CalendarX2 className="size-3" />
|
|
430
|
+
</button>
|
|
431
|
+
<button
|
|
432
|
+
onClick={() => load(debouncedSearch || undefined)}
|
|
403
433
|
disabled={loading}
|
|
404
434
|
className="p-0.5 rounded text-text-subtle hover:text-text-secondary transition-colors disabled:opacity-50"
|
|
405
435
|
title="Refresh"
|
|
@@ -543,7 +573,7 @@ export function ChatHistoryBar({
|
|
|
543
573
|
</div>
|
|
544
574
|
</SessionContextMenu>
|
|
545
575
|
))}
|
|
546
|
-
{hasMore &&
|
|
576
|
+
{hasMore && (
|
|
547
577
|
<button
|
|
548
578
|
onClick={loadMore}
|
|
549
579
|
disabled={loadingMore}
|
|
@@ -137,9 +137,12 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
137
137
|
maybeClear();
|
|
138
138
|
document.addEventListener("visibilitychange", maybeClear);
|
|
139
139
|
const unsub = usePanelStore.subscribe(maybeClear);
|
|
140
|
+
// Also auto-clear when notification store changes (cross-tab broadcast may add for active session)
|
|
141
|
+
const unsub2 = useNotificationStore.subscribe(maybeClear);
|
|
140
142
|
return () => {
|
|
141
143
|
document.removeEventListener("visibilitychange", maybeClear);
|
|
142
144
|
unsub();
|
|
145
|
+
unsub2();
|
|
143
146
|
};
|
|
144
147
|
}, [sessionId, tabId]);
|
|
145
148
|
|
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react";
|
|
|
2
2
|
import { ChevronDown, ChevronUp, MessageSquare, Pin, PinOff, Search, X } from "lucide-react";
|
|
3
3
|
import { api, projectUrl } from "@/lib/api-client";
|
|
4
4
|
import { formatRelativeDate } from "@/lib/format-date";
|
|
5
|
+
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|
5
6
|
import { useProjectTags, TagChipBar } from "./tag-filter-chips";
|
|
6
7
|
import { SessionContextMenu } from "./session-context-menu";
|
|
7
8
|
import type { SessionInfo, ProjectTag } from "../../../types/chat";
|
|
@@ -20,14 +21,17 @@ export function SessionListPanel({ projectName, onSelectSession, className }: Se
|
|
|
20
21
|
const [loading, setLoading] = useState(false);
|
|
21
22
|
const [showAll, setShowAll] = useState(false);
|
|
22
23
|
const [searchQuery, setSearchQuery] = useState("");
|
|
24
|
+
const debouncedSearch = useDebouncedValue(searchQuery, 300);
|
|
23
25
|
const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
|
|
24
26
|
const { projectTags, tagCounts, loadTags } = useProjectTags(projectName);
|
|
25
27
|
|
|
26
|
-
const loadSessions = useCallback(async () => {
|
|
28
|
+
const loadSessions = useCallback(async (query?: string) => {
|
|
27
29
|
if (!projectName) return;
|
|
28
30
|
setLoading(true);
|
|
29
31
|
try {
|
|
30
|
-
const
|
|
32
|
+
const params = new URLSearchParams({ limit: String(FETCH_SESSIONS_LIMIT) });
|
|
33
|
+
if (query) params.set("q", query);
|
|
34
|
+
const data = await api.get<{ sessions: SessionInfo[]; hasMore: boolean }>(`${projectUrl(projectName)}/chat/sessions?${params}`);
|
|
31
35
|
setSessions(data.sessions.slice(0, FETCH_SESSIONS_LIMIT));
|
|
32
36
|
} catch {
|
|
33
37
|
// silently ignore
|
|
@@ -38,6 +42,11 @@ export function SessionListPanel({ projectName, onSelectSession, className }: Se
|
|
|
38
42
|
|
|
39
43
|
useEffect(() => { loadSessions(); }, [loadSessions]);
|
|
40
44
|
|
|
45
|
+
// Re-fetch when debounced search query changes
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
loadSessions(debouncedSearch || undefined);
|
|
48
|
+
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
49
|
+
|
|
41
50
|
const togglePin = useCallback(async (e: React.MouseEvent, session: SessionInfo) => {
|
|
42
51
|
e.stopPropagation();
|
|
43
52
|
if (!projectName) return;
|
|
@@ -66,12 +75,10 @@ export function SessionListPanel({ projectName, onSelectSession, className }: Se
|
|
|
66
75
|
loadTags();
|
|
67
76
|
}, [loadTags]);
|
|
68
77
|
|
|
69
|
-
|
|
70
|
-
const filtered =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return true;
|
|
74
|
-
});
|
|
78
|
+
// Tag filter is client-side; search is now server-side via ?q=
|
|
79
|
+
const filtered = selectedTagId !== null
|
|
80
|
+
? sessions.filter((s) => s.tag?.id === selectedTagId)
|
|
81
|
+
: sessions;
|
|
75
82
|
const pinnedSessions = filtered.filter((s) => s.pinned);
|
|
76
83
|
const allRecentSessions = filtered.filter((s) => !s.pinned);
|
|
77
84
|
const recentSessions = showAll ? allRecentSessions : allRecentSessions.slice(0, MAX_RECENT_SESSIONS);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { api, projectUrl } from "@/lib/api-client";
|
|
3
|
-
import { Plus, Trash2, MessageSquare, ChevronDown, Pin, PinOff } from "lucide-react";
|
|
3
|
+
import { Plus, Trash2, MessageSquare, ChevronDown, Pin, PinOff, Search, X } from "lucide-react";
|
|
4
|
+
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|
4
5
|
import { ProviderBadge } from "./provider-selector";
|
|
5
6
|
import type { SessionInfo } from "../../../types/chat";
|
|
6
7
|
|
|
@@ -20,12 +21,16 @@ export function SessionPicker({
|
|
|
20
21
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
21
22
|
const [open, setOpen] = useState(false);
|
|
22
23
|
const [loading, setLoading] = useState(false);
|
|
24
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
25
|
+
const debouncedSearch = useDebouncedValue(searchQuery, 300);
|
|
23
26
|
|
|
24
|
-
const loadSessions = useCallback(async () => {
|
|
27
|
+
const loadSessions = useCallback(async (query?: string) => {
|
|
25
28
|
if (!projectName) return;
|
|
26
29
|
setLoading(true);
|
|
27
30
|
try {
|
|
28
|
-
const
|
|
31
|
+
const params = new URLSearchParams({ limit: "50" });
|
|
32
|
+
if (query) params.set("q", query);
|
|
33
|
+
const data = await api.get<{ sessions: SessionInfo[]; hasMore: boolean }>(`${projectUrl(projectName)}/chat/sessions?${params}`);
|
|
29
34
|
setSessions(data.sessions);
|
|
30
35
|
} catch {
|
|
31
36
|
// Silently fail — sessions list is non-critical
|
|
@@ -40,8 +45,13 @@ export function SessionPicker({
|
|
|
40
45
|
|
|
41
46
|
// Reload when dropdown opens
|
|
42
47
|
useEffect(() => {
|
|
43
|
-
if (open) loadSessions();
|
|
44
|
-
}, [open
|
|
48
|
+
if (open) loadSessions(debouncedSearch || undefined);
|
|
49
|
+
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
50
|
+
|
|
51
|
+
// Re-fetch on search
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (open) loadSessions(debouncedSearch || undefined);
|
|
54
|
+
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
45
55
|
|
|
46
56
|
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
|
47
57
|
|
|
@@ -163,6 +173,24 @@ export function SessionPicker({
|
|
|
163
173
|
<span>New Chat</span>
|
|
164
174
|
</button>
|
|
165
175
|
|
|
176
|
+
{/* Search */}
|
|
177
|
+
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-border">
|
|
178
|
+
<Search className="size-3 text-text-subtle shrink-0" />
|
|
179
|
+
<input
|
|
180
|
+
type="text"
|
|
181
|
+
value={searchQuery}
|
|
182
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
183
|
+
placeholder="Search sessions..."
|
|
184
|
+
className="flex-1 bg-transparent text-xs text-text-primary outline-none placeholder:text-text-subtle"
|
|
185
|
+
autoFocus
|
|
186
|
+
/>
|
|
187
|
+
{searchQuery && (
|
|
188
|
+
<button onClick={() => setSearchQuery("")} className="text-text-subtle hover:text-text-primary">
|
|
189
|
+
<X className="size-3" />
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
|
|
166
194
|
{/* Sessions list */}
|
|
167
195
|
<div className="max-h-60 overflow-y-auto">
|
|
168
196
|
{loading && (
|
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import { useState, useMemo } from "react";
|
|
2
|
-
import { ChevronRight, ChevronDown
|
|
3
|
-
import { cn } from "@/lib/utils";
|
|
2
|
+
import { ChevronRight, ChevronDown } from "lucide-react";
|
|
4
3
|
import type { Connection, CachedTable } from "./use-connections";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
name: string;
|
|
8
|
-
type: string;
|
|
9
|
-
nullable: boolean;
|
|
10
|
-
pk: boolean;
|
|
11
|
-
fk: { table: string; column: string } | null;
|
|
12
|
-
}
|
|
4
|
+
import type { ColumnInfo } from "./schema-table-tree";
|
|
5
|
+
import { ConnectionRow } from "./connection-row";
|
|
13
6
|
|
|
14
7
|
interface ConnectionListProps {
|
|
15
8
|
connections: Connection[];
|
|
@@ -23,10 +16,6 @@ interface ConnectionListProps {
|
|
|
23
16
|
columnCache?: Map<string, ColumnInfo[]>;
|
|
24
17
|
}
|
|
25
18
|
|
|
26
|
-
interface GroupMap {
|
|
27
|
-
[group: string]: Connection[];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
19
|
export function ConnectionList({
|
|
31
20
|
connections, cachedTables, refreshErrors,
|
|
32
21
|
onOpenTable, onRefreshTables, onEdit, onDelete,
|
|
@@ -38,13 +27,15 @@ export function ConnectionList({
|
|
|
38
27
|
const [refreshingIds, setRefreshingIds] = useState<Set<number>>(new Set());
|
|
39
28
|
const [tableFilter, setTableFilter] = useState<Map<number, string>>(new Map());
|
|
40
29
|
const [loadingColumns, setLoadingColumns] = useState<Set<string>>(new Set());
|
|
30
|
+
const [columnErrors, setColumnErrors] = useState<Set<string>>(new Set());
|
|
41
31
|
|
|
42
|
-
const toggleConn = (id: number) => {
|
|
32
|
+
const toggleConn = (id: number, autoRefresh: boolean) => {
|
|
43
33
|
setExpandedConns((prev) => {
|
|
44
34
|
const next = new Set(prev);
|
|
45
35
|
if (next.has(id)) next.delete(id); else next.add(id);
|
|
46
36
|
return next;
|
|
47
37
|
});
|
|
38
|
+
if (autoRefresh) handleRefresh(id);
|
|
48
39
|
};
|
|
49
40
|
|
|
50
41
|
const toggleGroup = (group: string) => {
|
|
@@ -62,10 +53,14 @@ export function ConnectionList({
|
|
|
62
53
|
return;
|
|
63
54
|
}
|
|
64
55
|
setExpandedTables((prev) => new Set(prev).add(key));
|
|
65
|
-
// Lazy load columns if not cached
|
|
66
56
|
if (onFetchColumns && !columnCache?.has(key)) {
|
|
67
57
|
setLoadingColumns((prev) => new Set(prev).add(key));
|
|
68
|
-
|
|
58
|
+
setColumnErrors((prev) => { const n = new Set(prev); n.delete(key); return n; });
|
|
59
|
+
try {
|
|
60
|
+
await onFetchColumns(connId, tableName, schemaName);
|
|
61
|
+
} catch {
|
|
62
|
+
setColumnErrors((prev) => new Set(prev).add(key));
|
|
63
|
+
}
|
|
69
64
|
setLoadingColumns((prev) => { const n = new Set(prev); n.delete(key); return n; });
|
|
70
65
|
}
|
|
71
66
|
};
|
|
@@ -77,19 +72,25 @@ export function ConnectionList({
|
|
|
77
72
|
}
|
|
78
73
|
};
|
|
79
74
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
75
|
+
const handleFilterChange = (connId: number, value: string) => {
|
|
76
|
+
setTableFilter((prev) => new Map(prev).set(connId, value));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// L3 fix: memoize group computation
|
|
80
|
+
const { groups, groupKeys } = useMemo(() => {
|
|
81
|
+
const g: Record<string, Connection[]> = {};
|
|
82
|
+
for (const conn of connections) {
|
|
83
|
+
const key = conn.group_name ?? "__ungrouped__";
|
|
84
|
+
(g[key] ??= []).push(conn);
|
|
85
|
+
}
|
|
86
|
+
const keys = Object.keys(g).sort((a, b) => {
|
|
87
|
+
if (a === "__ungrouped__") return 1;
|
|
88
|
+
if (b === "__ungrouped__") return -1;
|
|
89
|
+
return a.localeCompare(b);
|
|
90
|
+
});
|
|
91
|
+
return { groups: g, groupKeys: keys };
|
|
92
|
+
}, [connections]);
|
|
91
93
|
|
|
92
|
-
// Pre-compute schemas for all connections (hooks can't be inside .map())
|
|
93
94
|
const schemasPerConn = useMemo(() => {
|
|
94
95
|
const result = new Map<number, Map<string, CachedTable[]>>();
|
|
95
96
|
for (const conn of connections) {
|
|
@@ -134,97 +135,31 @@ export function ConnectionList({
|
|
|
134
135
|
|
|
135
136
|
{isGroupExpanded && (
|
|
136
137
|
<div className={hasGroup ? "ml-[11px] border-l border-dashed border-border" : ""}>
|
|
137
|
-
{groupConns.map((conn) =>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
onClick={() => {
|
|
163
|
-
toggleConn(conn.id);
|
|
164
|
-
if (!expandedConns.has(conn.id) && tables.length === 0) handleRefresh(conn.id);
|
|
165
|
-
}}
|
|
166
|
-
>
|
|
167
|
-
{conn.name}
|
|
168
|
-
</button>
|
|
169
|
-
<span className="shrink-0 text-[9px] text-text-subtle uppercase px-1 rounded bg-surface-elevated">
|
|
170
|
-
{conn.type === "postgres" ? "PG" : "DB"}
|
|
171
|
-
</span>
|
|
172
|
-
{conn.readonly === 1 && <span title="Readonly"><Lock className="shrink-0 size-2.5 text-text-subtle" /></span>}
|
|
173
|
-
<div className="flex can-hover:hidden can-hover:group-hover:flex items-center gap-0.5 shrink-0">
|
|
174
|
-
<button onClick={() => handleRefresh(conn.id)} disabled={isRefreshing} className="p-0.5 text-text-subtle hover:text-foreground transition-colors" title="Refresh tables">
|
|
175
|
-
<RefreshCw className={cn("size-3", isRefreshing && "animate-spin")} />
|
|
176
|
-
</button>
|
|
177
|
-
<button onClick={() => onEdit(conn)} className="p-0.5 text-text-subtle hover:text-foreground transition-colors" title="Edit">
|
|
178
|
-
<Pencil className="size-3" />
|
|
179
|
-
</button>
|
|
180
|
-
<button onClick={() => onDelete(conn.id)} className="p-0.5 text-text-subtle hover:text-red-500 transition-colors" title="Delete">
|
|
181
|
-
<Trash2 className="size-3" />
|
|
182
|
-
</button>
|
|
183
|
-
</div>
|
|
184
|
-
</div>
|
|
185
|
-
|
|
186
|
-
{/* Expanded tree: schemas > tables > columns */}
|
|
187
|
-
{isExpanded && (
|
|
188
|
-
<div className="ml-[11px] border-l border-dashed border-border pl-1">
|
|
189
|
-
{isRefreshing && tables.length === 0 && <p className="text-[10px] text-text-subtle px-2 py-1">Loading…</p>}
|
|
190
|
-
{!isRefreshing && tables.length === 0 && (
|
|
191
|
-
refreshErrors?.get(conn.id)
|
|
192
|
-
? <p className="text-[10px] text-red-500 px-2 py-1 break-all">{refreshErrors.get(conn.id)}</p>
|
|
193
|
-
: <p className="text-[10px] text-text-subtle px-2 py-1">No tables cached</p>
|
|
194
|
-
)}
|
|
195
|
-
{tables.length > 0 && (
|
|
196
|
-
<>
|
|
197
|
-
{tables.length > 5 && (
|
|
198
|
-
<div className="flex items-center gap-1 px-2 py-0.5">
|
|
199
|
-
<Search className="size-2.5 text-text-subtle shrink-0" />
|
|
200
|
-
<input
|
|
201
|
-
type="text"
|
|
202
|
-
value={filter}
|
|
203
|
-
onChange={(e) => setTableFilter((prev) => new Map(prev).set(conn.id, e.target.value))}
|
|
204
|
-
placeholder="Filter tables…"
|
|
205
|
-
className="w-full text-[10px] bg-transparent border-none outline-none text-foreground placeholder:text-text-subtle"
|
|
206
|
-
/>
|
|
207
|
-
</div>
|
|
208
|
-
)}
|
|
209
|
-
<SchemaTableTree
|
|
210
|
-
connId={conn.id}
|
|
211
|
-
connType={conn.type}
|
|
212
|
-
schemas={schemas}
|
|
213
|
-
isSingleSchema={isSingleSchema}
|
|
214
|
-
filter={filter}
|
|
215
|
-
expandedTables={expandedTables}
|
|
216
|
-
loadingColumns={loadingColumns}
|
|
217
|
-
columnCache={columnCache}
|
|
218
|
-
onToggleTable={toggleTable}
|
|
219
|
-
onOpenTable={(tableName, schemaName) => onOpenTable(conn, tableName, schemaName)}
|
|
220
|
-
/>
|
|
221
|
-
</>
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
224
|
-
)}
|
|
225
|
-
</div>
|
|
226
|
-
);
|
|
227
|
-
})}
|
|
138
|
+
{groupConns.map((conn) => (
|
|
139
|
+
<ConnectionRow
|
|
140
|
+
key={conn.id}
|
|
141
|
+
conn={conn}
|
|
142
|
+
isExpanded={expandedConns.has(conn.id)}
|
|
143
|
+
isRefreshing={refreshingIds.has(conn.id)}
|
|
144
|
+
tables={cachedTables.get(conn.id) ?? []}
|
|
145
|
+
schemas={schemasPerConn.get(conn.id) ?? new Map()}
|
|
146
|
+
isSingleSchema={(schemasPerConn.get(conn.id)?.size ?? 0) <= 1}
|
|
147
|
+
filter={tableFilter.get(conn.id) ?? ""}
|
|
148
|
+
expandedTables={expandedTables}
|
|
149
|
+
loadingColumns={loadingColumns}
|
|
150
|
+
columnCache={columnCache}
|
|
151
|
+
columnErrors={columnErrors}
|
|
152
|
+
refreshError={refreshErrors?.get(conn.id)}
|
|
153
|
+
hasGroup={hasGroup}
|
|
154
|
+
onToggle={toggleConn}
|
|
155
|
+
onRefresh={handleRefresh}
|
|
156
|
+
onEdit={onEdit}
|
|
157
|
+
onDelete={onDelete}
|
|
158
|
+
onOpenTable={onOpenTable}
|
|
159
|
+
onToggleTable={toggleTable}
|
|
160
|
+
onFilterChange={handleFilterChange}
|
|
161
|
+
/>
|
|
162
|
+
))}
|
|
228
163
|
</div>
|
|
229
164
|
)}
|
|
230
165
|
</div>
|
|
@@ -233,88 +168,3 @@ export function ConnectionList({
|
|
|
233
168
|
</div>
|
|
234
169
|
);
|
|
235
170
|
}
|
|
236
|
-
|
|
237
|
-
/* ---------- Schema > Table > Column tree ---------- */
|
|
238
|
-
|
|
239
|
-
function SchemaTableTree({ connId, connType, schemas, isSingleSchema, filter, expandedTables, loadingColumns, columnCache, onToggleTable, onOpenTable }: {
|
|
240
|
-
connId: number;
|
|
241
|
-
connType: "sqlite" | "postgres";
|
|
242
|
-
schemas: Map<string, CachedTable[]>;
|
|
243
|
-
isSingleSchema: boolean;
|
|
244
|
-
filter: string;
|
|
245
|
-
expandedTables: Set<string>;
|
|
246
|
-
loadingColumns: Set<string>;
|
|
247
|
-
columnCache?: Map<string, ColumnInfo[]>;
|
|
248
|
-
onToggleTable: (connId: number, tableName: string, schemaName: string) => void;
|
|
249
|
-
onOpenTable: (tableName: string, schemaName: string) => void;
|
|
250
|
-
}) {
|
|
251
|
-
const filterLower = filter.toLowerCase();
|
|
252
|
-
|
|
253
|
-
return (
|
|
254
|
-
<div className="overflow-y-auto" style={{ maxHeight: 300 }}>
|
|
255
|
-
{Array.from(schemas.entries()).map(([schemaName, tables]) => {
|
|
256
|
-
const filteredTables = filterLower
|
|
257
|
-
? tables.filter((t) => t.tableName.toLowerCase().includes(filterLower))
|
|
258
|
-
: tables;
|
|
259
|
-
if (filteredTables.length === 0) return null;
|
|
260
|
-
|
|
261
|
-
return (
|
|
262
|
-
<div key={schemaName}>
|
|
263
|
-
{/* Schema label (only for postgres with multiple schemas) */}
|
|
264
|
-
{!isSingleSchema && (
|
|
265
|
-
<p className="px-2 py-0.5 text-[9px] font-semibold text-text-subtle uppercase tracking-wider">{schemaName}</p>
|
|
266
|
-
)}
|
|
267
|
-
{filteredTables.map((t) => {
|
|
268
|
-
const tableKey = `${connId}:${t.schemaName}.${t.tableName}`;
|
|
269
|
-
const isTableExpanded = expandedTables.has(tableKey);
|
|
270
|
-
const isLoadingCols = loadingColumns.has(tableKey);
|
|
271
|
-
const columns = columnCache?.get(tableKey);
|
|
272
|
-
|
|
273
|
-
return (
|
|
274
|
-
<div key={tableKey}>
|
|
275
|
-
{/* Table row */}
|
|
276
|
-
<div className="flex items-center gap-1 pl-2 pr-2 py-0.5 hover:bg-surface-elevated transition-colors group/table">
|
|
277
|
-
<button
|
|
278
|
-
onClick={() => onToggleTable(connId, t.tableName, t.schemaName)}
|
|
279
|
-
className="shrink-0 text-text-subtle hover:text-foreground transition-colors"
|
|
280
|
-
>
|
|
281
|
-
{isTableExpanded ? <ChevronDown className="size-2.5" /> : <ChevronRight className="size-2.5" />}
|
|
282
|
-
</button>
|
|
283
|
-
<Database className="size-2.5 shrink-0 text-text-subtle" />
|
|
284
|
-
<button
|
|
285
|
-
onClick={() => onOpenTable(t.tableName, t.schemaName)}
|
|
286
|
-
className="flex-1 text-left text-[11px] text-text-secondary hover:text-foreground transition-colors truncate"
|
|
287
|
-
>
|
|
288
|
-
{t.tableName}
|
|
289
|
-
</button>
|
|
290
|
-
<span className="text-[9px] text-text-subtle">{t.rowCount}</span>
|
|
291
|
-
</div>
|
|
292
|
-
|
|
293
|
-
{/* Columns (lazy loaded) */}
|
|
294
|
-
{isTableExpanded && (
|
|
295
|
-
<div className="ml-[18px] border-l border-dotted border-border pl-2">
|
|
296
|
-
{isLoadingCols && <p className="text-[9px] text-text-subtle px-1 py-0.5">Loading…</p>}
|
|
297
|
-
{columns && columns.map((col) => (
|
|
298
|
-
<div key={col.name} className="flex items-center gap-1 px-1 py-px text-[10px] text-text-subtle" title={col.fk ? `FK → ${col.fk.table}.${col.fk.column}` : undefined}>
|
|
299
|
-
{col.pk && <Key className="size-2.5 text-amber-500 shrink-0" />}
|
|
300
|
-
{col.fk && <Link2 className="size-2.5 text-blue-400 shrink-0" />}
|
|
301
|
-
{!col.pk && !col.fk && <span className="size-2.5 shrink-0" />}
|
|
302
|
-
<span className="truncate">{col.name}{col.nullable ? "?" : ""}</span>
|
|
303
|
-
<span className="ml-auto text-[9px] text-text-subtle/60 shrink-0">{col.type}</span>
|
|
304
|
-
</div>
|
|
305
|
-
))}
|
|
306
|
-
{!isLoadingCols && !columns && <p className="text-[9px] text-text-subtle px-1 py-0.5">No columns</p>}
|
|
307
|
-
</div>
|
|
308
|
-
)}
|
|
309
|
-
</div>
|
|
310
|
-
);
|
|
311
|
-
})}
|
|
312
|
-
</div>
|
|
313
|
-
);
|
|
314
|
-
})}
|
|
315
|
-
{filter && Array.from(schemas.values()).every((t) => !t.some((x) => x.tableName.toLowerCase().includes(filterLower))) && (
|
|
316
|
-
<p className="text-[10px] text-text-subtle px-2 py-1">No match</p>
|
|
317
|
-
)}
|
|
318
|
-
</div>
|
|
319
|
-
);
|
|
320
|
-
}
|