feat: local terminal tabs — + button spawns WSL/Git Bash/PowerShell/CMD
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 16s
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>
This commit is contained in:
parent
2ad6da43eb
commit
4532f3beb6
@ -70,6 +70,10 @@ impl PtyService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// WSL (Windows Subsystem for Linux)
|
||||
if std::path::Path::new(r"C:\Windows\System32\wsl.exe").exists() {
|
||||
shells.push(ShellInfo { name: "WSL".to_string(), path: r"C:\Windows\System32\wsl.exe".to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
shells
|
||||
|
||||
@ -14,6 +14,19 @@
|
||||
/>
|
||||
</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"
|
||||
@ -60,6 +73,7 @@ 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";
|
||||
@ -81,6 +95,10 @@ 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"),
|
||||
);
|
||||
|
||||
@ -31,7 +31,7 @@ import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Connection protocol — drives the protocol-dot colour. */
|
||||
protocol: "ssh" | "rdp";
|
||||
protocol: "ssh" | "rdp" | "local";
|
||||
/** Username from the active session (if known). */
|
||||
username?: string;
|
||||
/** Raw tags from the connection record. */
|
||||
@ -40,9 +40,10 @@ const props = defineProps<{
|
||||
status?: "connected" | "disconnected";
|
||||
}>();
|
||||
|
||||
/** Green=connected SSH, blue=connected RDP, red=disconnected. */
|
||||
/** Green=connected SSH, blue=connected RDP, purple=local, red=disconnected. */
|
||||
const protocolDotClass = computed(() => {
|
||||
if (props.status === "disconnected") return "bg-[#f85149]";
|
||||
if (props.protocol === "local") return "bg-[#bc8cff]";
|
||||
return props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]";
|
||||
});
|
||||
|
||||
|
||||
@ -42,18 +42,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New tab button -->
|
||||
<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 shrink-0"
|
||||
title="New session"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<!-- 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ref, onMounted } 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";
|
||||
@ -61,6 +82,32 @@ 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);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
availableShells.value = await invoke<ShellInfo[]>("list_available_shells");
|
||||
} catch {
|
||||
availableShells.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
// Drag-and-drop tab reordering
|
||||
const draggedIndex = ref(-1);
|
||||
const dragOverIndex = ref(-1);
|
||||
|
||||
51
src/components/terminal/LocalTerminalView.vue
Normal file
51
src/components/terminal/LocalTerminalView.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="terminal-container flex-1"
|
||||
@click="terminal.focus()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTerminal } from "@/composables/useTerminal";
|
||||
import "@/assets/css/terminal.css";
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string;
|
||||
isActive: boolean;
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const { terminal, mount, fit } = useTerminal(props.sessionId, "pty");
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
mount(containerRef.value);
|
||||
}
|
||||
setTimeout(() => {
|
||||
fit();
|
||||
terminal.focus();
|
||||
invoke("pty_resize", {
|
||||
sessionId: props.sessionId,
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
}).catch(() => {});
|
||||
}, 50);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.isActive,
|
||||
(active) => {
|
||||
if (active) {
|
||||
setTimeout(() => {
|
||||
fit();
|
||||
terminal.focus();
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@ -9,7 +9,7 @@ export interface Session {
|
||||
id: string;
|
||||
connectionId: number;
|
||||
name: string;
|
||||
protocol: "ssh" | "rdp";
|
||||
protocol: "ssh" | "rdp" | "local";
|
||||
active: boolean;
|
||||
username?: string;
|
||||
status: "connected" | "disconnected";
|
||||
@ -70,7 +70,9 @@ export const useSessionStore = defineStore("session", () => {
|
||||
|
||||
// Disconnect the backend session using the protocol-appropriate command
|
||||
try {
|
||||
if (session.protocol === "rdp") {
|
||||
if (session.protocol === "local") {
|
||||
await invoke("disconnect_pty", { sessionId: session.id });
|
||||
} else if (session.protocol === "rdp") {
|
||||
await invoke("disconnect_rdp", { sessionId: session.id });
|
||||
} else {
|
||||
await invoke("disconnect_session", { sessionId: session.id });
|
||||
@ -315,6 +317,34 @@ export const useSessionStore = defineStore("session", () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Spawn a local shell as a full-size tab. */
|
||||
async function spawnLocalTab(shellName: string, shellPath: string): Promise<void> {
|
||||
try {
|
||||
const sessionId = await invoke<string>("spawn_local_shell", {
|
||||
shellPath,
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
});
|
||||
|
||||
sessions.value.push({
|
||||
id: sessionId,
|
||||
connectionId: 0,
|
||||
name: shellName,
|
||||
protocol: "local",
|
||||
active: true,
|
||||
status: "connected",
|
||||
});
|
||||
|
||||
// Listen for PTY close
|
||||
listen(`pty:close:${sessionId}`, () => markDisconnected(sessionId));
|
||||
|
||||
activeSessionId.value = sessionId;
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
alert(`Failed to spawn local shell: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a theme to all active terminal instances. */
|
||||
function setTheme(theme: ThemeDefinition): void {
|
||||
activeTheme.value = theme;
|
||||
@ -344,6 +374,7 @@ export const useSessionStore = defineStore("session", () => {
|
||||
activateSession,
|
||||
closeSession,
|
||||
connect,
|
||||
spawnLocalTab,
|
||||
moveSession,
|
||||
setTheme,
|
||||
setTerminalDimensions,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user