@hienlh/ppm 0.6.6 → 0.6.7

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 (39) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/web/assets/chat-tab-CWhxhPKH.js +7 -0
  3. package/dist/web/assets/{code-editor-ZFl5kZ4-.js → code-editor-BUg7alP6.js} +1 -1
  4. package/dist/web/assets/{database-viewer-DPpOsMqa.js → database-viewer-CAgZOkZc.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-CX74l6lV.js → diff-viewer-DVvY1aFb.js} +1 -1
  6. package/dist/web/assets/{git-graph-Dju1rygf.js → git-graph-xD6TLRVv.js} +1 -1
  7. package/dist/web/assets/index-CigdXBuQ.css +2 -0
  8. package/dist/web/assets/index-DBdw8tN_.js +22 -0
  9. package/dist/web/assets/keybindings-store-kHLASnRb.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-Bke6DHFh.js → markdown-renderer-z99RjIxZ.js} +1 -1
  11. package/dist/web/assets/{postgres-viewer-DaNYnInA.js → postgres-viewer-CaMySHpD.js} +1 -1
  12. package/dist/web/assets/{settings-tab-DD05d8rM.js → settings-tab-BnDkeQWk.js} +1 -1
  13. package/dist/web/assets/{sqlite-viewer-Cx7tLyT-.js → sqlite-viewer-EwHWc37J.js} +1 -1
  14. package/dist/web/assets/terminal-tab-CTN18lb6.js +36 -0
  15. package/dist/web/index.html +2 -2
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/src/server/index.ts +3 -79
  19. package/src/server/routes/database.ts +57 -0
  20. package/src/server/routes/fs-browse.ts +67 -0
  21. package/src/services/fs-browse.service.ts +216 -0
  22. package/src/web/app.tsx +23 -19
  23. package/src/web/components/chat/message-list.tsx +8 -105
  24. package/src/web/components/chat/question-card.tsx +334 -0
  25. package/src/web/components/database/connection-form-dialog.tsx +15 -6
  26. package/src/web/components/database/connection-import-export.tsx +116 -0
  27. package/src/web/components/database/database-sidebar.tsx +12 -8
  28. package/src/web/components/database/use-connections.ts +13 -1
  29. package/src/web/components/layout/add-project-form.tsx +23 -12
  30. package/src/web/components/layout/command-palette.tsx +1 -1
  31. package/src/web/components/projects/dir-suggest.tsx +22 -12
  32. package/src/web/components/ui/browse-button.tsx +42 -0
  33. package/src/web/components/ui/file-browser-picker.tsx +374 -0
  34. package/src/web/stores/project-store.ts +0 -14
  35. package/dist/web/assets/chat-tab-dwpaSkQD.js +0 -7
  36. package/dist/web/assets/index-DSg2VjxL.css +0 -2
  37. package/dist/web/assets/index-DXOEmhRm.js +0 -21
  38. package/dist/web/assets/keybindings-store-VhiJwp77.js +0 -1
  39. package/dist/web/assets/terminal-tab-_farMLMO.js +0 -36
@@ -21,6 +21,8 @@ import {
21
21
  RotateCcw,
22
22
  TerminalSquare,
23
23
  } from "lucide-react";
24
+ import { QuestionCard } from "./question-card";
25
+ import type { Question } from "./question-card";
24
26
 
