Skip to content

Commit 47fb87b

Browse files
UI/UX improvements: tooltips, skeleton loading, empty state, panel transitions, focus rings
1 parent e9062d9 commit 47fb87b

5 files changed

Lines changed: 135 additions & 37 deletions

File tree

src/renderer/src/assets/globals.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,15 @@ a, button, input, select, textarea,
151151
transition-timing-function: ease-out;
152152
transition-duration: 100ms;
153153
}
154+
155+
/* ── Focus ring — visible for keyboard navigation ──────────────────── */
156+
:focus-visible {
157+
outline: 2px solid hsl(var(--primary));
158+
outline-offset: 2px;
159+
border-radius: 6px;
160+
}
161+
162+
/* Remove default outline when not keyboard navigating */
163+
:focus:not(:focus-visible) {
164+
outline: none;
165+
}

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

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { useState, useRef, useCallback } from 'react'
2-
import { Search, Plus, FolderPlus, ChevronDown, ChevronRight, Server, Router, Monitor, Key, Usb, Pencil, Trash2, Download, Upload, MoreHorizontal } from 'lucide-react'
2+
import { Search, Plus, FolderPlus, ChevronDown, ChevronRight, Server, Router, Monitor, Key, Usb, Pencil, Trash2, Download, Upload, MoreHorizontal, Clock, Zap } from 'lucide-react'
33
import { useAppStore } from '../../store'
44
import { Connection, ConnectionGroup } from '../../types'
55
import { ConnectionContextMenu } from './ConnectionContextMenu'
66
import { GroupDialog } from './GroupDialog'
77
import { SSHKeyDialog } from '../dialogs/SSHKeyDialog'
88
import { ExportImportDialog } from '../dialogs/ExportImportDialog'
9-
import { cn } from '../../lib/utils'
9+
import { cn, timeAgo } from '../../lib/utils'
1010

