From 4532f3beb631dd85ce2f6e3606494be125fe3ca7 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 24 Mar 2026 23:46:09 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20local=20terminal=20tabs=20=E2=80=94=20+?= =?UTF-8?q?=20button=20spawns=20WSL/Git=20Bash/PowerShell/CMD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src-tauri/src/pty/mod.rs | 4 ++ src/components/session/SessionContainer.vue | 18 ++++++ src/components/session/TabBadge.vue | 5 +- src/components/session/TabBar.vue | 63 ++++++++++++++++--- src/components/terminal/LocalTerminalView.vue | 51 +++++++++++++++ src/stores/session.store.ts | 35 ++++++++++- 6 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 src/components/terminal/LocalTerminalView.vue diff --git a/src-tauri/src/pty/mod.rs b/src-tauri/src/pty/mod.rs index 7c5509c..b7467a3 100644 --- a/src-tauri/src/pty/mod.rs +++ b/src-tauri/src/pty/mod.rs @@ -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 diff --git a/src/components/session/SessionContainer.vue b/src/components/session/SessionContainer.vue index 2e5c825..751d501 100644 --- a/src/components/session/SessionContainer.vue +++ b/src/components/session/SessionContainer.vue @@ -14,6 +14,19 @@ /> + +
+ +
+
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"), ); diff --git a/src/components/session/TabBadge.vue b/src/components/session/TabBadge.vue index f1a6158..e491030 100644 --- a/src/components/session/TabBadge.vue +++ b/src/components/session/TabBadge.vue @@ -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]"; }); diff --git a/src/components/session/TabBar.vue b/src/components/session/TabBar.vue index 937f6e9..a6490e5 100644 --- a/src/components/session/TabBar.vue +++ b/src/components/session/TabBar.vue @@ -42,18 +42,39 @@
- - + +
+ +
+ +
+ No shells found +
+
+
diff --git a/src/stores/session.store.ts b/src/stores/session.store.ts index b2c8dba..7b471c0 100644 --- a/src/stores/session.store.ts +++ b/src/stores/session.store.ts @@ -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 { + try { + const sessionId = await invoke("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,