Skip to content

Commit 98012c5

Browse files
Add tab context menu, pin, export with credentials, and Quick Connect history label
- Tab right-click context menu: Rename, Pin/Unpin, Duplicate, Close others, Close - Pin tab: pinned sessions sort to front, shown with pin icon - Session rename: inline edit via right-click → Rename - Duplicate session: opens a new session with the same connection - Quick Connect: add "Recent" / "Results" section label above items - Export/Import with encrypted credentials: AES-256-GCM (Web Crypto) with PBKDF2 key derivation; new ExportImportDialog prompts for password on export and import - Add cryptoUtils.ts for encrypt/decrypt helpers
1 parent 9b3b8f1 commit 98012c5

7 files changed

Lines changed: 439 additions & 34 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { useState, useRef, useEffect } from 'react'
2+
import { Lock, Eye, EyeOff, Download, Upload } from 'lucide-react'
3+
import { cn } from '../../lib/utils'
4+
5+
interface Props {
6+
mode: 'export' | 'import'
7+
hasEncryptedCredentials?: boolean
8+
onConfirm: (password: string | undefined) => void
9+
onCancel: () => void
10+
}
11+
12+
export function ExportImportDialog({ mode, hasEncryptedCredentials, onConfirm, onCancel }: Props): JSX.Element {
13+
const [includeCredentials, setIncludeCredentials] = useState(mode === 'import' ? !!hasEncryptedCredentials : false)
14+
const [password, setPassword] = useState('')
15+
const [confirm, setConfirm] = useState('')
16+
const [showPw, setShowPw] = useState(false)
17+
const [error, setError] = useState('')
18+
const inputRef = useRef<HTMLInputElement>(null)
19+
20+
useEffect(() => {
21+
setTimeout(() => inputRef.current?.focus(), 50)
22+
}, [])
23+
24+
const handleSubmit = () => {
25+
if (!includeCredentials) { onConfirm(undefined); return }
26+
if (!password) { setError('Enter a password.'); return }
27+
if (mode === 'export' && password !== confirm) { setError('Passwords do not match.'); return }
28+
onConfirm(password)
29+
}
30+
31+
const Icon = mode === 'export' ? Download : Upload
32+
const isImport = mode === 'import'
33+
34+
return (
35+
<div
36+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
37+
onClick={onCancel}
38+
>
39+
<div
40+
className="bg-popover border border-border rounded-xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden"
41+
onClick={(e) => e.stopPropagation()}
42+
>
43+
{/* Header */}
44+
<div className="flex items-center gap-3 px-5 py-4 border-b border-border">
45+
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
46+
<Icon className="w-4 h-4 text-primary" />
47+
</div>
48+
<div>
49+
<p className="text-sm font-semibold text-foreground">
50+
{mode === 'export' ? 'Export Connections' : 'Import Connections'}
51+
</p>
52+
<p className="text-xs text-muted-foreground mt-0.5">
53+
{mode === 'export'
54+
? 'Optionally include encrypted credentials'
55+
: 'Provide the password if the file includes credentials'}
56+
</p>
57+
</div>
58+
</div>
59+
60+
{/* Body */}
61+
<div className="px-5 py-4 space-y-4">
62+
{/* Toggle */}
63+
{!isImport && (
64+
<label className="flex items-center gap-3 cursor-pointer">
65+
<div
66+
onClick={() => { setIncludeCredentials(!includeCredentials); setError('') }}
67+
className={cn(
68+
'w-9 h-5 rounded-full transition-colors relative',
69+
includeCredentials ? 'bg-primary' : 'bg-muted'
70+
)}
71+
>
72+
<span className={cn(
73+
'absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform',
74+
includeCredentials && 'translate-x-4'
75+
)} />
76+
</div>
77+
<span className="text-sm text-foreground/80">Include passwords (encrypted)</span>
78+
</label>
79+
)}
80+
81+
{isImport && hasEncryptedCredentials && (
82+
<div className="flex items-center gap-2 text-xs text-amber-400 bg-amber-400/10 rounded-lg px-3 py-2">
83+
<Lock className="w-3.5 h-3.5 shrink-0" />
84+
This file contains encrypted credentials. Enter the export password to restore them.
85+
</div>
86+
)}
87+
88+
{/* Password fields */}
89+
{(includeCredentials || (isImport && hasEncryptedCredentials)) && (
90+
<div className="space-y-3">
91+
<div className="relative">
92+
<input
93+
ref={inputRef}
94+
type={showPw ? 'text' : 'password'}
95+
value={password}
96+
onChange={(e) => { setPassword(e.target.value); setError('') }}
97+
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit() }}
98+
placeholder={isImport ? 'Export password' : 'Encryption password'}
99+
className="w-full bg-secondary text-sm text-foreground rounded-lg px-3 py-2 pr-9 focus:outline-none focus:ring-1 focus:ring-primary border border-transparent focus:border-primary transition"
100+
/>
101+
<button
102+
type="button"
103+
onClick={() => setShowPw(!showPw)}
104+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
105+
>
106+
{showPw ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
107+
</button>
108+
</div>
109+
110+
{!isImport && (
111+
<input
112+
type={showPw ? 'text' : 'password'}
113+
value={confirm}
114+
onChange={(e) => { setConfirm(e.target.value); setError('') }}
115+
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit() }}
116+
placeholder="Confirm password"
117+
className="w-full bg-secondary text-sm text-foreground rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary border border-transparent focus:border-primary transition"
118+
/>
119+
)}
120+
121+
{error && <p className="text-xs text-red-400">{error}</p>}
122+
</div>
123+
)}
124+
</div>
125+
126+
{/* Footer */}
127+
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border">
128+
<button
129+
onClick={onCancel}
130+
className="px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
131+
>
132+
Cancel
133+
</button>
134+
<button
135+
onClick={handleSubmit}
136+
className="px-4 py-1.5 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg transition-colors"
137+
>
138+
{mode === 'export' ? 'Export' : 'Import'}
139+
</button>
140+
</div>
141+
</div>
142+
</div>
143+
)
144+
}

