wraith/src/components/terminal/TerminalView.vue
Vantz Stockwell 38cb1f7430
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m53s
fix: 6 UX regressions — popups, themes, cursor, selection, status bar
Popup windows (tools/editor/help):
- CSP broadened for macOS: added tauri: and https://tauri.localhost to
  default-src, https://ipc.localhost and tauri: to connect-src. WKWebView
  uses different IPC scheme than Windows WebView2.

Theme application:
- terminal.refresh() after theme change forces xterm.js canvas repaint
  of existing text — was only changing background, not text colors

Selection highlighting:
- Removed CSS .xterm-selection div rule entirely — xterm.js v6 canvas
  renderer doesn't use DOM selection divs, the CSS was a no-op that
  conflicted with theme-driven selectionBackground

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:08:13 -04:00

237 lines
7.9 KiB
Vue

<template>
<div class="terminal-wrapper flex flex-col h-full relative">
<!-- Inline terminal search bar shown when Ctrl+F is pressed -->
<div
v-if="searchVisible"
class="absolute top-2 right-2 z-20 flex items-center gap-1 bg-[#161b22] border border-[#30363d] rounded-lg shadow-lg px-2 py-1"
>
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
placeholder="Find in terminal…"
class="w-48 px-1.5 py-0.5 text-xs bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] rounded transition-colors"
@keydown.enter="findNext"
@keydown.shift.enter.prevent="findPrevious"
@keydown.escape="closeSearch"
@input="onSearchInput"
/>
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Previous match (Shift+Enter)"
@click="findPrevious"
>
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.427 9.573 8 6l3.573 3.573a.75.75 0 0 0 1.06-1.06L8.53 4.409a.75.75 0 0 0-1.06 0L3.367 8.513a.75.75 0 0 0 1.06 1.06z" />
</svg>
</button>
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Next match (Enter)"
@click="findNext"
>
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.427 6.427 8 10l3.573-3.573a.75.75 0 0 1 1.06 1.06L8.53 11.591a.75.75 0 0 1-1.06 0L3.367 7.487a.75.75 0 0 1 1.06-1.06z" />
</svg>
</button>
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer"
title="Close (Esc)"
@click="closeSearch"
>
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z" />
</svg>
</button>
</div>
<!-- Terminal container -->
<div
ref="containerRef"
class="terminal-container flex-1"
@click="handleFocus"
@focus="handleFocus"
/>
<!-- Remote monitoring bar -->
<MonitorBar :session-id="props.sessionId" />
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted, onBeforeUnmount, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useTerminal } from "@/composables/useTerminal";
import { useSessionStore } from "@/stores/session.store";
import MonitorBar from "@/components/terminal/MonitorBar.vue";
import type { IDisposable } from "@xterm/xterm";
import "@/assets/css/terminal.css";
const props = defineProps<{
sessionId: string;
isActive: boolean;
}>();
const sessionStore = useSessionStore();
const containerRef = ref<HTMLElement | null>(null);
const { terminal, searchAddon, mount, fit } = useTerminal(props.sessionId);
let resizeDisposable: IDisposable | null = null;
// --- Search state ---
const searchVisible = ref(false);
const searchQuery = ref("");
const searchInputRef = ref<HTMLInputElement | null>(null);
/** Open the inline search bar and focus it. */
function openSearch(): void {
searchVisible.value = true;
nextTick(() => {
searchInputRef.value?.focus();
searchInputRef.value?.select();
});
}
/** Close the search bar and refocus the terminal. */
function closeSearch(): void {
searchVisible.value = false;
searchQuery.value = "";
terminal.focus();
}
/** Decoration options for search highlights — xterm SearchAddon spec. */
const searchDecorations = {
matchBackground: "#f0883e40",
matchBorder: "#f0883e",
matchOverviewRuler: "#f0883e",
activeMatchBackground: "#f0883e80",
activeMatchBorder: "#f0883e",
activeMatchColorOverviewRuler: "#f0883e",
} as const;
/** Find the next match. */
function findNext(): void {
if (!searchQuery.value) return;
searchAddon.findNext(searchQuery.value, { caseSensitive: false, decorations: searchDecorations });
}
/** Find the previous match. */
function findPrevious(): void {
if (!searchQuery.value) return;
searchAddon.findPrevious(searchQuery.value, { caseSensitive: false, decorations: searchDecorations });
}
/** Re-search as the user types. */
function onSearchInput(): void {
if (searchQuery.value) {
findNext();
}
}
// Expose openSearch so SessionContainer / MainLayout can trigger Ctrl+F
defineExpose({ openSearch });
onMounted(() => {
if (containerRef.value) {
mount(containerRef.value);
}
// Apply the current theme immediately if one is already active
if (sessionStore.activeTheme) {
applyTheme();
}
// Track terminal dimensions in the session store
resizeDisposable = terminal.onResize(({ cols, rows }) => {
sessionStore.setTerminalDimensions(props.sessionId, cols, rows);
});
// Focus the terminal after mount so keyboard input works immediately
setTimeout(() => {
fit();
terminal.focus();
}, 50);
});
// Re-fit and focus terminal when switching back to this tab.
// Must wait for the container to have real dimensions after becoming visible.
watch(
() => props.isActive,
(active) => {
if (active) {
// Double rAF ensures the container has been laid out by the browser
requestAnimationFrame(() => {
requestAnimationFrame(() => {
fit();
terminal.focus();
// Also notify the backend of the correct size
const session = sessionStore.sessions.find(s => s.id === props.sessionId);
const resizeCmd = session?.protocol === "local" ? "pty_resize" : "ssh_resize";
invoke(resizeCmd, {
sessionId: props.sessionId,
cols: terminal.cols,
rows: terminal.rows,
}).catch(() => {});
});
});
}
},
);
/** Apply the session store's active theme to this terminal instance. */
function applyTheme(): void {
const theme = sessionStore.activeTheme;
if (!theme) return;
terminal.options.theme = {
background: theme.background,
foreground: theme.foreground,
cursor: theme.cursor,
cursorAccent: theme.background,
selectionBackground: theme.selectionBackground ?? "rgba(88, 166, 255, 0.4)",
selectionForeground: theme.selectionForeground ?? "#ffffff",
selectionInactiveBackground: theme.selectionBackground ?? "rgba(88, 166, 255, 0.2)",
black: theme.black,
red: theme.red,
green: theme.green,
yellow: theme.yellow,
blue: theme.blue,
magenta: theme.magenta,
cyan: theme.cyan,
white: theme.white,
brightBlack: theme.brightBlack,
brightRed: theme.brightRed,
brightGreen: theme.brightGreen,
brightYellow: theme.brightYellow,
brightBlue: theme.brightBlue,
brightMagenta: theme.brightMagenta,
brightCyan: theme.brightCyan,
brightWhite: theme.brightWhite,
};
// Sync the container background so areas outside the canvas match the theme
if (containerRef.value) {
containerRef.value.style.backgroundColor = theme.background;
}
// Force xterm.js to repaint all visible rows with the new theme colors
terminal.refresh(0, terminal.rows - 1);
}
// Watch for theme changes in the session store and apply to this terminal.
// Uses deep comparison because the theme is an object — a shallow watch may miss
// updates if Pinia returns the same reactive proxy wrapper after reassignment.
watch(() => sessionStore.activeTheme, (newTheme) => {
if (newTheme) applyTheme();
}, { deep: true });
onBeforeUnmount(() => {
if (resizeDisposable) {
resizeDisposable.dispose();
resizeDisposable = null;
}
});
function handleFocus(): void {
terminal.focus();
}
</script>