Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 16s
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>
198 lines
5.9 KiB
Vue
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>
|