@hienlh/ppm 0.6.6 → 0.7.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +86 -313
  3. package/dist/web/assets/chat-tab-CbNbBMGw.js +7 -0
  4. package/dist/web/assets/{code-editor-ZFl5kZ4-.js → code-editor-D6OuzcC-.js} +1 -1
  5. package/dist/web/assets/{database-viewer-DPpOsMqa.js → database-viewer-BxUpM_uA.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-CX74l6lV.js → diff-viewer-DAhrHpNM.js} +1 -1
  7. package/dist/web/assets/{dist-Jb3Tnkpc.js → dist-CNRrBoQi.js} +14 -14
  8. package/dist/web/assets/git-graph-BpTt5iOd.js +1 -0
  9. package/dist/web/assets/index-BU_07_oW.js +29 -0
  10. package/dist/web/assets/index-CBQhXXeV.css +2 -0
  11. package/dist/web/assets/keybindings-store-C0m8_V9X.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-Bke6DHFh.js → markdown-renderer-CvGYO9sH.js} +2 -2
  13. package/dist/web/assets/postgres-viewer-BL99auSm.js +1 -0
  14. package/dist/web/assets/{settings-tab-DD05d8rM.js → settings-tab-Bwsxb41F.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-Cx7tLyT-.js → sqlite-viewer-DfgaCbWT.js} +1 -1
  16. package/dist/web/assets/terminal-tab-D27e4ZTD.js +36 -0
  17. package/dist/web/index.html +4 -3
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/lib/network-utils.ts +12 -0
  21. package/src/server/index.ts +3 -79
  22. package/src/server/routes/database.ts +57 -0
  23. package/src/server/routes/fs-browse.ts +67 -0
  24. package/src/server/routes/settings.ts +52 -0
  25. package/src/server/routes/tunnel.ts +1 -12
  26. package/src/server/ws/chat.ts +30 -3
  27. package/src/services/config.service.ts +1 -1
  28. package/src/services/fs-browse.service.ts +216 -0
  29. package/src/services/notification.service.ts +42 -0
  30. package/src/services/telegram-notification.service.ts +106 -0
  31. package/src/types/config.ts +6 -0
  32. package/src/web/app.tsx +61 -18
  33. package/src/web/components/chat/message-list.tsx +8 -105
  34. package/src/web/components/chat/question-card.tsx +334 -0
  35. package/src/web/components/database/connection-form-dialog.tsx +15 -6
  36. package/src/web/components/database/connection-import-export.tsx +116 -0
  37. package/src/web/components/database/database-sidebar.tsx +12 -8
  38. package/src/web/components/database/use-connections.ts +13 -1
  39. package/src/web/components/layout/add-project-form.tsx +23 -12
  40. package/src/web/components/layout/command-palette.tsx +1 -1
  41. package/src/web/components/layout/draggable-tab.tsx +10 -2
  42. package/src/web/components/layout/mobile-nav.tsx +42 -3
  43. package/src/web/components/layout/project-bar.tsx +16 -8
  44. package/src/web/components/layout/tab-bar.tsx +55 -4
  45. package/src/web/components/projects/dir-suggest.tsx +22 -12
  46. package/src/web/components/settings/settings-tab.tsx +135 -94
  47. package/src/web/components/settings/telegram-settings-section.tsx +113 -0
  48. package/src/web/components/ui/accordion.tsx +64 -0
  49. package/src/web/components/ui/browse-button.tsx +42 -0
  50. package/src/web/components/ui/file-browser-picker.tsx +374 -0
  51. package/src/web/hooks/use-chat.ts +29 -0
  52. package/src/web/hooks/use-notification-badge.ts +20 -0
  53. package/src/web/hooks/use-tab-overflow.ts +91 -0
  54. package/src/web/hooks/use-url-sync.ts +5 -2
  55. package/src/web/index.html +1 -0
  56. package/src/web/lib/favicon.ts +21 -0
  57. package/src/web/lib/notification-sounds.ts +61 -0
  58. package/src/web/stores/notification-store.ts +83 -0
  59. package/src/web/stores/project-store.ts +0 -14
  60. package/dist/web/assets/chat-tab-dwpaSkQD.js +0 -7
  61. package/dist/web/assets/git-graph-Dju1rygf.js +0 -1
  62. package/dist/web/assets/index-DSg2VjxL.css +0 -2
  63. package/dist/web/assets/index-DXOEmhRm.js +0 -21
  64. package/dist/web/assets/keybindings-store-VhiJwp77.js +0 -1
  65. package/dist/web/assets/postgres-viewer-DaNYnInA.js +0 -1
  66. package/dist/web/assets/terminal-tab-_farMLMO.js +0 -36
  67. /package/dist/web/assets/{tab-store-DIyJSjtr.js → tab-store-Bm1Hw8OR.js} +0 -0
@@ -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
  }
