@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.
- package/CHANGELOG.md +6 -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-C24nbKz1.js +7 -0
- package/dist/web/assets/{code-editor-0YVgeS1c.js → code-editor-DjIL6ta3.js} +1 -1
- package/dist/web/assets/{diff-viewer-CtEmKn4e.js → diff-viewer-BnvcXY3g.js} +1 -1
- package/dist/web/assets/{git-graph-DycoowxO.js → git-graph-iAf_zaqe.js} +1 -1
- package/dist/web/assets/index-BwLVvoev.js +21 -0
- package/dist/web/assets/index-CP_2zE5O.css +2 -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-LHjvxp5Q.js → markdown-renderer-CIfiE3o8.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-B_QwULcp.js +1 -0
- package/dist/web/assets/sqlite-viewer-DpGb3i2g.js +16 -0
- package/dist/web/assets/tab-store-L0a7ao4c.js +1 -0
- package/dist/web/assets/{terminal-tab-B2QEABNU.js → terminal-tab-4-DINw_B.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/routes/tunnel.ts +17 -2
- 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 +144 -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/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/project-bar.tsx +40 -17
- 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 +165 -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-D_LO6cRM.js +0 -7
- package/dist/web/assets/index-82E_pIrH.css +0 -2
- package/dist/web/assets/index-y49eIXuR.js +0 -21
- package/dist/web/assets/settings-store-DikslxSJ.js +0 -1
- package/dist/web/assets/settings-tab-Dt9Sv1zx.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);
|
|
@@ -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
|
|
|
@@ -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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
setCopied(
|
|
201
|
-
|
|
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
|
+
}
|