All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m51s
Tool/help/editor/detach windows: - Moved ALL child window creation from JS-side WebviewWindow to Rust-side WebviewWindowBuilder via new open_child_window command. JS WebviewWindow on macOS WKWebView was creating windows that never fully initialized — the webview content process failed silently. Rust-side creation uses the proper main thread context. - All four call sites (tool, help, editor, detach) now use invoke() - Errors surface as alert() instead of silent failure RDP tab switch: - Immediate force_refresh on tab activation for instant visual feedback - 300ms delayed dimension check (was double-rAF which was too fast) - If dimensions changed, resize + 500ms delayed refresh for clean repaint - Fixes 3/4 resolution rendering after copilot panel toggle Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
229 lines
8.4 KiB
Vue
229 lines
8.4 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)]' : '',
|
|
session.hasActivity && session.id !== sessionStore.activeSessionId ? 'animate-pulse text-[var(--wraith-accent-blue)]' : '',
|
|
!session.active ? 'opacity-40 italic' : '',
|
|
]"
|
|
@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"
|
|
@contextmenu.prevent="showTabMenu($event, session)"
|
|
>
|
|
<!-- 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)"
|
|
>
|
|
×
|
|
</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>
|
|
<!-- Tab context menu -->
|
|
<Teleport to="body">
|
|
<div v-if="tabMenu.visible" class="fixed z-[100] w-44 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden py-1"
|
|
:style="{ top: tabMenu.y + 'px', left: tabMenu.x + 'px' }">
|
|
<button class="w-full px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
|
|
@click="detachTab">Detach to Window</button>
|
|
<div class="border-t border-[#30363d] my-1" />
|
|
<button class="w-full px-4 py-2 text-xs text-left text-[var(--wraith-accent-red)] hover:bg-[#30363d] cursor-pointer"
|
|
@click="closeMenuTab">Close</button>
|
|
</div>
|
|
<div v-if="tabMenu.visible" class="fixed inset-0 z-[99]" @click="tabMenu.visible = false" @contextmenu.prevent="tabMenu.visible = false" />
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onBeforeUnmount } 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);
|
|
}
|
|
|
|
// Tab right-click context menu
|
|
const tabMenu = ref<{ visible: boolean; x: number; y: number; session: Session | null }>({
|
|
visible: false, x: 0, y: 0, session: null,
|
|
});
|
|
|
|
function showTabMenu(event: MouseEvent, session: Session): void {
|
|
tabMenu.value = { visible: true, x: event.clientX, y: event.clientY, session };
|
|
}
|
|
|
|
async function detachTab(): Promise<void> {
|
|
const session = tabMenu.value.session;
|
|
tabMenu.value.visible = false;
|
|
if (!session) return;
|
|
|
|
// Mark as detached in the store
|
|
session.active = false;
|
|
|
|
// Open a new Tauri window for this session
|
|
try {
|
|
await invoke("open_child_window", {
|
|
label: `detached-${session.id.substring(0, 8)}-${Date.now()}`,
|
|
title: `${session.name} — Wraith`,
|
|
url: `index.html#/detached-session?sessionId=${session.id}&name=${encodeURIComponent(session.name)}&protocol=${session.protocol}`,
|
|
width: 900, height: 600,
|
|
});
|
|
} catch (err) { console.error("Detach window error:", err); }
|
|
}
|
|
|
|
function closeMenuTab(): void {
|
|
const session = tabMenu.value.session;
|
|
tabMenu.value.visible = false;
|
|
if (session) sessionStore.closeSession(session.id);
|
|
}
|
|
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
|
|
|
let unlistenReattach: UnlistenFn | null = null;
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
availableShells.value = await invoke<ShellInfo[]>("list_available_shells");
|
|
} catch {
|
|
availableShells.value = [];
|
|
}
|
|
|
|
unlistenReattach = await listen<{ sessionId: string; name: string; protocol: string }>("session:reattach", (event) => {
|
|
const { sessionId } = event.payload;
|
|
const session = sessionStore.sessions.find(s => s.id === sessionId);
|
|
if (session) {
|
|
session.active = true;
|
|
sessionStore.activateSession(sessionId);
|
|
}
|
|
});
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
unlistenReattach?.();
|
|
});
|
|
|
|
// 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>
|