@hienlh/ppm 0.5.21 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/bun.lock +45 -0
- package/dist/web/assets/{api-client-BxCvlogn.js → api-client-ANLU-Irq.js} +1 -1
- package/dist/web/assets/chat-tab-CjKO_uYf.js +7 -0
- package/dist/web/assets/code-editor-CCvD-8SS.js +1 -0
- package/dist/web/assets/{diff-viewer-CwMGJLkZ.js → diff-viewer-D_bM4Kmw.js} +1 -1
- package/dist/web/assets/{git-graph-HUZNEwuR.js → git-graph-zmdDLInW.js} +1 -1
- package/dist/web/assets/index-CP_2zE5O.css +2 -0
- package/dist/web/assets/index-l7z-nYoz.js +21 -0
- package/dist/web/assets/{input-Bzyi1GeB.js → input-DV4tynJq.js} +1 -1
- package/dist/web/assets/{jsx-runtime-Bzk8w7Zh.js → jsx-runtime-B4BJKQ1u.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DhYu0Drk.js → markdown-renderer-BKfKwtec.js} +2 -2
- package/dist/web/assets/react-WvgCEYPV.js +1 -0
- package/dist/web/assets/{rotate-ccw-ZqeedZLA.js → rotate-ccw-BesidNnx.js} +1 -1
- package/dist/web/assets/settings-store-BGF8--S9.js +1 -0
- package/dist/web/assets/settings-tab-CP5UZGRD.js +1 -0
- package/dist/web/assets/sqlite-viewer-C1MIuoOX.js +16 -0
- package/dist/web/assets/tab-store-L0a7ao4c.js +1 -0
- package/dist/web/assets/{terminal-tab-DhPMvT7b.js → terminal-tab-CmdZtyZW.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-BFv4d2_j.js → use-monaco-theme-RFoGvnp0.js} +2 -2
- package/dist/web/index.html +9 -8
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +96 -61
- package/docs/deployment-guide.md +16 -14
- package/docs/design-guidelines.md +5 -2
- package/docs/project-overview-pdr.md +20 -17
- package/docs/project-roadmap.md +35 -23
- package/docs/system-architecture.md +27 -18
- package/package.json +4 -1
- package/src/cli/commands/init.ts +7 -2
- package/src/cli/commands/restart.ts +6 -0
- package/src/index.ts +9 -1
- package/src/providers/claude-agent-sdk.ts +59 -28
- package/src/server/index.ts +10 -2
- package/src/server/routes/chat.ts +19 -0
- package/src/server/routes/project-scoped.ts +2 -0
- package/src/server/routes/sqlite.ts +75 -0
- package/src/server/ws/chat.ts +33 -1
- package/src/services/config.service.ts +182 -58
- package/src/services/db.service.ts +303 -0
- package/src/services/push-notification.service.ts +23 -37
- package/src/services/session-log.service.ts +12 -24
- package/src/services/sqlite.service.ts +145 -0
- package/src/web/components/chat/chat-history-bar.tsx +68 -8
- package/src/web/components/chat/chat-tab.tsx +10 -1
- package/src/web/components/chat/file-picker.tsx +1 -1
- package/src/web/components/chat/slash-command-picker.tsx +1 -1
- package/src/web/components/editor/code-editor.tsx +8 -0
- package/src/web/components/explorer/file-tree.tsx +3 -1
- package/src/web/components/layout/draggable-tab.tsx +50 -4
- package/src/web/components/layout/editor-panel.tsx +1 -0
- package/src/web/components/layout/mobile-nav.tsx +2 -2
- package/src/web/components/layout/tab-bar.tsx +16 -1
- package/src/web/components/layout/tab-content.tsx +5 -0
- package/src/web/components/sqlite/sqlite-data-grid.tsx +166 -0
- package/src/web/components/sqlite/sqlite-query-editor.tsx +97 -0
- package/src/web/components/sqlite/sqlite-table-list.tsx +48 -0
- package/src/web/components/sqlite/sqlite-viewer.tsx +117 -0
- package/src/web/components/sqlite/use-sqlite.ts +97 -0
- package/src/web/hooks/use-chat.ts +12 -0
- package/src/web/stores/tab-store.ts +1 -0
- package/dist/web/assets/chat-tab-ClNqZsi6.js +0 -7
- package/dist/web/assets/code-editor-kXJmlnIt.js +0 -1
- package/dist/web/assets/index-B1ga7VY4.js +0 -21
- package/dist/web/assets/index-c5tJni8Z.css +0 -2
- package/dist/web/assets/settings-store-DikslxSJ.js +0 -1
- package/dist/web/assets/settings-tab-Dt3jaLUC.js +0 -1
- package/dist/web/assets/tab-store-BNgVKR5w.js +0 -1
- /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
|
-
<
|
|
229
|
+
<div
|
|
206
230
|
key={session.id}
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
</
|
|
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);
|
|
@@ -11,6 +11,8 @@ import { Loader2, FileWarning, ExternalLink, Code, Eye, WrapText } from "lucide-
|
|
|
11
11
|
|
|
12
12
|
/** Image extensions renderable inline */
|
|
13
13
|
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico"]);
|
|
14
|
+
/** SQLite extensions — redirect to sqlite viewer */
|
|
15
|
+
const SQLITE_EXTS = new Set(["db", "sqlite", "sqlite3"]);
|
|
14
16
|
|
|
15
17
|
function getFileExt(filename: string): string {
|
|
16
18
|
return filename.split(".").pop()?.toLowerCase() ?? "";
|
|
@@ -54,9 +56,15 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
|
|
|
54
56
|
const ext = filePath ? getFileExt(filePath) : "";
|
|
55
57
|
const isImage = IMAGE_EXTS.has(ext);
|
|
56
58
|
const isPdf = ext === "pdf";
|
|
59
|
+
const isSqlite = SQLITE_EXTS.has(ext);
|
|
57
60
|
const isMarkdown = ext === "md" || ext === "mdx";
|
|
58
61
|
const [mdMode, setMdMode] = useState<"edit" | "preview">("preview");
|
|
59
62
|
|
|
63
|
+
// Redirect .db files to sqlite viewer by changing tab type
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (isSqlite && tabId) updateTab(tabId, { type: "sqlite" });
|
|
66
|
+
}, [isSqlite, tabId, updateTab]);
|
|
67
|
+
|
|
60
68
|
// Detect external (absolute) file path — not relative to project
|
|
61
69
|
const isExternalFile = filePath ? /^(\/|[A-Za-z]:[/\\])/.test(filePath) : false;
|
|
62
70
|
|
|
@@ -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
|
-
|
|
46
|
-
|
|
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,166 @@
|
|
|
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
|
+
if (!row) return;
|
|
77
|
+
const rowid = row.rowid as number;
|
|
78
|
+
const oldVal = row[editingCell.col];
|
|
79
|
+
if (String(oldVal ?? "") !== editValue) {
|
|
80
|
+
onCellUpdate(rowid, editingCell.col, editValue === "" ? null : editValue);
|
|
81
|
+
}
|
|
82
|
+
setEditingCell(null);
|
|
83
|
+
}, [editingCell, editValue, rows, onCellUpdate]);
|
|
84
|
+
|
|
85
|
+
const cancelEdit = useCallback(() => setEditingCell(null), []);
|
|
86
|
+
|
|
87
|
+
const columnDefs = useMemo<ColumnDef<Record<string, unknown>>[]>(() =>
|
|
88
|
+
columns.map((col) => ({
|
|
89
|
+
id: col,
|
|
90
|
+
accessorFn: (row) => row[col],
|
|
91
|
+
header: () => (
|
|
92
|
+
<span className={`${pkColumns.has(col) ? "font-bold" : ""} ${col === "rowid" ? "text-muted-foreground/50" : ""}`}>
|
|
93
|
+
{col}
|
|
94
|
+
</span>
|
|
95
|
+
),
|
|
96
|
+
cell: ({ row, getValue }) => {
|
|
97
|
+
const rowIdx = row.index;
|
|
98
|
+
const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.col === col;
|
|
99
|
+
const val = getValue();
|
|
100
|
+
|
|
101
|
+
if (isEditing) {
|
|
102
|
+
return (
|
|
103
|
+
<input
|
|
104
|
+
autoFocus
|
|
105
|
+
className="w-full bg-transparent border border-primary/50 rounded px-1 py-0 text-xs outline-none"
|
|
106
|
+
value={editValue}
|
|
107
|
+
onChange={(e) => setEditValue(e.target.value)}
|
|
108
|
+
onBlur={commitEdit}
|
|
109
|
+
onKeyDown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") cancelEdit(); }}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<span
|
|
116
|
+
className={`cursor-pointer truncate block ${val == null ? "text-muted-foreground/40 italic" : ""} ${col === "rowid" ? "text-muted-foreground/50" : ""}`}
|
|
117
|
+
onDoubleClick={() => startEdit(rowIdx, col, val)}
|
|
118
|
+
title={val == null ? "NULL" : String(val)}
|
|
119
|
+
>
|
|
120
|
+
{val == null ? "NULL" : String(val)}
|
|
121
|
+
</span>
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
})),
|
|
125
|
+
[columns, pkColumns, editingCell, editValue, commitEdit, cancelEdit, startEdit]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
126
|
+
|
|
127
|
+
const table = useReactTable({
|
|
128
|
+
data: rows,
|
|
129
|
+
columns: columnDefs,
|
|
130
|
+
getCoreRowModel: getCoreRowModel(),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<table className="w-full text-xs border-collapse">
|
|
135
|
+
<thead className="sticky top-0 z-10 bg-muted">
|
|
136
|
+
{table.getHeaderGroups().map((hg) => (
|
|
137
|
+
<tr key={hg.id}>
|
|
138
|
+
{hg.headers.map((h) => (
|
|
139
|
+
<th key={h.id} className="px-2 py-1.5 text-left font-medium text-muted-foreground border-b border-border whitespace-nowrap">
|
|
140
|
+
{flexRender(h.column.columnDef.header, h.getContext())}
|
|
141
|
+
</th>
|
|
142
|
+
))}
|
|
143
|
+
</tr>
|
|
144
|
+
))}
|
|
145
|
+
</thead>
|
|
146
|
+
<tbody>
|
|
147
|
+
{table.getRowModel().rows.map((row) => (
|
|
148
|
+
<tr key={row.id} className="hover:bg-muted/30 border-b border-border/50">
|
|
149
|
+
{row.getVisibleCells().map((cell) => (
|
|
150
|
+
<td key={cell.id} className="px-2 py-1 max-w-[300px]">
|
|
151
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
152
|
+
</td>
|
|
153
|
+
))}
|
|
154
|
+
</tr>
|
|
155
|
+
))}
|
|
156
|
+
{rows.length === 0 && (
|
|
157
|
+
<tr>
|
|
158
|
+
<td colSpan={columns.length} className="px-2 py-8 text-center text-muted-foreground">
|
|
159
|
+
No data
|
|
160
|
+
</td>
|
|
161
|
+
</tr>
|
|
162
|
+
)}
|
|
163
|
+
</tbody>
|
|
164
|
+
</table>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -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
|
+
}
|