@@ -3,6 +3,7 @@ import { Loader2, FolderOpen } from "lucide-react";
3
3
  import { useProjectStore } from "@/stores/project-store";
4
4
  import { api } from "@/lib/api-client";
5
5
  import { cn } from "@/lib/utils";
6
+ import { BrowseButton } from "@/components/ui/browse-button";
6
7
 
7
8
  interface SuggestedDir {
8
9
  path: string;
@@ -83,19 +84,29 @@ export function AddProjectForm({ onSuccess, onCancel, footerClassName }: AddProj
83
84
  {/* Path input with suggestions */}
84
85
  <div ref={wrapperRef} className="relative">
85
86
  <label className="block text-xs font-medium text-foreground mb-1">Project path</label>
86
- <div className="relative flex items-center">
87
- <FolderOpen className="absolute left-2.5 size-3.5 text-text-subtle pointer-events-none" />
88
- <input
89
- type="text"
90
- value={path}
91
- onChange={(e) => { setPath(e.target.value); setError(""); }}
92
- onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
93
- placeholder="/path/to/project"
94
- className="w-full pl-8 pr-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
95
- autoFocus
96
- autoComplete="off"
87
+ <div className="flex gap-1.5 items-center">
88
+ <div className="relative flex items-center flex-1">
89
+ <FolderOpen className="absolute left-2.5 size-3.5 text-text-subtle pointer-events-none" />
90
+ <input
91
+ type="text"
92
+ value={path}
93
+ onChange={(e) => { setPath(e.target.value); setError(""); }}
94
+ onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
95
+ placeholder="/path/to/project"
96
+ className="w-full pl-8 pr-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
97
+ autoFocus
98
+ autoComplete="off"
99
+ />
100
+ {loading && <Loader2 className="absolute right-2.5 size-3.5 text-text-subtle animate-spin" />}
101
+ </div>
102
+ <BrowseButton
103
+ mode="folder"
104
+ onSelect={(selectedPath) => {
105
+ setPath(selectedPath);
106
+ if (!name) setName(selectedPath.split("/").pop() ?? "");
107
+ setError("");
108
+ }}
97
109
  />
98
- {loading && <Loader2 className="absolute right-2.5 size-3.5 text-text-subtle animate-spin" />}
99
110
  </div>
100
111
 
101
112
  {/* Suggestions dropdown */}
@@ -137,8 +137,8 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
137
137
  };
138
138
 
139
139
  return [
140
- { id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action" },
141
140
  { id: "chat", label: "New AI Chat", icon: MessageSquare, action: openNewTab("chat", "AI Chat"), keywords: "ai assistant claude", group: "action" },
141
+ { id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action" },
142
142
  { id: "git-graph", label: "Git Graph", icon: GitBranch, action: openNewTab("git-graph", "Git Graph"), keywords: "branch history log", group: "action" },
143
143
  { id: "postgres", label: "PostgreSQL", icon: Database, action: openNewTab("postgres", "PostgreSQL"), keywords: "database pg sql query", group: "action" },
144
144
  { id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action" },
@@ -3,12 +3,15 @@ import { X } from "lucide-react";
3
3
  import type { Tab, TabType } from "@/stores/tab-store";
4
4
  import { cn } from "@/lib/utils";
5
5
  import { isDarkColor } from "@/lib/color-utils";
6
+ import { notificationColor } from "@/stores/notification-store";
6
7
 
7
8
  interface DraggableTabProps {
8
9
  tab: Tab;
9
10
  isActive: boolean;
10
11
  icon: React.ElementType;
11
12
  showDropBefore: boolean;
13
+ /** Notification type if unread (null = no unread). Controls badge color. */
14
+ notificationType?: string | null;
12
15
  onSelect: () => void;
13
16
  onClose: () => void;
14
17
  onDragStart: (e: React.DragEvent) => void;
@@ -20,7 +23,7 @@ interface DraggableTabProps {
20
23
  }
21
24
 
22
25
  export function DraggableTab({
23
- tab, isActive, icon: Icon, showDropBefore, onSelect, onClose,
26
+ tab, isActive, icon: Icon, showDropBefore, notificationType, onSelect, onClose,
24
27
  onDragStart, onDragOver, onDragEnd, tabRef, onRename,
25
28
  }: DraggableTabProps) {
26
29
  const [editing, setEditing] = useState(false);
@@ -74,7 +77,12 @@ export function DraggableTab({
74
77
  colorStyle && "border-transparent",
75
78
  )}
76
79
  >
77
- <Icon className="size-4" />
80
+ <span className="relative">
81
+ <Icon className="size-4" />
82
+ {notificationType && !isActive && (
83
+ <span className={cn("absolute -top-1 -right-1 size-2 rounded-full", notificationColor(notificationType))} />
84
+ )}
85
+ </span>
78
86
  {editing ? (
79
87
  <input
80
88
  ref={inputRef}