feat: tab detach/reattach — pop sessions into separate windows
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m3s
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m3s
Right-click any tab → "Detach to Window" opens the session in its own Tauri window. The tab dims (opacity + italic) while detached. Close the detached window → session reattaches to the main tab bar. Architecture: - DetachedSession.vue: standalone terminal that connects to the same backend session (SSH/PTY events keep flowing) - App.vue detects #/detached-session?sessionId=X hash - Tab context menu: Detach to Window, Close - session:reattach event emitted on window close, main window listens - Monitor bar included in detached SSH windows - Session.active flag tracks detached state Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ddce484eb9
commit
3638745436
10
src/App.vue
10
src/App.vue
@ -9,11 +9,15 @@ const MainLayout = defineAsyncComponent(
|
||||
const ToolWindow = defineAsyncComponent(
|
||||
() => import("@/components/tools/ToolWindow.vue")
|
||||
);
|
||||
const DetachedSession = defineAsyncComponent(
|
||||
() => import("@/components/session/DetachedSession.vue")
|
||||
);
|
||||
|
||||
const app = useAppStore();
|
||||
|
||||
// Tool window mode — detected from URL hash: #/tool/network-scanner?sessionId=abc
|
||||
const isToolMode = ref(false);
|
||||
const isDetachedMode = ref(false);
|
||||
const toolName = ref("");
|
||||
const toolSessionId = ref("");
|
||||
|
||||
@ -25,6 +29,8 @@ onMounted(async () => {
|
||||
const [name, query] = rest.split("?");
|
||||
toolName.value = name;
|
||||
toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || "";
|
||||
} else if (hash.startsWith("#/detached-session")) {
|
||||
isDetachedMode.value = true;
|
||||
} else {
|
||||
await app.checkVaultState();
|
||||
}
|
||||
@ -32,8 +38,10 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Detached session window mode -->
|
||||
<DetachedSession v-if="isDetachedMode" />
|
||||
<!-- Tool popup window mode -->
|
||||
<ToolWindow v-if="isToolMode" :tool="toolName" :session-id="toolSessionId" />
|
||||
<ToolWindow v-else-if="isToolMode" :tool="toolName" :session-id="toolSessionId" />
|
||||
<!-- Normal app mode -->
|
||||
<div v-else class="app-root">
|
||||
<UnlockLayout v-if="!app.isUnlocked" />
|
||||
|
||||
80
src/components/session/DetachedSession.vue
Normal file
80
src/components/session/DetachedSession.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="h-screen w-screen flex flex-col bg-[#0d1117]">
|
||||
<!-- Minimal title bar -->
|
||||
<div class="h-8 flex items-center justify-between px-3 bg-[#161b22] border-b border-[#30363d] shrink-0" data-tauri-drag-region>
|
||||
<span class="text-xs text-[#8b949e]">{{ sessionName }}</span>
|
||||
<span class="text-[10px] text-[#484f58]">Detached — close to reattach</span>
|
||||
</div>
|
||||
|
||||
<!-- Terminal -->
|
||||
<div ref="containerRef" class="flex-1 min-h-0" />
|
||||
|
||||
<!-- Monitor bar for SSH sessions -->
|
||||
<MonitorBar v-if="protocol === 'ssh'" :session-id="sessionId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useTerminal } from "@/composables/useTerminal";
|
||||
import MonitorBar from "@/components/terminal/MonitorBar.vue";
|
||||
|
||||
const sessionId = ref("");
|
||||
const sessionName = ref("Detached Session");
|
||||
const protocol = ref("ssh");
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
let terminalInstance: ReturnType<typeof useTerminal> | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
// Parse session info from URL hash
|
||||
const hash = window.location.hash;
|
||||
const params = new URLSearchParams(hash.split("?")[1] || "");
|
||||
sessionId.value = params.get("sessionId") || "";
|
||||
sessionName.value = decodeURIComponent(params.get("name") || "Detached Session");
|
||||
protocol.value = params.get("protocol") || "ssh";
|
||||
|
||||
if (!sessionId.value || !containerRef.value) return;
|
||||
|
||||
// Determine backend type
|
||||
const backend = protocol.value === "local" ? "pty" : "ssh";
|
||||
terminalInstance = useTerminal(sessionId.value, backend as 'ssh' | 'pty');
|
||||
terminalInstance.mount(containerRef.value);
|
||||
|
||||
setTimeout(() => {
|
||||
if (terminalInstance) {
|
||||
terminalInstance.fit();
|
||||
terminalInstance.terminal.focus();
|
||||
|
||||
// Resize the backend
|
||||
const resizeCmd = backend === "ssh" ? "ssh_resize" : "pty_resize";
|
||||
invoke(resizeCmd, {
|
||||
sessionId: sessionId.value,
|
||||
cols: terminalInstance.terminal.cols,
|
||||
rows: terminalInstance.terminal.rows,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// On window close, emit event so main window reattaches the tab
|
||||
const appWindow = getCurrentWindow();
|
||||
appWindow.onCloseRequested(async () => {
|
||||
// Emit a custom event that the main window listens for
|
||||
const { emit } = await import("@tauri-apps/api/event");
|
||||
await emit("session:reattach", {
|
||||
sessionId: sessionId.value,
|
||||
name: sessionName.value,
|
||||
protocol: protocol.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (terminalInstance) {
|
||||
terminalInstance.destroy();
|
||||
terminalInstance = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -15,6 +15,7 @@
|
||||
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)"
|
||||
@ -22,6 +23,7 @@
|
||||
@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
|
||||
@ -70,6 +72,18 @@
|
||||
</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>
|
||||
|
||||
@ -101,6 +115,53 @@ async function spawnShell(shell: ShellInfo): Promise<void> {
|
||||
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
|
||||
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||
const label = `detached-${session.id.substring(0, 8)}-${Date.now()}`;
|
||||
new WebviewWindow(label, {
|
||||
title: `${session.name} — Wraith`,
|
||||
width: 900,
|
||||
height: 600,
|
||||
resizable: true,
|
||||
center: true,
|
||||
url: `index.html#/detached-session?sessionId=${session.id}&name=${encodeURIComponent(session.name)}&protocol=${session.protocol}`,
|
||||
});
|
||||
}
|
||||
|
||||
function closeMenuTab(): void {
|
||||
const session = tabMenu.value.session;
|
||||
tabMenu.value.visible = false;
|
||||
if (session) sessionStore.closeSession(session.id);
|
||||
}
|
||||
|
||||
// Listen for reattach events from detached windows
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
availableShells.value = await invoke<ShellInfo[]>("list_available_shells");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user