wraith/src/components/session/SessionContainer.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

198 lines
5.9 KiB
Vue

<template>
<div class="flex-1 flex flex-col bg-[var(--wraith-bg-primary)] min-h-0 relative">
<!-- SSH terminal views v-show keeps xterm alive across tab switches -->
<div
v-for="session in sshSessions"
:key="session.id"
v-show="session.id === sessionStore.activeSessionId"
class="absolute inset-0"
>
<TerminalView
:ref="(el) => setTerminalRef(session.id, el)"
:session-id="session.id"
:is-active="session.id === sessionStore.activeSessionId"
/>
</div>
<!-- Local PTY views — v-show keeps xterm alive across tab switches -->
<div
v-for="session in localSessions"
:key="session.id"
v-show="session.id === sessionStore.activeSessionId"
class="absolute inset-0"
>
<LocalTerminalView
:session-id="session.id"
:is-active="session.id === sessionStore.activeSessionId"
/>
</div>
<!-- RDP views — toolbar + canvas, kept alive via v-show -->
<div
v-for="session in rdpSessions"
:key="session.id"
v-show="session.id === sessionStore.activeSessionId"
class="absolute inset-0 flex flex-col"
>
<RdpToolbar
:session-id="session.id"
:keyboard-grabbed="rdpViewRefs[session.id]?.keyboardGrabbed.value ?? false"
:clipboard-sync="rdpViewRefs[session.id]?.clipboardSync.value ?? false"
@toggle-keyboard="rdpViewRefs[session.id]?.toggleKeyboardGrab()"
@toggle-clipboard="rdpViewRefs[session.id]?.toggleClipboardSync()"
@ctrl-alt-del="sendCtrlAltDel(session.id)"
@fullscreen="toggleFullscreen(rdpViewRefs[session.id]?.canvasWrapper)"
@send-clipboard="(text) => sendClipboardToSession(session.id, text)"
/>
<RdpView
:ref="(el) => setRdpRef(session.id, el)"
:session-id="session.id"
:is-active="session.id === sessionStore.activeSessionId"
/>
</div>
<!-- No session placeholder -->
<div
v-if="!sessionStore.activeSession"
class="flex-1 flex items-center justify-center"
>
<div class="text-center">
<p class="text-[var(--wraith-text-muted)] text-sm">
No active session
</p>
<p class="text-[var(--wraith-text-muted)] text-xs mt-1">
Double-click a connection to start a session
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useSessionStore } from "@/stores/session.store";
import TerminalView from "@/components/terminal/TerminalView.vue";
import LocalTerminalView from "@/components/terminal/LocalTerminalView.vue";
import RdpView from "@/components/rdp/RdpView.vue";
import RdpToolbar from "@/components/rdp/RdpToolbar.vue";
import { ScancodeMap } from "@/composables/useRdp";
/** Map from session ID TerminalView instance ref (for search access). */
const terminalViewRefs: Record<string, { openSearch: () => void } | null> = {};
function setTerminalRef(sessionId: string, el: unknown): void {
if (el) {
terminalViewRefs[sessionId] = el as { openSearch: () => void };
} else {
delete terminalViewRefs[sessionId];
}
}
const sessionStore = useSessionStore();
const sshSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "ssh"),
);
const localSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "local"),
);
const rdpSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "rdp"),
);
/**
* Map from session ID → exposed RdpView instance ref.
* The ref callback pattern (`:ref="(el) => ..."`) gives us per-session access
* to each RdpView's composable state without a single shared ref.
*/
const rdpViewRefs = ref<
Record<
string,
{
keyboardGrabbed: { value: boolean };
clipboardSync: { value: boolean };
toggleKeyboardGrab: () => void;
toggleClipboardSync: () => void;
canvasWrapper: { value: HTMLElement | null };
} | null
>
>({});
function setRdpRef(
sessionId: string,
el: unknown,
): void {
if (el) {
rdpViewRefs.value[sessionId] = el as typeof rdpViewRefs.value[string];
} else {
delete rdpViewRefs.value[sessionId];
}
}
/**
* Send Ctrl+Alt+Del to a remote RDP session by injecting the three
* scancodes in the correct order: Ctrl down, Alt down, Del down, then up.
*/
async function sendCtrlAltDel(sessionId: string): Promise<void> {
const ctrl = ScancodeMap["ControlLeft"];
const alt = ScancodeMap["AltLeft"];
const del = ScancodeMap["Delete"];
const sequence: Array<[number, boolean]> = [
[ctrl, true],
[alt, true],
[del, true],
[del, false],
[alt, false],
[ctrl, false],
];
for (const [scancode, pressed] of sequence) {
try {
await invoke("rdp_send_key", { sessionId, scancode, pressed });
} catch (err) {
console.warn("[SessionContainer] Ctrl+Alt+Del key send failed:", err);
}
}
}
/**
* Forward clipboard text from the host to the remote RDP session.
*/
function sendClipboardToSession(sessionId: string, text: string): void {
invoke("rdp_send_clipboard", { sessionId, text }).catch((err: unknown) => {
console.warn("[SessionContainer] sendClipboard failed:", err);
});
}
/**
* Toggle fullscreen on the canvas wrapper element.
*/
function toggleFullscreen(wrapperRef: { value: HTMLElement | null } | undefined): void {
const wrapper = wrapperRef?.value;
if (!wrapper) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
wrapper.requestFullscreen();
}
}
/**
* Open the inline search bar on the currently active SSH terminal.
* No-op if the active session is RDP (no terminal to search).
*/
function openActiveSearch(): void {
const activeId = sessionStore.activeSessionId;
if (!activeId) return;
const termRef = terminalViewRefs[activeId];
termRef?.openSearch();
}
defineExpose({ openActiveSearch });
</script>