@hienlh/ppm 0.5.20 → 0.6.0

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 (69) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/bun.lock +45 -0
  3. package/dist/web/assets/{api-client-BxCvlogn.js → api-client-ANLU-Irq.js} +1 -1
  4. package/dist/web/assets/chat-tab-C24nbKz1.js +7 -0
  5. package/dist/web/assets/{code-editor-0YVgeS1c.js → code-editor-DjIL6ta3.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-CtEmKn4e.js → diff-viewer-BnvcXY3g.js} +1 -1
  7. package/dist/web/assets/{git-graph-DycoowxO.js → git-graph-iAf_zaqe.js} +1 -1
  8. package/dist/web/assets/index-BwLVvoev.js +21 -0
  9. package/dist/web/assets/index-CP_2zE5O.css +2 -0
  10. package/dist/web/assets/{input-Bzyi1GeB.js → input-DV4tynJq.js} +1 -1
  11. package/dist/web/assets/{jsx-runtime-Bzk8w7Zh.js → jsx-runtime-B4BJKQ1u.js} +1 -1
  12. package/dist/web/assets/{markdown-renderer-LHjvxp5Q.js → markdown-renderer-CIfiE3o8.js} +2 -2
  13. package/dist/web/assets/react-WvgCEYPV.js +1 -0
  14. package/dist/web/assets/{rotate-ccw-ZqeedZLA.js → rotate-ccw-BesidNnx.js} +1 -1
  15. package/dist/web/assets/settings-store-BGF8--S9.js +1 -0
  16. package/dist/web/assets/settings-tab-B_QwULcp.js +1 -0
  17. package/dist/web/assets/sqlite-viewer-DpGb3i2g.js +16 -0
  18. package/dist/web/assets/tab-store-L0a7ao4c.js +1 -0
  19. package/dist/web/assets/{terminal-tab-B2QEABNU.js → terminal-tab-4-DINw_B.js} +1 -1
  20. package/dist/web/assets/{use-monaco-theme-BFv4d2_j.js → use-monaco-theme-RFoGvnp0.js} +2 -2
  21. package/dist/web/index.html +9 -8
  22. package/dist/web/sw.js +1 -1
  23. package/docs/codebase-summary.md +96 -61
  24. package/docs/deployment-guide.md +16 -14
  25. package/docs/design-guidelines.md +5 -2
  26. package/docs/project-overview-pdr.md +20 -17
  27. package/docs/project-roadmap.md +35 -23
  28. package/docs/system-architecture.md +27 -18
  29. package/package.json +4 -1
  30. package/src/cli/commands/init.ts +7 -2
  31. package/src/cli/commands/restart.ts +6 -0
  32. package/src/index.ts +9 -1
  33. package/src/providers/claude-agent-sdk.ts +59 -28
  34. package/src/server/index.ts +10 -2
  35. package/src/server/routes/chat.ts +19 -0
  36. package/src/server/routes/project-scoped.ts +2 -0
  37. package/src/server/routes/sqlite.ts +75 -0
  38. package/src/server/routes/tunnel.ts +17 -2
  39. package/src/server/ws/chat.ts +33 -1
  40. package/src/services/config.service.ts +182 -58
  41. package/src/services/db.service.ts +303 -0
  42. package/src/services/push-notification.service.ts +23 -37
  43. package/src/services/session-log.service.ts +12 -24
  44. package/src/services/sqlite.service.ts +144 -0
  45. package/src/web/components/chat/chat-history-bar.tsx +68 -8
  46. package/src/web/components/chat/chat-tab.tsx +10 -1
  47. package/src/web/components/chat/file-picker.tsx +1 -1
  48. package/src/web/components/chat/slash-command-picker.tsx +1 -1
  49. package/src/web/components/explorer/file-tree.tsx +3 -1
  50. package/src/web/components/layout/draggable-tab.tsx +50 -4
  51. package/src/web/components/layout/editor-panel.tsx +1 -0
  52. package/src/web/components/layout/mobile-nav.tsx +2 -2
  53. package/src/web/components/layout/project-bar.tsx +40 -17
  54. package/src/web/components/layout/tab-bar.tsx +16 -1
  55. package/src/web/components/layout/tab-content.tsx +5 -0
  56. package/src/web/components/sqlite/sqlite-data-grid.tsx +165 -0
  57. package/src/web/components/sqlite/sqlite-query-editor.tsx +97 -0
  58. package/src/web/components/sqlite/sqlite-table-list.tsx +48 -0
  59. package/src/web/components/sqlite/sqlite-viewer.tsx +117 -0
  60. package/src/web/components/sqlite/use-sqlite.ts +97 -0
  61. package/src/web/hooks/use-chat.ts +12 -0
  62. package/src/web/stores/tab-store.ts +1 -0
  63. package/dist/web/assets/chat-tab-D_LO6cRM.js +0 -7
  64. package/dist/web/assets/index-82E_pIrH.css +0 -2
  65. package/dist/web/assets/index-y49eIXuR.js +0 -21
  66. package/dist/web/assets/settings-store-DikslxSJ.js +0 -1
  67. package/dist/web/assets/settings-tab-Dt9Sv1zx.js +0 -1
  68. package/dist/web/assets/tab-store-BNgVKR5w.js +0 -1
  69. /package/dist/web/assets/{utils-EM9hC5pN.js → utils-C2KxHr1H.js} +0 -0
