Skip to content

Commit 5434088

Browse files
feat: tags filter, terminal overlays, sidebar resize feedback
- HomeScreen: add tag filter pill bar — click any tag to filter connections, click again or Clear to reset - TerminalTab: replace plain text states with full-screen overlays for connecting (spinner), disconnected (icon + reconnect button), and error (icon + retry button) - Sidebar: enhance resize handle with active drag indicator and hover feedback
1 parent bd8cfc3 commit 5434088

3 files changed

Lines changed: 213 additions & 16 deletions

File tree

src/renderer/src/components/home/HomeScreen.tsx

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -199,22 +199,33 @@ export function HomeScreen(): JSX.Element {
199199

200200
const [search, setSearch] = useState('')
201201
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
202+
const [selectedTag, setSelectedTag] = useState<string | null>(null)
202203
const [groupDialogOpen, setGroupDialogOpen] = useState(false)
203204
const [sshKeyDialogOpen, setSshKeyDialogOpen] = useState(false)
204205

205206
const connectedIds = new Set(sessions.filter(s => s.status === 'connected').map(s => s.connectionId))
206207
const totalConnected = sessions.filter(s => s.status === 'connected').length
207208

209+
// All unique tags across connections
210+
const allTags = useMemo(() => {
211+
const set = new Set<string>()
212+
connections.forEach(c => c.tags?.forEach(t => set.add(t)))
213+
return [...set].sort()
214+
}, [connections])
215+
208216
const filtered = useMemo(() => {
209217
const q = search.toLowerCase()
210-
return connections.filter(c =>
211-
!q ||
212-
c.name.toLowerCase().includes(q) ||
213-
c.host.toLowerCase().includes(q) ||
214-
c.username?.toLowerCase().includes(q) ||
215-
c.tags.some(t => t.toLowerCase().includes(q))
216-
)
217-
}, [connections, search])
218+
return connections.filter(c => {
219+
if (selectedTag && !c.tags?.includes(selectedTag)) return false
220+
return (
221+
!q ||
222+
c.name.toLowerCase().includes(q) ||
223+
c.host.toLowerCase().includes(q) ||
224+
c.username?.toLowerCase().includes(q) ||
225+
c.tags.some(t => t.toLowerCase().includes(q))
226+
)
227+
})
228+
}, [connections, search, selectedTag])
218229

219230
const groupIds = new Set(groups.map(g => g.id))
220231

