wraith/src/components/session/TabBar.vue
Vantz Stockwell 4532f3beb6
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 16s
feat: local terminal tabs — + button spawns WSL/Git Bash/PowerShell/CMD
The + button in the tab bar now shows a dropdown of detected local
shells. Clicking one opens a full-size PTY terminal in the main
content area as a proper tab — not the copilot sidebar.

- New "local" protocol type in Session interface
- LocalTerminalView component uses useTerminal(id, 'pty')
- SessionContainer renders local sessions alongside SSH/RDP
- TabBadge shows purple dot for local sessions
- Shell detection includes WSL (wsl.exe) on Windows
- closeSession handles PTY disconnect for local tabs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:46:09 -04:00

162 lines
5.7 KiB
Vue

<template>
<div class="flex items-center bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] h-9 shrink-0">
<!-- Tabs -->
<div class="flex items-center overflow-x-auto min-w-0">
<div
v-for="(session, index) in sessionStore.sessions"
:key="session.id"
draggable="true"
role="tab"
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 select-none"
:class="[
session.id === sessionStore.activeSessionId
? '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)]',
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '',
]"
@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 -->
<TabBadge
:protocol="session.protocol"
:username="session.username"
:tags="getSessionTags(session)"
:status="session.status"
/>
<span>{{ session.name }}</span>
<!-- Close button -->
<span
class="ml-1 opacity-0 group-hover:opacity-100 hover:text-[var(--wraith-accent-red)] transition-opacity"
@click.stop="sessionStore.closeSession(session.id)"
>
&times;
</span>
</div>
</div>
<!-- New tab button with shell dropdown -->
<div class="relative shrink-0">
<button
class="flex items-center justify-center w-9 h-9 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
title="New local terminal"
@click="toggleShellMenu"
@blur="closeShellMenuDeferred"
>
+
</button>
<div
v-if="shellMenuOpen"
class="absolute top-full right-0 mt-0.5 w-48 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden z-50 py-1"
>
<button
v-for="shell in availableShells"
:key="shell.path"
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="spawnShell(shell)"
>
{{ shell.name }}
</button>
<div v-if="availableShells.length === 0" class="px-4 py-2 text-xs text-[var(--wraith-text-muted)]">
No shells found
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useSessionStore, type Session } from "@/stores/session.store";
import { useConnectionStore } from "@/stores/connection.store";
import TabBadge from "@/components/session/TabBadge.vue";
const sessionStore = useSessionStore();
const connectionStore = useConnectionStore();
// Shell menu for + button
interface ShellInfo { name: string; path: string; }
const availableShells = ref<ShellInfo[]>([]);
const shellMenuOpen = ref(false);
function toggleShellMenu(): void {
shellMenuOpen.value = !shellMenuOpen.value;
}
function closeShellMenuDeferred(): void {
setTimeout(() => { shellMenuOpen.value = false; }, 150);
}
async function spawnShell(shell: ShellInfo): Promise<void> {
shellMenuOpen.value = false;
await sessionStore.spawnLocalTab(shell.name, shell.path);
}
onMounted(async () => {
try {
availableShells.value = await invoke<ShellInfo[]>("list_available_shells");
} catch {
availableShells.value = [];
}
});
// 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";
event.dataTransfer.setData("text/plain", String(index));
}
}
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. */
function getSessionTags(session: Session): string[] {
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
return conn?.tags ?? [];
}
/** Check if the connection for this session uses the root user (drives the orange top border). */
function isRootUser(session: Session): boolean {
if (session.username === "root" || session.username === "Administrator") return true;
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
if (!conn) return false;
if (conn.options) {
try {
const opts = JSON.parse(conn.options);
if (opts?.username === "root" || opts?.username === "Administrator") return true;
} catch {
// ignore malformed options
}
}
return conn.tags?.some((t) => t === "root" || t === "Administrator") ?? false;
}
</script>