@@ -1,5 +1,5 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search } from "lucide-react";
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X } 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";
@@ -55,6 +55,9 @@ export function ChatHistoryBar({
55
55
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
56
56
  const [loading, setLoading] = useState(false);
57
57
  const [searchQuery, setSearchQuery] = useState("");
58
+ const [editingId, setEditingId] = useState<string | null>(null);
59
+ const [editingTitle, setEditingTitle] = useState("");
60
+ const editInputRef = useRef<HTMLInputElement>(null);
58
61
  const openTab = useTabStore((s) => s.openTab);
59
62
 
60
63
  const togglePanel = (panel: PanelType) => {
@@ -94,6 +97,27 @@ export function ChatHistoryBar({
94
97
  }
95
98
  }
96
99
 
100
+ const startEditing = useCallback((session: SessionInfo, e: React.MouseEvent) => {
101
+ e.stopPropagation();
102
+ setEditingId(session.id);
103
+ setEditingTitle(session.title || "");
104
+ setTimeout(() => editInputRef.current?.select(), 0);
105
+ }, []);
106
+
107
+ const saveTitle = useCallback(async () => {
108
+ if (!editingId || !editingTitle.trim() || !projectName) {
109
+ setEditingId(null);
110
+ return;
111
+ }
112
+ try {
113
+ await api.patch(`${projectUrl(projectName)}/chat/sessions/${editingId}`, { title: editingTitle.trim() });
114
+ setSessions((prev) => prev.map((s) => s.id === editingId ? { ...s, title: editingTitle.trim() } : s));
115
+ } catch { /* silent */ }
116
+ setEditingId(null);
117
+ }, [editingId, editingTitle, projectName]);
118
+
119
+ const cancelEditing = useCallback(() => setEditingId(null), []);
120
+
97
121
  // Filter sessions by search query
98
122
  const filteredSessions = searchQuery.trim()
99
123
  ? sessions.filter((s) => (s.title || "").toLowerCase().includes(searchQuery.toLowerCase()))
@@ -202,17 +226,53 @@ export function ChatHistoryBar({
202
226
  </div>
203
227
  ) : (
204
228
  filteredSessions.map((session) => (
205
- <button
229
+ <div
206
230
  key={session.id}
207
- onClick={() => openSession(session)}
208
- className="flex items-center gap-2 w-full px-3 py-1.5 text-left hover:bg-surface-elevated transition-colors"
231
+ className="flex items-center gap-2 w-full px-3 py-1.5 text-left hover:bg-surface-elevated transition-colors group"
209
232
  >
210
233
  <MessageSquare className="size-3 shrink-0 text-text-subtle" />
211
- <span className="text-[11px] truncate flex-1">{session.title || "Untitled"}</span>
212
- {session.updatedAt && (
234
+ {editingId === session.id ? (
235
+ <form
236
+ className="flex items-center gap-1 flex-1 min-w-0"
237
+ onSubmit={(e) => { e.preventDefault(); saveTitle(); }}
238
+ >
239
+ <input
240
+ ref={editInputRef}
241
+ value={editingTitle}
242
+ onChange={(e) => setEditingTitle(e.target.value)}
243
+ onBlur={saveTitle}
244
+ onKeyDown={(e) => { if (e.key === "Escape") cancelEditing(); }}
245
+ className="flex-1 min-w-0 bg-surface-elevated text-[11px] text-text-primary px-1 py-0.5 rounded border border-border outline-none focus:border-primary"
246
+ autoFocus
247
+ />
248
+ <button type="submit" className="p-0.5 text-green-500 hover:text-green-400" onClick={(e) => e.stopPropagation()}>
249
+ <Check className="size-3" />
250
+ </button>
251
+ <button type="button" className="p-0.5 text-text-subtle hover:text-text-secondary" onClick={(e) => { e.stopPropagation(); cancelEditing(); }}>
252
+ <X className="size-3" />
253
+ </button>
254
+ </form>
255
+ ) : (
256
+ <>
257
+ <button
258
+ onClick={() => openSession(session)}
259
+ className="text-[11px] truncate flex-1 text-left"
260
+ >
261
+ {session.title || "Untitled"}
262
+ </button>
263
+ <button
264
+ onClick={(e) => startEditing(session, e)}
265
+ className="p-0.5 rounded text-text-subtle hover:text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity"
266
+ title="Rename session"
267
+ >
268
+ <Pencil className="size-3" />
269
+ </button>
270
+ </>
271
+ )}
272
+ {editingId !== session.id && session.updatedAt && (
213
273
  <span className="text-[10px] text-text-subtle shrink-0">{formatDate(session.updatedAt)}</span>
214
274
  )}
215
- </button>
275
+ </div>
216
276
  ))
217
277
  )}
218
278
  </div>
@@ -70,6 +70,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
70
70
  thinkingWarningThreshold,
71
71
  pendingApproval,
72
72
  contextWindowPct,
73
+ sessionTitle,
73
74
  sendMessage,
74
75
  respondToApproval,
75
76
  cancelStreaming,
@@ -78,6 +79,13 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
78
79
  isConnected,
79
80
  } = useChat(sessionId, providerId, projectName);
80
81
 
82
+ // Update tab title when SDK summary arrives
83
+ useEffect(() => {
84
+ if (tabId && sessionTitle) {
85
+ updateTab(tabId, { title: sessionTitle });
86
+ }
87
+ }, [sessionTitle]); // eslint-disable-line react-hooks/exhaustive-deps
88
+
81
89
  // Auto-send pending message for forked sessions (set by handleFork)
82
90
  const pendingForkMsgRef = useRef(metadata?.pendingMessage as string | undefined);
83
91
  useEffect(() => {
@@ -102,7 +110,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
102
110
  const handleSelectSession = useCallback((session: SessionInfo) => {
103
111
  setSessionId(session.id);
104
112
  setProviderId(session.providerId);
105
- }, []);
113
+ if (tabId) updateTab(tabId, { title: session.title || "Chat" });
114
+ }, [tabId, updateTab]);
106
115
 
107
116
  /** Fork current session and open new tab with the forked session, resending userMessage */
108
117
  const handleFork = useCallback(async (userMessage: string) => {
@@ -88,7 +88,7 @@ export function FilePicker({
88
88
  useEffect(() => {
89
89
  if (!visible) return;
90
90
  const handler = (e: globalThis.KeyboardEvent) => {
91
- handleKeyDown(e);
91
+ if (handleKeyDown(e)) e.stopPropagation();
92
92
  };
93
93
  document.addEventListener("keydown", handler, true);
94
94
  return () => document.removeEventListener("keydown", handler, true);
@@ -82,7 +82,7 @@ export function SlashCommandPicker({
82
82
  useEffect(() => {
83
83
  if (!visible) return;
84
84
  const handler = (e: globalThis.KeyboardEvent) => {
85
- handleKeyDown(e);
85
+ if (handleKeyDown(e)) e.stopPropagation();
86
86
  };
87
87
  document.addEventListener("keydown", handler, true);
88
88
  return () => document.removeEventListener("keydown", handler, true);
@@ -73,8 +73,10 @@ function TreeNode({ node, depth, projectName, onAction, onFileOpen }: TreeNodePr
73
73
  toggleFileSelect(node.path);
74
74
  return;
75
75
  }
76
+ const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
77
+ const isSqlite = ext === "db" || ext === "sqlite" || ext === "sqlite3";
76
78
  openTab({
77
- type: "editor",
79
+ type: isSqlite ? "sqlite" : "editor",
78
80
  title: node.name,
79
81
  metadata: { filePath: node.path, projectName },
80
82
  projectId: projectName,
@@ -1,3 +1,4 @@
1
+ import { useState, useRef, useEffect } from "react";
1
2
  import { X } from "lucide-react";
2
3
  import type { Tab, TabType } from "@/stores/tab-store";
3
4
  import { cn } from "@/lib/utils";
@@ -13,12 +14,33 @@ interface DraggableTabProps {
13
14
  onDragOver: (e: React.DragEvent) => void;
14
15
  onDragEnd: () => void;
15
16
  tabRef: (el: HTMLButtonElement | null) => void;
17
+ /** If provided, double-clicking the title enters inline rename mode */
18
+ onRename?: (newTitle: string) => void;
16
19
  }
17
20
 
18
21
  export function DraggableTab({
19
22
  tab, isActive, icon: Icon, showDropBefore, onSelect, onClose,
20
- onDragStart, onDragOver, onDragEnd, tabRef,
23
+ onDragStart, onDragOver, onDragEnd, tabRef, onRename,
21
24
  }: DraggableTabProps) {
25
+ const [editing, setEditing] = useState(false);
26
+ const [editValue, setEditValue] = useState(tab.title);
27
+ const inputRef = useRef<HTMLInputElement>(null);
28
+
29
+ useEffect(() => {
30
+ if (editing) {
31
+ setEditValue(tab.title);
32
+ setTimeout(() => inputRef.current?.select(), 0);
33
+ }
34
+ }, [editing]); // eslint-disable-line react-hooks/exhaustive-deps
35
+
36
+ const commitRename = () => {
37
+ setEditing(false);
38
+ const trimmed = editValue.trim();
39
+ if (trimmed && trimmed !== tab.title && onRename) {
40
+ onRename(trimmed);
41
+ }
42
+ };
43
+
22
44
  return (
23
45
  <div className="relative flex items-center">
24
46
  {showDropBefore && (
@@ -27,7 +49,7 @@ export function DraggableTab({
27
49
  <button
28
50
  ref={tabRef}
29
51
  data-tab-item
30
- draggable
52
+ draggable={!editing}
31
53
  onClick={onSelect}
32
54
  onAuxClick={(e) => { if (e.button === 1 && tab.closable) { e.preventDefault(); onClose(); } }}
33
55
  onDragStart={onDragStart}
@@ -42,8 +64,32 @@ export function DraggableTab({
42
64
  )}
43
65
  >
44
66
  <Icon className="size-4" />
45
- <span className="max-w-[120px] truncate">{tab.title}</span>
46
- {tab.closable && (
67
+ {editing ? (
68
+ <input
69
+ ref={inputRef}
70
+ value={editValue}
71
+ onChange={(e) => setEditValue(e.target.value)}
72
+ onBlur={commitRename}
73
+ onKeyDown={(e) => {
74
+ if (e.key === "Enter") commitRename();
75
+ if (e.key === "Escape") setEditing(false);
76
+ e.stopPropagation();
77
+ }}
78
+ onClick={(e) => e.stopPropagation()}
79
+ className="max-w-[120px] bg-surface-elevated text-xs px-1 py-0.5 rounded border border-border outline-none focus:border-primary"
80
+ autoFocus
81
+ />
82
+ ) : (
83
+ <span
84
+ className="max-w-[120px] truncate"
85
+ onDoubleClick={(e) => {
86
+ if (onRename) { e.stopPropagation(); setEditing(true); }
87
+ }}
88
+ >
89
+ {tab.title}
90
+ </span>
91
+ )}
92
+ {tab.closable && !editing && (
47
93
  <span
48
94
  role="button"
49
95
  tabIndex={0}
@@ -17,6 +17,7 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
17
17
  terminal: lazy(() => import("@/components/terminal/terminal-tab").then((m) => ({ default: m.TerminalTab }))),
18
18
  chat: lazy(() => import("@/components/chat/chat-tab").then((m) => ({ default: m.ChatTab }))),
19
19
  editor: lazy(() => import("@/components/editor/code-editor").then((m) => ({ default: m.CodeEditor }))),
20
+ sqlite: lazy(() => import("@/components/sqlite/sqlite-viewer").then((m) => ({ default: m.SqliteViewer }))),
20
21
  "git-graph": lazy(() => import("@/components/git/git-graph").then((m) => ({ default: m.GitGraph }))),
21
22
  "git-diff": lazy(() => import("@/components/editor/diff-viewer").then((m) => ({ default: m.DiffViewer }))),
22
23
  settings: lazy(() => import("@/components/settings/settings-tab").then((m) => ({ default: m.SettingsTab }))),
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import {
3
- Terminal, MessageSquare, GitBranch,
3
+ Terminal, MessageSquare, GitBranch, Database,
4
4
  FileDiff, FileCode, Settings, Menu, X, ArrowLeft, ArrowRight, SplitSquareVertical, MoveVertical, Layers, Plus,
5
5
  } from "lucide-react";
6
6
  import { usePanelStore } from "@/stores/panel-store";
@@ -21,7 +21,7 @@ const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
21
21
  const NEW_TAB_LABELS: Partial<Record<TabType, string>> = Object.fromEntries(NEW_TAB_OPTIONS.map((o) => [o.type, o.label]));
22
22
 
23
23
  const TAB_ICONS: Record<TabType, React.ElementType> = {
24
- terminal: Terminal, chat: MessageSquare, editor: FileCode,
24
+ terminal: Terminal, chat: MessageSquare, editor: FileCode, sqlite: Database,
25
25
  "git-graph": GitBranch, "git-diff": FileDiff, settings: Settings,
26
26
  };
27
27
 
@@ -150,10 +150,11 @@ export function ProjectBar() {
150
150
  // Share tunnel
151
151
  const [shareOpen, setShareOpen] = useState(false);
152
152
  const [shareUrl, setShareUrl] = useState<string | null>(null);
153
+ const [localUrl, setLocalUrl] = useState<string | null>(null);
153
154
  const [shareLoading, setShareLoading] = useState(false);
154
155
  const [shareChecking, setShareChecking] = useState(false);
155
156
  const [shareError, setShareError] = useState<string | null>(null);
156
- const [copied, setCopied] = useState(false);
157
+ const [copied, setCopied] = useState<string | null>(null);
157
158
  const shareBtnRef = useRef<HTMLButtonElement>(null);
158
159
  const [popoverPos, setPopoverPos] = useState<{ left: number; bottom: number } | null>(null);
159
160
 
@@ -169,14 +170,14 @@ export function ProjectBar() {
169
170
  setShareOpen(true);
170
171
  setShareError(null);
171
172
  setShareUrl(null);
173
+ setLocalUrl(null);
172
174
  setShareChecking(true);
173
175
 
174
- // Only check existing tunnel, don't auto-start
176
+ // Check existing tunnel + get local IP
175
177
  try {
176
- const status = await api.get<{ active: boolean; url: string | null }>("/api/tunnel");
177
- if (status.active && status.url) {
178
- setShareUrl(status.url);
179
- }
178
+ const status = await api.get<{ active: boolean; url: string | null; localUrl: string | null }>("/api/tunnel");
179
+ if (status.active && status.url) setShareUrl(status.url);
180
+ if (status.localUrl) setLocalUrl(status.localUrl);
180
181
  } catch { /* no existing tunnel */ }
181
182
  setShareChecking(false);
182
183
  }, [shareOpen]);
@@ -194,12 +195,11 @@ export function ProjectBar() {
194
195
  }
195
196
  }, []);
196
197
 
197
- const handleCopyUrl = useCallback(() => {
198
- if (!shareUrl) return;
199
- navigator.clipboard.writeText(shareUrl);
200
- setCopied(true);
201
- setTimeout(() => setCopied(false), 2000);
202
- }, [shareUrl]);
198
+ const handleCopyUrl = useCallback((url: string) => {
199
+ navigator.clipboard.writeText(url);
200
+ setCopied(url);
201
+ setTimeout(() => setCopied(null), 2000);
202
+ }, []);
203
203
 
204
204
  function handleSettings() {
205
205
  const { sidebarCollapsed, toggleSidebar, setSidebarActiveTab } = useSettingsStore.getState();
@@ -334,6 +334,28 @@ export function ProjectBar() {
334
334
  </div>
335
335
  )}
336
336
 
337
+ {/* Local network URL — always show when available */}
338
+ {!shareChecking && localUrl && (
339
+ <div className="space-y-1">
340
+ <span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Local Network</span>
341
+ <div className="flex items-center gap-1">
342
+ <input
343
+ readOnly
344
+ value={localUrl}
345
+ className="flex-1 text-xs font-mono text-foreground bg-muted px-2 py-1.5 rounded border border-border truncate"
346
+ onClick={(e) => (e.target as HTMLInputElement).select()}
347
+ />
348
+ <button
349
+ onClick={() => handleCopyUrl(localUrl)}
350
+ className="flex items-center justify-center size-7 rounded border border-border text-muted-foreground bg-muted hover:bg-accent hover:text-foreground transition-colors shrink-0"
351
+ title="Copy URL"
352
+ >
353
+ {copied === localUrl ? <Check className="size-3.5 text-primary" /> : <Copy className="size-3.5" />}
354
+ </button>
355
+ </div>
356
+ </div>
357
+ )}
358
+
337
359
  {/* No tunnel yet — show start button */}
338
360
  {!shareChecking && !shareUrl && !shareLoading && !shareError && (
339
361
  <div className="space-y-2">
@@ -380,9 +402,10 @@ export function ProjectBar() {
380
402
  </div>
381
403
  )}
382
404
 
383
- {/* Tunnel active — show QR + URL */}
405
+ {/* Tunnel active — show QR + public URL */}
384
406
  {shareUrl && (
385
- <>
407
+ <div className="space-y-1">
408
+ <span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Public (Cloudflare)</span>
386
409
  <div className="flex justify-center">
387
410
  <QRCodeSVG value={shareUrl} size={160} />
388
411
  </div>
@@ -394,14 +417,14 @@ export function ProjectBar() {
394
417
  onClick={(e) => (e.target as HTMLInputElement).select()}
395
418
  />
396
419
  <button
397
- onClick={handleCopyUrl}
420
+ onClick={() => handleCopyUrl(shareUrl)}
398
421
  className="flex items-center justify-center size-7 rounded border border-border text-muted-foreground bg-muted hover:bg-accent hover:text-foreground transition-colors shrink-0"
399
422
  title="Copy URL"
400
423
  >
401
- {copied ? <Check className="size-3.5 text-primary" /> : <Copy className="size-3.5" />}
424
+ {copied === shareUrl ? <Check className="size-3.5 text-primary" /> : <Copy className="size-3.5" />}
402
425
  </button>
403
426
  </div>
404
- </>
427
+ </div>
405
428
  )}
406
429
  </div>
407
430
  </>,
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef } from "react";
1
+ import { useEffect, useRef, useCallback } from "react";
2
2
  import {
3
3
  Plus,
4
4
  Terminal,
@@ -7,18 +7,22 @@ import {
7
7
  FileDiff,
8
8
  Settings,
9
9
  FileCode,
10
+ Database,
10
11
  } from "lucide-react";
11
12
  import { useTabStore, type TabType } from "@/stores/tab-store";
12
13
  import { usePanelStore } from "@/stores/panel-store";
13
14
  import { useProjectStore } from "@/stores/project-store";
14
15
  import { useTabDrag } from "@/hooks/use-tab-drag";
15
16
  import { openCommandPalette } from "@/hooks/use-global-keybindings";
17
+ import { api, projectUrl } from "@/lib/api-client";
16
18
  import { DraggableTab } from "./draggable-tab";
19
+ import type { Tab } from "@/stores/tab-store";
17
20
 
18
21
  const TAB_ICONS: Record<TabType, React.ElementType> = {
19
22
  terminal: Terminal,
20
23
  chat: MessageSquare,
21
24
  editor: FileCode,
25
+ sqlite: Database,
22
26
  "git-graph": GitBranch,
23
27
  "git-diff": FileDiff,
24
28
  settings: Settings,
@@ -52,6 +56,16 @@ export function TabBar({ panelId }: TabBarProps) {
52
56
  prevTabCount.current = tabs.length;
53
57
  }, [tabs.length, activeTabId]);
54
58
 
59
+ /** Rename a chat session tab — calls PATCH API + updates tab store */
60
+ const handleRenameTab = useCallback((tab: Tab, newTitle: string) => {
61
+ useTabStore.getState().updateTab(tab.id, { title: newTitle });
62
+ const pName = tab.metadata?.projectName as string | undefined;
63
+ const sId = tab.metadata?.sessionId as string | undefined;
64
+ if (pName && sId) {
65
+ api.patch(`${projectUrl(pName)}/chat/sessions/${sId}`, { title: newTitle }).catch(() => {});
66
+ }
67
+ }, []);
68
+
55
69
  /** Double-click on empty bar area → open command palette */
56
70
  function handleBarDoubleClick(e: React.MouseEvent) {
57
71
  // Only trigger if clicking directly on the bar or scroll container (not on a tab)
@@ -98,6 +112,7 @@ export function TabBar({ panelId }: TabBarProps) {
98
112
  if (el) tabRefs.current.set(tab.id, el);
99
113
  else tabRefs.current.delete(tab.id);
100
114
  }}
115
+ onRename={tab.type === "chat" ? (title) => handleRenameTab(tab, title) : undefined}
101
116
  />
102
117
  ))}
103
118
  {/* Show drop indicator at the end */}
@@ -18,6 +18,11 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
18
18
  default: m.CodeEditor,
19
19
  })),
20
20
  ),
21
+ sqlite: lazy(() =>
22
+ import("@/components/sqlite/sqlite-viewer").then((m) => ({
23
+ default: m.SqliteViewer,
24
+ })),
25
+ ),
21
26
  "git-graph": lazy(() =>
22
27
  import("@/components/git/git-graph").then((m) => ({
23
28
  default: m.GitGraph,
@@ -0,0 +1,165 @@
1
+ import { useState, useCallback, useMemo } from "react";
2
+ import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from "@tanstack/react-table";
3
+ import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
4
+ import type { ColumnInfo } from "./use-sqlite";
5
+
6
+ interface Props {
7
+ tableData: { columns: string[]; rows: Record<string, unknown>[]; total: number; page: number; limit: number } | null;
8
+ schema: ColumnInfo[];
9
+ loading: boolean;
10
+ page: number;
11
+ onPageChange: (page: number) => void;
12
+ onCellUpdate: (rowid: number, column: string, value: unknown) => void;
13
+ }
14
+
15
+ export function SqliteDataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate }: Props) {
16
+ if (!tableData) {
17
+ return (
18
+ <div className="flex items-center justify-center h-full text-xs text-muted-foreground">
19
+ {loading ? <Loader2 className="size-4 animate-spin" /> : "Select a table"}
20
+ </div>
21
+ );
22
+ }
23
+
24
+ const totalPages = Math.ceil(tableData.total / tableData.limit) || 1;
25
+
26
+ return (
27
+ <div className="flex flex-col h-full overflow-hidden">
28
+ <div className="flex-1 overflow-auto">
29
+ <DataTable
30
+ columns={tableData.columns}
31
+ rows={tableData.rows}
32
+ schema={schema}
33
+ onCellUpdate={onCellUpdate}
34
+ />
35
+ </div>
36
+ {/* Pagination */}
37
+ <div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-background shrink-0 text-xs text-muted-foreground">
38
+ <span>{tableData.total.toLocaleString()} rows</span>
39
+ <div className="flex items-center gap-2">
40
+ <button type="button" disabled={page <= 1} onClick={() => onPageChange(page - 1)}
41
+ className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
42
+ <ChevronLeft className="size-3.5" />
43
+ </button>
44
+ <span>{page} / {totalPages}</span>
45
+ <button type="button" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}
46
+ className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
47
+ <ChevronRight className="size-3.5" />
48
+ </button>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ /** Inner table component with TanStack */
56
+ function DataTable({ columns, rows, schema, onCellUpdate }: {
57
+ columns: string[];
58
+ rows: Record<string, unknown>[];
59
+ schema: ColumnInfo[];
60
+ onCellUpdate: (rowid: number, column: string, value: unknown) => void;
61
+ }) {
62
+ const [editingCell, setEditingCell] = useState<{ rowIdx: number; col: string } | null>(null);
63
+ const [editValue, setEditValue] = useState("");
64
+
65
+ const pkColumns = useMemo(() => new Set(schema.filter((c) => c.pk).map((c) => c.name)), [schema]);
66
+
67
+ const startEdit = useCallback((rowIdx: number, col: string, currentValue: unknown) => {
68
+ if (col === "rowid") return; // Don't edit rowid
69
+ setEditingCell({ rowIdx, col });
70
+ setEditValue(currentValue == null ? "" : String(currentValue));
71
+ }, []);
72
+
73
+ const commitEdit = useCallback(() => {
74
+ if (!editingCell) return;
75
+ const row = rows[editingCell.rowIdx];
76
+ const rowid = row.rowid as number;
77
+ const oldVal = row[editingCell.col];
78
+ if (String(oldVal ?? "") !== editValue) {
79
+ onCellUpdate(rowid, editingCell.col, editValue === "" ? null : editValue);
80
+ }
81
+ setEditingCell(null);
82
+ }, [editingCell, editValue, rows, onCellUpdate]);
83
+
84
+ const cancelEdit = useCallback(() => setEditingCell(null), []);
85
+
86
+ const columnDefs = useMemo<ColumnDef<Record<string, unknown>>[]>(() =>
87
+ columns.map((col) => ({
88
+ id: col,
89
+ accessorFn: (row) => row[col],
90
+ header: () => (
91
+ <span className={`${pkColumns.has(col) ? "font-bold" : ""} ${col === "rowid" ? "text-muted-foreground/50" : ""}`}>
92
+ {col}
93
+ </span>
94
+ ),
95
+ cell: ({ row, getValue }) => {
96
+ const rowIdx = row.index;
97
+ const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.col === col;
98
+ const val = getValue();
99
+
100
+ if (isEditing) {
101
+ return (
102
+ <input
103
+ autoFocus
104
+ className="w-full bg-transparent border border-primary/50 rounded px-1 py-0 text-xs outline-none"
105
+ value={editValue}
106
+ onChange={(e) => setEditValue(e.target.value)}
107
+ onBlur={commitEdit}
108
+ onKeyDown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") cancelEdit(); }}
109
+ />
110
+ );
111
+ }
112
+
113
+ return (
114
+ <span
115
+ className={`cursor-pointer truncate block ${val == null ? "text-muted-foreground/40 italic" : ""} ${col === "rowid" ? "text-muted-foreground/50" : ""}`}
116
+ onDoubleClick={() => startEdit(rowIdx, col, val)}
117
+ title={val == null ? "NULL" : String(val)}
118
+ >
119
+ {val == null ? "NULL" : String(val)}
120
+ </span>
121
+ );
122
+ },
123
+ })),
124
+ [columns, pkColumns, editingCell, editValue, commitEdit, cancelEdit, startEdit]); // eslint-disable-line react-hooks/exhaustive-deps
125
+
126
+ const table = useReactTable({
127
+ data: rows,
128
+ columns: columnDefs,
129
+ getCoreRowModel: getCoreRowModel(),
130
+ });
131
+
132
+ return (
133
+ <table className="w-full text-xs border-collapse">
134
+ <thead className="sticky top-0 z-10 bg-muted">
135
+ {table.getHeaderGroups().map((hg) => (
136
+ <tr key={hg.id}>
137
+ {hg.headers.map((h) => (
138
+ <th key={h.id} className="px-2 py-1.5 text-left font-medium text-muted-foreground border-b border-border whitespace-nowrap">
139
+ {flexRender(h.column.columnDef.header, h.getContext())}
140
+ </th>
141
+ ))}
142
+ </tr>
143
+ ))}
144
+ </thead>
145
+ <tbody>
146
+ {table.getRowModel().rows.map((row) => (
147
+ <tr key={row.id} className="hover:bg-muted/30 border-b border-border/50">
148
+ {row.getVisibleCells().map((cell) => (
149
+ <td key={cell.id} className="px-2 py-1 max-w-[300px]">
150
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
151
+ </td>
152
+ ))}
153
+ </tr>
154
+ ))}
155
+ {rows.length === 0 && (
156
+ <tr>
157
+ <td colSpan={columns.length} className="px-2 py-8 text-center text-muted-foreground">
158
+ No data
159
+ </td>
160
+ </tr>
161
+ )}
162
+ </tbody>
163
+ </table>
164
+ );
165
+ }