@@ -275,6 +286,35 @@ export function HomeScreen(): JSX.Element {
275286
</button>
276287
</div>
277288

289+
{/* ── Tags filter bar ─────────────────────────────────────────────────── */}
290+
{allTags.length > 0 && (
291+
<div className="flex items-center gap-1.5 px-5 py-2 border-b border-border/60 overflow-x-auto shrink-0 scrollbar-none">
292+
<span className="text-[10px] text-muted-foreground/40 font-semibold uppercase tracking-wider shrink-0">Tags</span>
293+
{allTags.map(tag => (
294+
<button
295+
key={tag}
296+
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
297+
className={cn(
298+
'text-[11px] px-2.5 py-1 rounded-full border font-medium transition-all cursor-pointer shrink-0 whitespace-nowrap',
299+
selectedTag === tag
300+
? 'bg-primary/15 border-primary/40 text-primary'
301+
: 'bg-muted/40 border-border/60 text-muted-foreground hover:text-foreground hover:border-border'
302+
)}
303+
>
304+
{tag}
305+
</button>
306+
))}
307+
{selectedTag && (
308+
<button
309+
onClick={() => setSelectedTag(null)}
310+
className="text-[11px] px-2 py-1 rounded-full text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer shrink-0"
311+
>
312+
Clear ×
313+
</button>
314+
)}
315+
</div>
316+
)}
317+
278318
{/* ── Content ────────────────────────────────────────────────────────── */}
279319
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-6">
280320

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function Sidebar(): JSX.Element {
3434

3535
const [importMsg, setImportMsg] = useState<string | null>(null)
3636
const [sshKeyDialogOpen, setSshKeyDialogOpen] = useState(false)
37+
const [resizing, setResizing] = useState(false)
3738

3839
const [search, setSearch] = useState('')
3940
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
@@ -44,6 +45,7 @@ export function Sidebar(): JSX.Element {
4445

4546
const handleMouseDown = useCallback((e: React.MouseEvent) => {
4647
isResizing.current = true
48+
setResizing(true)
4749
startX.current = e.clientX
4850
startWidth.current = sidebarWidth
4951

@@ -55,6 +57,7 @@ export function Sidebar(): JSX.Element {
5557
}
5658
const onUp = () => {
5759
isResizing.current = false
60+
setResizing(false)
5861
window.removeEventListener('mousemove', onMove)
5962
window.removeEventListener('mouseup', onUp)
6063
}
@@ -249,8 +252,16 @@ export function Sidebar(): JSX.Element {
249252
{/* Resize handle */}
250253
<div
251254
onMouseDown={handleMouseDown}
252-
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/30 transition-colors"
253-
/>
255+
className={cn(
256+
'absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize group flex items-center justify-center transition-colors',
257+
resizing ? 'bg-primary/40' : 'hover:bg-primary/20'
258+
)}
259+
>
260+
<div className={cn(
261+
'w-0.5 h-8 rounded-full transition-all',
262+
resizing ? 'bg-primary/70 opacity-100' : 'bg-border/60 opacity-0 group-hover:opacity-100'
263+
)} />
264+
</div>
254265

255266
{groupDialog.open && (
256267
<GroupDialog

src/renderer/src/components/terminal/TerminalTab.tsx

Lines changed: 152 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { FitAddon } from '@xterm/addon-fit'
44
import { WebLinksAddon } from '@xterm/addon-web-links'
55
import { SearchAddon } from '@xterm/addon-search'
66
import '@xterm/xterm/css/xterm.css'
7-
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
7+
import { Search, X, ChevronUp, ChevronDown, Copy, Clipboard, Eraser } from 'lucide-react'
88
import { toast } from 'sonner'
99
import { Session } from '../../types'
1010
import { useAppStore } from '../../store'
@@ -50,6 +50,9 @@ export function TerminalTab({ session }: Props): JSX.Element {
5050
const [searchRegex, setSearchRegex] = useState(false)
5151
const searchInputRef = useRef<HTMLInputElement>(null)
5252

53+
// ── Context menu state ────────────────────────────────────────────────────────
54+
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null)
55+
5356
const { setSessionStatus, setSessionLogging } = useAppStore()
5457

5558
// Derive logging state from the session's loggingPath (store is source of truth)
@@ -128,6 +131,41 @@ export function TerminalTab({ session }: Props): JSX.Element {
128131
termRef.current?.focus()
129132
}, [])
130133

134+
// ── Context menu handlers ─────────────────────────────────────────────────────
135+
const handleContextMenu = useCallback((e: React.MouseEvent) => {
136+
e.preventDefault()
137+
setCtxMenu({ x: e.clientX, y: e.clientY })
138+
}, [])
139+
140+
const ctxCopy = useCallback(() => {
141+
const sel = termRef.current?.getSelection()
142+
if (sel) navigator.clipboard.writeText(sel)
143+
setCtxMenu(null)
144+
termRef.current?.focus()
145+
}, [])
146+
147+
const ctxPaste = useCallback(async () => {
148+
setCtxMenu(null)
149+
const text = await navigator.clipboard.readText()
150+
if (!text) return
151+
const proto = session.connection.protocol
152+
if (proto === 'ssh') window.api.ssh.send(session.id, text)
153+
else if (proto === 'serial') window.api.serial.send(session.id, text)
154+
else window.api.telnet.send(session.id, text)
155+
termRef.current?.focus()
156+
}, [session.id, session.connection.protocol])
157+
158+
const ctxClear = useCallback(() => {
159+
termRef.current?.clear()
160+
setCtxMenu(null)
161+
termRef.current?.focus()
162+
}, [])
163+
164+
const ctxSearch = useCallback(() => {
165+
setCtxMenu(null)
166+
openSearch()
167+
}, [openSearch])
168+
131169
// ── Logging helpers ───────────────────────────────────────────────────────────
132170
const startLogging = async () => {
133171
const filePath = await window.api.log.start(session.connection.name)
@@ -803,11 +841,101 @@ export function TerminalTab({ session }: Props): JSX.Element {
803841
</div>
804842

805843
{/* Terminal */}
806-
<div
807-
ref={containerRef}
808-
className="flex-1 w-full min-h-0 overflow-hidden bg-[#0B0718]"
809-
style={{ fontVariantLigatures: 'none' }}
810-
/>
844+
<div className="relative flex-1 w-full min-h-0 overflow-hidden">
845+
<div
846+
ref={containerRef}
847+
onContextMenu={handleContextMenu}
848+
className="w-full h-full bg-[#0B0718]"
849+
style={{ fontVariantLigatures: 'none' }}
850+
/>
851+
852+
{/* Connecting overlay */}
853+
{session.status === 'connecting' && (
854+
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-[#0B0718]/90 backdrop-blur-sm gap-4">
855+
<div className="relative">
856+
<div className="w-12 h-12 rounded-full border-2 border-primary/20" />
857+
<div className="absolute inset-0 w-12 h-12 rounded-full border-2 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin" />
858+
</div>
859+
<div className="flex flex-col items-center gap-1">
860+
<span className="text-sm font-medium text-foreground">Connecting…</span>
861+
<span className="text-xs text-muted-foreground font-mono">
862+
{session.connection.username ? `${session.connection.username}@` : ''}{session.connection.host}
863+
</span>
864+
</div>
865+
</div>
866+
)}
867+
868+
{/* Disconnected overlay */}
869+
{session.status === 'disconnected' && (
870+
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-[#0B0718]/90 backdrop-blur-sm gap-4">
871+
<div className="w-12 h-12 rounded-full bg-red-500/10 border border-red-500/30 flex items-center justify-center">
872+
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
873+
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757" />
874+
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 15.312a4.5 4.5 0 0 1-1.242-7.244l4.5-4.5a4.5 4.5 0 0 1 6.364 6.364l-1.757 1.757" />
875+
</svg>
876+
</div>
877+
<div className="flex flex-col items-center gap-1.5">
878+
<span className="text-sm font-medium text-foreground">Disconnected</span>
879+
<span className="text-xs text-muted-foreground font-mono">
880+
{session.connection.host}
881+
</span>
882+
</div>
883+
{session.connection.autoReconnect ? (
884+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
885+
<span className="w-1.5 h-1.5 rounded-full bg-yellow-500 animate-pulse" />
886+
Reconnecting…
887+
</div>
888+
) : (
889+
<button
890+
onClick={() => doConnectRef.current?.(true)}
891+
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-card border border-border text-sm text-foreground hover:bg-accent transition-colors cursor-pointer"
892+
>
893+
Reconnect
894+
</button>
895+
)}
896+
</div>
897+
)}
898+
899+
{/* Error overlay */}
900+
{session.status === 'error' && (
901+
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-[#0B0718]/90 backdrop-blur-sm gap-4">
902+
<div className="w-12 h-12 rounded-full bg-red-500/10 border border-red-500/30 flex items-center justify-center">
903+
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
904+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
905+
</svg>
906+
</div>
907+
<div className="flex flex-col items-center gap-1.5">
908+
<span className="text-sm font-medium text-foreground">Connection Error</span>
909+
{session.error && (
910+
<span className="text-xs text-muted-foreground font-mono max-w-xs text-center">{session.error}</span>
911+
)}
912+
</div>
913+
<button
914+
onClick={() => doConnectRef.current?.(true)}
915+
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-card border border-border text-sm text-foreground hover:bg-accent transition-colors cursor-pointer"
916+
>
917+
Retry
918+
</button>
919+
</div>
920+
)}
921+
</div>
922+
923+
{/* Context menu */}
924+
{ctxMenu && (
925+
<>
926+
<div className="fixed inset-0 z-40" onClick={() => { setCtxMenu(null); termRef.current?.focus() }} />
927+
<div
928+
className="fixed z-50 bg-popover border border-border rounded-xl shadow-2xl py-1 w-44 overflow-hidden"
929+
style={{ top: ctxMenu.y, left: ctxMenu.x }}
930+
>
931+
<CtxItem icon={Copy} label="Copy" onClick={ctxCopy} hint="⌘C" />
932+
<CtxItem icon={Clipboard} label="Paste" onClick={ctxPaste} hint="⌘V" />
933+
<div className="h-px bg-border/60 my-1 mx-2" />
934+
<CtxItem icon={Search} label="Search" onClick={ctxSearch} hint="⌘F" />
935+
<CtxItem icon={Eraser} label="Clear" onClick={ctxClear} />
936+
</div>
937+
</>
938+
)}
811939

812940
{promptState.visible && (
813941
<PasswordPrompt
@@ -820,3 +948,21 @@ export function TerminalTab({ session }: Props): JSX.Element {
820948
</div>
821949
)
822950
}
951+
952+
function CtxItem({ icon: Icon, label, onClick, hint }: {
953+
icon: React.ComponentType<{ className?: string }>
954+
label: string
955+
onClick: () => void
956+
hint?: string
957+
}): JSX.Element {
958+
return (
959+
<button
960+
onClick={onClick}
961+
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-foreground hover:bg-accent transition-colors text-left cursor-pointer"
962+
>
963+
<Icon className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
964+
<span className="flex-1">{label}</span>
965+
{hint && <span className="text-[11px] text-muted-foreground/50 font-mono">{hint}</span>}
966+
</button>
967+
)
968+
}

0 commit comments

Comments
 (0)