1111
const GROUP_COLORS = [
1212
'#8b5cf6', '#3b82f6', '#06b6d4', '#10b981',
@@ -31,7 +31,7 @@ export function Sidebar(): JSX.Element {
3131
sidebarWidth, setSidebarWidth,
3232
setConnectionDialogOpen, setQuickConnectOpen,
3333
openSession, openSftpSession, exportConnections, importConnections,
34-
saveConnection,
34+
saveConnection, connectionsLoaded,
3535
} = useAppStore()
3636

3737
const [importMsg, setImportMsg] = useState<string | null>(null)
@@ -147,16 +147,52 @@ export function Sidebar(): JSX.Element {
147147

148148
{/* Connection list */}
149149
<div className="flex-1 overflow-y-auto py-1">
150-
{connections.length === 0 && (
151-
<div className="flex flex-col items-center gap-2 p-6 text-center">
152-
<Server className="w-8 h-8 text-sidebar-foreground/20" />
153-
<p className="text-xs text-sidebar-foreground/40">No connections yet</p>
154-
<button
155-
onClick={() => setQuickConnectOpen(true)}
156-
className="text-xs text-primary hover:underline"
157-
>
158-
Quick connect
159-
</button>
150+
151+
{/* Skeleton loading */}
152+
{!connectionsLoaded && (
153+
<div className="px-2 py-1 space-y-1">
154+
{[...Array(5)].map((_, i) => (
155+
<div key={i} className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg mx-1 animate-pulse">
156+
<div className="w-8 h-8 rounded-lg bg-sidebar-accent/60 shrink-0" />
157+
<div className="flex-1 space-y-1.5">
158+
<div className="h-3 rounded bg-sidebar-accent/60" style={{ width: `${55 + (i * 13) % 35}%` }} />
159+
<div className="h-2.5 rounded bg-sidebar-accent/40" style={{ width: `${35 + (i * 17) % 30}%` }} />
160+
</div>
161+
</div>
162+
))}
163+
</div>
164+
)}
165+
166+
{/* Better empty state */}
167+
{connectionsLoaded && connections.length === 0 && (
168+
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
169+
<div className="w-12 h-12 rounded-2xl bg-sidebar-accent/50 flex items-center justify-center">
170+
<Server className="w-6 h-6 text-sidebar-foreground/20" />
171+
</div>
172+
<div>
173+
<p className="text-[13px] font-medium text-sidebar-foreground/50">No connections yet</p>
174+
<p className="text-[11px] text-sidebar-foreground/30 mt-1">Add your first host to get started</p>
175+
</div>
176+
<div className="flex flex-col gap-1.5 w-full">
177+
<button
178+
onClick={() => setConnectionDialogOpen(true)}
179+
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-primary/10 text-primary text-[12px] font-medium hover:bg-primary/20 transition-colors cursor-pointer"
180+
>
181+
<Plus className="w-3.5 h-3.5" /> New Connection
182+
</button>
183+
<button
184+
onClick={() => setQuickConnectOpen(true)}
185+
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-sidebar-accent/50 text-sidebar-foreground/60 text-[12px] hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors cursor-pointer"
186+
>
187+
<Zap className="w-3.5 h-3.5" /> Quick Connect
188+
</button>
189+
<button
190+
onClick={() => setImportDialogOpen(true)}
191+
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-sidebar-accent/50 text-sidebar-foreground/60 text-[12px] hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors cursor-pointer"
192+
>
193+
<Upload className="w-3.5 h-3.5" /> Import
194+
</button>
195+
</div>
160196
</div>
161197
)}
162198

@@ -370,19 +406,44 @@ function ConnectionItem({
370406

371407
return (
372408
<>
409+
{/* Tooltip wrapper */}
410+
<div className="relative group/tip mx-1" style={{ width: 'calc(100% - 8px)' }}>
411+
{/* Hover tooltip */}
412+
<div className={cn(
413+
'pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 z-50',
414+
'w-52 bg-popover border border-border rounded-xl shadow-xl p-3 text-left',
415+
'opacity-0 group-hover/tip:opacity-100 transition-opacity duration-150',
416+
'hidden group-hover/tip:block'
417+
)}>
418+
<p className="text-[12px] font-semibold text-foreground truncate">{connection.name}</p>
419+
<p className="text-[11px] text-muted-foreground font-mono mt-0.5">
420+
{connection.protocol.toUpperCase()} · {connection.host}{connection.port ? `:${connection.port}` : ''}
421+
</p>
422+
{connection.lastConnectedAt && (
423+
<div className="flex items-center gap-1 mt-1.5 text-[10px] text-muted-foreground/60">
424+
<Clock className="w-2.5 h-2.5 shrink-0" />
425+
Last connected {timeAgo(connection.lastConnectedAt)}
426+
</div>
427+
)}
428+
{connection.notes && (
429+
<p className="text-[11px] text-muted-foreground/70 mt-1.5 border-t border-border pt-1.5 line-clamp-3">
430+
{connection.notes}
431+
</p>
432+
)}
433+
</div>
434+
373435
<button
374436
draggable
375437
onDragStart={(e) => { e.dataTransfer.effectAllowed = 'move'; onDragStart?.() }}
376438
onDoubleClick={onConnect}
377439
onContextMenu={handleContextMenu}
378440
className={cn(
379-
'w-full flex items-center gap-2.5 px-2.5 py-2 text-left group transition-all rounded-lg mx-1 cursor-pointer',
441+
'w-full flex items-center gap-2.5 px-2.5 py-2 text-left group transition-all rounded-lg cursor-pointer',
380442
indent && 'pl-6',
381443
isActive
382444
? 'bg-sidebar-accent text-sidebar-foreground'
383445
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent/70 hover:text-sidebar-foreground'
384446
)}
385-
style={{ width: 'calc(100% - 8px)' }}
386447
>
387448
<div
388449
className="relative w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
@@ -397,9 +458,12 @@ function ConnectionItem({
397458
<div className="flex-1 min-w-0">
398459
<p className="text-[13px] font-medium truncate leading-tight">{connection.name}</p>
399460
<p className="text-[11px] text-sidebar-foreground/40 truncate mt-0.5">
400-
{connection.protocol === 'serial'
401-
? (connection.serialConfig?.path ?? connection.host)
402-
: connection.host}
461+
{connection.lastConnectedAt
462+
? <span className="flex items-center gap-1"><Clock className="w-2.5 h-2.5 shrink-0" />{timeAgo(connection.lastConnectedAt)}</span>
463+
: (connection.protocol === 'serial'
464+
? (connection.serialConfig?.path ?? connection.host)
465+
: connection.host)
466+
}
403467
</p>
404468
</div>
405469

@@ -426,6 +490,7 @@ function ConnectionItem({
426490
</button>
427491
</div>
428492
</button>
493+
</div>
429494

430495
{menuPos && (
431496
<ConnectionContextMenu

src/renderer/src/components/terminal/TerminalArea.tsx

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -125,33 +125,39 @@ export function TerminalArea(): JSX.Element {
125125
</div>
126126
)}
127127

128-
{/* Theme panel */}
129-
{themePanelOpen && (
130-
<div className="shrink-0 flex flex-col overflow-hidden min-h-0" style={{ width: THEME_PANEL_WIDTH }}>
131-
<ThemePanel />
132-
</div>
133-
)}
128+
{/* Theme panel — slide in from right */}
129+
<div
130+
className="shrink-0 flex flex-col overflow-hidden min-h-0 transition-all duration-200 ease-out"
131+
style={{ width: themePanelOpen ? THEME_PANEL_WIDTH : 0, opacity: themePanelOpen ? 1 : 0 }}
132+
>
133+
{themePanelOpen && <ThemePanel />}
134+
</div>
134135

135-
{/* AI panel */}
136-
{aiPanelOpen && (
136+
{/* AI panel — slide in from right */}
137+
{(aiPanelOpen) && (
137138
<>
138139
{/* Drag handle */}
139140
<div
140141
onMouseDown={onDragStart}
141142
className="w-1 shrink-0 cursor-col-resize hover:bg-primary/30 transition-colors bg-border/40"
142143
/>
143-
<div className="shrink-0 flex flex-col overflow-hidden min-h-0" style={{ width: aiWidth }}>
144-
<AiPanel
145-
activeSession={activeSession}
146-
splitSession={isSplit ? sessions.find(s => s.id === splitSessionId) ?? null : null}
147-
allSessions={sessions}
148-
getTerminalContext={getTerminalContext}
149-
sendToTerminal={sendToTerminal}
150-
sendToSession={sendToSession}
151-
/>
152-
</div>
153144
</>
154145
)}
146+
<div
147+
className="shrink-0 flex flex-col overflow-hidden min-h-0 transition-all duration-200 ease-out"
148+
style={{ width: aiPanelOpen ? aiWidth : 0, opacity: aiPanelOpen ? 1 : 0 }}
149+
>
150+
{aiPanelOpen && (
151+
<AiPanel
152+
activeSession={activeSession}
153+
splitSession={isSplit ? sessions.find(s => s.id === splitSessionId) ?? null : null}
154+
allSessions={sessions}
155+
getTerminalContext={getTerminalContext}
156+
sendToTerminal={sendToTerminal}
157+
sendToSession={sendToSession}
158+
/>
159+
)}
160+
</div>
155161
</div>
156162
</div>
157163
)

src/renderer/src/lib/utils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ export function cn(...inputs: ClassValue[]): string {
55
return twMerge(clsx(inputs))
66
}
77

8+
export function timeAgo(ts: number): string {
9+
const diff = Date.now() - ts
10+
const m = Math.floor(diff / 60000)
11+
if (m < 1) return 'just now'
12+
if (m < 60) return `${m}m ago`
13+
const h = Math.floor(m / 60)
14+
if (h < 24) return `${h}h ago`
15+
const d = Math.floor(h / 24)
16+
if (d < 30) return `${d}d ago`
17+
const mo = Math.floor(d / 30)
18+
return `${mo}mo ago`
19+
}
20+
821
const RELEASE_BASE = 'https://github.com/AnasProgrammer2/netcopilot/releases/download'
922

1023
export function getInstallerUrl(version: string): string {

src/renderer/src/store/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ interface AppState {
7575
connections: Connection[]
7676
groups: ConnectionGroup[]
7777
sshKeys: SSHKey[]
78+
connectionsLoaded: boolean
7879

7980
// Sessions (active terminal tabs)
8081
sessions: Session[]
@@ -189,6 +190,7 @@ export const useAppStore = create<AppState>((set, get) => ({
189190
connections: [],
190191
groups: [],
191192
sshKeys: [],
193+
connectionsLoaded: false,
192194
sessions: [],
193195
activeSessionId: null,
194196
terminalSettings: { ...DEFAULT_TERMINAL_SETTINGS },
@@ -230,7 +232,7 @@ export const useAppStore = create<AppState>((set, get) => ({
230232

231233
loadConnections: async () => {
232234
const connections = await window.api.store.getConnections()
233-
set({ connections })
235+
set({ connections, connectionsLoaded: true })
234236
},
235237

236238
saveConnection: async (connData) => {

0 commit comments

Comments
 (0)