""" PowerShell/CMD tab — request-response terminal for WinRM servers. No pyte needed: WinRM is not an interactive PTY, just command → output. """ import threading import customtkinter as ctk from core.winrm_client import WinRMClient from core.i18n import t from core.icons import icon_text, make_icon_button class PowershellTab(ctk.CTkFrame): """Simplified terminal for WinRM command execution (PS or CMD).""" _MAX_HISTORY = 200 def __init__(self, master, store): super().__init__(master, fg_color="transparent") self.store = store self._current_alias: str | None = None self._client: WinRMClient | None = None self._mode: str = "ps" # "ps" or "cmd" self._history: list[str] = [] self._history_index: int = -1 self._running = False self._build_ui() # ── UI construction ────────────────────────────────────────────── def _build_ui(self): # Top bar: mode toggle top = ctk.CTkFrame(self, fg_color="transparent") top.pack(fill="x", padx=8, pady=(8, 0)) self._mode_var = ctk.StringVar(value="ps") self._ps_radio = ctk.CTkRadioButton( top, text=t("ps_mode_ps"), variable=self._mode_var, value="ps", command=self._on_mode_changed, ) self._ps_radio.pack(side="left", padx=(0, 12)) self._cmd_radio = ctk.CTkRadioButton( top, text=t("ps_mode_cmd"), variable=self._mode_var, value="cmd", command=self._on_mode_changed, ) self._cmd_radio.pack(side="left") # Output console self._output = ctk.CTkTextbox( self, font=ctk.CTkFont(family="Consolas", size=13), state="disabled", wrap="word", ) self._output.pack(fill="both", expand=True, padx=8, pady=8) # Input row: entry + execute button input_row = ctk.CTkFrame(self, fg_color="transparent") input_row.pack(fill="x", padx=8, pady=(0, 4)) self._entry = ctk.CTkEntry( input_row, placeholder_text="PS> ...", font=ctk.CTkFont(family="Consolas", size=13), ) self._entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) self._entry.bind("", lambda e: self._execute()) self._entry.bind("", lambda e: self._history_navigate(-1)) self._entry.bind("", lambda e: self._history_navigate(1)) self._exec_btn = make_icon_button( input_row, "execute", t("ps_execute"), width=100, command=self._execute, ) self._exec_btn.pack(side="right") # Status bar self._status = ctk.CTkLabel( self, text="", anchor="w", font=ctk.CTkFont(size=11), text_color="#888888", ) self._status.pack(fill="x", padx=10, pady=(0, 6)) # ── Public API ─────────────────────────────────────────────────── def set_server(self, alias: str | None): """Switch to a different server (or None to disconnect).""" if alias == self._current_alias: return self._disconnect() self._current_alias = alias self._history.clear() self._history_index = -1 if alias is None: self._set_status(t("ps_disconnected"), "#888888") return self._connect(alias) # ── Connection ─────────────────────────────────────────────────── def _connect(self, alias: str): server = self.store.get_server(alias) if not server: self._set_status(t("server_not_found").format(alias=alias), "#ff4444") return self._set_status(t("ps_connecting").format(alias=alias), "#ccaa00") def _do(): try: client = WinRMClient(server) client.connect() self._client = client self.after(0, lambda: self._set_status( t("ps_connected").format(alias=alias), "#44cc44", )) self.after(0, lambda: self._entry.focus()) except Exception as exc: self.after(0, lambda: self._set_status( t("ps_connect_failed").format(error=str(exc)), "#ff4444", )) threading.Thread(target=_do, daemon=True).start() def _disconnect(self): if self._client: try: self._client.close() except Exception: pass self._client = None # ── Command execution ──────────────────────────────────────────── def _execute(self): cmd = self._entry.get().strip() if not cmd: return if not self._client: self._set_status(t("ps_not_connected"), "#ff4444") return if self._running: return # Save to history if not self._history or self._history[-1] != cmd: self._history.append(cmd) if len(self._history) > self._MAX_HISTORY: self._history.pop(0) self._history_index = -1 # Show command in output prompt = "PS>" if self._mode == "ps" else "CMD>" self._append_output(f"\n{prompt} {cmd}\n") self._entry.delete(0, "end") self._running = True self._exec_btn.configure(state="disabled") self._set_status(t("ps_running"), "#ccaa00") mode = self._mode client = self._client def _run(): try: if mode == "ps": result = client.exec_ps(cmd) else: result = client.exec_cmd(cmd) stdout = result.get("stdout", "") stderr = result.get("stderr", "") rc = result.get("return_code", None) def _show(): if stdout: self._append_output(stdout) if stderr: self._append_output(f"[STDERR] {stderr}") if rc is not None and rc != 0: self._append_output(f"[Exit code: {rc}]") self._set_status(t("ps_done"), "#44cc44") self.after(0, _show) except Exception as exc: self.after(0, lambda: self._append_output( f"\n[ERROR] {exc}\n" )) self.after(0, lambda: self._set_status( t("ps_exec_error"), "#ff4444", )) finally: self._running = False self.after(0, lambda: self._exec_btn.configure(state="normal")) threading.Thread(target=_run, daemon=True).start() # ── History navigation ─────────────────────────────────────────── def _history_navigate(self, direction: int): """Navigate command history. direction: -1 = older, +1 = newer.""" if not self._history: self._set_status(t("ps_history_empty"), "#888888") return if self._history_index == -1: if direction == -1: self._history_index = len(self._history) - 1 else: return else: self._history_index += direction if self._history_index < 0: self._history_index = 0 elif self._history_index >= len(self._history): self._history_index = -1 self._entry.delete(0, "end") return self._entry.delete(0, "end") self._entry.insert(0, self._history[self._history_index]) # ── Mode toggle ────────────────────────────────────────────────── def _on_mode_changed(self): self._mode = self._mode_var.get() placeholder = "PS> ..." if self._mode == "ps" else "CMD> ..." self._entry.configure(placeholder_text=placeholder) # ── Helpers ────────────────────────────────────────────────────── def _append_output(self, text: str): self._output.configure(state="normal") self._output.insert("end", text) self._output.see("end") self._output.configure(state="disabled") def _set_status(self, text: str, color: str = "#888888"): self._status.configure(text=text, text_color=color)