feat: quick connect, search, recent connections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-12 17:32:18 -04:00
parent 19183ee546
commit 8546824b97
2 changed files with 196 additions and 35 deletions

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
const input = ref('')
const protocol = ref<'ssh' | 'rdp'>('ssh')
const emit = defineEmits<{
connect: [{ hostname: string; port: number; username: string; protocol: 'ssh' | 'rdp' }]
}>()
function handleConnect() {
const raw = input.value.trim()
if (!raw) return
// Parse user@hostname:port format
let username = ''
let hostname = raw
let port = protocol.value === 'rdp' ? 3389 : 22
if (hostname.includes('@')) {
[username, hostname] = hostname.split('@')
}
if (hostname.includes(':')) {
const parts = hostname.split(':')
hostname = parts[0]
port = parseInt(parts[1], 10)
}
emit('connect', { hostname, port, username, protocol: protocol.value })
input.value = ''
}
</script>
<template>
<div class="flex items-center gap-2 px-3 py-2 bg-gray-800 border-b border-gray-700">
<select v-model="protocol" class="bg-gray-700 text-gray-300 text-xs rounded px-2 py-1.5 border-none">
<option value="ssh">SSH</option>
<option value="rdp">RDP</option>
</select>
<input
v-model="input"
@keydown.enter="handleConnect"
:placeholder="`user@hostname:${protocol === 'rdp' ? '3389' : '22'}`"
class="flex-1 bg-gray-900 text-white px-3 py-1.5 rounded text-sm border border-gray-700 focus:border-wraith-500 focus:outline-none"
/>
<button @click="handleConnect" class="text-sm text-wraith-400 hover:text-wraith-300 px-2">Connect</button>
</div>
</template>

View File

