feat: connection status indicators + draggable tab reordering
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m52s

Status indicators:
- Session.status field tracks connected/disconnected state
- Listens for ssh:close and ssh:exit backend events
- Tab dot turns red when connection drops (green=SSH, blue=RDP, red=dead)

Draggable tabs:
- HTML5 drag-and-drop on tab buttons
- Blue left-border indicator shows drop target
- moveSession() in store reorders the sessions array

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-24 21:30:20 -04:00
parent 087b00c886
commit 02b3ee053d
3 changed files with 70 additions and 5 deletions

View File

@ -36,12 +36,15 @@ const props = defineProps<{
username?: string; username?: string;
/** Raw tags from the connection record. */ /** Raw tags from the connection record. */
tags?: string[]; tags?: string[];
/** Connection status — drives the dot colour. */
status?: "connected" | "disconnected";
}>(); }>();
/** Green for SSH, blue for RDP. */ /** Green=connected SSH, blue=connected RDP, red=disconnected. */
const protocolDotClass = computed(() => const protocolDotClass = computed(() => {
props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]", if (props.status === "disconnected") return "bg-[#f85149]";
); return props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]";
});
/** True when the session is running as root or Administrator. */ /** True when the session is running as root or Administrator. */
const isRoot = computed(() => { const isRoot = computed(() => {

View File

@ -3,22 +3,30 @@
<!-- Tabs --> <!-- Tabs -->
<div class="flex items-center overflow-x-auto min-w-0"> <div class="flex items-center overflow-x-auto min-w-0">
<button <button
v-for="session in sessionStore.sessions" v-for="(session, index) in sessionStore.sessions"
:key="session.id" :key="session.id"
draggable="true"
class="group flex items-center gap-1.5 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0" class="group flex items-center gap-1.5 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0"
:class="[ :class="[
session.id === sessionStore.activeSessionId session.id === sessionStore.activeSessionId
? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]' ? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]'
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]', : 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]',
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '', isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '',
]" ]"
@click="sessionStore.activateSession(session.id)" @click="sessionStore.activateSession(session.id)"
@dragstart="onDragStart(index, $event)"
@dragover.prevent="onDragOver(index)"
@dragleave="dragOverIndex = -1"
@drop.prevent="onDrop(index)"
@dragend="draggedIndex = -1; dragOverIndex = -1"
> >
<!-- Badge: protocol dot + root dot + env pills --> <!-- Badge: protocol dot + root dot + env pills -->
<TabBadge <TabBadge
:protocol="session.protocol" :protocol="session.protocol"
:username="session.username" :username="session.username"
:tags="getSessionTags(session)" :tags="getSessionTags(session)"
:status="session.status"
/> />
<span>{{ session.name }}</span> <span>{{ session.name }}</span>
@ -44,6 +52,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue";
import { useSessionStore, type Session } from "@/stores/session.store"; import { useSessionStore, type Session } from "@/stores/session.store";
import { useConnectionStore } from "@/stores/connection.store"; import { useConnectionStore } from "@/stores/connection.store";
import TabBadge from "@/components/session/TabBadge.vue"; import TabBadge from "@/components/session/TabBadge.vue";
@ -51,6 +60,31 @@ import TabBadge from "@/components/session/TabBadge.vue";
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
// Drag-and-drop tab reordering
const draggedIndex = ref(-1);
const dragOverIndex = ref(-1);
function onDragStart(index: number, event: DragEvent): void {
draggedIndex.value = index;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
}
}
function onDragOver(index: number): void {
if (draggedIndex.value !== -1 && draggedIndex.value !== index) {
dragOverIndex.value = index;
}
}
function onDrop(toIndex: number): void {
if (draggedIndex.value !== -1 && draggedIndex.value !== toIndex) {
sessionStore.moveSession(draggedIndex.value, toIndex);
}
draggedIndex.value = -1;
dragOverIndex.value = -1;
}
/** Get tags for a session's underlying connection. */ /** Get tags for a session's underlying connection. */
function getSessionTags(session: Session): string[] { function getSessionTags(session: Session): string[] {
const conn = connectionStore.connections.find((c) => c.id === session.connectionId); const conn = connectionStore.connections.find((c) => c.id === session.connectionId);

View File

@ -1,6 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useConnectionStore } from "@/stores/connection.store"; import { useConnectionStore } from "@/stores/connection.store";
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue"; import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
@ -11,6 +12,7 @@ export interface Session {
protocol: "ssh" | "rdp"; protocol: "ssh" | "rdp";
active: boolean; active: boolean;
username?: string; username?: string;
status: "connected" | "disconnected";
} }
export interface TerminalDimensions { export interface TerminalDimensions {
@ -36,10 +38,30 @@ export const useSessionStore = defineStore("session", () => {
const sessionCount = computed(() => sessions.value.length); const sessionCount = computed(() => sessions.value.length);
// Listen for backend close/exit events to update session status
function setupStatusListeners(sessionId: string): void {
listen(`ssh:close:${sessionId}`, () => markDisconnected(sessionId));
listen(`ssh:exit:${sessionId}`, () => markDisconnected(sessionId));
}
function markDisconnected(sessionId: string): void {
const session = sessions.value.find((s) => s.id === sessionId);
if (session) session.status = "disconnected";
}
function activateSession(id: string): void { function activateSession(id: string): void {
activeSessionId.value = id; activeSessionId.value = id;
} }
/** Reorder sessions by moving a tab from one index to another. */
function moveSession(fromIndex: number, toIndex: number): void {
if (fromIndex === toIndex) return;
if (fromIndex < 0 || toIndex < 0) return;
if (fromIndex >= sessions.value.length || toIndex >= sessions.value.length) return;
const [moved] = sessions.value.splice(fromIndex, 1);
sessions.value.splice(toIndex, 0, moved);
}
async function closeSession(id: string): Promise<void> { async function closeSession(id: string): Promise<void> {
const idx = sessions.value.findIndex((s) => s.id === id); const idx = sessions.value.findIndex((s) => s.id === id);
if (idx === -1) return; if (idx === -1) return;
@ -130,7 +152,9 @@ export const useSessionStore = defineStore("session", () => {
protocol: "ssh", protocol: "ssh",
active: true, active: true,
username: resolvedUsername, username: resolvedUsername,
status: "connected",
}); });
setupStatusListeners(sessionId);
activeSessionId.value = sessionId; activeSessionId.value = sessionId;
return; // early return — key auth handled return; // early return — key auth handled
} else { } else {
@ -192,7 +216,9 @@ export const useSessionStore = defineStore("session", () => {
protocol: "ssh", protocol: "ssh",
active: true, active: true,
username: resolvedUsername, username: resolvedUsername,
status: "connected",
}); });
setupStatusListeners(sessionId);
activeSessionId.value = sessionId; activeSessionId.value = sessionId;
} else if (conn.protocol === "rdp") { } else if (conn.protocol === "rdp") {
let username = ""; let username = "";
@ -274,6 +300,7 @@ export const useSessionStore = defineStore("session", () => {
protocol: "rdp", protocol: "rdp",
active: true, active: true,
username, username,
status: "connected",
}); });
activeSessionId.value = sessionId; activeSessionId.value = sessionId;
} }
@ -317,6 +344,7 @@ export const useSessionStore = defineStore("session", () => {
activateSession, activateSession,
closeSession, closeSession,
connect, connect,
moveSession,
setTheme, setTheme,
setTerminalDimensions, setTerminalDimensions,
}; };