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(
|
const ToolWindow = defineAsyncComponent(
|
||||||
() => import("@/components/tools/ToolWindow.vue")
|
() => import("@/components/tools/ToolWindow.vue")
|
||||||
);
|
);
|
||||||
|
const DetachedSession = defineAsyncComponent(
|
||||||
|
() => import("@/components/session/DetachedSession.vue")
|
||||||
|
);
|
||||||
|
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
|
|
||||||
// Tool window mode — detected from URL hash: #/tool/network-scanner?sessionId=abc
|
// Tool window mode — detected from URL hash: #/tool/network-scanner?sessionId=abc
|
||||||
const isToolMode = ref(false);
|
const isToolMode = ref(false);
|
||||||
|
const isDetachedMode = ref(false);
|
||||||
const toolName = ref("");
|
const toolName = ref("");
|
||||||
const toolSessionId = ref("");
|
const toolSessionId = ref("");
|
||||||
|
|
||||||
@ -25,6 +29,8 @@ onMounted(async () => {
|
|||||||
const [name, query] = rest.split("?");
|
const [name, query] = rest.split("?");
|
||||||
toolName.value = name;
|
toolName.value = name;
|
||||||
toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || "";
|
toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || "";
|
||||||
|
} else if (hash.startsWith("#/detached-session")) {
|
||||||
|
isDetachedMode.value = true;
|
||||||
} else {
|
} else {
|
||||||
await app.checkVaultState();
|
await app.checkVaultState();
|
||||||
}
|
}
|
||||||
@ -32,8 +38,10 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- Detached session window mode -->
|
||||||
|
<DetachedSession v-if="isDetachedMode" />
|
||||||
<!-- Tool popup window mode -->
|
<!-- 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 -->
|
<!-- Normal app mode -->
|
||||||
<div v-else class="app-root">
|
<div v-else class="app-root">
|
||||||
<UnlockLayout v-if="!app.isUnlocked" />
|
<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]' : '',
|
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
|
||||||
dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '',
|
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.hasActivity && session.id !== sessionStore.activeSessionId ? 'animate-pulse text-[var(--wraith-accent-blue)]' : '',
|
||||||
|
!session.active ? 'opacity-40 italic' : '',
|
||||||
]"
|
]"
|
||||||
@click="sessionStore.activateSession(session.id)"
|
@click="sessionStore.activateSession(session.id)"
|
||||||
@dragstart="onDragStart(index, $event)"
|
@dragstart="onDragStart(index, $event)"
|
||||||
@ -22,6 +23,7 @@
|
|||||||
@dragleave="dragOverIndex = -1"
|
@dragleave="dragOverIndex = -1"
|
||||||
@drop.prevent="onDrop(index)"
|
@drop.prevent="onDrop(index)"
|
||||||
@dragend="draggedIndex = -1; dragOverIndex = -1"
|
@dragend="draggedIndex = -1; dragOverIndex = -1"
|
||||||
|
@contextmenu.prevent="showTabMenu($event, session)"
|
||||||
>
|
>
|
||||||
<!-- Badge: protocol dot + root dot + env pills -->
|
<!-- Badge: protocol dot + root dot + env pills -->
|
||||||
<TabBadge
|
<TabBadge
|
||||||
@ -70,6 +72,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -101,6 +115,53 @@ async function spawnShell(shell: ShellInfo): Promise<void> {
|
|||||||
await sessionStore.spawnLocalTab(shell.name, shell.path);
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
availableShells.value = await invoke<ShellInfo[]>("list_available_shells");
|
availableShells.value = await invoke<ShellInfo[]>("list_available_shells");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user