@ -2,9 +2,13 @@
import { useTerminal } from '~/composables/useTerminal' import { useTerminal } from '~/composables/useTerminal'
const connections = useConnectionStore() const connections = useConnectionStore()
const sessions = useSessionStore()
const showHostDialog = ref(false) const showHostDialog = ref(false)
const editingHost = ref<any>(null) const editingHost = ref<any>(null)
const showGroupDialog = ref(false) const showGroupDialog = ref(false)
const searchQuery = ref('')
const showSavePrompt = ref(false)
const pendingQuickConnect = ref<any>(null)
// Terminal composable for connect-on-click // Terminal composable for connect-on-click
const { createTerminal, connectToHost } = useTerminal() const { createTerminal, connectToHost } = useTerminal()
@ -25,15 +29,9 @@ function openEditHost(host: any) {
function connectHost(host: any) { function connectHost(host: any) {
if (host.protocol !== 'ssh') { if (host.protocol !== 'ssh') {
// RDP support in Phase 3 // RDP handled via quick connect for now
return return
} }
// We connect via useTerminal TerminalInstance will handle the actual mount
// Here we just trigger SessionContainer to render a new TerminalInstance
// The terminal composable is invoked inside TerminalInstance on mount
// We signal the session store directly to open a pending session slot
// TerminalInstance picks up the hostId prop and opens the WS connection
const sessions = useSessionStore()
const pendingId = `pending-${Date.now()}` const pendingId = `pending-${Date.now()}`
sessions.addSession({ sessions.addSession({
id: pendingId, id: pendingId,
@ -44,38 +42,155 @@ function connectHost(host: any) {
active: true, active: true,
}) })
} }
// Quick connect: launch a temporary session without saving
function handleQuickConnect(params: { hostname: string; port: number; username: string; protocol: 'ssh' | 'rdp' }) {
const sessionId = `quick-${Date.now()}`
const displayName = params.username
? `${params.username}@${params.hostname}`
: params.hostname
sessions.addSession({
id: sessionId,
hostId: null,
hostName: displayName,
hostname: params.hostname,
port: params.port,
username: params.username,
protocol: params.protocol,
color: null,
active: true,
isTemporary: true,
})
pendingQuickConnect.value = params
showSavePrompt.value = true
}
// Save a quick-connect as a permanent host
function saveQuickConnectHost() {
if (!pendingQuickConnect.value) return
const p = pendingQuickConnect.value
editingHost.value = {
hostname: p.hostname,
port: p.port,
protocol: p.protocol,
name: p.hostname,
}
showSavePrompt.value = false
pendingQuickConnect.value = null
showHostDialog.value = true
}
function dismissSavePrompt() {
showSavePrompt.value = false
pendingQuickConnect.value = null
}
// Client-side search filtering
const filteredHosts = computed(() => {
if (!searchQuery.value.trim()) return connections.hosts
const q = searchQuery.value.toLowerCase()
return connections.hosts.filter((h: any) =>
h.name?.toLowerCase().includes(q) ||
h.hostname?.toLowerCase().includes(q) ||
(h.tags || []).some((t: string) => t.toLowerCase().includes(q))
)
})
// Recent connections hosts sorted by lastConnectedAt, non-null only, top 5
const recentHosts = computed(() => {
return [...connections.hosts]
.filter((h: any) => h.lastConnectedAt)
.sort((a: any, b: any) =>
new Date(b.lastConnectedAt).getTime() - new Date(a.lastConnectedAt).getTime()
)
.slice(0, 5)
})
</script> </script>
<template> <template>
<div class="flex w-full"> <div class="flex w-full flex-col">
<!-- Sidebar: Group tree --> <!-- Quick Connect bar -->
<aside class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col overflow-y-auto shrink-0"> <QuickConnect @connect="handleQuickConnect" />
<div class="p-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-400">Connections</span>
<div class="flex gap-1">
<button @click="showGroupDialog = true" class="text-xs text-gray-500 hover:text-wraith-400" title="New Group">+ Group</button>
<button @click="openNewHost()" class="text-xs text-gray-500 hover:text-wraith-400" title="New Host">+ Host</button>
</div>
</div>
<HostTree :groups="connections.groups" @select-host="openEditHost" @new-host="openNewHost" />
</aside>
<!-- Main: host list --> <!-- Save prompt toast -->
<main class="flex-1 p-4 overflow-y-auto"> <div v-if="showSavePrompt"
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> class="fixed bottom-4 right-4 z-40 bg-gray-800 border border-gray-700 rounded-lg shadow-xl p-4 flex items-center gap-4 text-sm">
<HostCard <span class="text-gray-300">Save this connection for later?</span>
v-for="host in connections.hosts" <button @click="saveQuickConnectHost" class="text-wraith-400 hover:text-wraith-300 font-medium">Save</button>
:key="host.id" <button @click="dismissSavePrompt" class="text-gray-500 hover:text-gray-300">Dismiss</button>
:host="host" </div>
@connect="connectHost(host)"
@edit="openEditHost(host)" <div class="flex flex-1 overflow-hidden">
@delete="connections.deleteHost(host.id)" <!-- Sidebar: Group tree -->
/> <aside class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col overflow-y-auto shrink-0">
</div> <div class="p-3 flex items-center justify-between">
<p v-if="!connections.hosts.length && !connections.loading" class="text-gray-600 text-center mt-12"> <span class="text-sm font-medium text-gray-400">Connections</span>
No hosts yet. Click "+ Host" to add your first connection. <div class="flex gap-1">
</p> <button @click="showGroupDialog = true" class="text-xs text-gray-500 hover:text-wraith-400" title="New Group">+ Group</button>
</main> <button @click="openNewHost()" class="text-xs text-gray-500 hover:text-wraith-400" title="New Host">+ Host</button>
</div>
</div>
<HostTree :groups="connections.groups" @select-host="openEditHost" @new-host="openNewHost" />
</aside>
<!-- Main: host grid with search + recent -->
<main class="flex-1 p-4 overflow-y-auto">
<!-- Search bar -->
<div class="mb-4">
<input
v-model="searchQuery"
type="text"
placeholder="Search hosts by name, hostname, or tag..."
class="w-full bg-gray-800 text-white px-3 py-2 rounded border border-gray-700 focus:border-wraith-500 focus:outline-none text-sm"
/>
</div>
<!-- Recent connections (only when not searching) -->
<div v-if="!searchQuery && recentHosts.length > 0" class="mb-6">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Recent</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-2">
<button
v-for="host in recentHosts"
:key="host.id"
@click="connectHost(host)"
class="flex items-center gap-2 p-2 bg-gray-800/70 hover:bg-gray-700 rounded border border-gray-700/50 text-left"
>
<span class="w-2 h-2 rounded-full shrink-0 bg-wraith-500" />
<div class="min-w-0">
<p class="text-sm text-white truncate">{{ host.name }}</p>
<p class="text-xs text-gray-500 truncate">{{ host.hostname }}</p>
</div>
</button>
</div>
</div>
<!-- All hosts grid -->
<div>
<h3 v-if="!searchQuery && recentHosts.length > 0"
class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
All Hosts
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<HostCard
v-for="host in filteredHosts"
:key="host.id"
:host="host"
@connect="connectHost(host)"
@edit="openEditHost(host)"
@delete="connections.deleteHost(host.id)"
/>
</div>
<p v-if="filteredHosts.length === 0 && searchQuery" class="text-gray-600 text-center mt-12">
No hosts match "{{ searchQuery }}"
</p>
<p v-if="!connections.hosts.length && !connections.loading && !searchQuery" class="text-gray-600 text-center mt-12">
No hosts yet. Click "+ Host" to add your first connection.
</p>
</div>
</main>
</div>
<!-- Dialogs --> <!-- Dialogs -->
<HostEditDialog v-model:visible="showHostDialog" :host="editingHost" @saved="connections.fetchHosts()" /> <HostEditDialog v-model:visible="showHostDialog" :host="editingHost" @saved="connections.fetchHosts()" />