25
27
  interface MessageListProps {
26
28
  messages: ChatMessage[];
@@ -575,113 +577,14 @@ function AskUserQuestionCard({
575
577
  approval: { requestId: string; tool: string; input: unknown };
576
578
  onRespond: (requestId: string, approved: boolean, data?: unknown) => void;
577
579
  }) {
578
- const input = approval.input as {
579
- questions?: Array<{
580
- question: string;
581
- header?: string;
582
- options: Array<{ label: string; description?: string }>;
583
- multiSelect?: boolean;
584
- }>;
585
- };
580
+ const input = approval.input as { questions?: Question[] };
586
581
  const questions = input.questions ?? [];
587
582
 
588
- const [answers, setAnswers] = useState<Record<string, string>>({});
589
- // Track which questions have "Other" active
590
- const [otherActive, setOtherActive] = useState<Record<string, boolean>>({});
591
-
592
- const handleSelect = (question: string, label: string, multiSelect?: boolean) => {
593
- // Deactivate "Other" when selecting a predefined option
594
- setOtherActive((prev) => ({ ...prev, [question]: false }));
595
- setAnswers((prev) => {
596
- if (!multiSelect) return { ...prev, [question]: label };
597
- const current = prev[question] ?? "";
598
- const labels = current ? current.split(", ") : [];
599
- const idx = labels.indexOf(label);
600
- if (idx >= 0) labels.splice(idx, 1);
601
- else labels.push(label);
602
- return { ...prev, [question]: labels.join(", ") };
603
- });
604
- };
605
-
606
- const handleOtherToggle = (question: string) => {
607
- setOtherActive((prev) => ({ ...prev, [question]: true }));
608
- setAnswers((prev) => ({ ...prev, [question]: "" }));
609
- };
610
-
611
- const handleOtherText = (question: string, text: string) => {
612
- setAnswers((prev) => ({ ...prev, [question]: text }));
613
- };
614
-
615
- const allAnswered = questions.every((q) => answers[q.question]?.trim());
616
-
617
583
  return (
618
- <div className="rounded-lg border-2 border-accent/40 bg-accent/5 p-3 space-y-3">
619
- {questions.map((q, qi) => (
620
- <div key={qi} className="space-y-1.5">
621
- <p className="text-sm text-text-primary font-medium">
622
- {q.header ? `${q.header}: ` : ""}{q.question}
623
- </p>
624
- {q.multiSelect && (
625
- <p className="text-xs text-text-subtle">Select multiple</p>
626
- )}
627
- <div className="flex flex-col gap-1">
628
- {q.options.map((opt, oi) => {
629
- const isOther = otherActive[q.question];
630
- const selected = !isOther && (answers[q.question] ?? "").split(", ").includes(opt.label);
631
- return (
632
- <button
633
- key={oi}
634
- onClick={() => handleSelect(q.question, opt.label, q.multiSelect)}
635
- className={`text-left rounded px-2.5 py-1.5 text-xs border transition-colors ${
636
- selected
637
- ? "border-accent bg-accent/20 text-text-primary"
638
- : "border-border bg-background text-text-secondary hover:bg-surface-elevated"
639
- }`}
640
- >
641
- <span className="font-medium">{opt.label}</span>
642
- {opt.description && (
643
- <span className="text-text-subtle ml-1.5">— {opt.description}</span>
644
- )}
645
- </button>
646
- );
647
- })}
648
- {/* Other option */}
649
- {otherActive[q.question] ? (
650
- <input
651
- type="text"
652
- autoFocus
653
- placeholder="Type your answer..."
654
- value={answers[q.question] ?? ""}
655
- onChange={(e) => handleOtherText(q.question, e.target.value)}
656
- className="rounded px-2.5 py-1.5 text-xs border border-accent bg-accent/10 text-text-primary outline-none placeholder:text-text-subtle"
657
- />
658
- ) : (
659
- <button
660
- onClick={() => handleOtherToggle(q.question)}
661
- className="text-left rounded px-2.5 py-1.5 text-xs border border-dashed border-border text-text-subtle hover:bg-surface-elevated transition-colors"
662
- >
663
- Other — type your own answer
664
- </button>
665
- )}
666
- </div>
667
- </div>
668
- ))}
669
-
670
- <div className="flex gap-2 pt-1">
671
- <button
672
- onClick={() => onRespond(approval.requestId, true, answers)}
673
- disabled={!allAnswered}
674
- className="px-4 py-1.5 rounded bg-accent text-white text-xs font-medium hover:bg-accent/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
675
- >
676
- Submit
677
- </button>
678
- <button
679
- onClick={() => onRespond(approval.requestId, false)}
680
- className="px-4 py-1.5 rounded bg-surface-elevated text-text-secondary text-xs hover:bg-surface transition-colors"
681
- >
682
- Skip
683
- </button>
684
- </div>
685
- </div>
584
+ <QuestionCard
585
+ questions={questions}
586
+ onSubmit={(answers) => onRespond(approval.requestId, true, answers)}
587
+ onSkip={() => onRespond(approval.requestId, false)}
588
+ />
686
589
  );
687
590
  }
@@ -0,0 +1,334 @@
1
+ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
2
+
3
+ /* ── Types ── */
4
+ export interface QuestionOption {
5
+ label: string;
6
+ description?: string;
7
+ }
8
+
9
+ export interface Question {
10
+ question: string;
11
+ header?: string;
12
+ options: QuestionOption[];
13
+ multiSelect?: boolean;
14
+ }
15
+
16
+ interface QuestionCardProps {
17
+ questions: Question[];
18
+ onSubmit: (answers: Record<string, string>) => void;
19
+ onSkip: () => void;
20
+ }
21
+
22
+ /* ── Hook: form state ── */
23
+ function useQuestionForm(questions: Question[]) {
24
+ const [answers, setAnswers] = useState<Record<number, string[]>>({});
25
+ const [customInputs, setCustomInputs] = useState<Record<number, string>>({});
26
+ const [activeTab, setActiveTab] = useState(0);
27
+
28
+ const handleSingleSelect = useCallback((qi: number, label: string) => {
29
+ setAnswers((p) => ({ ...p, [qi]: [label] }));
30
+ setCustomInputs((p) => ({ ...p, [qi]: "" }));
31
+ }, []);
32
+
33
+ const handleMultiSelect = useCallback((qi: number, label: string) => {
34
+ setAnswers((p) => {
35
+ const cur = p[qi] || [];
36
+ return { ...p, [qi]: cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label] };
37
+ });
38
+ }, []);
39
+
40
+ const handleCustomInput = useCallback((qi: number, value: string) => {
41
+ setCustomInputs((p) => ({ ...p, [qi]: value }));
42
+ if (value) setAnswers((p) => ({ ...p, [qi]: [] }));
43
+ }, []);
44
+
45
+ const hasAnswer = useCallback(
46
+ (qi: number) => (answers[qi]?.length ?? 0) > 0 || (customInputs[qi]?.trim().length ?? 0) > 0,
47
+ [answers, customInputs],
48
+ );
49
+
50
+ const allAnswered = useMemo(() => questions.every((_, i) => hasAnswer(i)), [questions, hasAnswer]);
51
+
52
+ const getFinalAnswer = useCallback(
53
+ (qi: number) => {
54
+ const custom = customInputs[qi]?.trim();
55
+ if (custom) return custom;
56
+ return (answers[qi] ?? []).join(", ");
57
+ },
58
+ [answers, customInputs],
59
+ );
60
+
61
+ const goToNextTab = useCallback(() => setActiveTab((p) => Math.min(p + 1, questions.length - 1)), [questions.length]);
62
+ const goToPrevTab = useCallback(() => setActiveTab((p) => Math.max(p - 1, 0)), []);
63
+
64
+ return {
65
+ answers, customInputs, activeTab, setActiveTab,
66
+ handleSingleSelect, handleMultiSelect, handleCustomInput,
67
+ hasAnswer, allAnswered, getFinalAnswer, goToNextTab, goToPrevTab,
68
+ };
69
+ }
70
+
71
+ /* ── Hook: keyboard navigation ── */
72
+ function useQuestionKeyboard(config: {
73
+ questions: Question[];
74
+ activeTab: number;
75
+ totalOptions: number;
76
+ allAnswered: boolean;
77
+ hasAnswer: (i: number) => boolean;
78
+ onSelectOption: (i: number) => void;
79
+ goToNextTab: () => void;
80
+ goToPrevTab: () => void;
81
+ onSubmit: () => void;
82
+ customInputRef: React.RefObject<HTMLInputElement | null>;
83
+ enabled: boolean;
84
+ }) {
85
+ const [focusedOption, setFocusedOption] = useState(0);
86
+ const containerRef = useRef<HTMLDivElement>(null);
87
+
88
+ useEffect(() => setFocusedOption(0), [config.activeTab]);
89
+
90
+ useEffect(() => {
91
+ if (!config.enabled) return;
92
+ const handleKeyDown = (e: KeyboardEvent) => {
93
+ const isTyping = document.activeElement === config.customInputRef.current;
94
+
95
+ // Number keys 1-9
96
+ if (!isTyping && e.key >= "1" && e.key <= "9") {
97
+ e.preventDefault();
98
+ const idx = parseInt(e.key) - 1;
99
+ if (idx < config.totalOptions - 1) { setFocusedOption(idx); config.onSelectOption(idx); }
100
+ return;
101
+ }
102
+ // O/0 → focus custom input
103
+ if (!isTyping && (e.key === "o" || e.key === "O" || e.key === "0")) {
104
+ e.preventDefault();
105
+ config.customInputRef.current?.focus();
106
+ setFocusedOption(config.totalOptions - 1);
107
+ return;
108
+ }
109
+ // Tab between questions
110
+ if (e.key === "Tab" && config.questions.length > 1) {
111
+ e.preventDefault();
112
+ e.shiftKey ? config.goToPrevTab() : config.goToNextTab();
113
+ return;
114
+ }
115
+ if (!isTyping) {
116
+ if (e.key === "ArrowLeft") { e.preventDefault(); config.goToPrevTab(); return; }
117
+ if (e.key === "ArrowRight") { e.preventDefault(); config.goToNextTab(); return; }
118
+ if (e.key === "ArrowUp") { e.preventDefault(); setFocusedOption((p) => Math.max(0, p - 1)); return; }
119
+ if (e.key === "ArrowDown") { e.preventDefault(); setFocusedOption((p) => Math.min(config.totalOptions - 1, p + 1)); return; }
120
+ if (e.key === " ") { e.preventDefault(); config.onSelectOption(focusedOption); return; }
121
+ }
122
+ if (e.key === "Enter") {
123
+ e.preventDefault();
124
+ if (config.allAnswered) config.onSubmit();
125
+ else if (config.hasAnswer(config.activeTab)) config.goToNextTab();
126
+ return;
127
+ }
128
+ if (e.key === "Escape" && isTyping) { config.customInputRef.current?.blur(); }
129
+ };
130
+
131
+ const el = containerRef.current;
132
+ if (el) {
133
+ el.addEventListener("keydown", handleKeyDown);
134
+ el.setAttribute("tabindex", "0");
135
+ if (!el.contains(document.activeElement)) el.focus();
136
+ }
137
+ return () => { el?.removeEventListener("keydown", handleKeyDown); };
138
+ }, [config, focusedOption]);
139
+
140
+ return { focusedOption, setFocusedOption, containerRef };
141
+ }
142
+
143
+ /* ── Component ── */
144
+ export function QuestionCard({ questions, onSubmit, onSkip }: QuestionCardProps) {
145
+ const customInputRef = useRef<HTMLInputElement>(null);
146
+ const form = useQuestionForm(questions);
147
+ const currentQ = questions[form.activeTab];
148
+ const totalOptions = currentQ ? currentQ.options.length + 1 : 0;
149
+ const hasMultiple = questions.length > 1;
150
+
151
+ const handleSubmit = useCallback(() => {
152
+ if (!form.allAnswered) return;
153
+ const result: Record<string, string> = {};
154
+ questions.forEach((q, i) => { result[q.question] = form.getFinalAnswer(i); });
155
+ onSubmit(result);
156
+ }, [form.allAnswered, form.getFinalAnswer, questions, onSubmit]);
157
+
158
+ const handleSelectOption = useCallback(
159
+ (index: number) => {
160
+ if (!currentQ || index < 0) return;
161
+ if (index < currentQ.options.length) {
162
+ const label = currentQ.options[index]?.label;
163
+ if (!label) return;
164
+ if (currentQ.multiSelect) form.handleMultiSelect(form.activeTab, label);
165
+ else form.handleSingleSelect(form.activeTab, label);
166
+ } else if (index === currentQ.options.length) {
167
+ customInputRef.current?.focus();
168
+ }
169
+ },
170
+ [currentQ, form],
171
+ );
172
+
173
+ const kb = useQuestionKeyboard({
174
+ questions, activeTab: form.activeTab, totalOptions,
175
+ allAnswered: form.allAnswered, hasAnswer: form.hasAnswer,
176
+ onSelectOption: handleSelectOption,
177
+ goToNextTab: form.goToNextTab, goToPrevTab: form.goToPrevTab,
178
+ onSubmit: handleSubmit, customInputRef, enabled: true,
179
+ });
180
+
181
+ const selectWithFocus = useCallback(
182
+ (index: number) => { handleSelectOption(index); kb.setFocusedOption(index); },
183
+ [handleSelectOption, kb.setFocusedOption],
184
+ );
185
+
186
+ return (
187
+ <div
188
+ ref={kb.containerRef}
189
+ className="rounded-lg border-2 border-primary/30 bg-primary/5 p-3 space-y-3 outline-none animate-in slide-in-from-bottom-2"
190
+ >
191
+ {/* Header */}
192
+ <div className="flex items-center justify-between text-sm font-medium text-text-primary">
193
+ <span>
194
+ AI has {hasMultiple ? `${questions.length} questions` : "a question"}
195
+ </span>
196
+ <span className="text-[10px] text-text-secondary font-normal">
197
+ {hasMultiple ? "←→ tabs · " : ""}↑↓ options · 1-{Math.min(totalOptions - 1, 9)} select · Enter submit
198
+ </span>
199
+ </div>
200
+
201
+ {/* Tabs */}
202
+ {hasMultiple && (
203
+ <div className="flex gap-1 p-1 bg-background rounded-md overflow-x-auto border border-border">
204
+ {questions.map((q, i) => (
205
+ <button
206
+ key={i}
207
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-xs whitespace-nowrap transition-all ${
208
+ form.activeTab === i
209
+ ? "bg-primary text-primary-foreground"
210
+ : form.hasAnswer(i)
211
+ ? "text-primary bg-transparent"
212
+ : "text-text-secondary hover:bg-surface-elevated"
213
+ }`}
214
+ onClick={() => { form.setActiveTab(i); kb.setFocusedOption(0); }}
215
+ tabIndex={-1}
216
+ >
217
+ <span
218
+ className={`flex items-center justify-center w-4 h-4 rounded-full text-[10px] font-semibold ${
219
+ form.activeTab === i
220
+ ? "bg-white/20"
221
+ : form.hasAnswer(i)
222
+ ? "bg-primary/20 text-primary"
223
+ : "bg-surface-elevated text-text-secondary"
224
+ }`}
225
+ >
226
+ {form.hasAnswer(i) ? "✓" : i + 1}
227
+ </span>
228
+ <span className="max-w-[100px] overflow-hidden text-ellipsis">{q.header || `Q${i + 1}`}</span>
229
+ </button>
230
+ ))}
231
+ </div>
232
+ )}
233
+
234
+ {/* Current question */}
235
+ {currentQ && (
236
+ <div className="space-y-2">
237
+ {!hasMultiple && currentQ.header && (
238
+ <div className="text-[11px] font-semibold uppercase tracking-wide text-text-secondary">{currentQ.header}</div>
239
+ )}
240
+ <div className="text-sm text-text-primary">{currentQ.question}</div>
241
+ {currentQ.multiSelect && <div className="text-[11px] text-text-secondary">Select multiple</div>}
242
+
243
+ {/* Options */}
244
+ <div className="flex flex-col gap-1.5">
245
+ {currentQ.options.map((opt, oi) => {
246
+ const isSelected = (form.answers[form.activeTab] || []).includes(opt.label);
247
+ const isFocused = kb.focusedOption === oi;
248
+ return (
249
+ <button
250
+ key={oi}
251
+ onClick={() => selectWithFocus(oi)}
252
+ className={`text-left flex items-start gap-2.5 rounded px-2.5 py-2 text-xs border transition-all ${
253
+ isSelected
254
+ ? "border-primary bg-primary/10 text-text-primary"
255
+ : "border-border bg-background text-text-secondary hover:border-primary/40 hover:bg-primary/5"
256
+ } ${isFocused ? "ring-2 ring-primary/40 ring-offset-1 ring-offset-background" : ""}`}
257
+ >
258
+ <span className={`flex items-center justify-center w-4.5 h-4.5 rounded text-[10px] font-semibold shrink-0 mt-px ${
259
+ isSelected ? "bg-primary/20 text-primary" : "bg-surface-elevated text-text-secondary"
260
+ }`}>
261
+ {oi + 1}
262
+ </span>
263
+ <div className="flex flex-col gap-0.5 flex-1">
264
+ <span className="font-medium text-text-primary">{opt.label}</span>
265
+ {opt.description && <span className="text-[11px] text-text-secondary">{opt.description}</span>}
266
+ </div>
267
+ </button>
268
+ );
269
+ })}
270
+
271
+ {/* Other / custom input */}
272
+ <div
273
+ className={`flex items-start gap-2.5 rounded px-2.5 py-2 text-xs border border-dashed transition-all border-border bg-transparent ${
274
+ kb.focusedOption === totalOptions - 1 ? "ring-2 ring-primary/40 ring-offset-1 ring-offset-background" : ""
275
+ }`}
276
+ >
277
+ <span className="flex items-center justify-center w-4.5 h-4.5 rounded bg-surface-elevated text-text-secondary text-[10px] font-semibold shrink-0 mt-px">
278
+ O
279
+ </span>
280
+ <input
281
+ ref={customInputRef}
282
+ type="text"
283
+ className="flex-1 px-2 py-1 text-xs bg-surface border border-border rounded text-text-primary outline-none placeholder:text-text-subtle focus:border-primary"
284
+ placeholder="Other (press O to type)..."
285
+ value={form.customInputs[form.activeTab] || ""}
286
+ onChange={(e) => form.handleCustomInput(form.activeTab, e.target.value)}
287
+ onFocus={() => kb.setFocusedOption(totalOptions - 1)}
288
+ />
289
+ </div>
290
+ </div>
291
+ </div>
292
+ )}
293
+
294
+ {/* Buttons */}
295
+ <div className="flex gap-2 justify-end pt-1">
296
+ {hasMultiple && (
297
+ <>
298
+ <button
299
+ className="px-3 py-1.5 text-xs rounded border border-border bg-background text-text-primary hover:bg-surface-elevated disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
300
+ onClick={form.goToPrevTab}
301
+ disabled={form.activeTab === 0}
302
+ tabIndex={-1}
303
+ >
304
+ ← Prev
305
+ </button>
306
+ <button
307
+ className="px-3 py-1.5 text-xs rounded border border-border bg-background text-text-primary hover:bg-surface-elevated disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
308
+ onClick={form.goToNextTab}
309
+ disabled={form.activeTab === questions.length - 1}
310
+ tabIndex={-1}
311
+ >
312
+ Next →
313
+ </button>
314
+ </>
315
+ )}
316
+ <button
317
+ onClick={onSkip}
318
+ className="px-4 py-1.5 rounded border border-border bg-background text-text-secondary text-xs hover:bg-surface-elevated transition-colors"
319
+ tabIndex={-1}
320
+ >
321
+ Skip
322
+ </button>
323
+ <button
324
+ onClick={handleSubmit}
325
+ disabled={!form.allAnswered}
326
+ className="px-4 py-1.5 rounded bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
327
+ tabIndex={-1}
328
+ >
329
+ Submit {form.allAnswered ? "✓" : `(${questions.filter((_, i) => form.hasAnswer(i)).length}/${questions.length})`}
330
+ </button>
331
+ </div>
332
+ </div>
333
+ );
334
+ }
@@ -3,6 +3,7 @@ import {
3
3
  Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
4
4
  } from "@/components/ui/dialog";
5
5
  import { Button } from "@/components/ui/button";
6
+ import { BrowseButton } from "@/components/ui/browse-button";
6
7
  import { ConnectionColorPicker } from "./connection-color-picker";
7
8
  import type { Connection, CreateConnectionData, UpdateConnectionData } from "./use-connections";
8
9
 
@@ -161,12 +162,20 @@ export function ConnectionFormDialog({
161
162
  ) : (
162
163
  <div>
163
164
  <label className="text-xs font-medium text-text-secondary mb-1 block">File Path *</label>
164
- <input
165
- value={form.path}
166
- onChange={(e) => set("path", e.target.value)}
167
- placeholder="/path/to/database.db"
168
- className="w-full h-8 text-sm px-2.5 rounded-md border border-border bg-background focus:outline-none focus:border-primary font-mono"
169
- />
165
+ <div className="flex gap-1.5 items-center">
166
+ <input
167
+ value={form.path}
168
+ onChange={(e) => set("path", e.target.value)}
169
+ placeholder="/path/to/database.db"
170
+ className="flex-1 h-8 text-sm px-2.5 rounded-md border border-border bg-background focus:outline-none focus:border-primary font-mono"
171
+ />
172
+ <BrowseButton
173
+ mode="file"
174
+ accept={[".db", ".sqlite", ".sqlite3"]}
175
+ title="Browse for SQLite database"
176
+ onSelect={(path) => set("path", path)}
177
+ />
178
+ </div>
170
179
  </div>
171
180
  )}
172
181
 
@@ -0,0 +1,116 @@
1
+ import { useRef, useState } from "react";
2
+ import { MoreVertical, Download, Upload, Clipboard, ClipboardPaste } from "lucide-react";
3
+
4
+ interface Props {
5
+ onExport: () => Promise<{ version: number; exported_at: string; connections: unknown[] }>;
6
+ onImport: (data: { connections: unknown[] }) => Promise<{ imported: number; skipped: number; errors: string[] }>;
7
+ }
8
+
9
+ export function ConnectionImportExport({ onExport, onImport }: Props) {
10
+ const [open, setOpen] = useState(false);
11
+ const fileRef = useRef<HTMLInputElement>(null);
12
+
13
+ const close = () => setOpen(false);
14
+
15
+ const handleExportFile = async () => {
16
+ close();
17
+ try {
18
+ const data = await onExport();
19
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
20
+ const url = URL.createObjectURL(blob);
21
+ const a = document.createElement("a");
22
+ a.href = url;
23
+ a.download = `ppm-connections-${new Date().toISOString().slice(0, 10)}.json`;
24
+ a.click();
25
+ URL.revokeObjectURL(url);
26
+ } catch (e) {
27
+ alert(`Export failed: ${(e as Error).message}`);
28
+ }
29
+ };
30
+
31
+ const handleExportClipboard = async () => {
32
+ close();
33
+ try {
34
+ const data = await onExport();
35
+ await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
36
+ alert(`Copied ${data.connections.length} connection(s) to clipboard`);
37
+ } catch (e) {
38
+ alert(`Export failed: ${(e as Error).message}`);
39
+ }
40
+ };
41
+
42
+ const doImport = async (json: string) => {
43
+ try {
44
+ const data = JSON.parse(json);
45
+ const conns = data.connections ?? data;
46
+ if (!Array.isArray(conns)) { alert("Invalid format: expected connections array"); return; }
47
+ const result = await onImport({ connections: conns });
48
+ let msg = `Imported ${result.imported} connection(s)`;
49
+ if (result.skipped > 0) msg += `, ${result.skipped} skipped`;
50
+ if (result.errors?.length > 0) msg += `\n\nErrors:\n${result.errors.join("\n")}`;
51
+ alert(msg);
52
+ } catch (e) {
53
+ alert(`Import failed: ${(e as Error).message}`);
54
+ }
55
+ };
56
+
57
+ const handleImportFile = () => {
58
+ close();
59
+ fileRef.current?.click();
60
+ };
61
+
62
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
63
+ const file = e.target.files?.[0];
64
+ if (!file) return;
65
+ const reader = new FileReader();
66
+ reader.onload = () => doImport(reader.result as string);
67
+ reader.readAsText(file);
68
+ e.target.value = "";
69
+ };
70
+
71
+ const handleImportClipboard = async () => {
72
+ close();
73
+ try {
74
+ const text = await navigator.clipboard.readText();
75
+ if (!text.trim()) { alert("Clipboard is empty"); return; }
76
+ await doImport(text);
77
+ } catch (e) {
78
+ alert(`Clipboard read failed: ${(e as Error).message}`);
79
+ }
80
+ };
81
+
82
+ return (
83
+ <div className="relative">
84
+ <button
85
+ onClick={() => setOpen((v) => !v)}
86
+ className="flex items-center justify-center size-5 rounded hover:bg-surface-elevated transition-colors text-text-subtle hover:text-foreground"
87
+ title="Import / Export"
88
+ >
89
+ <MoreVertical className="size-3.5" />
90
+ </button>
91
+
92
+ {open && (
93
+ <>
94
+ <div className="fixed inset-0 z-40" onClick={close} />
95
+ <div className="absolute right-0 top-full mt-1 z-50 w-44 bg-background border border-border rounded-md shadow-lg py-1 text-xs">
96
+ <button onClick={handleExportFile} className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-surface-elevated transition-colors text-left">
97
+ <Download className="size-3" /> Export to file
98
+ </button>
99
+ <button onClick={handleExportClipboard} className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-surface-elevated transition-colors text-left">
100
+ <Clipboard className="size-3" /> Export to clipboard
101
+ </button>
102
+ <div className="border-t border-border my-1" />
103
+ <button onClick={handleImportFile} className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-surface-elevated transition-colors text-left">
104
+ <Upload className="size-3" /> Import from file
105
+ </button>
106
+ <button onClick={handleImportClipboard} className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-surface-elevated transition-colors text-left">
107
+ <ClipboardPaste className="size-3" /> Import from clipboard
108
+ </button>
109
+ </div>
110
+ </>
111
+ )}
112
+
113
+ <input ref={fileRef} type="file" accept=".json" className="hidden" onChange={handleFileChange} />
114
+ </div>
115
+ );
116
+ }
@@ -3,10 +3,11 @@ import { Plus } from "lucide-react";
3
3
  import { useTabStore } from "@/stores/tab-store";
4
4
  import { ConnectionList } from "./connection-list";
5
5
  import { ConnectionFormDialog } from "./connection-form-dialog";
6
+ import { ConnectionImportExport } from "./connection-import-export";
6
7
  import { useConnections, type Connection, type CreateConnectionData, type UpdateConnectionData } from "./use-connections";
7
8
 
8
9
  export function DatabaseSidebar() {
9
- const { connections, loading, cachedTables, createConnection, updateConnection, deleteConnection, testConnection, refreshTables } = useConnections();
10
+ const { connections, loading, cachedTables, createConnection, updateConnection, deleteConnection, testConnection, refreshTables, exportConnections, importConnections } = useConnections();
10
11
  const openTab = useTabStore((s) => s.openTab);
11
12
  const [addOpen, setAddOpen] = useState(false);
12
13
  const [editConn, setEditConn] = useState<Connection | null>(null);
@@ -41,13 +42,16 @@ export function DatabaseSidebar() {
41
42
  {/* Header */}
42
43
  <div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
43
44
  <span className="text-[10px] font-semibold text-text-subtle uppercase tracking-wider">Database</span>
44
- <button
45
- onClick={() => setAddOpen(true)}
46
- className="flex items-center justify-center size-5 rounded hover:bg-surface-elevated transition-colors text-text-subtle hover:text-foreground"
47
- title="Add connection"
48
- >
49
- <Plus className="size-3.5" />
50
- </button>
45
+ <div className="flex items-center gap-0.5">
46
+ <ConnectionImportExport onExport={exportConnections} onImport={importConnections} />
47
+ <button
48
+ onClick={() => setAddOpen(true)}
49
+ className="flex items-center justify-center size-5 rounded hover:bg-surface-elevated transition-colors text-text-subtle hover:text-foreground"
50
+ title="Add connection"
51
+ >
52
+ <Plus className="size-3.5" />
53
+ </button>
54
+ </div>
51
55
  </div>
52
56
 
53
57
  {/* Connection list */}
@@ -88,5 +88,17 @@ export function useConnections() {
88
88
  setCachedTables((prev) => new Map(prev).set(id, tables));
89
89
  }, []);
90
90
 
91
- return { connections, loading, cachedTables, createConnection, updateConnection, deleteConnection, testConnection, refreshTables };
91
+ const exportConnections = useCallback(async () => {
92
+ return api.get<{ version: number; exported_at: string; connections: unknown[] }>("/api/db/connections/export");
93
+ }, []);
94
+
95
+ const importConnections = useCallback(async (data: { connections: unknown[] }) => {
96
+ const result = await api.post<{ imported: number; skipped: number; errors: string[]; connections: Connection[] }>(
97
+ "/api/db/connections/import", data,
98
+ );
99
+ await fetchConnections();
100
+ return result;
101
+ }, [fetchConnections]);
102
+
103
+ return { connections, loading, cachedTables, createConnection, updateConnection, deleteConnection, testConnection, refreshTables, exportConnections, importConnections };
92
104
  }