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;
|
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
|
shells
|
||||||
|
|||||||
@ -14,6 +14,19 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- RDP views — toolbar + canvas, kept alive via v-show -->
|
||||||
<div
|
<div
|
||||||
v-for="session in rdpSessions"
|
v-for="session in rdpSessions"
|
||||||
@ -60,6 +73,7 @@ import { computed, ref } from "vue";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useSessionStore } from "@/stores/session.store";
|
import { useSessionStore } from "@/stores/session.store";
|
||||||
import TerminalView from "@/components/terminal/TerminalView.vue";
|
import TerminalView from "@/components/terminal/TerminalView.vue";
|
||||||
|
import LocalTerminalView from "@/components/terminal/LocalTerminalView.vue";
|
||||||
import RdpView from "@/components/rdp/RdpView.vue";
|
import RdpView from "@/components/rdp/RdpView.vue";
|
||||||
import RdpToolbar from "@/components/rdp/RdpToolbar.vue";
|
import RdpToolbar from "@/components/rdp/RdpToolbar.vue";
|
||||||
import { ScancodeMap } from "@/composables/useRdp";
|
import { ScancodeMap } from "@/composables/useRdp";
|
||||||
@ -81,6 +95,10 @@ const sshSessions = computed(() =>
|
|||||||
sessionStore.sessions.filter((s) => s.protocol === "ssh"),
|
sessionStore.sessions.filter((s) => s.protocol === "ssh"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const localSessions = computed(() =>
|
||||||
|
sessionStore.sessions.filter((s) => s.protocol === "local"),
|
||||||
|
);
|
||||||
|
|
||||||
const rdpSessions = computed(() =>
|
const rdpSessions = computed(() =>
|
||||||
sessionStore.sessions.filter((s) => s.protocol === "rdp"),
|
sessionStore.sessions.filter((s) => s.protocol === "rdp"),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -31,7 +31,7 @@ import { computed } from "vue";
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** Connection protocol — drives the protocol-dot colour. */
|
/** Connection protocol — drives the protocol-dot colour. */
|
||||||
protocol: "ssh" | "rdp";
|
protocol: "ssh" | "rdp" | "local";
|
||||||
/** Username from the active session (if known). */
|
/** Username from the active session (if known). */
|
||||||
username?: string;
|
username?: string;
|
||||||
/** Raw tags from the connection record. */
|
/** Raw tags from the connection record. */
|
||||||
@ -40,9 +40,10 @@ const props = defineProps<{
|
|||||||
status?: "connected" | "disconnected";
|
status?: "connected" | "disconnected";
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
/** Green=connected SSH, blue=connected RDP, red=disconnected. */
|
/** Green=connected SSH, blue=connected RDP, purple=local, red=disconnected. */
|
||||||
const protocolDotClass = computed(() => {
|
const protocolDotClass = computed(() => {
|
||||||
if (props.status === "disconnected") return "bg-[#f85149]";
|
if (props.status === "disconnected") return "bg-[#f85149]";
|
||||||
|
if (props.protocol === "local") return "bg-[#bc8cff]";
|
||||||
return props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]";
|
return props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -42,18 +42,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New tab button -->
|
<!-- New tab button with shell dropdown -->
|
||||||
|
<div class="relative shrink-0">
|
||||||
<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"
|
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 session"
|
title="New local terminal"
|
||||||
|
@click="toggleShellMenu"
|
||||||
|
@blur="closeShellMenuDeferred"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useSessionStore, type Session } from "@/stores/session.store";
|
||||||
import { useConnectionStore } from "@/stores/connection.store";
|
import { useConnectionStore } from "@/stores/connection.store";
|
||||||
import TabBadge from "@/components/session/TabBadge.vue";
|
import TabBadge from "@/components/session/TabBadge.vue";
|
||||||
@ -61,6 +82,32 @@ import TabBadge from "@/components/session/TabBadge.vue";
|
|||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const connectionStore = useConnectionStore();
|
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
|
// Drag-and-drop tab reordering
|
||||||
const draggedIndex = ref(-1);
|
const draggedIndex = ref(-1);
|
||||||
const dragOverIndex = 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;
|
id: string;
|
||||||
connectionId: number;
|
connectionId: number;
|
||||||
name: string;
|
name: string;
|
||||||
protocol: "ssh" | "rdp";
|
protocol: "ssh" | "rdp" | "local";
|
||||||
active: boolean;
|
active: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
status: "connected" | "disconnected";
|
status: "connected" | "disconnected";
|
||||||
@ -70,7 +70,9 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
|
|
||||||
// Disconnect the backend session using the protocol-appropriate command
|
// Disconnect the backend session using the protocol-appropriate command
|
||||||
try {
|
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 });
|
await invoke("disconnect_rdp", { sessionId: session.id });
|
||||||
} else {
|
} else {
|
||||||
await invoke("disconnect_session", { sessionId: session.id });
|
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. */
|
/** Apply a theme to all active terminal instances. */
|
||||||
function setTheme(theme: ThemeDefinition): void {
|
function setTheme(theme: ThemeDefinition): void {
|
||||||
activeTheme.value = theme;
|
activeTheme.value = theme;
|
||||||
@ -344,6 +374,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
activateSession,
|
activateSession,
|
||||||
closeSession,
|
closeSession,
|
||||||
connect,
|
connect,
|
||||||
|
spawnLocalTab,
|
||||||
moveSession,
|
moveSession,
|
||||||
setTheme,
|
setTheme,
|
||||||
setTerminalDimensions,
|
setTerminalDimensions,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user