@hienlh/ppm 0.6.5 → 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.
- package/CHANGELOG.md +22 -5
- package/dist/web/assets/chat-tab-CWhxhPKH.js +7 -0
- package/dist/web/assets/{code-editor-CVMeIylx.js → code-editor-BUg7alP6.js} +1 -1
- package/dist/web/assets/database-viewer-CAgZOkZc.js +1 -0
- package/dist/web/assets/{diff-viewer-B1vnegRS.js → diff-viewer-DVvY1aFb.js} +1 -1
- package/dist/web/assets/{git-graph-Bi4PM-z2.js → git-graph-xD6TLRVv.js} +1 -1
- package/dist/web/assets/index-CigdXBuQ.css +2 -0
- package/dist/web/assets/index-DBdw8tN_.js +22 -0
- package/dist/web/assets/keybindings-store-kHLASnRb.js +1 -0
- package/dist/web/assets/{markdown-renderer-ChvoCZNm.js → markdown-renderer-z99RjIxZ.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DPsoDR4y.js → postgres-viewer-CaMySHpD.js} +1 -1
- package/dist/web/assets/{settings-tab-D7pNWvVE.js → settings-tab-BnDkeQWk.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CTPkNEEe.js → sqlite-viewer-EwHWc37J.js} +1 -1
- package/dist/web/assets/terminal-tab-CTN18lb6.js +36 -0
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/index.ts +3 -79
- package/src/server/routes/database.ts +57 -0
- package/src/server/routes/fs-browse.ts +67 -0
- package/src/services/database/sqlite-adapter.ts +1 -2
- package/src/services/fs-browse.service.ts +216 -0
- package/src/services/sqlite.service.ts +2 -1
- package/src/web/app.tsx +23 -19
- package/src/web/components/chat/message-list.tsx +8 -105
- package/src/web/components/chat/question-card.tsx +334 -0
- package/src/web/components/database/connection-form-dialog.tsx +15 -6
- package/src/web/components/database/connection-import-export.tsx +116 -0
- package/src/web/components/database/database-sidebar.tsx +12 -8
- package/src/web/components/database/database-viewer.tsx +6 -2
- package/src/web/components/database/use-connections.ts +13 -1
- package/src/web/components/database/use-database.ts +7 -4
- package/src/web/components/layout/add-project-form.tsx +23 -12
- package/src/web/components/layout/command-palette.tsx +1 -1
- package/src/web/components/projects/dir-suggest.tsx +22 -12
- package/src/web/components/ui/browse-button.tsx +42 -0
- package/src/web/components/ui/file-browser-picker.tsx +374 -0
- package/src/web/stores/project-store.ts +0 -14
- package/dist/web/assets/chat-tab-DkgRZpbj.js +0 -7
- package/dist/web/assets/database-viewer-BX0F2yv0.js +0 -1
- package/dist/web/assets/index-DSg2VjxL.css +0 -2
- package/dist/web/assets/index-DUb5kwfL.js +0 -21
- package/dist/web/assets/keybindings-store-BVTJScRw.js +0 -1
- package/dist/web/assets/terminal-tab-B_75oJaQ.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
|
-
<
|
|
619
|
-
{questions
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 */}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|
2
|
-
import { Database, Loader2, Play, ChevronLeft, ChevronRight } from "lucide-react";
|
|
2
|
+
import { Database, Loader2, Play, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
|
|
3
3
|
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from "@tanstack/react-table";
|
|
4
4
|
import CodeMirror from "@uiw/react-codemirror";
|
|
5
5
|
import { sql, PostgreSQL, SQLite } from "@codemirror/lang-sql";
|
|
@@ -36,7 +36,11 @@ export function DatabaseViewer({ metadata }: Props) {
|
|
|
36
36
|
<Database className="size-3.5 text-muted-foreground" />
|
|
37
37
|
<span className="text-xs text-muted-foreground truncate">{connectionName ?? "Database"}</span>
|
|
38
38
|
{db.selectedTable && <span className="text-xs text-muted-foreground">/ {db.selectedTable}</span>}
|
|
39
|
-
<div className="ml-auto">
|
|
39
|
+
<div className="ml-auto flex items-center gap-1">
|
|
40
|
+
<button type="button" onClick={() => db.refreshData()} title="Reload data"
|
|
41
|
+
className="p-1 rounded text-muted-foreground hover:text-foreground transition-colors">
|
|
42
|
+
<RefreshCw className={`size-3 ${db.loading ? "animate-spin" : ""}`} />
|
|
43
|
+
</button>
|
|
40
44
|
<button type="button" onClick={() => setQueryPanelOpen((v) => !v)}
|
|
41
45
|
className={`px-2 py-1 rounded text-xs transition-colors ${queryPanelOpen ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}>
|
|
42
46
|
SQL
|