src/renderer/src/components/dialogs/QuickConnect.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ export function QuickConnect(): JSX.Element {
149149
<p className="text-center text-sm text-muted-foreground py-10">No connections found</p>
150150
)}
151151

152+
{/* Section label */}
153+
{items.length > 0 && (
154+
<p className="px-4 pt-1.5 pb-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/40">
155+
{!query ? 'Recent' : 'Results'}
156+
</p>
157+
)}
158+
152159
{items.map((item, idx) => (
153160
<button
154161
key={idx}

src/renderer/src/components/sidebar/Sidebar.tsx

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Connection, ConnectionGroup } from '../../types'
55
import { ConnectionContextMenu } from './ConnectionContextMenu'
66
import { GroupDialog } from './GroupDialog'
77
import { SSHKeyDialog } from '../dialogs/SSHKeyDialog'
8+
import { ExportImportDialog } from '../dialogs/ExportImportDialog'
89
import { cn } from '../../lib/utils'
910

1011
const GROUP_COLORS = [
@@ -35,6 +36,8 @@ export function Sidebar(): JSX.Element {
3536
const [importMsg, setImportMsg] = useState<string | null>(null)
3637
const [sshKeyDialogOpen, setSshKeyDialogOpen] = useState(false)
3738
const [resizing, setResizing] = useState(false)
39+
const [exportDialogOpen, setExportDialogOpen] = useState(false)
40+
const [importDialogOpen, setImportDialogOpen] = useState(false)
3841

3942
const [search, setSearch] = useState('')
4043
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
@@ -218,15 +221,8 @@ export function Sidebar(): JSX.Element {
218221
<div className="border-t border-sidebar-border p-2.5 space-y-0.5">
219222
{[
220223
{ icon: Key, label: 'SSH Keys', action: () => setSshKeyDialogOpen(true) },
221-
{ icon: Download, label: 'Export Connections', action: () => exportConnections() },
222-
{ icon: Upload, label: 'Import Connections', action: async () => {
223-
setImportMsg(null)
224-
const count = await importConnections()
225-
if (count === -1) setImportMsg('Import failed — invalid file')
226-
else if (count === 0) setImportMsg('No connections imported')
227-
else setImportMsg(`Imported ${count} connection${count !== 1 ? 's' : ''}`)
228-
setTimeout(() => setImportMsg(null), 3500)
229-
}},
224+
{ icon: Download, label: 'Export Connections', action: () => setExportDialogOpen(true) },
225+
{ icon: Upload, label: 'Import Connections', action: () => setImportDialogOpen(true) },
230226
].map(({ icon: Icon, label, action }) => (
231227
<button
232228
key={label}
@@ -274,6 +270,33 @@ export function Sidebar(): JSX.Element {
274270
{sshKeyDialogOpen && (
275271
<SSHKeyDialog onClose={() => setSshKeyDialogOpen(false)} />
276272
)}
273+
274+
{exportDialogOpen && (
275+
<ExportImportDialog
276+
mode="export"
277+
onConfirm={async (password) => {
278+
setExportDialogOpen(false)
279+
await exportConnections(password)
280+
}}
281+
onCancel={() => setExportDialogOpen(false)}
282+
/>
283+
)}
284+
285+
{importDialogOpen && (
286+
<ExportImportDialog
287+
mode="import"
288+
onConfirm={async (password) => {
289+
setImportDialogOpen(false)
290+
setImportMsg(null)
291+
const count = await importConnections(password)
292+
if (count === -1) setImportMsg('Import failed — invalid or wrong password')
293+
else if (count === 0) setImportMsg('No connections imported')
294+
else setImportMsg(`Imported ${count} connection${count !== 1 ? 's' : ''}`)
295+
setTimeout(() => setImportMsg(null), 3500)
296+
}}
297+
onCancel={() => setImportDialogOpen(false)}
298+
/>
299+
)}
277300
</div>
278301
)
279302
}

0 commit comments

Comments
 (0)