Skip to content

Commit 619cfe2

Browse files
feat: SSH config import, sidebar UI refresh
- Import connections from ~/.ssh/config with a preview dialog (checkbox per host, shows hostname/port/user/IdentityFile, Browse button for custom path, works on macOS / Windows / Linux via os.homedir()) - Sidebar typography overhaul: bolder connection names (14px semibold), larger icon containers (w-9 rounded-xl), higher-contrast subtitles, group headers use font-bold tracking-tight - Footer tools always visible with colored icon badges per action - Connections header bumped to 15px bold
1 parent 198fb62 commit 619cfe2

8 files changed

Lines changed: 415 additions & 40 deletions

File tree

src/main/fileDialog.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { IpcMain, dialog, BrowserWindow, app } from 'electron'
22
import path from 'path'
3+
import os from 'os'
34
import { readFile, writeFile } from 'fs/promises'
45

56
export function setupFileDialogHandlers(
@@ -55,4 +56,33 @@ export function setupFileDialogHandlers(
5556
ipcMain.handle('dialog:getDefaultLogDir', () => {
5657
return path.join(app.getPath('documents'), 'NetCopilot Logs')
5758
})
59+
60+
// ── SSH Config reader ────────────────────────────────────────────────────────
61+
ipcMain.handle('dialog:read-ssh-config', async (_, pickFile = false) => {
62+
const win = getWindow() ?? undefined
63+
if (pickFile) {
64+
try {
65+
const result = await dialog.showOpenDialog(win!, {
66+
title: 'Select SSH Config File',
67+
defaultPath: path.join(os.homedir(), '.ssh', 'config'),
68+
filters: [
69+
{ name: 'SSH Config', extensions: ['config', 'conf', ''] },
70+
{ name: 'All Files', extensions: ['*'] }
71+
],
72+
properties: ['openFile']
73+
})
74+
if (result.canceled || result.filePaths.length === 0) return null
75+
return await readFile(result.filePaths[0], 'utf-8')
76+
} catch {
77+
return null
78+
}
79+
}
80+
// Auto-read default location
81+
try {
82+
const defaultPath = path.join(os.homedir(), '.ssh', 'config')
83+
return await readFile(defaultPath, 'utf-8')
84+
} catch {
85+
return null
86+
}
87+
})
5888
}

src/preload/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ const api = {
8181
selectFolder: (): Promise<string | null> =>
8282
ipcRenderer.invoke('dialog:selectFolder'),
8383
getDefaultLogDir: (): Promise<string> =>
84-
ipcRenderer.invoke('dialog:getDefaultLogDir')
84+
ipcRenderer.invoke('dialog:getDefaultLogDir'),
85+
readSshConfig: (pickFile?: boolean): Promise<string | null> =>
86+
ipcRenderer.invoke('dialog:read-ssh-config', pickFile ?? false),
8587
},
8688

8789
// App info

src/renderer/src/components/TitleBar.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,13 @@ export function TitleBar({ onShortcuts, onWelcome }: Props): JSX.Element {
3636
style={{ paddingLeft: isMac ? '80px' : '16px', paddingRight: isMac ? '12px' : '0px' }}
3737
>
3838
{/* Logo */}
39-
<div className="flex items-center gap-2 no-drag">
40-
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-primary/15">
41-
<Network className="w-3.5 h-3.5 text-primary" />
39+
<div className="flex items-center gap-2.5 no-drag">
40+
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-primary/15">
41+
<Network className="w-4 h-4 text-primary" />
4242
</div>
43-
<span className="text-sm font-semibold text-foreground tracking-tight">AI Network</span>
44-
<span className="hidden sm:inline text-[10px] text-muted-foreground/40 font-medium ml-0.5 tracking-wider uppercase">
45-
beta
46-
</span>
43+
<span className="text-[15px] font-bold text-foreground tracking-tight">NetCopilot</span>
4744
{activeSessions > 0 && (
48-
<span className="hidden sm:inline text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-500 tabular-nums">
45+
<span className="hidden sm:inline text-[11px] font-semibold px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-600 tabular-nums">
4946
{activeSessions} live
5047
</span>
5148
)}
@@ -55,7 +52,7 @@ export function TitleBar({ onShortcuts, onWelcome }: Props): JSX.Element {
5552
<div className="flex items-center gap-1 no-drag">
5653
<button
5754
onClick={() => setQuickConnectOpen(true)}
58-
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
55+
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[13px] text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
5956
title="Quick Connect"
6057
>
6158
<Zap className="w-3.5 h-3.5" />
@@ -113,14 +110,14 @@ export function TitleBar({ onShortcuts, onWelcome }: Props): JSX.Element {
113110
<div className="flex items-center no-drag ml-2">
114111
<button
115112
onClick={() => window.api.window.minimize()}
116-
className="w-11 h-11 flex items-center justify-center text-muted-foreground hover:bg-white/10 hover:text-foreground transition-colors"
113+
className="w-11 h-11 flex items-center justify-center text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
117114
title="Minimize"
118115
>
119116
<Minus className="w-4 h-4" />
120117
</button>
121118
<button
122119
onClick={() => window.api.window.maximize()}
123-
className="w-11 h-11 flex items-center justify-center text-muted-foreground hover:bg-white/10 hover:text-foreground transition-colors"
120+
className="w-11 h-11 flex items-center justify-center text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
124121
title={isMaximized ? 'Restore' : 'Maximize'}
125122
>
126123
{isMaximized

src/renderer/src/components/ai/AiPanel.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,11 +252,21 @@ export function AiPanel({ activeSession, splitSession, allSessions, getTerminalC
252252
else sendToTerminal(d)
253253
}
254254

255+
// Scroll to bottom before sending so user sees the command and its output
256+
if (resolvedSession) {
257+
terminalRegistry.get(resolvedSession.id)?.scrollToBottom()
258+
}
259+
255260
// Collect terminal output from the correct session only
256261
offData = collectTerminalOutput((data) => {
257262
output += data
258263
clearTimeout(timer)
259264

265+
// Keep scrolling to bottom as output arrives
266+
if (resolvedSession) {
267+
terminalRegistry.get(resolvedSession.id)?.scrollToBottom()
268+
}
269+
260270
// If device is paginating, send space to get the next page
261271
if (MORE_PATTERN.test(data)) {
262272
sendData(' ')
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { useState, useEffect } from 'react'
2+
import { FileCode, Check, ChevronDown, ChevronRight, Server, AlertCircle, FolderOpen, Loader2 } from 'lucide-react'
3+
import { parseSshConfig, SshConfigHost } from '../../lib/sshConfigParser'
4+
import { cn } from '../../lib/utils'
5+
6+
interface Props {
7+
onImport: (hosts: SshConfigHost[]) => Promise<void>
8+
onCancel: () => void
9+
}
10+
11+
export function SshConfigImportDialog({ onImport, onCancel }: Props): JSX.Element {
12+
const [hosts, setHosts] = useState<SshConfigHost[]>([])
13+
const [selected, setSelected] = useState<Set<string>>(new Set())
14+
const [loading, setLoading] = useState(true)
15+
const [error, setError] = useState('')
16+
const [importing, setImporting] = useState(false)
17+
const [expanded, setExpanded] = useState<Set<string>>(new Set())
18+
19+
const loadDefault = async () => {
20+
setLoading(true); setError('')
21+
try {
22+
const content = await window.api.file.readSshConfig(false)
23+
if (!content) { setError('~/.ssh/config not found'); setLoading(false); return }
24+
const parsed = parseSshConfig(content)
25+
if (parsed.length === 0) { setError('No hosts found in config'); setLoading(false); return }
26+
setHosts(parsed)
27+
setSelected(new Set(parsed.map((h) => h.name)))
28+
} catch {
29+
setError('Failed to read ~/.ssh/config')
30+
}
31+
setLoading(false)
32+
}
33+
34+
const loadCustom = async () => {
35+
setLoading(true); setError('')
36+
try {
37+
const content = await window.api.file.readSshConfig(true)
38+
if (!content) { setLoading(false); return }
39+
const parsed = parseSshConfig(content)
40+
if (parsed.length === 0) { setError('No hosts found in selected file'); setLoading(false); return }
41+
setHosts(parsed)
42+
setSelected(new Set(parsed.map((h) => h.name)))
43+
} catch {
44+
setError('Failed to read selected file')
45+
}
46+
setLoading(false)
47+
}
48+
49+
useEffect(() => { loadDefault() }, [])
50+
51+
const toggleHost = (name: string) => {
52+
setSelected((prev) => {
53+
const next = new Set(prev)
54+
next.has(name) ? next.delete(name) : next.add(name)
55+
return next
56+
})
57+
}
58+
59+
const toggleAll = () => {
60+
setSelected(selected.size === hosts.length ? new Set() : new Set(hosts.map((h) => h.name)))
61+
}
62+
63+
const toggleExpand = (name: string) => {
64+
setExpanded((prev) => {
65+
const next = new Set(prev)
66+
next.has(name) ? next.delete(name) : next.add(name)
67+
return next
68+
})
69+
}
70+
71+
const handleImport = async () => {
72+
setImporting(true)
73+
await onImport(hosts.filter((h) => selected.has(h.name)))
74+
setImporting(false)
75+
}
76+
77+
return (
78+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
79+
onClick={onCancel}>
80+
<div
81+
className="bg-popover border border-border rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden flex flex-col"
82+
style={{ maxHeight: '80vh' }}
83+
onClick={(e) => e.stopPropagation()}
84+
>
85+
{/* Header */}
86+
<div className="flex items-center gap-3 px-5 py-4 border-b border-border shrink-0">
87+
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
88+
<FileCode className="w-4 h-4 text-primary" />
89+
</div>
90+
<div className="flex-1 min-w-0">
91+
<p className="text-sm font-semibold text-foreground">Import from SSH Config</p>
92+
<p className="text-xs text-muted-foreground mt-0.5">
93+
Reading <code className="bg-secondary px-1 rounded text-[10px]">~/.ssh/config</code>
94+
</p>
95+
</div>
96+
<button
97+
onClick={loadCustom}
98+
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1.5 rounded-lg hover:bg-accent cursor-pointer"
99+
>
100+
<FolderOpen className="w-3.5 h-3.5" /> Browse
101+
</button>
102+
</div>
103+
104+
{/* Body */}
105+
<div className="flex-1 overflow-y-auto">
106+
{loading && (
107+
<div className="flex items-center justify-center py-16 gap-2 text-muted-foreground">
108+
<Loader2 className="w-4 h-4 animate-spin" />
109+
<span className="text-sm">Reading SSH config…</span>
110+
</div>
111+
)}
112+
113+
{!loading && error && (
114+
<div className="flex flex-col items-center gap-3 py-12 px-6 text-center">
115+
<AlertCircle className="w-8 h-8 text-amber-400" />
116+
<p className="text-sm text-muted-foreground">{error}</p>
117+
<button
118+
onClick={loadCustom}
119+
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors cursor-pointer"
120+
>
121+
<FolderOpen className="w-3.5 h-3.5" /> Browse for file
122+
</button>
123+
</div>
124+
)}
125+
126+
{!loading && !error && hosts.length > 0 && (
127+
<div className="py-2">
128+
{/* Select all row */}
129+
<button
130+
onClick={toggleAll}
131+
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-accent/50 transition-colors text-left"
132+
>
133+
<div className={cn(
134+
'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
135+
selected.size === hosts.length ? 'bg-primary border-primary' : 'border-border'
136+
)}>
137+
{selected.size === hosts.length && <Check className="w-3 h-3 text-white" />}
138+
{selected.size > 0 && selected.size < hosts.length && (
139+
<div className="w-2 h-0.5 bg-primary" />
140+
)}
141+
</div>
142+
<span className="text-sm font-medium text-foreground">
143+
Select all
144+
</span>
145+
<span className="ml-auto text-xs text-muted-foreground">{selected.size} / {hosts.length}</span>
146+
</button>
147+
148+
<div className="h-px bg-border mx-4 my-1" />
149+
150+
{/* Host rows */}
151+
{hosts.map((host) => {
152+
const isSelected = selected.has(host.name)
153+
const isExpanded = expanded.has(host.name)
154+
const hasExtras = Object.keys(host.extra).length > 0 || !!host.identityFile
155+
156+
return (
157+
<div key={host.name}>
158+
<div className="flex items-center gap-3 px-4 py-2 hover:bg-accent/40 transition-colors">
159+
{/* Checkbox */}
160+
<button
161+
onClick={() => toggleHost(host.name)}
162+
className={cn(
163+
'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors cursor-pointer',
164+
isSelected ? 'bg-primary border-primary' : 'border-border'
165+
)}
166+
>
167+
{isSelected && <Check className="w-3 h-3 text-white" />}
168+
</button>
169+
170+
{/* Icon */}
171+
<div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
172+
<Server className="w-3.5 h-3.5 text-primary" />
173+
</div>
174+
175+
{/* Info */}
176+
<div className="flex-1 min-w-0">
177+
<p className="text-[13px] font-medium text-foreground truncate">{host.name}</p>
178+
<p className="text-[11px] text-muted-foreground font-mono truncate">
179+
{host.username ? `${host.username}@` : ''}{host.hostname}:{host.port}
180+
</p>
181+
</div>
182+
183+
{/* Expand button */}
184+
{hasExtras && (
185+
<button
186+
onClick={() => toggleExpand(host.name)}
187+
className="p-1 rounded hover:bg-accent text-muted-foreground/60 hover:text-foreground transition-colors cursor-pointer"
188+
>
189+
{isExpanded
190+
? <ChevronDown className="w-3.5 h-3.5" />
191+
: <ChevronRight className="w-3.5 h-3.5" />
192+
}
193+
</button>
194+
)}
195+
</div>
196+
197+
{/* Expanded extra fields */}
198+
{isExpanded && (
199+
<div className="mx-4 mb-1 px-3 py-2 bg-secondary/50 rounded-lg text-[11px] font-mono text-muted-foreground space-y-0.5">
200+
{host.identityFile && (
201+
<p><span className="text-foreground/60">IdentityFile</span> {host.identityFile}</p>
202+
)}
203+
{Object.entries(host.extra).map(([k, v]) => (
204+
<p key={k}><span className="text-foreground/60">{k}</span> {v}</p>
205+
))}
206+
</div>
207+
)}
208+
</div>
209+
)
210+
})}
211+
</div>
212+
)}
213+
</div>
214+
215+
{/* Footer */}
216+
{!loading && !error && (
217+
<div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border shrink-0">
218+
<p className="text-xs text-muted-foreground">
219+
{selected.size} host{selected.size !== 1 ? 's' : ''} selected
220+
</p>
221+
<div className="flex items-center gap-2">
222+
<button onClick={onCancel} className="px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
223+
Cancel
224+
</button>
225+
<button
226+
onClick={handleImport}
227+
disabled={selected.size === 0 || importing}
228+
className="flex items-center gap-2 px-4 py-1.5 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
229+
>
230+
{importing && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
231+
Import {selected.size > 0 ? selected.size : ''} host{selected.size !== 1 ? 's' : ''}
232+
</button>
233+
</div>
234+
</div>
235+
)}
236+
</div>
237+
</div>
238+
)
239+
}

0 commit comments

Comments
 (0)