dbdiff-app 0.1.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/README.md +73 -0
- package/bin/cli.js +83 -0
- package/bin/install-local.js +57 -0
- package/electron/generate-icon.mjs +54 -0
- package/electron/icon.icns +0 -0
- package/electron/icon.png +0 -0
- package/electron/icon.svg +21 -0
- package/electron/main.js +169 -0
- package/electron/patch-dev-plist.js +31 -0
- package/electron/preload.cjs +18 -0
- package/electron/wait-for-vite.js +43 -0
- package/index.html +13 -0
- package/package.json +91 -0
- package/public/favicon.svg +15 -0
- package/public/vite.svg +1 -0
- package/server/export.ts +57 -0
- package/server/index.ts +392 -0
- package/src/App.css +1 -0
- package/src/App.tsx +543 -0
- package/src/assets/react.svg +1 -0
- package/src/components/CommandPalette.tsx +243 -0
- package/src/components/ConnectedView.tsx +78 -0
- package/src/components/ConnectionPicker.tsx +381 -0
- package/src/components/ConsoleView.tsx +360 -0
- package/src/components/CsvExportModal.tsx +144 -0
- package/src/components/DataGrid/DataGrid.tsx +262 -0
- package/src/components/DataGrid/DataGridCell.tsx +73 -0
- package/src/components/DataGrid/DataGridHeader.tsx +89 -0
- package/src/components/DataGrid/index.ts +20 -0
- package/src/components/DataGrid/types.ts +63 -0
- package/src/components/DataGrid/useColumnResize.ts +153 -0
- package/src/components/DataGrid/useDataGridSelection.ts +340 -0
- package/src/components/DataGrid/utils.ts +184 -0
- package/src/components/DatabaseMenu.tsx +93 -0
- package/src/components/DatabaseSwitcher.tsx +208 -0
- package/src/components/DiffView.tsx +215 -0
- package/src/components/EditConnectionModal.tsx +417 -0
- package/src/components/ErrorBoundary.tsx +69 -0
- package/src/components/GlobalShortcuts.tsx +201 -0
- package/src/components/InnerTabBar.tsx +129 -0
- package/src/components/JsonTreeViewer.tsx +387 -0
- package/src/components/MemberAccessEditor.tsx +443 -0
- package/src/components/MembersModal.tsx +446 -0
- package/src/components/NewConnectionModal.tsx +274 -0
- package/src/components/Resizer.tsx +66 -0
- package/src/components/ScanSuccessModal.tsx +113 -0
- package/src/components/ShortcutSettingsModal.tsx +318 -0
- package/src/components/Sidebar.tsx +532 -0
- package/src/components/TabBar.tsx +188 -0
- package/src/components/TableView.tsx +2147 -0
- package/src/components/ThemeToggle.tsx +44 -0
- package/src/components/index.ts +17 -0
- package/src/constants.ts +12 -0
- package/src/electron.d.ts +12 -0
- package/src/index.css +44 -0
- package/src/main.tsx +13 -0
- package/src/stores/hooks.ts +1146 -0
- package/src/stores/index.ts +12 -0
- package/src/stores/store.ts +1514 -0
- package/src/stores/useCloudSync.ts +274 -0
- package/src/stores/useSyncDatabase.ts +422 -0
- package/src/types.ts +277 -0
- package/src/utils/csv.ts +27 -0
- package/src/vite-env.d.ts +2 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/tsconfig.server.json +14 -0
- package/vite.config.ts +14 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { InnerTab } from "../types";
|
|
3
|
+
|
|
4
|
+
interface InnerTabBarProps {
|
|
5
|
+
innerTabs: InnerTab[];
|
|
6
|
+
activeInnerTabId: string | null;
|
|
7
|
+
draggedInnerTabId: string | null;
|
|
8
|
+
onTabSelect: (tabId: string) => void;
|
|
9
|
+
onTabClose: (tabId: string) => void;
|
|
10
|
+
onNewConsole: () => void;
|
|
11
|
+
onDragStart: (e: React.DragEvent, tabId: string) => void;
|
|
12
|
+
onDragOver: (e: React.DragEvent, tabId: string) => void;
|
|
13
|
+
onDragEnd: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function InnerTabBar({
|
|
17
|
+
innerTabs,
|
|
18
|
+
activeInnerTabId,
|
|
19
|
+
draggedInnerTabId,
|
|
20
|
+
onTabSelect,
|
|
21
|
+
onTabClose,
|
|
22
|
+
onNewConsole,
|
|
23
|
+
onDragStart,
|
|
24
|
+
onDragOver,
|
|
25
|
+
onDragEnd,
|
|
26
|
+
}: InnerTabBarProps) {
|
|
27
|
+
const activeTabRef = useRef<HTMLDivElement | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (activeInnerTabId && activeTabRef.current) {
|
|
31
|
+
activeTabRef.current.scrollIntoView({
|
|
32
|
+
behavior: "smooth",
|
|
33
|
+
block: "nearest",
|
|
34
|
+
inline: "nearest",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}, [activeInnerTabId]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex items-center min-h-9 bg-stone-50 dark:bg-[#0f0f0f] border-b border-stone-200 dark:border-white/[0.06] gap-1 px-2 py-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
41
|
+
{innerTabs.map((tab) => (
|
|
42
|
+
<div
|
|
43
|
+
key={tab.id}
|
|
44
|
+
ref={tab.id === activeInnerTabId ? activeTabRef : undefined}
|
|
45
|
+
draggable
|
|
46
|
+
onDragStart={(e) => onDragStart(e, tab.id)}
|
|
47
|
+
onDragOver={(e) => onDragOver(e, tab.id)}
|
|
48
|
+
onDragEnd={onDragEnd}
|
|
49
|
+
className={`group flex items-center gap-1.5 pl-3 pr-1.5 h-7 rounded-md cursor-pointer select-none transition-all duration-150 flex-shrink-0 ${
|
|
50
|
+
tab.id === activeInnerTabId
|
|
51
|
+
? "bg-white dark:bg-white/[0.06] text-primary shadow-sm dark:shadow-none"
|
|
52
|
+
: "text-tertiary hover:text-primary hover:bg-stone-100 dark:hover:bg-white/[0.03]"
|
|
53
|
+
} ${draggedInnerTabId === tab.id ? "opacity-50" : ""}`}
|
|
54
|
+
onClick={() => onTabSelect(tab.id)}
|
|
55
|
+
>
|
|
56
|
+
{tab.type === "table" && (
|
|
57
|
+
<svg
|
|
58
|
+
className="w-3.5 h-3.5 flex-shrink-0 opacity-60"
|
|
59
|
+
viewBox="0 0 24 24"
|
|
60
|
+
fill="none"
|
|
61
|
+
stroke="currentColor"
|
|
62
|
+
strokeWidth="1.5"
|
|
63
|
+
>
|
|
64
|
+
<path d="M3 6h18M3 12h18M3 18h18M9 6v12M15 6v12" />
|
|
65
|
+
</svg>
|
|
66
|
+
)}
|
|
67
|
+
{tab.type === "console" && (
|
|
68
|
+
<svg
|
|
69
|
+
className="w-3.5 h-3.5 flex-shrink-0 opacity-60"
|
|
70
|
+
viewBox="0 0 24 24"
|
|
71
|
+
fill="none"
|
|
72
|
+
stroke="currentColor"
|
|
73
|
+
strokeWidth="1.5"
|
|
74
|
+
>
|
|
75
|
+
<path d="M4 17l6-6-6-6M12 19h8" />
|
|
76
|
+
</svg>
|
|
77
|
+
)}
|
|
78
|
+
{tab.type === "query" && (
|
|
79
|
+
<svg
|
|
80
|
+
className="w-3.5 h-3.5 flex-shrink-0 opacity-60"
|
|
81
|
+
viewBox="0 0 24 24"
|
|
82
|
+
fill="none"
|
|
83
|
+
stroke="currentColor"
|
|
84
|
+
strokeWidth="1.5"
|
|
85
|
+
>
|
|
86
|
+
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
|
87
|
+
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" />
|
|
88
|
+
</svg>
|
|
89
|
+
)}
|
|
90
|
+
<span className="text-[12px] font-medium tracking-[-0.01em] truncate max-w-[100px]">
|
|
91
|
+
{tab.name}
|
|
92
|
+
</span>
|
|
93
|
+
<button
|
|
94
|
+
className="w-4 h-4 flex items-center justify-center rounded-full transition-all hover:bg-stone-200 dark:hover:bg-white/10 opacity-0 group-hover:opacity-40 hover:!opacity-100 cursor-pointer"
|
|
95
|
+
onClick={(e) => {
|
|
96
|
+
e.stopPropagation();
|
|
97
|
+
onTabClose(tab.id);
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
<svg
|
|
101
|
+
className="w-2.5 h-2.5"
|
|
102
|
+
viewBox="0 0 12 12"
|
|
103
|
+
fill="none"
|
|
104
|
+
stroke="currentColor"
|
|
105
|
+
strokeWidth="1.5"
|
|
106
|
+
>
|
|
107
|
+
<path d="M3 3l6 6M9 3l-6 6" />
|
|
108
|
+
</svg>
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
))}
|
|
112
|
+
<button
|
|
113
|
+
className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-md text-interactive-subtle hover:bg-stone-100 dark:hover:bg-white/[0.03] transition-all duration-150 focus:outline-none"
|
|
114
|
+
onClick={onNewConsole}
|
|
115
|
+
title="New Console"
|
|
116
|
+
>
|
|
117
|
+
<svg
|
|
118
|
+
className="w-3.5 h-3.5"
|
|
119
|
+
viewBox="0 0 16 16"
|
|
120
|
+
fill="none"
|
|
121
|
+
stroke="currentColor"
|
|
122
|
+
strokeWidth="1.5"
|
|
123
|
+
>
|
|
124
|
+
<path d="M8 3v10M3 8h10" />
|
|
125
|
+
</svg>
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface JsonTreeViewerProps {
|
|
5
|
+
data: unknown;
|
|
6
|
+
columnName: string;
|
|
7
|
+
onEdit?: (newData: unknown) => void;
|
|
8
|
+
canEdit: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function JsonTreeViewer({
|
|
12
|
+
data,
|
|
13
|
+
columnName,
|
|
14
|
+
onEdit,
|
|
15
|
+
canEdit,
|
|
16
|
+
}: JsonTreeViewerProps) {
|
|
17
|
+
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(
|
|
18
|
+
() => new Set(["$"]),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const allPaths = useMemo(() => {
|
|
22
|
+
const paths = new Set<string>();
|
|
23
|
+
function collect(value: unknown, path: string) {
|
|
24
|
+
if (value !== null && typeof value === "object") {
|
|
25
|
+
paths.add(path);
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
value.forEach((item, i) => collect(item, `${path}[${i}]`));
|
|
28
|
+
} else {
|
|
29
|
+
Object.keys(value as Record<string, unknown>).forEach((key) =>
|
|
30
|
+
collect((value as Record<string, unknown>)[key], `${path}.${key}`),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
collect(data, "$");
|
|
36
|
+
return paths;
|
|
37
|
+
}, [data]);
|
|
38
|
+
|
|
39
|
+
const togglePath = useCallback((path: string) => {
|
|
40
|
+
setExpandedPaths((prev) => {
|
|
41
|
+
const next = new Set(prev);
|
|
42
|
+
if (next.has(path)) {
|
|
43
|
+
next.delete(path);
|
|
44
|
+
} else {
|
|
45
|
+
next.add(path);
|
|
46
|
+
}
|
|
47
|
+
return next;
|
|
48
|
+
});
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const expandAll = useCallback(() => {
|
|
52
|
+
setExpandedPaths(new Set(allPaths));
|
|
53
|
+
}, [allPaths]);
|
|
54
|
+
|
|
55
|
+
const collapseAll = useCallback(() => {
|
|
56
|
+
setExpandedPaths(new Set());
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const handleEdit = useCallback(
|
|
60
|
+
(path: string[], newValue: unknown) => {
|
|
61
|
+
if (!onEdit) return;
|
|
62
|
+
// Deep clone and set value at path
|
|
63
|
+
const cloned = JSON.parse(JSON.stringify(data));
|
|
64
|
+
let target = cloned;
|
|
65
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
66
|
+
target = target[path[i]];
|
|
67
|
+
}
|
|
68
|
+
target[path[path.length - 1]] = newValue;
|
|
69
|
+
onEdit(cloned);
|
|
70
|
+
},
|
|
71
|
+
[data, onEdit],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const isAllExpanded = expandedPaths.size >= allPaths.size;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="h-full flex flex-col">
|
|
78
|
+
<div className="flex-shrink-0 flex items-center justify-between px-3 py-1.5 border-b border-stone-200 dark:border-white/[0.06] bg-stone-50 dark:bg-white/[0.02]">
|
|
79
|
+
<span className="text-[11px] font-medium text-tertiary uppercase tracking-wide">
|
|
80
|
+
JSON: {columnName}
|
|
81
|
+
</span>
|
|
82
|
+
<button
|
|
83
|
+
onClick={isAllExpanded ? collapseAll : expandAll}
|
|
84
|
+
className="flex items-center gap-1 px-1.5 py-0.5 text-[11px] text-tertiary hover:text-secondary rounded hover:bg-stone-200/70 dark:hover:bg-white/[0.06] transition-colors"
|
|
85
|
+
>
|
|
86
|
+
{isAllExpanded ? (
|
|
87
|
+
<>
|
|
88
|
+
<ChevronsDownUp className="w-3 h-3" />
|
|
89
|
+
Collapse All
|
|
90
|
+
</>
|
|
91
|
+
) : (
|
|
92
|
+
<>
|
|
93
|
+
<ChevronsUpDown className="w-3 h-3" />
|
|
94
|
+
Expand All
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="flex-1 overflow-auto p-2 font-mono text-[12px]">
|
|
100
|
+
<JsonNode
|
|
101
|
+
value={data}
|
|
102
|
+
path="$"
|
|
103
|
+
keyPath={[]}
|
|
104
|
+
expandedPaths={expandedPaths}
|
|
105
|
+
onToggle={togglePath}
|
|
106
|
+
canEdit={canEdit}
|
|
107
|
+
onEdit={handleEdit}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ARRAY_TRUNCATE_LIMIT = 100;
|
|
115
|
+
|
|
116
|
+
interface JsonNodeProps {
|
|
117
|
+
value: unknown;
|
|
118
|
+
path: string;
|
|
119
|
+
keyPath: string[];
|
|
120
|
+
keyName?: string;
|
|
121
|
+
expandedPaths: Set<string>;
|
|
122
|
+
onToggle: (path: string) => void;
|
|
123
|
+
canEdit: boolean;
|
|
124
|
+
onEdit: (path: string[], newValue: unknown) => void;
|
|
125
|
+
isArrayItem?: boolean;
|
|
126
|
+
arrayIndex?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const JsonNode = React.memo(function JsonNode({
|
|
130
|
+
value,
|
|
131
|
+
path,
|
|
132
|
+
keyPath,
|
|
133
|
+
keyName,
|
|
134
|
+
expandedPaths,
|
|
135
|
+
onToggle,
|
|
136
|
+
canEdit,
|
|
137
|
+
onEdit,
|
|
138
|
+
isArrayItem,
|
|
139
|
+
arrayIndex,
|
|
140
|
+
}: JsonNodeProps) {
|
|
141
|
+
const isExpanded = expandedPaths.has(path);
|
|
142
|
+
const isObject = value !== null && typeof value === "object";
|
|
143
|
+
const isArray = Array.isArray(value);
|
|
144
|
+
|
|
145
|
+
if (!isObject) {
|
|
146
|
+
return (
|
|
147
|
+
<div className="flex items-start">
|
|
148
|
+
{keyName !== undefined && (
|
|
149
|
+
<span className="text-secondary">
|
|
150
|
+
{keyName}
|
|
151
|
+
<span className="text-tertiary">: </span>
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
{isArrayItem && arrayIndex !== undefined && (
|
|
155
|
+
<span className="text-tertiary mr-1">{arrayIndex}: </span>
|
|
156
|
+
)}
|
|
157
|
+
<LeafValue
|
|
158
|
+
value={value}
|
|
159
|
+
canEdit={canEdit}
|
|
160
|
+
onEdit={(newVal) => onEdit(keyPath, newVal)}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const entries = isArray
|
|
167
|
+
? (value as unknown[])
|
|
168
|
+
: Object.entries(value as Record<string, unknown>);
|
|
169
|
+
const entryCount = isArray
|
|
170
|
+
? (value as unknown[]).length
|
|
171
|
+
: Object.keys(value as Record<string, unknown>).length;
|
|
172
|
+
|
|
173
|
+
const collapsedPreview = isArray
|
|
174
|
+
? `[${entryCount} item${entryCount !== 1 ? "s" : ""}]`
|
|
175
|
+
: getObjectPreview(value as Record<string, unknown>);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div>
|
|
179
|
+
<div
|
|
180
|
+
className="flex items-start cursor-pointer hover:bg-stone-100 dark:hover:bg-white/[0.04] rounded px-0.5 -mx-0.5"
|
|
181
|
+
onClick={() => onToggle(path)}
|
|
182
|
+
>
|
|
183
|
+
<ChevronRight
|
|
184
|
+
className={`w-3 h-3 mt-0.5 flex-shrink-0 text-tertiary transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
|
185
|
+
/>
|
|
186
|
+
<span className="ml-0.5">
|
|
187
|
+
{keyName !== undefined && (
|
|
188
|
+
<span className="text-secondary">
|
|
189
|
+
{keyName}
|
|
190
|
+
<span className="text-tertiary">: </span>
|
|
191
|
+
</span>
|
|
192
|
+
)}
|
|
193
|
+
{isArrayItem && arrayIndex !== undefined && (
|
|
194
|
+
<span className="text-tertiary">{arrayIndex}: </span>
|
|
195
|
+
)}
|
|
196
|
+
{!isExpanded && (
|
|
197
|
+
<span className="text-tertiary">{collapsedPreview}</span>
|
|
198
|
+
)}
|
|
199
|
+
{isExpanded && (
|
|
200
|
+
<span className="text-tertiary">{isArray ? "[" : "{"}</span>
|
|
201
|
+
)}
|
|
202
|
+
</span>
|
|
203
|
+
</div>
|
|
204
|
+
{isExpanded && (
|
|
205
|
+
<div className="pl-4">
|
|
206
|
+
{isArray
|
|
207
|
+
? (entries as unknown[]).map((item, i) => {
|
|
208
|
+
if (i >= ARRAY_TRUNCATE_LIMIT) {
|
|
209
|
+
if (i === ARRAY_TRUNCATE_LIMIT) {
|
|
210
|
+
return (
|
|
211
|
+
<ShowMoreButton
|
|
212
|
+
key="__show_more__"
|
|
213
|
+
remaining={entryCount - ARRAY_TRUNCATE_LIMIT}
|
|
214
|
+
onToggle={() => onToggle(path)}
|
|
215
|
+
/>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
return (
|
|
221
|
+
<JsonNode
|
|
222
|
+
key={i}
|
|
223
|
+
value={item}
|
|
224
|
+
path={`${path}[${i}]`}
|
|
225
|
+
keyPath={[...keyPath, String(i)]}
|
|
226
|
+
expandedPaths={expandedPaths}
|
|
227
|
+
onToggle={onToggle}
|
|
228
|
+
canEdit={canEdit}
|
|
229
|
+
onEdit={onEdit}
|
|
230
|
+
isArrayItem
|
|
231
|
+
arrayIndex={i}
|
|
232
|
+
/>
|
|
233
|
+
);
|
|
234
|
+
})
|
|
235
|
+
: (entries as [string, unknown][]).map(([k, v]) => (
|
|
236
|
+
<JsonNode
|
|
237
|
+
key={k}
|
|
238
|
+
value={v}
|
|
239
|
+
path={`${path}.${k}`}
|
|
240
|
+
keyPath={[...keyPath, k]}
|
|
241
|
+
keyName={k}
|
|
242
|
+
expandedPaths={expandedPaths}
|
|
243
|
+
onToggle={onToggle}
|
|
244
|
+
canEdit={canEdit}
|
|
245
|
+
onEdit={onEdit}
|
|
246
|
+
/>
|
|
247
|
+
))}
|
|
248
|
+
<div
|
|
249
|
+
className="text-tertiary px-0.5 cursor-pointer hover:bg-stone-100 dark:hover:bg-white/[0.04] rounded -mx-0.5"
|
|
250
|
+
onClick={() => onToggle(path)}
|
|
251
|
+
>
|
|
252
|
+
{isArray ? "]" : "}"}
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
function ShowMoreButton({
|
|
261
|
+
remaining,
|
|
262
|
+
onToggle,
|
|
263
|
+
}: {
|
|
264
|
+
remaining: number;
|
|
265
|
+
onToggle: () => void;
|
|
266
|
+
}) {
|
|
267
|
+
return (
|
|
268
|
+
<div
|
|
269
|
+
className="px-0.5 -mx-0.5 text-blue-600 dark:text-blue-400 cursor-pointer hover:underline"
|
|
270
|
+
onClick={(e) => {
|
|
271
|
+
e.stopPropagation();
|
|
272
|
+
onToggle();
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
... {remaining} more item{remaining !== 1 ? "s" : ""} (collapse to reset)
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface LeafValueProps {
|
|
281
|
+
value: unknown;
|
|
282
|
+
canEdit: boolean;
|
|
283
|
+
onEdit: (newValue: unknown) => void;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function LeafValue({ value, canEdit, onEdit }: LeafValueProps) {
|
|
287
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
288
|
+
const [editText, setEditText] = useState("");
|
|
289
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
290
|
+
|
|
291
|
+
const handleDoubleClick = (e: React.MouseEvent) => {
|
|
292
|
+
e.stopPropagation();
|
|
293
|
+
if (!canEdit) return;
|
|
294
|
+
setEditText(value === null ? "" : String(value));
|
|
295
|
+
setIsEditing(true);
|
|
296
|
+
setTimeout(() => {
|
|
297
|
+
inputRef.current?.focus();
|
|
298
|
+
inputRef.current?.select();
|
|
299
|
+
}, 0);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const handleCommit = () => {
|
|
303
|
+
setIsEditing(false);
|
|
304
|
+
const coerced = coerceValue(editText, value);
|
|
305
|
+
onEdit(coerced);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const handleCancel = () => {
|
|
309
|
+
setIsEditing(false);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
313
|
+
if (e.key === "Enter") {
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
handleCommit();
|
|
316
|
+
} else if (e.key === "Escape") {
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
handleCancel();
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const handleCopy = (e: React.MouseEvent) => {
|
|
323
|
+
e.stopPropagation();
|
|
324
|
+
if (isEditing) return;
|
|
325
|
+
const text = value === null ? "null" : String(value);
|
|
326
|
+
navigator.clipboard.writeText(text);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
if (isEditing) {
|
|
330
|
+
return (
|
|
331
|
+
<input
|
|
332
|
+
ref={inputRef}
|
|
333
|
+
type="text"
|
|
334
|
+
value={editText}
|
|
335
|
+
onChange={(e) => setEditText(e.target.value)}
|
|
336
|
+
onKeyDown={handleKeyDown}
|
|
337
|
+
onBlur={handleCommit}
|
|
338
|
+
onClick={(e) => e.stopPropagation()}
|
|
339
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
340
|
+
className="px-1 py-0 text-[12px] font-mono bg-white dark:bg-stone-800 border border-blue-500 dark:border-blue-400 rounded outline-none min-w-[60px]"
|
|
341
|
+
/>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<span
|
|
347
|
+
className={`${getValueColorClass(value)} cursor-pointer hover:underline decoration-dotted`}
|
|
348
|
+
onClick={handleCopy}
|
|
349
|
+
onDoubleClick={handleDoubleClick}
|
|
350
|
+
title={canEdit ? "Click to copy, double-click to edit" : "Click to copy"}
|
|
351
|
+
>
|
|
352
|
+
{formatLeafValue(value)}
|
|
353
|
+
</span>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function getValueColorClass(value: unknown): string {
|
|
358
|
+
if (value === null) return "text-tertiary italic";
|
|
359
|
+
if (typeof value === "string") return "text-green-600 dark:text-green-400";
|
|
360
|
+
if (typeof value === "number") return "text-blue-600 dark:text-blue-400";
|
|
361
|
+
if (typeof value === "boolean") return "text-purple-600 dark:text-purple-400";
|
|
362
|
+
return "text-secondary";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function formatLeafValue(value: unknown): string {
|
|
366
|
+
if (value === null) return "null";
|
|
367
|
+
if (typeof value === "string") return `"${value}"`;
|
|
368
|
+
return String(value);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function getObjectPreview(obj: Record<string, unknown>): string {
|
|
372
|
+
const keys = Object.keys(obj);
|
|
373
|
+
if (keys.length === 0) return "{}";
|
|
374
|
+
if (keys.length <= 3) return `{ ${keys.join(", ")} }`;
|
|
375
|
+
return `{ ${keys.slice(0, 3).join(", ")}, ... }`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function coerceValue(text: string, originalValue: unknown): unknown {
|
|
379
|
+
if (text === "" || text === "null") return null;
|
|
380
|
+
if (text === "true") return true;
|
|
381
|
+
if (text === "false") return false;
|
|
382
|
+
if (typeof originalValue === "number") {
|
|
383
|
+
const num = Number(text);
|
|
384
|
+
if (!isNaN(num)) return num;
|
|
385
|
+
}
|
|
386
|
+
return text;
|
|
387
|
+
}
|