@hienlh/ppm 0.5.21 → 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 (66) hide show
  1. package/bun.lock +45 -0
  2. package/dist/web/assets/{api-client-BxCvlogn.js → api-client-ANLU-Irq.js} +1 -1
  3. package/dist/web/assets/chat-tab-C24nbKz1.js +7 -0
  4. package/dist/web/assets/{code-editor-kXJmlnIt.js → code-editor-DjIL6ta3.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-CwMGJLkZ.js → diff-viewer-BnvcXY3g.js} +1 -1
  6. package/dist/web/assets/{git-graph-HUZNEwuR.js → git-graph-iAf_zaqe.js} +1 -1
  7. package/dist/web/assets/index-BwLVvoev.js +21 -0
  8. package/dist/web/assets/index-CP_2zE5O.css +2 -0
  9. package/dist/web/assets/{input-Bzyi1GeB.js → input-DV4tynJq.js} +1 -1
  10. package/dist/web/assets/{jsx-runtime-Bzk8w7Zh.js → jsx-runtime-B4BJKQ1u.js} +1 -1
  11. package/dist/web/assets/{markdown-renderer-DhYu0Drk.js → markdown-renderer-CIfiE3o8.js} +2 -2
  12. package/dist/web/assets/react-WvgCEYPV.js +1 -0
  13. package/dist/web/assets/{rotate-ccw-ZqeedZLA.js → rotate-ccw-BesidNnx.js} +1 -1
  14. package/dist/web/assets/settings-store-BGF8--S9.js +1 -0
  15. package/dist/web/assets/settings-tab-B_QwULcp.js +1 -0
  16. package/dist/web/assets/sqlite-viewer-DpGb3i2g.js +16 -0
  17. package/dist/web/assets/tab-store-L0a7ao4c.js +1 -0
  18. package/dist/web/assets/{terminal-tab-DhPMvT7b.js → terminal-tab-4-DINw_B.js} +1 -1
  19. package/dist/web/assets/{use-monaco-theme-BFv4d2_j.js → use-monaco-theme-RFoGvnp0.js} +2 -2
  20. package/dist/web/index.html +9 -8
  21. package/dist/web/sw.js +1 -1
  22. package/docs/codebase-summary.md +96 -61
  23. package/docs/deployment-guide.md +16 -14
  24. package/docs/design-guidelines.md +5 -2
  25. package/docs/project-overview-pdr.md +20 -17
  26. package/docs/project-roadmap.md +35 -23
  27. package/docs/system-architecture.md +27 -18
  28. package/package.json +4 -1
  29. package/src/cli/commands/init.ts +7 -2
  30. package/src/cli/commands/restart.ts +6 -0
  31. package/src/index.ts +9 -1
  32. package/src/providers/claude-agent-sdk.ts +59 -28
  33. package/src/server/index.ts +10 -2
  34. package/src/server/routes/chat.ts +19 -0
  35. package/src/server/routes/project-scoped.ts +2 -0
  36. package/src/server/routes/sqlite.ts +75 -0
  37. package/src/server/ws/chat.ts +33 -1
  38. package/src/services/config.service.ts +182 -58
  39. package/src/services/db.service.ts +303 -0
  40. package/src/services/push-notification.service.ts +23 -37
  41. package/src/services/session-log.service.ts +12 -24
  42. package/src/services/sqlite.service.ts +144 -0
  43. package/src/web/components/chat/chat-history-bar.tsx +68 -8
  44. package/src/web/components/chat/chat-tab.tsx +10 -1
  45. package/src/web/components/chat/file-picker.tsx +1 -1
  46. package/src/web/components/chat/slash-command-picker.tsx +1 -1
  47. package/src/web/components/explorer/file-tree.tsx +3 -1
  48. package/src/web/components/layout/draggable-tab.tsx +50 -4
  49. package/src/web/components/layout/editor-panel.tsx +1 -0
  50. package/src/web/components/layout/mobile-nav.tsx +2 -2
  51. package/src/web/components/layout/tab-bar.tsx +16 -1
  52. package/src/web/components/layout/tab-content.tsx +5 -0
  53. package/src/web/components/sqlite/sqlite-data-grid.tsx +165 -0
  54. package/src/web/components/sqlite/sqlite-query-editor.tsx +97 -0
  55. package/src/web/components/sqlite/sqlite-table-list.tsx +48 -0
  56. package/src/web/components/sqlite/sqlite-viewer.tsx +117 -0
  57. package/src/web/components/sqlite/use-sqlite.ts +97 -0
  58. package/src/web/hooks/use-chat.ts +12 -0
  59. package/src/web/stores/tab-store.ts +1 -0
  60. package/dist/web/assets/chat-tab-ClNqZsi6.js +0 -7
  61. package/dist/web/assets/index-B1ga7VY4.js +0 -21
  62. package/dist/web/assets/index-c5tJni8Z.css +0 -2
  63. package/dist/web/assets/settings-store-DikslxSJ.js +0 -1
  64. package/dist/web/assets/settings-tab-Dt3jaLUC.js +0 -1
  65. package/dist/web/assets/tab-store-BNgVKR5w.js +0 -1
  66. /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
 
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ import { useState, useCallback } from "react";
2
+ import CodeMirror from "@uiw/react-codemirror";
3
+ import { sql, SQLite } from "@codemirror/lang-sql";
4
+ import { Play, Loader2 } from "lucide-react";
5
+ import type { QueryResult } from "./use-sqlite";
6
+
7
+ interface Props {
8
+ onExecute: (sql: string) => void;
9
+ result: QueryResult | null;
10
+ error: string | null;
11
+ loading: boolean;
12
+ }
13
+
14
+ export function SqliteQueryEditor({ onExecute, result, error, loading }: Props) {
15
+ const [query, setQuery] = useState("SELECT * FROM ");
16
+
17
+ const handleExecute = useCallback(() => {
18
+ const trimmed = query.trim();
19
+ if (!trimmed) return;
20
+ onExecute(trimmed);
21
+ }, [query, onExecute]);
22
+
23
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
24
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
25
+ e.preventDefault();
26
+ handleExecute();
27
+ }
28
+ }, [handleExecute]);
29
+
30
+ return (
31
+ <div className="flex flex-col h-full overflow-hidden">
32
+ {/* Editor area */}
33
+ <div className="flex items-start gap-1 border-b border-border bg-background" onKeyDown={handleKeyDown}>
34
+ <div className="flex-1 max-h-[120px] overflow-auto">
35
+ <CodeMirror
36
+ value={query}
37
+ onChange={setQuery}
38
+ extensions={[sql({ dialect: SQLite })]}
39
+ basicSetup={{ lineNumbers: false, foldGutter: false, highlightActiveLine: false }}
40
+ className="text-xs [&_.cm-editor]:!outline-none [&_.cm-scroller]:!overflow-auto"
41
+ />
42
+ </div>
43
+ <button
44
+ type="button"
45
+ onClick={handleExecute}
46
+ disabled={loading}
47
+ className="shrink-0 m-1 p-1.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
48
+ title="Execute (Cmd+Enter)"
49
+ >
50
+ {loading ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
51
+ </button>
52
+ </div>
53
+
54
+ {/* Results area */}
55
+ <div className="flex-1 overflow-auto text-xs">
56
+ {error && (
57
+ <div className="px-3 py-2 text-destructive bg-destructive/5">{error}</div>
58
+ )}
59
+
60
+ {result && result.changeType === "modify" && (
61
+ <div className="px-3 py-2 text-green-500">
62
+ Query executed. {result.rowsAffected} row(s) affected.
63
+ </div>
64
+ )}
65
+
66
+ {result && result.changeType === "select" && result.rows.length > 0 && (
67
+ <table className="w-full border-collapse">
68
+ <thead className="sticky top-0 bg-muted">
69
+ <tr>
70
+ {result.columns.map((col) => (
71
+ <th key={col} className="px-2 py-1 text-left font-medium text-muted-foreground border-b border-border whitespace-nowrap">
72
+ {col}
73
+ </th>
74
+ ))}
75
+ </tr>
76
+ </thead>
77
+ <tbody>
78
+ {result.rows.map((row, i) => (
79
+ <tr key={i} className="hover:bg-muted/30 border-b border-border/50">
80
+ {result.columns.map((col) => (
81
+ <td key={col} className="px-2 py-1 max-w-[300px] truncate" title={row[col] == null ? "NULL" : String(row[col])}>
82
+ {row[col] == null ? <span className="text-muted-foreground/40 italic">NULL</span> : String(row[col])}
83
+ </td>
84
+ ))}
85
+ </tr>
86
+ ))}
87
+ </tbody>
88
+ </table>
89
+ )}
90
+
91
+ {result && result.changeType === "select" && result.rows.length === 0 && (
92
+ <div className="px-3 py-2 text-muted-foreground">No results</div>
93
+ )}
94
+ </div>
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,48 @@
1
+ import { Table, RefreshCw } from "lucide-react";
2
+ import type { TableInfo } from "./use-sqlite";
3
+
4
+ interface Props {
5
+ tables: TableInfo[];
6
+ selectedTable: string | null;
7
+ onSelect: (name: string) => void;
8
+ onRefresh: () => void;
9
+ }
10
+
11
+ export function SqliteTableList({ tables, selectedTable, onSelect, onRefresh }: Props) {
12
+ return (
13
+ <div className="w-48 shrink-0 flex flex-col bg-background overflow-hidden">
14
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
15
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Tables</span>
16
+ <button
17
+ type="button"
18
+ onClick={onRefresh}
19
+ className="text-muted-foreground hover:text-foreground transition-colors"
20
+ title="Refresh tables"
21
+ >
22
+ <RefreshCw className="size-3" />
23
+ </button>
24
+ </div>
25
+ <div className="flex-1 overflow-y-auto">
26
+ {tables.map((t) => (
27
+ <button
28
+ key={t.name}
29
+ type="button"
30
+ onClick={() => onSelect(t.name)}
31
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors ${
32
+ selectedTable === t.name
33
+ ? "bg-muted text-foreground"
34
+ : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
35
+ }`}
36
+ >
37
+ <Table className="size-3 shrink-0" />
38
+ <span className="truncate flex-1">{t.name}</span>
39
+ <span className="text-[10px] opacity-60">{t.rowCount}</span>
40
+ </button>
41
+ ))}
42
+ {tables.length === 0 && (
43
+ <p className="px-3 py-4 text-xs text-muted-foreground text-center">No tables found</p>
44
+ )}
45
+ </div>
46
+ </div>
47
+ );
48
+ }