@hienlh/ppm 0.13.20 → 0.13.22

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{ai-settings-section-DR5BueEL.js → ai-settings-section-DN4egS8e.js} +1 -1
  5. package/dist/web/assets/architecture-PBZL5I3N-CZBayZMd.js +1 -0
  6. package/dist/web/assets/{audio-preview-DwyrUe-V.js → audio-preview-CrTLA4VQ.js} +1 -1
  7. package/dist/web/assets/chat-tab-DFCOXFk8.js +12 -0
  8. package/dist/web/assets/code-editor-J864BoOW.js +8 -0
  9. package/dist/web/assets/{conflict-editor-C8vTvS9w.js → conflict-editor-BIwUtzO5.js} +1 -1
  10. package/dist/web/assets/{csv-preview-Bo-N3GHl.js → csv-preview-BIfojSWd.js} +1 -1
  11. package/dist/web/assets/{data-grid-overlay-editor-DqcDQ9st.js → data-grid-overlay-editor-DZIqEOsz.js} +1 -1
  12. package/dist/web/assets/{database-viewer-_RTlPC26.js → database-viewer-DhawNQtp.js} +1 -1
  13. package/dist/web/assets/{diff-viewer-P2Dc__bQ.js → diff-viewer-nupJr1AG.js} +1 -1
  14. package/dist/web/assets/{esm-Dvc8oJly.js → esm-UZtw2QcY.js} +1 -1
  15. package/dist/web/assets/{extension-webview-CHqtkQBd.js → extension-webview-BXDYtTXe.js} +2 -2
  16. package/dist/web/assets/gitGraph-HDMCJU4V-CboO1wK8.js +1 -0
  17. package/dist/web/assets/{glide-data-grid-9TPVejSQ.js → glide-data-grid-DttB_tob.js} +6 -6
  18. package/dist/web/assets/{image-preview--nh-wHgF.js → image-preview-Dh11TP_j.js} +1 -1
  19. package/dist/web/assets/index-C5sLGvFC.css +2 -0
  20. package/dist/web/assets/index-CPcnZtNl.js +27 -0
  21. package/dist/web/assets/info-3K5VOQVL-D_qKNgUf.js +1 -0
  22. package/dist/web/assets/keybindings-store-DvBC5IaA.js +1 -0
  23. package/dist/web/assets/{markdown-renderer-Bsow9WVr.js → markdown-renderer-Bwpgzn7n.js} +3 -3
  24. package/dist/web/assets/notification-store-D1sxDh0s.js +1 -0
  25. package/dist/web/assets/{number-overlay-editor-XTjjEXtk.js → number-overlay-editor-CewUR5pB.js} +1 -1
  26. package/dist/web/assets/packet-RMMSAZCW-XtGc2GdX.js +1 -0
  27. package/dist/web/assets/{pdf-preview-BqntOcNA.js → pdf-preview-CI-lrcdD.js} +1 -1
  28. package/dist/web/assets/pie-UPGHQEXC-DNZ5YtCW.js +1 -0
  29. package/dist/web/assets/{port-forwarding-tab-WWRLWcTB.js → port-forwarding-tab-DOEfu8ca.js} +1 -1
  30. package/dist/web/assets/{postgres-viewer-g7-3kOzD.js → postgres-viewer-Bb3RwFMj.js} +3 -3
  31. package/dist/web/assets/radar-KQ55EAFF-uCGpAvZE.js +1 -0
  32. package/dist/web/assets/{settings-store-D2MtC9tm.js → settings-store-CVrIYYCB.js} +2 -2
  33. package/dist/web/assets/settings-tab-i8KAi1LY.js +1 -0
  34. package/dist/web/assets/{sql-query-editor-CultKZsI.js → sql-query-editor-C3ZrhqZr.js} +1 -1
  35. package/dist/web/assets/sqlite-viewer-Cucs41S6.js +1 -0
  36. package/dist/web/assets/terminal-tab-upGE8feC.js +1 -0
  37. package/dist/web/assets/treemap-KZPCXAKY-DQvivjBa.js +1 -0
  38. package/dist/web/assets/{use-monaco-theme-CugUkORI.js → use-monaco-theme-BePWbY58.js} +1 -1
  39. package/dist/web/assets/{vendor-mermaid-CPtQ2zua.js → vendor-mermaid-Cl50p6TB.js} +2 -2
  40. package/dist/web/assets/{video-preview-C4PxtiOc.js → video-preview-CSdxf4fH.js} +1 -1
  41. package/dist/web/index.html +6 -6
  42. package/dist/web/sw.js +1 -1
  43. package/package.json +1 -1
  44. package/src/providers/claude-agent-sdk.ts +13 -1
  45. package/src/server/routes/chat.ts +73 -3
  46. package/src/server/routes/database.ts +11 -2
  47. package/src/server/ws/chat.ts +12 -3
  48. package/src/services/autostart-generator.ts +2 -2
  49. package/src/services/autostart-register.ts +6 -3
  50. package/src/services/db.service.ts +41 -1
  51. package/src/services/supervisor.ts +20 -7
  52. package/src/web/app.tsx +12 -0
  53. package/src/web/components/chat/chat-history-bar.tsx +43 -13
  54. package/src/web/components/chat/chat-tab.tsx +3 -0
  55. package/src/web/components/chat/session-list-panel.tsx +15 -8
  56. package/src/web/components/chat/session-picker.tsx +33 -5
  57. package/src/web/components/database/connection-list.tsx +55 -205
  58. package/src/web/components/database/connection-row.tsx +104 -0
  59. package/src/web/components/database/database-sidebar.tsx +1 -1
  60. package/src/web/components/database/schema-table-tree.tsx +98 -0
  61. package/src/web/components/database/use-connections.ts +9 -6
  62. package/src/web/components/layout/command-palette.tsx +10 -2
  63. package/src/web/components/layout/editor-panel.tsx +16 -39
  64. package/src/web/components/layout/tab-pool.tsx +196 -0
  65. package/src/web/hooks/use-chat.ts +9 -2
  66. package/src/web/hooks/use-debounced-value.ts +10 -0
  67. package/src/web/stores/notification-store.ts +42 -0
  68. package/dist/web/assets/architecture-PBZL5I3N-7JKY4P1L.js +0 -1
  69. package/dist/web/assets/chat-tab-DqS9Qk3O.js +0 -12
  70. package/dist/web/assets/code-editor-DwdeigGe.js +0 -8
  71. package/dist/web/assets/gitGraph-HDMCJU4V-Daf9rhiF.js +0 -1
  72. package/dist/web/assets/index-nC9UURj4.css +0 -2
  73. package/dist/web/assets/index-xpTdWKsA.js +0 -27
  74. package/dist/web/assets/info-3K5VOQVL-gn0pjNiT.js +0 -1
  75. package/dist/web/assets/keybindings-store-C0XkvJcm.js +0 -1
  76. package/dist/web/assets/packet-RMMSAZCW-Csaeizjc.js +0 -1
  77. package/dist/web/assets/pie-UPGHQEXC-DatkjxTH.js +0 -1
  78. package/dist/web/assets/radar-KQ55EAFF-BnGB20hR.js +0 -1
  79. package/dist/web/assets/settings-tab-DO3s244B.js +0 -1
  80. package/dist/web/assets/sqlite-viewer-BtYh66b0.js +0 -1
  81. package/dist/web/assets/terminal-tab-C25rc_34.js +0 -1
  82. package/dist/web/assets/treemap-KZPCXAKY-CgEYv38e.js +0 -1
  83. /package/dist/web/assets/{api-settings-DowGyuVy.js → api-settings-DnHv6JgF.js} +0 -0
  84. /package/dist/web/assets/{data-grid-types-DqqspyVw.js → data-grid-types-BISkUXAY.js} +0 -0
  85. /package/dist/web/assets/{dist-_jZs3YZC.js → dist-B1I_4Jtc.js} +0 -0
  86. /package/dist/web/assets/{dist-D1SZxtVS.js → dist-CcDNqGjt.js} +0 -0
  87. /package/dist/web/assets/{katex-DzXRfQ_m.js → katex-Bqvo_ZG0.js} +0 -0
  88. /package/dist/web/assets/{lib-Dub8DlCJ.js → lib-Bu71-TFS.js} +0 -0
  89. /package/dist/web/assets/{use-blob-url-DGY5qKiT.js → use-blob-url-QX-XajU8.js} +0 -0
  90. /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 data = await api.get<SessionListResponse>(`${projectUrl(projectName)}/chat/sessions?limit=${PAGE_SIZE}&offset=0`);
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 data = await api.get<SessionListResponse>(`${projectUrl(projectName)}/chat/sessions?limit=${PAGE_SIZE}&offset=${unpinnedCount}`);
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 sessions by search query + tag
264
- const filteredSessions = sessions.filter((s) => {
265
- if (searchQuery.trim() && !(s.title || "").toLowerCase().includes(searchQuery.toLowerCase())) return false;
266
- if (selectedTagId !== null && s.tag?.id !== selectedTagId) return false;
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={load}
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 && !searchQuery && (
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 data = await api.get<{ sessions: SessionInfo[]; hasMore: boolean }>(`${projectUrl(projectName)}/chat/sessions?limit=${FETCH_SESSIONS_LIMIT}`);
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
- const query = searchQuery.toLowerCase().trim();
70
- const filtered = sessions.filter((s) => {
71
- if (selectedTagId !== null && s.tag?.id !== selectedTagId) return false;
72
- if (query && !(s.title || "").toLowerCase().includes(query)) return false;
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 data = await api.get<{ sessions: SessionInfo[]; hasMore: boolean }>(`${projectUrl(projectName)}/chat/sessions`);
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, loadSessions]);
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, Database, RefreshCw, Pencil, Trash2, Lock, Search, Key, Link2 } from "lucide-react";
3
- import { cn } from "@/lib/utils";
2
+ import { ChevronRight, ChevronDown } from "lucide-react";
4
3
  import type { Connection, CachedTable } from "./use-connections";
5
-
6
- interface ColumnInfo {
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
- try { await onFetchColumns(connId, tableName, schemaName); } catch { /* ignore */ }
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
- // Group connections
81
- const groups: GroupMap = {};
82
- for (const conn of connections) {
83
- const key = conn.group_name ?? "__ungrouped__";
84
- (groups[key] ??= []).push(conn);
85
- }
86
- const groupKeys = Object.keys(groups).sort((a, b) => {
87
- if (a === "__ungrouped__") return 1;
88
- if (b === "__ungrouped__") return -1;
89
- return a.localeCompare(b);
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
- const isExpanded = expandedConns.has(conn.id);
139
- const tables = cachedTables.get(conn.id) ?? [];
140
- const isRefreshing = refreshingIds.has(conn.id);
141
-
142
- const schemas = schemasPerConn.get(conn.id) ?? new Map();
143
- const isSingleSchema = schemas.size <= 1;
144
- const filter = tableFilter.get(conn.id) ?? "";
145
-
146
- return (
147
- <div key={conn.id}>
148
- {/* Connection row */}
149
- <div className={cn("group flex items-center gap-1 py-1 hover:bg-surface-elevated transition-colors", hasGroup ? "pl-3 pr-2" : "px-2")}>
150
- <button
151
- onClick={() => {
152
- toggleConn(conn.id);
153
- if (!expandedConns.has(conn.id) && tables.length === 0) handleRefresh(conn.id);
154
- }}
155
- className="shrink-0 text-text-subtle hover:text-foreground transition-colors"
156
- >
157
- {isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
158
- </button>
159
- <span className="shrink-0 size-2 rounded-full border border-border" style={{ backgroundColor: conn.color ?? "transparent" }} />
160
- <button
161
- className="flex-1 text-left text-xs truncate hover:text-primary transition-colors"
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
- }