From d941e9a5e0393d7b065614c81d40a4e20fd8e0e0 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Fri, 16 Jan 2026 17:56:28 +0000 Subject: [PATCH] Add integrated terminal with PTY backend and xterm.js frontend (Vibe Kanban) (#2104) * integrated terminal * ci * persist terminal * Done! I've moved the TerminalPanelContainer from the main panel area to the right sidebar. Here's a summary of the changes: - Added `isTerminalVisible: boolean` to global state (defaults to `true`) - Added `toggleTerminal()` and `setTerminalVisible(value)` actions - Added `isTerminalVisible` to the persisted state and the `useWorkspacePanelState` hook - Imported `TerminalPanelContainer` and `useUiPreferencesStore` - Restructured the component to always include a terminal section at the bottom (when not in create mode and `isTerminalVisible` is true) - Dynamic flex ratios: - With upper content + terminal: flex-4 (content) / flex-2 (git) / flex-4 (terminal) - Without upper content + terminal: flex-3 (git) / flex-7 (terminal) - When terminal is hidden: original ratios preserved - Removed the `TerminalPanelContainer` import - Removed the TERMINAL case from the right main panel rendering - Added `isTerminalVisible` to `ActionVisibilityContext` interface - Updated `ToggleTerminalMode` action to use `toggleTerminal()` and check `isTerminalVisible` - Added `isTerminalVisible` from `panelState` to the visibility context The terminal now appears permanently at the bottom of the right sidebar (when visible), and the toggle button collapses/expands just the terminal section within the sidebar. The visibility state is persisted to localStorage. * Frontend ESLint passes with no errors. The backend Rust error (`crates/utils/src/shell.rs:31`) is a pre-existing issue in the codebase, not related to my changes. * `cargo check` passes now. The fix was adding `.to_path_buf()` to convert the `&Path` reference to an owned `PathBuf`. * The code compiles. Done! I've added three environment variables in `crates/local-deployment/src/pty.rs:73-75`: 1. `ZDOTDIR=/nonexistent` - prevents zsh from loading `~/.zshrc` 2. `PS1="$ "` - sets the bash prompt 3. `PROMPT="$ "` - sets the zsh prompt The terminal prompt will now show just `$ ` instead of `lkw@Louiss-MacBook-Pro vibe-kanban %`. Run `pnpm run dev:qa` and open the terminal panel to verify. * Cleanup script changes for workspace 928e696e-5f40-4603-9227-798f0520e07f * Done! The fix is simpler - using `cmd.arg("-f")` to tell zsh/bash to skip loading config files. This: 1. Removes the `ZDOTDIR=/nonexistent` that was causing errors 2. Adds `-f` flag which skips `.zshrc`/`.bashrc` loading 3. Keeps `PS1` and `PROMPT` env vars which will now be respected Run `pnpm run dev:qa` and open the terminal to verify the prompt shows `$ `. * Cleanup script changes for workspace 928e696e-5f40-4603-9227-798f0520e07f * I've implemented the terminal theme integration. Here's a summary of the changes: ## Changes Made **1. Created `frontend/src/utils/terminalTheme.ts`** - `hslToHex()` function to convert HSL CSS variable format (`"210 40% 98%"`) to hex colors - `getCssVariable()` helper to read CSS variables from the `.new-design` element - `getTerminalTheme()` function that builds an xterm.js `ITheme` object from CSS variables: - Uses `--console-background` and `--console-foreground` for main colors - Uses `--console-success` and `--console-error` for green/red ANSI colors - Provides appropriate ANSI color palettes for both light and dark modes **2. Updated `frontend/src/components/ui-new/terminal/XTermInstance.tsx`** - Imported `useTheme` hook and `getTerminalTheme` utility - Replaced hardcoded Tokyo Night theme with dynamic `getTerminalTheme()` call - Added effect to update terminal theme when app theme changes (`terminal.options.theme = getTerminalTheme()`) The terminal will now: - Initialize with colors matching the current theme - Automatically update colors when switching between light and dark mode - Use the console color variables defined in `index.css` * Cleanup script changes for workspace 928e696e-5f40-4603-9227-798f0520e07f * Now I understand the issue clearly. Here are the relevant variables: | Variable | Light Mode | Dark Mode | Purpose | |----------|------------|-----------|---------| | `--_primary` | `0 0% 5%` (dark) | `240 4.8% 95.9%` (light) | Button background - **inverted** | | `--_console-background` | `0 0% 100%` (white) | `0 0% 0%` (black) | Terminal background - **correct** | | `--_console-foreground` | `0 0% 5%` (dark) | `210 40% 98%` (light) | Terminal text - **correct** | | `--bg-primary` | `0 0% 100%` (white) | `0 0% 13%` (dark gray) | Page background | The code was changed to use `--primary` but that's the wrong variable. For the terminal, you should use: - `--console-background` / `--_console-background` for the background - `--console-foreground` / `--_console-foreground` for the text Or if you prefer to match other UI elements: - `--bg-primary` for background - `--text-high` for foreground text Which approach would you prefer? The original `--console-*` variables were specifically designed for terminal styling, or we can use `--bg-primary`/`--text-high` to match the general UI background. * Cleanup script changes for workspace 928e696e-5f40-4603-9227-798f0520e07f * Done. The terminal will now use `--bg-primary` for the background and `--text-high` for the foreground text. This means: - **Light mode**: White background (`0 0% 100%`) with dark text (`0 0% 5%`) - **Dark mode**: Dark gray background (`0 0% 13%`) with light text (`0 0% 96%`) * Done. The terminal will now use `--bg-secondary` for the background and `--text-high` for the foreground text. This means: - **Light mode**: Light gray background (`0 0% 95%`) with dark text (`0 0% 5%`) - **Dark mode**: Darker gray background (`0 0% 11%`) with light text (`0 0% 96%`) * Let me know if you find any issues during testing or need adjustments! * Cleanup script changes for workspace 928e696e-5f40-4603-9227-798f0520e07f * terminal * sidebar cleanup * fix chat collapse Amp-Thread-ID: https://ampcode.com/threads/T-019bc754-8db9-712a-a915-58d74d210cad Co-authored-by: Amp * for the terminal on windows we need to default to powershell if avalibale for the pty (vibe-kanban 049dbf73) only if powershell.exe cannot be resolved, we should use cmd. * he colour theme used for the terminal ui isn't visible enough in light mode (vibe-kanban 5f50878a) t I think we either don't override the ANSI colour mapping with our own, or it's not contrasted enough * fmt --------- Co-authored-by: Gabriel Gordon-Hall Co-authored-by: Amp Co-authored-by: Solomon --- Cargo.lock | 148 +++++++- crates/local-deployment/Cargo.toml | 1 + crates/local-deployment/src/lib.rs | 11 +- crates/local-deployment/src/pty.rs | 220 ++++++++++++ crates/server/Cargo.toml | 1 + crates/server/src/error.rs | 8 + crates/server/src/routes/mod.rs | 2 + crates/server/src/routes/terminal.rs | 173 ++++++++++ crates/utils/src/shell.rs | 18 + frontend/package.json | 4 + frontend/src/App.tsx | 5 +- .../containers/GitPanelCreateContainer.tsx | 72 ---- .../containers/ProcessListContainer.tsx | 48 ++- .../containers/ProjectSelectorContainer.tsx | 166 ++++----- .../ui-new/containers/RightSidebar.tsx | 323 ++++++++++++++---- .../containers/TerminalPanelContainer.tsx | 65 ++++ .../ui-new/containers/WorkspacesLayout.tsx | 16 +- .../primitives/CollapsibleSectionHeader.tsx | 72 ++-- .../components/ui-new/primitives/RepoCard.tsx | 13 +- .../ui-new/primitives/SelectedReposList.tsx | 4 +- .../ui-new/terminal/TerminalTabBar.tsx | 62 ++++ .../ui-new/terminal/XTermInstance.tsx | 120 +++++++ .../src/components/ui-new/views/FileTree.tsx | 92 +++-- .../src/components/ui-new/views/GitPanel.tsx | 100 +++--- .../ui-new/views/GitPanelCreate.tsx | 110 ------ .../ui-new/views/PreviewControls.tsx | 96 +++--- .../components/ui-new/views/TerminalPanel.tsx | 32 ++ frontend/src/contexts/TerminalContext.tsx | 237 +++++++++++++ frontend/src/hooks/useTerminalWebSocket.ts | 100 ++++++ frontend/src/i18n/locales/en/common.json | 3 +- frontend/src/i18n/locales/en/tasks.json | 3 + frontend/src/i18n/locales/es/common.json | 3 +- frontend/src/i18n/locales/es/tasks.json | 3 + frontend/src/i18n/locales/ja/common.json | 3 +- frontend/src/i18n/locales/ja/tasks.json | 3 + frontend/src/i18n/locales/ko/common.json | 3 +- frontend/src/i18n/locales/ko/tasks.json | 3 + frontend/src/i18n/locales/zh-Hans/common.json | 3 +- frontend/src/i18n/locales/zh-Hans/tasks.json | 3 + frontend/src/i18n/locales/zh-Hant/common.json | 3 +- frontend/src/i18n/locales/zh-Hant/tasks.json | 3 + frontend/src/stores/useUiPreferencesStore.ts | 23 +- frontend/src/styles/new/index.css | 74 ++-- frontend/src/utils/terminalTheme.ts | 139 ++++++++ pnpm-lock.yaml | 40 +++ 45 files changed, 2016 insertions(+), 615 deletions(-) create mode 100644 crates/local-deployment/src/pty.rs create mode 100644 crates/server/src/routes/terminal.rs delete mode 100644 frontend/src/components/ui-new/containers/GitPanelCreateContainer.tsx create mode 100644 frontend/src/components/ui-new/containers/TerminalPanelContainer.tsx create mode 100644 frontend/src/components/ui-new/terminal/TerminalTabBar.tsx create mode 100644 frontend/src/components/ui-new/terminal/XTermInstance.tsx delete mode 100644 frontend/src/components/ui-new/views/GitPanelCreate.tsx create mode 100644 frontend/src/components/ui-new/views/TerminalPanel.tsx create mode 100644 frontend/src/contexts/TerminalContext.tsx create mode 100644 frontend/src/hooks/useTerminalWebSocket.ts create mode 100644 frontend/src/utils/terminalTheme.ts diff --git a/Cargo.lock b/Cargo.lock index 4155fa9a..3cce82c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1577,6 +1577,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -1868,6 +1874,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.26" @@ -2736,6 +2753,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2998,6 +3024,7 @@ dependencies = [ "globwalk", "json-patch", "nix 0.29.0", + "portable-pty", "reqwest", "sentry", "serde_json", @@ -3090,6 +3117,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3191,6 +3227,20 @@ dependencies = [ "version_check", ] +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.27.1" @@ -3224,7 +3274,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3815,6 +3865,27 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4730,12 +4801,55 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "server" version = "0.0.153" dependencies = [ "anyhow", "axum", + "base64", "chrono", "db", "deployment", @@ -4860,6 +4974,16 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -5332,6 +5456,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5825,7 +5958,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "winapi", ] @@ -5970,7 +6103,7 @@ dependencies = [ "uuid", "which", "windows-sys 0.61.2", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -6616,6 +6749,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/crates/local-deployment/Cargo.toml b/crates/local-deployment/Cargo.toml index cfbf48c5..87f63737 100644 --- a/crates/local-deployment/Cargo.toml +++ b/crates/local-deployment/Cargo.toml @@ -26,6 +26,7 @@ futures = "0.3" json-patch = "2.0" tokio = { workspace = true } globwalk = "0.9" +portable-pty = "0.8" [dev-dependencies] tempfile = "3.8" diff --git a/crates/local-deployment/src/lib.rs b/crates/local-deployment/src/lib.rs index b486bb3a..0067720e 100644 --- a/crates/local-deployment/src/lib.rs +++ b/crates/local-deployment/src/lib.rs @@ -30,10 +30,11 @@ use utils::{ }; use uuid::Uuid; -use crate::container::LocalContainerService; +use crate::{container::LocalContainerService, pty::PtyService}; mod command; pub mod container; mod copy; +pub mod pty; #[derive(Clone)] pub struct LocalDeployment { @@ -56,6 +57,7 @@ pub struct LocalDeployment { remote_client: Result, auth_context: AuthContext, oauth_handoffs: Arc>>, + pty: PtyService, } #[derive(Debug, Clone)] @@ -189,6 +191,8 @@ impl Deployment for LocalDeployment { let file_search_cache = Arc::new(FileSearchCache::new()); + let pty = PtyService::new(); + let deployment = Self { config, user_id, @@ -209,6 +213,7 @@ impl Deployment for LocalDeployment { remote_client, auth_context, oauth_handoffs, + pty, }; Ok(deployment) @@ -340,4 +345,8 @@ impl LocalDeployment { pub fn share_config(&self) -> Option<&ShareConfig> { self.share_config.as_ref() } + + pub fn pty(&self) -> &PtyService { + &self.pty + } } diff --git a/crates/local-deployment/src/pty.rs b/crates/local-deployment/src/pty.rs new file mode 100644 index 00000000..4c4e7791 --- /dev/null +++ b/crates/local-deployment/src/pty.rs @@ -0,0 +1,220 @@ +use std::{ + collections::HashMap, + io::{Read, Write}, + path::PathBuf, + sync::{Arc, Mutex}, + thread, +}; + +use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; +use thiserror::Error; +use tokio::sync::mpsc; +use utils::shell::get_interactive_shell; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum PtyError { + #[error("Failed to create PTY: {0}")] + CreateFailed(String), + #[error("Session not found: {0}")] + SessionNotFound(Uuid), + #[error("Failed to write to PTY: {0}")] + WriteFailed(String), + #[error("Failed to resize PTY: {0}")] + ResizeFailed(String), + #[error("Session already closed")] + SessionClosed, +} + +struct PtySession { + writer: Box, + master: Box, + _output_handle: thread::JoinHandle<()>, + closed: bool, +} + +#[derive(Clone)] +pub struct PtyService { + sessions: Arc>>, +} + +impl PtyService { + pub fn new() -> Self { + Self { + sessions: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn create_session( + &self, + working_dir: PathBuf, + cols: u16, + rows: u16, + ) -> Result<(Uuid, mpsc::UnboundedReceiver>), PtyError> { + let session_id = Uuid::new_v4(); + let (output_tx, output_rx) = mpsc::unbounded_channel(); + let shell = get_interactive_shell().await; + + let result = tokio::task::spawn_blocking(move || { + let pty_system = NativePtySystem::default(); + + let pty_pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| PtyError::CreateFailed(e.to_string()))?; + + let mut cmd = CommandBuilder::new(&shell); + cmd.cwd(&working_dir); + + // Configure shell-specific options + let shell_name = shell.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if shell_name == "powershell.exe" || shell_name == "pwsh.exe" { + // PowerShell: use -NoLogo for cleaner startup + cmd.arg("-NoLogo"); + } else if shell_name == "cmd.exe" { + // cmd.exe: no special args needed + } else { + // Unix shells (bash, zsh, etc.): skip loading rc files + cmd.arg("-f"); + cmd.env("PS1", "$ "); // Bash prompt + cmd.env("PROMPT", "$ "); // Zsh prompt + } + + cmd.env("TERM", "xterm-256color"); + cmd.env("COLORTERM", "truecolor"); + + let child = pty_pair + .slave + .spawn_command(cmd) + .map_err(|e| PtyError::CreateFailed(e.to_string()))?; + + let writer = pty_pair + .master + .take_writer() + .map_err(|e| PtyError::CreateFailed(e.to_string()))?; + + let mut reader = pty_pair + .master + .try_clone_reader() + .map_err(|e| PtyError::CreateFailed(e.to_string()))?; + + let output_handle = thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if output_tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + Err(_) => break, + } + } + drop(child); + }); + + Ok::<_, PtyError>((pty_pair.master, writer, output_handle)) + }) + .await + .map_err(|e| PtyError::CreateFailed(e.to_string()))??; + + let (master, writer, output_handle) = result; + + let session = PtySession { + writer, + master, + _output_handle: output_handle, + closed: false, + }; + + self.sessions + .lock() + .map_err(|e| PtyError::CreateFailed(e.to_string()))? + .insert(session_id, session); + + Ok((session_id, output_rx)) + } + + pub async fn write(&self, session_id: Uuid, data: &[u8]) -> Result<(), PtyError> { + let mut sessions = self + .sessions + .lock() + .map_err(|e| PtyError::WriteFailed(e.to_string()))?; + let session = sessions + .get_mut(&session_id) + .ok_or(PtyError::SessionNotFound(session_id))?; + + if session.closed { + return Err(PtyError::SessionClosed); + } + + session + .writer + .write_all(data) + .map_err(|e| PtyError::WriteFailed(e.to_string()))?; + + session + .writer + .flush() + .map_err(|e| PtyError::WriteFailed(e.to_string()))?; + + Ok(()) + } + + pub async fn resize(&self, session_id: Uuid, cols: u16, rows: u16) -> Result<(), PtyError> { + let sessions = self + .sessions + .lock() + .map_err(|e| PtyError::ResizeFailed(e.to_string()))?; + let session = sessions + .get(&session_id) + .ok_or(PtyError::SessionNotFound(session_id))?; + + if session.closed { + return Err(PtyError::SessionClosed); + } + + session + .master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| PtyError::ResizeFailed(e.to_string()))?; + + Ok(()) + } + + pub async fn close_session(&self, session_id: Uuid) -> Result<(), PtyError> { + if let Some(mut session) = self + .sessions + .lock() + .map_err(|_| PtyError::SessionClosed)? + .remove(&session_id) + { + session.closed = true; + } + Ok(()) + } + + pub fn session_exists(&self, session_id: &Uuid) -> bool { + self.sessions + .lock() + .map(|s| s.contains_key(session_id)) + .unwrap_or(false) + } +} + +impl Default for PtyService { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index d46e8cb5..fe98a2a4 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -39,6 +39,7 @@ strip-ansi-escapes = "0.2.1" thiserror = { workspace = true } os_info = "3.12.0" futures-util = "0.3" +base64 = "0.22" ignore = "0.4" git2 = { workspace = true } mime_guess = "2.0" diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index ca9574dd..a032e2c2 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -12,6 +12,7 @@ use db::models::{ use deployment::{DeploymentError, RemoteClientNotConfigured}; use executors::{command::CommandBuildError, executors::ExecutorError}; use git2::Error as Git2Error; +use local_deployment::pty::PtyError; use services::services::{ config::{ConfigError, EditorOpenError}, container::ContainerError, @@ -78,6 +79,8 @@ pub enum ApiError { Forbidden(String), #[error(transparent)] CommandBuilder(#[from] CommandBuildError), + #[error(transparent)] + Pty(#[from] PtyError), } impl From<&'static str> for ApiError { @@ -180,6 +183,11 @@ impl IntoResponse for ApiError { ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, "BadRequest"), ApiError::Conflict(_) => (StatusCode::CONFLICT, "ConflictError"), ApiError::Forbidden(_) => (StatusCode::FORBIDDEN, "ForbiddenError"), + ApiError::Pty(err) => match err { + PtyError::SessionNotFound(_) => (StatusCode::NOT_FOUND, "PtyError"), + PtyError::SessionClosed => (StatusCode::GONE, "PtyError"), + _ => (StatusCode::INTERNAL_SERVER_ERROR, "PtyError"), + }, }; let error_message = match &self { diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index 2c3c6ebb..d02cca56 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -25,6 +25,7 @@ pub mod shared_tasks; pub mod tags; pub mod task_attempts; pub mod tasks; +pub mod terminal; pub fn router(deployment: DeploymentImpl) -> IntoMakeService { // Create routers with different middleware layers @@ -46,6 +47,7 @@ pub fn router(deployment: DeploymentImpl) -> IntoMakeService { .merge(approvals::router()) .merge(scratch::router(&deployment)) .merge(sessions::router(&deployment)) + .merge(terminal::router()) .nest("/images", images::routes()) .with_state(deployment); diff --git a/crates/server/src/routes/terminal.rs b/crates/server/src/routes/terminal.rs new file mode 100644 index 00000000..17e33a93 --- /dev/null +++ b/crates/server/src/routes/terminal.rs @@ -0,0 +1,173 @@ +use std::path::PathBuf; + +use axum::{ + Router, + extract::{ + Query, State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + response::IntoResponse, + routing::get, +}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use db::models::{workspace::Workspace, workspace_repo::WorkspaceRepo}; +use deployment::Deployment; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{DeploymentImpl, error::ApiError}; + +#[derive(Debug, Deserialize)] +pub struct TerminalQuery { + pub workspace_id: Uuid, + #[serde(default = "default_cols")] + pub cols: u16, + #[serde(default = "default_rows")] + pub rows: u16, +} + +fn default_cols() -> u16 { + 80 +} + +fn default_rows() -> u16 { + 24 +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum TerminalCommand { + Input { data: String }, + Resize { cols: u16, rows: u16 }, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum TerminalMessage { + Output { data: String }, + Error { message: String }, +} + +pub async fn terminal_ws( + ws: WebSocketUpgrade, + State(deployment): State, + Query(query): Query, +) -> Result { + let attempt = Workspace::find_by_id(&deployment.db().pool, query.workspace_id) + .await? + .ok_or_else(|| ApiError::BadRequest("Attempt not found".to_string()))?; + + let container_ref = attempt + .container_ref + .ok_or_else(|| ApiError::BadRequest("Attempt has no workspace directory".to_string()))?; + + let base_dir = PathBuf::from(&container_ref); + if !base_dir.exists() { + return Err(ApiError::BadRequest( + "Workspace directory does not exist".to_string(), + )); + } + + let mut working_dir = base_dir.clone(); + match WorkspaceRepo::find_repos_for_workspace(&deployment.db().pool, query.workspace_id).await { + Ok(repos) if repos.len() == 1 => { + let repo_dir = base_dir.join(&repos[0].name); + if repo_dir.exists() { + working_dir = repo_dir; + } + } + Ok(_) => {} + Err(e) => { + tracing::warn!( + "Failed to resolve repos for workspace {}: {}", + attempt.id, + e + ); + } + } + + Ok(ws.on_upgrade(move |socket| { + handle_terminal_ws(socket, deployment, working_dir, query.cols, query.rows) + })) +} + +async fn handle_terminal_ws( + socket: WebSocket, + deployment: DeploymentImpl, + working_dir: PathBuf, + cols: u16, + rows: u16, +) { + let (session_id, mut output_rx) = match deployment + .pty() + .create_session(working_dir, cols, rows) + .await + { + Ok(result) => result, + Err(e) => { + tracing::error!("Failed to create PTY session: {}", e); + let _ = send_error(socket, &e.to_string()).await; + return; + } + }; + + let (mut ws_sender, mut ws_receiver) = socket.split(); + + let pty_service = deployment.pty().clone(); + let session_id_for_input = session_id; + + let output_task = tokio::spawn(async move { + while let Some(data) = output_rx.recv().await { + let msg = TerminalMessage::Output { + data: BASE64.encode(&data), + }; + let json = match serde_json::to_string(&msg) { + Ok(j) => j, + Err(_) => continue, + }; + if ws_sender.send(Message::Text(json.into())).await.is_err() { + break; + } + } + ws_sender + }); + + while let Some(Ok(msg)) = ws_receiver.next().await { + match msg { + Message::Text(text) => { + if let Ok(cmd) = serde_json::from_str::(&text) { + match cmd { + TerminalCommand::Input { data } => { + if let Ok(bytes) = BASE64.decode(&data) { + let _ = pty_service.write(session_id_for_input, &bytes).await; + } + } + TerminalCommand::Resize { cols, rows } => { + let _ = pty_service.resize(session_id_for_input, cols, rows).await; + } + } + } + } + Message::Close(_) => break, + _ => {} + } + } + + let _ = deployment.pty().close_session(session_id).await; + output_task.abort(); +} + +async fn send_error(mut socket: WebSocket, message: &str) -> Result<(), axum::Error> { + let msg = TerminalMessage::Error { + message: message.to_string(), + }; + let json = serde_json::to_string(&msg).unwrap_or_default(); + socket.send(Message::Text(json.into())).await?; + socket.close().await?; + Ok(()) +} + +pub fn router() -> Router { + Router::new().route("/terminal/ws", get(terminal_ws)) +} diff --git a/crates/utils/src/shell.rs b/crates/utils/src/shell.rs index 0a9a6dc3..c6708dff 100644 --- a/crates/utils/src/shell.rs +++ b/crates/utils/src/shell.rs @@ -22,6 +22,24 @@ pub fn get_shell_command() -> (String, &'static str) { } } +/// Returns the path to an interactive shell for the current platform. +/// Used for spawning PTY sessions. +/// +/// On Windows, prefers PowerShell if available, falling back to cmd.exe. +/// On Unix, returns the user's configured shell from $SHELL. +pub async fn get_interactive_shell() -> PathBuf { + if cfg!(windows) { + // Prefer PowerShell if available, fall back to cmd.exe + if let Some(powershell) = resolve_executable_path("powershell.exe").await { + powershell + } else { + PathBuf::from("cmd.exe") + } + } else { + UnixShell::current_shell().path().to_path_buf() + } +} + /// Resolve an executable by name, falling back to a refreshed PATH if needed. /// /// The search order is: diff --git a/frontend/package.json b/frontend/package.json index aaacf610..57b35407 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -79,7 +79,11 @@ "tailwind-merge": "^2.2.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", + "react-use-websocket": "^4.13.0", "vibe-kanban-web-companion": "^0.0.4", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", "wa-sqlite": "^1.0.0", "zustand": "^4.5.4" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f19103c2..341b04ea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,7 @@ import { ClickedElementsProvider } from './contexts/ClickedElementsProvider'; // Design scope components import { LegacyDesignScope } from '@/components/legacy-design/LegacyDesignScope'; import { NewDesignScope } from '@/components/ui-new/scope/NewDesignScope'; +import { TerminalProvider } from '@/contexts/TerminalContext'; // New design pages import { Workspaces } from '@/pages/ui-new/Workspaces'; @@ -184,7 +185,9 @@ function AppContent() { path="/workspaces" element={ - + + + } > diff --git a/frontend/src/components/ui-new/containers/GitPanelCreateContainer.tsx b/frontend/src/components/ui-new/containers/GitPanelCreateContainer.tsx deleted file mode 100644 index 45b2c140..00000000 --- a/frontend/src/components/ui-new/containers/GitPanelCreateContainer.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useMemo, useEffect, useCallback } from 'react'; -import { GitPanelCreate } from '@/components/ui-new/views/GitPanelCreate'; -import { useMultiRepoBranches } from '@/hooks/useRepoBranches'; -import { useProjects } from '@/hooks/useProjects'; -import { useCreateMode } from '@/contexts/CreateModeContext'; -import { CreateProjectDialog } from '@/components/ui-new/dialogs/CreateProjectDialog'; - -interface GitPanelCreateContainerProps { - className?: string; -} - -export function GitPanelCreateContainer({ - className, -}: GitPanelCreateContainerProps) { - const { - repos, - addRepo, - removeRepo, - clearRepos, - targetBranches, - setTargetBranch, - selectedProjectId, - setSelectedProjectId, - } = useCreateMode(); - const { projects } = useProjects(); - - const repoIds = useMemo(() => repos.map((r) => r.id), [repos]); - const { branchesByRepo } = useMultiRepoBranches(repoIds); - - // Auto-select current branch when branches load - useEffect(() => { - repos.forEach((repo) => { - const branches = branchesByRepo[repo.id]; - if (branches && !targetBranches[repo.id]) { - const currentBranch = branches.find((b) => b.is_current); - if (currentBranch) { - setTargetBranch(repo.id, currentBranch.name); - } - } - }); - }, [repos, branchesByRepo, targetBranches, setTargetBranch]); - - const selectedProject = projects.find((p) => p.id === selectedProjectId); - - const registeredRepoPaths = useMemo(() => repos.map((r) => r.path), [repos]); - - const handleCreateProject = useCallback(async () => { - const result = await CreateProjectDialog.show({}); - if (result.status === 'saved') { - setSelectedProjectId(result.project.id); - clearRepos(); - } - }, [setSelectedProjectId, clearRepos]); - - return ( - setSelectedProjectId(p.id)} - onCreateProject={handleCreateProject} - onRepoRemove={removeRepo} - branchesByRepo={branchesByRepo} - targetBranches={targetBranches} - onBranchChange={setTargetBranch} - registeredRepoPaths={registeredRepoPaths} - onRepoRegistered={addRepo} - /> - ); -} diff --git a/frontend/src/components/ui-new/containers/ProcessListContainer.tsx b/frontend/src/components/ui-new/containers/ProcessListContainer.tsx index 135401f6..791a31f3 100644 --- a/frontend/src/components/ui-new/containers/ProcessListContainer.tsx +++ b/frontend/src/components/ui-new/containers/ProcessListContainer.tsx @@ -3,10 +3,8 @@ import { useTranslation } from 'react-i18next'; import { useExecutionProcessesContext } from '@/contexts/ExecutionProcessesContext'; import { useLogsPanel } from '@/contexts/LogsPanelContext'; import { ProcessListItem } from '../primitives/ProcessListItem'; -import { CollapsibleSectionHeader } from '../primitives/CollapsibleSectionHeader'; import { InputField } from '../primitives/InputField'; import { CaretUpIcon, CaretDownIcon } from '@phosphor-icons/react'; -import { PERSIST_KEYS } from '@/stores/useUiPreferencesStore'; export function ProcessListContainer() { const { @@ -73,7 +71,7 @@ export function ProcessListContainer() { const searchBar = showSearch && (
- - {sortedProcesses.length === 0 ? ( -
-

{t('processes.noProcesses')}

-
- ) : ( -
- {sortedProcesses.map((process) => ( - handleSelectProcess(process.id)} - /> - ))} -
- )} -
+
+ {sortedProcesses.length === 0 ? ( +
+

{t('processes.noProcesses')}

+
+ ) : ( +
+ {sortedProcesses.map((process) => ( + handleSelectProcess(process.id)} + /> + ))} +
+ )} {searchBar}
); diff --git a/frontend/src/components/ui-new/containers/ProjectSelectorContainer.tsx b/frontend/src/components/ui-new/containers/ProjectSelectorContainer.tsx index e9217ae8..d778b64d 100644 --- a/frontend/src/components/ui-new/containers/ProjectSelectorContainer.tsx +++ b/frontend/src/components/ui-new/containers/ProjectSelectorContainer.tsx @@ -139,89 +139,91 @@ export function ProjectSelectorContainer({ }, [onCreateProject]); return ( - - - - - - - - {/* Create new project button */} - setHighlightedIndex(0)} - preventFocusOnHover - icon={PlusIcon} - className={cn( - 'text-accent', - safeHighlightedIndex === 0 && 'bg-secondary' - )} - > - {t('projects.createNew')} - - - {filteredItems.length === 0 ? ( -
- {t('projects.noProjectsFound')} -
- ) : ( - filteredItems[idx]?.id ?? String(idx)} - itemContent={(idx) => { - const item = filteredItems[idx]; - // Highlight index is offset by 1 (create button is at 0) - const isHighlighted = idx + 1 === safeHighlightedIndex; - const isSelected = selectedProjectId === item.id; - return ( - handleSelect(item)} - onMouseEnter={() => setHighlightedIndex(idx + 1)} - preventFocusOnHover - className={cn( - isSelected && 'bg-secondary', - isHighlighted && 'bg-secondary' - )} - > - {item.name} - - ); - }} + + {selectedProjectName ?? 'Select project'} + + + + + + + + - )} - -
+ + {/* Create new project button */} + setHighlightedIndex(0)} + preventFocusOnHover + icon={PlusIcon} + className={cn( + 'text-accent', + safeHighlightedIndex === 0 && 'bg-secondary' + )} + > + {t('projects.createNew')} + + + {filteredItems.length === 0 ? ( +
+ {t('projects.noProjectsFound')} +
+ ) : ( + filteredItems[idx]?.id ?? String(idx)} + itemContent={(idx) => { + const item = filteredItems[idx]; + // Highlight index is offset by 1 (create button is at 0) + const isHighlighted = idx + 1 === safeHighlightedIndex; + const isSelected = selectedProjectId === item.id; + return ( + handleSelect(item)} + onMouseEnter={() => setHighlightedIndex(idx + 1)} + preventFocusOnHover + className={cn( + isSelected && 'bg-secondary', + isHighlighted && 'bg-secondary' + )} + > + {item.name} + + ); + }} + /> + )} + + +
); } diff --git a/frontend/src/components/ui-new/containers/RightSidebar.tsx b/frontend/src/components/ui-new/containers/RightSidebar.tsx index 7788dd1c..03fad86e 100644 --- a/frontend/src/components/ui-new/containers/RightSidebar.tsx +++ b/frontend/src/components/ui-new/containers/RightSidebar.tsx @@ -1,16 +1,41 @@ -import { GitPanelCreateContainer } from '@/components/ui-new/containers/GitPanelCreateContainer'; +import { useMemo, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { FileTreeContainer } from '@/components/ui-new/containers/FileTreeContainer'; import { ProcessListContainer } from '@/components/ui-new/containers/ProcessListContainer'; import { PreviewControlsContainer } from '@/components/ui-new/containers/PreviewControlsContainer'; import { GitPanelContainer } from '@/components/ui-new/containers/GitPanelContainer'; +import { TerminalPanelContainer } from '@/components/ui-new/containers/TerminalPanelContainer'; +import { ProjectSelectorContainer } from '@/components/ui-new/containers/ProjectSelectorContainer'; +import { RecentReposListContainer } from '@/components/ui-new/containers/RecentReposListContainer'; +import { BrowseRepoButtonContainer } from '@/components/ui-new/containers/BrowseRepoButtonContainer'; +import { CreateRepoButtonContainer } from '@/components/ui-new/containers/CreateRepoButtonContainer'; import { useChangesView } from '@/contexts/ChangesViewContext'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; +import { useCreateMode } from '@/contexts/CreateModeContext'; +import { useMultiRepoBranches } from '@/hooks/useRepoBranches'; +import { useProjects } from '@/hooks/useProjects'; +import { CreateProjectDialog } from '@/components/ui-new/dialogs/CreateProjectDialog'; +import { SelectedReposList } from '@/components/ui-new/primitives/SelectedReposList'; +import { WarningIcon } from '@phosphor-icons/react'; import type { Workspace, RepoWithTargetBranch } from 'shared/types'; import { RIGHT_MAIN_PANEL_MODES, + PERSIST_KEYS, type RightMainPanelMode, useExpandedAll, + usePersistedExpanded, + useUiPreferencesStore, + PersistKey, } from '@/stores/useUiPreferencesStore'; +import { CollapsibleSectionHeader } from '../primitives/CollapsibleSectionHeader'; + +type SectionDef = { + title: string; + persistKey: PersistKey; + visible: boolean; + expanded: boolean; + content: React.ReactNode; +}; export interface RightSidebarProps { isCreateMode: boolean; @@ -25,78 +50,258 @@ export function RightSidebar({ selectedWorkspace, repos, }: RightSidebarProps) { + const { t } = useTranslation(['tasks', 'common']); const { selectFile } = useChangesView(); const { diffs } = useWorkspaceContext(); const { setExpanded } = useExpandedAll(); + const isTerminalVisible = useUiPreferencesStore((s) => s.isTerminalVisible); - if (isCreateMode) { - return ; - } + const { + repos: createRepos, + addRepo, + removeRepo, + clearRepos, + targetBranches, + setTargetBranch, + selectedProjectId, + setSelectedProjectId, + } = useCreateMode(); + const { projects } = useProjects(); - if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES) { - return ( -
-
- { - selectFile(path); - setExpanded(`diff:${path}`, true); - }} - /> -
-
+ const repoIds = useMemo(() => createRepos.map((r) => r.id), [createRepos]); + const { branchesByRepo } = useMultiRepoBranches(repoIds); + + useEffect(() => { + if (!isCreateMode) return; + createRepos.forEach((repo) => { + const branches = branchesByRepo[repo.id]; + if (branches && !targetBranches[repo.id]) { + const currentBranch = branches.find((b) => b.is_current); + if (currentBranch) { + setTargetBranch(repo.id, currentBranch.name); + } + } + }); + }, [ + isCreateMode, + createRepos, + branchesByRepo, + targetBranches, + setTargetBranch, + ]); + + const [changesExpanded] = usePersistedExpanded( + PERSIST_KEYS.changesSection, + true + ); + const [processesExpanded] = usePersistedExpanded( + PERSIST_KEYS.processesSection, + true + ); + const [devServerExpanded] = usePersistedExpanded( + PERSIST_KEYS.devServerSection, + true + ); + const [gitExpanded] = usePersistedExpanded( + PERSIST_KEYS.gitPanelRepositories, + true + ); + const [terminalExpanded] = usePersistedExpanded( + PERSIST_KEYS.terminalSection, + true + ); + + const selectedProject = projects.find((p) => p.id === selectedProjectId); + const registeredRepoPaths = useMemo( + () => createRepos.map((r) => r.path), + [createRepos] + ); + + const handleCreateProject = useCallback(async () => { + const result = await CreateProjectDialog.show({}); + if (result.status === 'saved') { + setSelectedProjectId(result.project.id); + clearRepos(); + } + }, [setSelectedProjectId, clearRepos]); + + const hasNoRepos = createRepos.length === 0; + + const hasUpperContent = + rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES || + rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS || + rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW; + + const getUpperExpanded = () => { + if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES) + return changesExpanded; + if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS) + return processesExpanded; + if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW) + return devServerExpanded; + return false; + }; + + const upperExpanded = getUpperExpanded(); + + const sections: SectionDef[] = isCreateMode + ? [ + { + title: t('common:sections.project'), + persistKey: PERSIST_KEYS.gitPanelProject, + visible: true, + expanded: true, + content: ( +
+ setSelectedProjectId(p.id)} + onCreateProject={handleCreateProject} + /> +
+ ), + }, + { + title: t('common:sections.repositories'), + persistKey: PERSIST_KEYS.gitPanelRepositories, + visible: true, + expanded: true, + content: hasNoRepos ? ( +
+
+ +

+ {t('gitPanel.create.warnings.noReposSelected')} +

+
+
+ ) : ( + + ), + }, + { + title: t('common:sections.addRepositories'), + persistKey: PERSIST_KEYS.gitPanelAddRepositories, + visible: true, + expanded: true, + content: ( +
+

+ {t('common:sections.recent')} +

+ +

+ {t('common:sections.other')} +

+ + +
+ ), + }, + ] + : buildWorkspaceSections(); + + function buildWorkspaceSections(): SectionDef[] { + const result: SectionDef[] = [ + { + title: 'Git', + persistKey: PERSIST_KEYS.gitPanelRepositories, + visible: true, + expanded: gitExpanded, + content: ( -
-
- ); - } + ), + }, + { + title: 'Terminal', + persistKey: PERSIST_KEYS.terminalSection, + visible: isTerminalVisible, + expanded: terminalExpanded, + content: , + }, + ]; - if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS) { - return ( -
-
- -
-
- -
-
- ); - } + switch (rightMainPanelMode) { + case RIGHT_MAIN_PANEL_MODES.CHANGES: + result.unshift({ + title: 'Changes', + persistKey: PERSIST_KEYS.changesSection, + visible: hasUpperContent, + expanded: upperExpanded, + content: ( + { + selectFile(path); + setExpanded(`diff:${path}`, true); + }} + /> + ), + }); + break; + case RIGHT_MAIN_PANEL_MODES.LOGS: + result.unshift({ + title: 'Logs', + persistKey: PERSIST_KEYS.rightPanelprocesses, + visible: hasUpperContent, + expanded: upperExpanded, + content: , + }); + break; + case RIGHT_MAIN_PANEL_MODES.PREVIEW: + result.unshift({ + title: 'Preview', + persistKey: PERSIST_KEYS.rightPanelPreview, + visible: hasUpperContent, + expanded: upperExpanded, + content: ( + + ), + }); + break; + case null: + break; + } - if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW) { - return ( -
-
- -
-
- -
-
- ); + return result; } return ( - +
+
+ {sections.map((section) => ( +
+ +
+ {section.content} +
+
+
+ ))} +
+
); } diff --git a/frontend/src/components/ui-new/containers/TerminalPanelContainer.tsx b/frontend/src/components/ui-new/containers/TerminalPanelContainer.tsx new file mode 100644 index 00000000..5791ba02 --- /dev/null +++ b/frontend/src/components/ui-new/containers/TerminalPanelContainer.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react'; +import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; +import { useTerminal } from '@/contexts/TerminalContext'; +import { TerminalPanel } from '../views/TerminalPanel'; + +export function TerminalPanelContainer() { + const { workspace } = useWorkspaceContext(); + const { + getTabsForWorkspace, + getActiveTab, + createTab, + closeTab, + setActiveTab, + clearWorkspaceTabs, + } = useTerminal(); + + const workspaceId = workspace?.id; + const containerRef = workspace?.container_ref ?? null; + const tabs = workspaceId ? getTabsForWorkspace(workspaceId) : []; + const activeTab = workspaceId ? getActiveTab(workspaceId) : null; + + const creatingRef = useRef(false); + const prevWorkspaceIdRef = useRef(null); + + // Clean up terminals when workspace changes + useEffect(() => { + if ( + prevWorkspaceIdRef.current && + prevWorkspaceIdRef.current !== workspaceId + ) { + clearWorkspaceTabs(prevWorkspaceIdRef.current); + } + prevWorkspaceIdRef.current = workspaceId ?? null; + }, [workspaceId, clearWorkspaceTabs]); + + // Auto-create first tab when workspace is selected and terminal mode is active + useEffect(() => { + if ( + workspaceId && + containerRef && + tabs.length === 0 && + !creatingRef.current + ) { + creatingRef.current = true; + createTab(workspaceId, containerRef); + } + if (tabs.length > 0) { + creatingRef.current = false; + } + }, [workspaceId, containerRef, tabs.length, createTab]); + + return ( + workspaceId && setActiveTab(workspaceId, tabId)} + onTabClose={(tabId) => workspaceId && closeTab(workspaceId, tabId)} + onNewTab={() => + workspaceId && containerRef && createTab(workspaceId, containerRef) + } + /> + ); +} diff --git a/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx b/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx index 9bb175aa..1fb1f122 100644 --- a/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx +++ b/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx @@ -48,13 +48,15 @@ function ModeProvider({ return {children}; } return ( - - {children} - + + + {children} + + ); } diff --git a/frontend/src/components/ui-new/primitives/CollapsibleSectionHeader.tsx b/frontend/src/components/ui-new/primitives/CollapsibleSectionHeader.tsx index d6d68aa9..788c74f4 100644 --- a/frontend/src/components/ui-new/primitives/CollapsibleSectionHeader.tsx +++ b/frontend/src/components/ui-new/primitives/CollapsibleSectionHeader.tsx @@ -14,7 +14,6 @@ interface CollapsibleSectionHeaderProps { onIconClick?: () => void; children?: React.ReactNode; className?: string; - contentClassName?: string; } export function CollapsibleSectionHeader({ @@ -25,7 +24,6 @@ export function CollapsibleSectionHeader({ onIconClick, children, className, - contentClassName, }: CollapsibleSectionHeaderProps) { const [expanded, toggle] = usePersistedExpanded(persistKey, defaultExpanded); @@ -35,42 +33,44 @@ export function CollapsibleSectionHeader({ }; return ( -
- - {expanded &&
{children}
} + +
+ + + {expanded && children} ); } diff --git a/frontend/src/components/ui-new/primitives/RepoCard.tsx b/frontend/src/components/ui-new/primitives/RepoCard.tsx index 7b171765..cfc5ba8e 100644 --- a/frontend/src/components/ui-new/primitives/RepoCard.tsx +++ b/frontend/src/components/ui-new/primitives/RepoCard.tsx @@ -25,9 +25,8 @@ import { DropdownMenuContent, DropdownMenuItem, } from './Dropdown'; -import { CollapsibleSection } from './CollapsibleSection'; import { SplitButton, type SplitButtonOption } from './SplitButton'; -import { useRepoAction, PERSIST_KEYS } from '@/stores/useUiPreferencesStore'; +import { useRepoAction } from '@/stores/useUiPreferencesStore'; export type RepoAction = | 'pull-request' @@ -113,12 +112,8 @@ export function RepoCard({ hasPrOpen && selectedAction === 'pull-request' ? 'merge' : selectedAction; return ( - +
+
{name}
{/* Branch row */}
@@ -290,6 +285,6 @@ export function RepoCard({ onAction={(action) => onActionsClick?.(action)} />
- +
); } diff --git a/frontend/src/components/ui-new/primitives/SelectedReposList.tsx b/frontend/src/components/ui-new/primitives/SelectedReposList.tsx index 6c5edd41..831788b1 100644 --- a/frontend/src/components/ui-new/primitives/SelectedReposList.tsx +++ b/frontend/src/components/ui-new/primitives/SelectedReposList.tsx @@ -22,7 +22,7 @@ export function SelectedReposList({ if (repos.length === 0) { return ( -
+
+
{repos.map((repo) => ( void; + onTabClose: (tabId: string) => void; + onNewTab: () => void; +} + +export function TerminalTabBar({ + tabs, + activeTabId, + onTabSelect, + onTabClose, + onNewTab, +}: TerminalTabBarProps) { + return ( +
+
+ {tabs.map((tab) => ( +
onTabSelect(tab.id)} + > + {tab.title} + +
+ ))} +
+ +
+ ); +} diff --git a/frontend/src/components/ui-new/terminal/XTermInstance.tsx b/frontend/src/components/ui-new/terminal/XTermInstance.tsx new file mode 100644 index 00000000..ab99b42f --- /dev/null +++ b/frontend/src/components/ui-new/terminal/XTermInstance.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef, useCallback, useMemo } from 'react'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import '@xterm/xterm/css/xterm.css'; + +import { useTerminalWebSocket } from '@/hooks/useTerminalWebSocket'; +import { useTheme } from '@/components/ThemeProvider'; +import { getTerminalTheme } from '@/utils/terminalTheme'; + +interface XTermInstanceProps { + workspaceId: string; + isActive: boolean; + onClose?: () => void; +} + +export function XTermInstance({ + workspaceId, + isActive, + onClose, +}: XTermInstanceProps) { + const containerRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const initialSizeRef = useRef({ cols: 80, rows: 24 }); + const { theme } = useTheme(); + + const onData = useCallback((data: string) => { + terminalRef.current?.write(data); + }, []); + + const endpoint = useMemo(() => { + const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'; + const host = window.location.host; + return `${protocol}//${host}/api/terminal/ws?workspace_id=${workspaceId}&cols=${initialSizeRef.current.cols}&rows=${initialSizeRef.current.rows}`; + }, [workspaceId]); + + const { send, resize } = useTerminalWebSocket({ + endpoint, + onData, + onExit: onClose, + }); + + useEffect(() => { + if (!containerRef.current || terminalRef.current) return; + + const terminal = new Terminal({ + cursorBlink: true, + fontSize: 12, + fontFamily: '"IBM Plex Mono", monospace', + theme: getTerminalTheme(), + }); + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + + terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); + terminal.open(containerRef.current); + + fitAddon.fit(); + initialSizeRef.current = { cols: terminal.cols, rows: terminal.rows }; + + terminalRef.current = terminal; + fitAddonRef.current = fitAddon; + + terminal.onData((data) => { + send(data); + }); + + return () => { + terminal.dispose(); + terminalRef.current = null; + fitAddonRef.current = null; + }; + }, [send]); + + useEffect(() => { + if (!isActive || !fitAddonRef.current) return; + + const handleResize = () => { + fitAddonRef.current?.fit(); + if (terminalRef.current) { + resize(terminalRef.current.cols, terminalRef.current.rows); + } + }; + + const resizeObserver = new ResizeObserver(handleResize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + handleResize(); + + return () => { + resizeObserver.disconnect(); + }; + }, [isActive, resize]); + + useEffect(() => { + if (isActive) { + terminalRef.current?.focus(); + } + }, [isActive]); + + // Update terminal theme when app theme changes + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.options.theme = getTerminalTheme(); + } + }, [theme]); + + return ( +
+ ); +} diff --git a/frontend/src/components/ui-new/views/FileTree.tsx b/frontend/src/components/ui-new/views/FileTree.tsx index 9cc605a1..67973e36 100644 --- a/frontend/src/components/ui-new/views/FileTree.tsx +++ b/frontend/src/components/ui-new/views/FileTree.tsx @@ -5,8 +5,6 @@ import { Tooltip } from '../primitives/Tooltip'; import { FileTreeSearchBar } from './FileTreeSearchBar'; import { FileTreeNode } from './FileTreeNode'; import type { TreeNode } from '../types/fileTree'; -import { CollapsibleSectionHeader } from '../primitives/CollapsibleSectionHeader'; -import { PERSIST_KEYS } from '@/stores/useUiPreferencesStore'; interface FileTreeProps { nodes: TreeNode[]; @@ -82,62 +80,54 @@ export function FileTree({ }; return ( -
- -
-
-
- -
- {onToggleGitHubComments && ( - +
+
+
+ +
+ {onToggleGitHubComments && ( + + - - )} -
-
-
- {nodes.length > 0 ? ( - renderNodes(nodes) - ) : ( -
- {searchQuery - ? t('common:fileTree.noResults') - : 'No changed files'} -
+ + + )}
- +
+
+ {nodes.length > 0 ? ( + renderNodes(nodes) + ) : ( +
+ {searchQuery ? t('common:fileTree.noResults') : 'No changed files'} +
+ )} +
); } diff --git a/frontend/src/components/ui-new/views/GitPanel.tsx b/frontend/src/components/ui-new/views/GitPanel.tsx index a2defb4c..a40f8984 100644 --- a/frontend/src/components/ui-new/views/GitPanel.tsx +++ b/frontend/src/components/ui-new/views/GitPanel.tsx @@ -7,9 +7,6 @@ import { } from '@/components/ui-new/primitives/RepoCard'; import { InputField } from '@/components/ui-new/primitives/InputField'; import { ErrorAlert } from '@/components/ui-new/primitives/ErrorAlert'; -import { CollapsibleSection } from '../primitives/CollapsibleSection'; -import { CollapsibleSectionHeader } from '../primitives/CollapsibleSectionHeader'; -import { PERSIST_KEYS } from '@/stores/useUiPreferencesStore'; export interface RepoInfo { id: string; @@ -60,66 +57,53 @@ export function GitPanel({ return (
{error && } - -
- {repos.map((repo) => ( - onActionsClick?.(repo.id, 'change-target')} - onRebase={() => onActionsClick?.(repo.id, 'rebase')} - onActionsClick={(action) => onActionsClick?.(repo.id, action)} - onPushClick={() => onPushClick?.(repo.id)} - onOpenInEditor={() => onOpenInEditor?.(repo.id)} - onCopyPath={() => onCopyPath?.(repo.id)} - onOpenSettings={() => onOpenSettings?.(repo.id)} - /> - ))} +
+ {repos.map((repo) => ( + onActionsClick?.(repo.id, 'change-target')} + onRebase={() => onActionsClick?.(repo.id, 'rebase')} + onActionsClick={(action) => onActionsClick?.(repo.id, action)} + onPushClick={() => onPushClick?.(repo.id)} + onOpenInEditor={() => onOpenInEditor?.(repo.id)} + onCopyPath={() => onCopyPath?.(repo.id)} + onOpenSettings={() => onOpenSettings?.(repo.id)} + /> + ))} +
+
+ +

+ {t('common:sections.workingBranch')} +

+
+
-
- -
- -

- {t('common:sections.workingBranch')} -

-
- -
-
- +
); } diff --git a/frontend/src/components/ui-new/views/GitPanelCreate.tsx b/frontend/src/components/ui-new/views/GitPanelCreate.tsx deleted file mode 100644 index 42b8333a..00000000 --- a/frontend/src/components/ui-new/views/GitPanelCreate.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { cn } from '@/lib/utils'; -import { CollapsibleSectionHeader } from '@/components/ui-new/primitives/CollapsibleSectionHeader'; -import { SelectedReposList } from '@/components/ui-new/primitives/SelectedReposList'; -import { ProjectSelectorContainer } from '@/components/ui-new/containers/ProjectSelectorContainer'; -import { RecentReposListContainer } from '@/components/ui-new/containers/RecentReposListContainer'; -import { BrowseRepoButtonContainer } from '@/components/ui-new/containers/BrowseRepoButtonContainer'; -import { CreateRepoButtonContainer } from '@/components/ui-new/containers/CreateRepoButtonContainer'; -import { WarningIcon } from '@phosphor-icons/react'; -import { PERSIST_KEYS } from '@/stores/useUiPreferencesStore'; -import type { Project, GitBranch, Repo } from 'shared/types'; - -interface GitPanelCreateProps { - className?: string; - repos: Repo[]; - projects: Project[]; - selectedProjectId: string | null; - selectedProjectName?: string; - onProjectSelect: (project: Project) => void; - onCreateProject: () => void; - onRepoRemove: (repoId: string) => void; - branchesByRepo: Record; - targetBranches: Record; - onBranchChange: (repoId: string, branch: string) => void; - registeredRepoPaths: string[]; - onRepoRegistered: (repo: Repo) => void; -} - -export function GitPanelCreate({ - className, - repos, - projects, - selectedProjectId, - selectedProjectName, - onProjectSelect, - onCreateProject, - onRepoRemove, - branchesByRepo, - targetBranches, - onBranchChange, - registeredRepoPaths, - onRepoRegistered, -}: GitPanelCreateProps) { - const { t } = useTranslation(['tasks', 'common']); - const hasNoRepos = repos.length === 0; - - return ( -
- - - - - - {hasNoRepos ? ( -
- -

- {t('gitPanel.create.warnings.noReposSelected')} -

-
- ) : ( - - )} -
- -

- {t('common:sections.recent')} -

- -

- {t('common:sections.other')} -

- - -
-
- ); -} diff --git a/frontend/src/components/ui-new/views/PreviewControls.tsx b/frontend/src/components/ui-new/views/PreviewControls.tsx index 7c2a87da..c302d028 100644 --- a/frontend/src/components/ui-new/views/PreviewControls.tsx +++ b/frontend/src/components/ui-new/views/PreviewControls.tsx @@ -1,9 +1,7 @@ import { ArrowSquareOutIcon, SpinnerIcon } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; -import { CollapsibleSectionHeader } from '../primitives/CollapsibleSectionHeader'; import { VirtualizedProcessLogs } from '../containers/VirtualizedProcessLogs'; -import { PERSIST_KEYS } from '@/stores/useUiPreferencesStore'; import { getDevServerWorkingDir } from '@/lib/devServerUtils'; import type { ExecutionProcess, PatchType } from 'shared/types'; @@ -42,57 +40,51 @@ export function PreviewControls({ className )} > - -
-
- - {t('preview.logs.label')} - - -
- - {devServerProcesses.length > 1 && ( -
- {devServerProcesses.map((process) => ( - - ))} -
- )} - -
- {isLoading && devServerProcesses.length === 0 ? ( -
- -
- ) : devServerProcesses.length > 0 ? ( - - ) : null} -
+
+
+ + {t('preview.logs.label')} + +
- + + {devServerProcesses.length > 1 && ( +
+ {devServerProcesses.map((process) => ( + + ))} +
+ )} + +
+ {isLoading && devServerProcesses.length === 0 ? ( +
+ +
+ ) : devServerProcesses.length > 0 ? ( + + ) : null} +
+
); } diff --git a/frontend/src/components/ui-new/views/TerminalPanel.tsx b/frontend/src/components/ui-new/views/TerminalPanel.tsx new file mode 100644 index 00000000..cf123a90 --- /dev/null +++ b/frontend/src/components/ui-new/views/TerminalPanel.tsx @@ -0,0 +1,32 @@ +import type { TerminalTab } from '@/contexts/TerminalContext'; +import { XTermInstance } from '../terminal/XTermInstance'; + +interface TerminalPanelProps { + tabs: TerminalTab[]; + activeTabId: string | null; + workspaceId: string; + containerRef: string | null; + onTabSelect: (tabId: string) => void; + onTabClose: (tabId: string) => void; + onNewTab: () => void; +} + +export function TerminalPanel({ + tabs, + activeTabId, + workspaceId, + onTabClose, +}: TerminalPanelProps) { + return ( +
+ {tabs.map((tab) => ( + onTabClose(tab.id)} + /> + ))} +
+ ); +} diff --git a/frontend/src/contexts/TerminalContext.tsx b/frontend/src/contexts/TerminalContext.tsx new file mode 100644 index 00000000..f5eddb5e --- /dev/null +++ b/frontend/src/contexts/TerminalContext.tsx @@ -0,0 +1,237 @@ +import { + createContext, + useContext, + useReducer, + useMemo, + useCallback, + ReactNode, +} from 'react'; + +export interface TerminalTab { + id: string; + title: string; + workspaceId: string; + cwd: string; +} + +interface TerminalState { + tabsByWorkspace: Record; + activeTabByWorkspace: Record; +} + +type TerminalAction = + | { type: 'CREATE_TAB'; workspaceId: string; cwd: string } + | { type: 'CLOSE_TAB'; workspaceId: string; tabId: string } + | { type: 'SET_ACTIVE_TAB'; workspaceId: string; tabId: string } + | { + type: 'UPDATE_TAB_TITLE'; + workspaceId: string; + tabId: string; + title: string; + } + | { type: 'CLEAR_WORKSPACE_TABS'; workspaceId: string }; + +function generateTabId(): string { + return `term-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +function terminalReducer( + state: TerminalState, + action: TerminalAction +): TerminalState { + switch (action.type) { + case 'CREATE_TAB': { + const { workspaceId, cwd } = action; + const existingTabs = state.tabsByWorkspace[workspaceId] || []; + const newTab: TerminalTab = { + id: generateTabId(), + title: `Terminal ${existingTabs.length + 1}`, + workspaceId, + cwd, + }; + return { + ...state, + tabsByWorkspace: { + ...state.tabsByWorkspace, + [workspaceId]: [...existingTabs, newTab], + }, + activeTabByWorkspace: { + ...state.activeTabByWorkspace, + [workspaceId]: newTab.id, + }, + }; + } + + case 'CLOSE_TAB': { + const { workspaceId, tabId } = action; + const tabs = state.tabsByWorkspace[workspaceId] || []; + const newTabs = tabs.filter((t) => t.id !== tabId); + const wasActive = state.activeTabByWorkspace[workspaceId] === tabId; + let newActiveTab = state.activeTabByWorkspace[workspaceId]; + + if (wasActive && newTabs.length > 0) { + const closedIndex = tabs.findIndex((t) => t.id === tabId); + const newIndex = Math.min(closedIndex, newTabs.length - 1); + newActiveTab = newTabs[newIndex]?.id ?? null; + } else if (newTabs.length === 0) { + newActiveTab = null; + } + + return { + ...state, + tabsByWorkspace: { + ...state.tabsByWorkspace, + [workspaceId]: newTabs, + }, + activeTabByWorkspace: { + ...state.activeTabByWorkspace, + [workspaceId]: newActiveTab, + }, + }; + } + + case 'SET_ACTIVE_TAB': { + const { workspaceId, tabId } = action; + return { + ...state, + activeTabByWorkspace: { + ...state.activeTabByWorkspace, + [workspaceId]: tabId, + }, + }; + } + + case 'UPDATE_TAB_TITLE': { + const { workspaceId, tabId, title } = action; + const tabs = state.tabsByWorkspace[workspaceId] || []; + return { + ...state, + tabsByWorkspace: { + ...state.tabsByWorkspace, + [workspaceId]: tabs.map((t) => + t.id === tabId ? { ...t, title } : t + ), + }, + }; + } + + case 'CLEAR_WORKSPACE_TABS': { + const { workspaceId } = action; + const restTabs = Object.fromEntries( + Object.entries(state.tabsByWorkspace).filter( + ([key]) => key !== workspaceId + ) + ); + const restActive = Object.fromEntries( + Object.entries(state.activeTabByWorkspace).filter( + ([key]) => key !== workspaceId + ) + ); + return { + tabsByWorkspace: restTabs, + activeTabByWorkspace: restActive, + }; + } + + default: + return state; + } +} + +interface TerminalContextType { + getTabsForWorkspace: (workspaceId: string) => TerminalTab[]; + getActiveTab: (workspaceId: string) => TerminalTab | null; + createTab: (workspaceId: string, cwd: string) => void; + closeTab: (workspaceId: string, tabId: string) => void; + setActiveTab: (workspaceId: string, tabId: string) => void; + updateTabTitle: (workspaceId: string, tabId: string, title: string) => void; + clearWorkspaceTabs: (workspaceId: string) => void; +} + +const TerminalContext = createContext(null); + +interface TerminalProviderProps { + children: ReactNode; +} + +export function TerminalProvider({ children }: TerminalProviderProps) { + const [state, dispatch] = useReducer(terminalReducer, { + tabsByWorkspace: {}, + activeTabByWorkspace: {}, + }); + + const getTabsForWorkspace = useCallback( + (workspaceId: string): TerminalTab[] => { + return state.tabsByWorkspace[workspaceId] || []; + }, + [state.tabsByWorkspace] + ); + + const getActiveTab = useCallback( + (workspaceId: string): TerminalTab | null => { + const activeId = state.activeTabByWorkspace[workspaceId]; + if (!activeId) return null; + const tabs = state.tabsByWorkspace[workspaceId] || []; + return tabs.find((t) => t.id === activeId) || null; + }, + [state.tabsByWorkspace, state.activeTabByWorkspace] + ); + + const createTab = useCallback((workspaceId: string, cwd: string) => { + dispatch({ type: 'CREATE_TAB', workspaceId, cwd }); + }, []); + + const closeTab = useCallback((workspaceId: string, tabId: string) => { + dispatch({ type: 'CLOSE_TAB', workspaceId, tabId }); + }, []); + + const setActiveTab = useCallback((workspaceId: string, tabId: string) => { + dispatch({ type: 'SET_ACTIVE_TAB', workspaceId, tabId }); + }, []); + + const updateTabTitle = useCallback( + (workspaceId: string, tabId: string, title: string) => { + dispatch({ type: 'UPDATE_TAB_TITLE', workspaceId, tabId, title }); + }, + [] + ); + + const clearWorkspaceTabs = useCallback((workspaceId: string) => { + dispatch({ type: 'CLEAR_WORKSPACE_TABS', workspaceId }); + }, []); + + const value = useMemo( + () => ({ + getTabsForWorkspace, + getActiveTab, + createTab, + closeTab, + setActiveTab, + updateTabTitle, + clearWorkspaceTabs, + }), + [ + getTabsForWorkspace, + getActiveTab, + createTab, + closeTab, + setActiveTab, + updateTabTitle, + clearWorkspaceTabs, + ] + ); + + return ( + + {children} + + ); +} + +export function useTerminal() { + const context = useContext(TerminalContext); + if (!context) { + throw new Error('useTerminal must be used within TerminalProvider'); + } + return context; +} diff --git a/frontend/src/hooks/useTerminalWebSocket.ts b/frontend/src/hooks/useTerminalWebSocket.ts new file mode 100644 index 00000000..c45f17bc --- /dev/null +++ b/frontend/src/hooks/useTerminalWebSocket.ts @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef } from 'react'; +import useWebSocket, { ReadyState } from 'react-use-websocket'; + +interface TerminalMessage { + type: 'output' | 'error' | 'exit'; + data?: string; + message?: string; + code?: number; +} + +interface UseTerminalWebSocketOptions { + endpoint: string | null; + onData: (data: string) => void; + onExit?: () => void; + onError?: (error: string) => void; +} + +interface UseTerminalWebSocketReturn { + send: (data: string) => void; + resize: (cols: number, rows: number) => void; + isConnected: boolean; +} + +function encodeBase64(str: string): string { + const bytes = new TextEncoder().encode(str); + const binString = Array.from(bytes, (b) => String.fromCodePoint(b)).join(''); + return btoa(binString); +} + +function decodeBase64(base64: string): string { + const binString = atob(base64); + const bytes = Uint8Array.from(binString, (c) => c.codePointAt(0)!); + return new TextDecoder().decode(bytes); +} + +export function useTerminalWebSocket({ + endpoint, + onData, + onExit, + onError, +}: UseTerminalWebSocketOptions): UseTerminalWebSocketReturn { + const onDataRef = useRef(onData); + const onExitRef = useRef(onExit); + const onErrorRef = useRef(onError); + + useEffect(() => { + onDataRef.current = onData; + onExitRef.current = onExit; + onErrorRef.current = onError; + }, [onData, onExit, onError]); + + const wsEndpoint = endpoint ? endpoint.replace(/^http/, 'ws') : null; + + const { sendMessage, readyState } = useWebSocket(wsEndpoint, { + onMessage: (event) => { + try { + const msg: TerminalMessage = JSON.parse(event.data); + switch (msg.type) { + case 'output': + if (msg.data) { + onDataRef.current(decodeBase64(msg.data)); + } + break; + case 'error': + onErrorRef.current?.(msg.message || 'Unknown error'); + break; + case 'exit': + onExitRef.current?.(); + break; + } + } catch { + // Ignore parse errors + } + }, + onError: () => { + onErrorRef.current?.('WebSocket connection error'); + }, + shouldReconnect: () => false, + }); + + const send = useCallback( + (data: string) => { + sendMessage(JSON.stringify({ type: 'input', data: encodeBase64(data) })); + }, + [sendMessage] + ); + + const resize = useCallback( + (cols: number, rows: number) => { + sendMessage(JSON.stringify({ type: 'resize', cols, rows })); + }, + [sendMessage] + ); + + return { + send, + resize, + isConnected: readyState === ReadyState.OPEN, + }; +} diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 439dd820..2a7caf3d 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -165,7 +165,8 @@ "workingBranch": "Working Branch", "recent": "Recent", "other": "Other", - "devServerPreview": "Dev Server Preview" + "devServerPreview": "Dev Server Preview", + "terminal": "Terminal" }, "repos": { "loading": "Loading repositories...", diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index b568bc19..a562be99 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -653,6 +653,9 @@ "discardChanges": "Discard Changes" } }, + "terminal": { + "selectWorkspace": "Select a workspace to open terminal" + }, "restoreLogsDialog": { "title": "Confirm Retry", "historyChange": { diff --git a/frontend/src/i18n/locales/es/common.json b/frontend/src/i18n/locales/es/common.json index a8da61b5..ad0189c4 100644 --- a/frontend/src/i18n/locales/es/common.json +++ b/frontend/src/i18n/locales/es/common.json @@ -165,7 +165,8 @@ "workingBranch": "Rama de trabajo", "recent": "Reciente", "other": "Otro", - "devServerPreview": "Vista previa del servidor de desarrollo" + "devServerPreview": "Vista previa del servidor de desarrollo", + "terminal": "Terminal" }, "repos": { "loading": "Cargando repositorios...", diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index 06432df5..e132c474 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -586,6 +586,9 @@ "discardChanges": "Descartar Cambios" } }, + "terminal": { + "selectWorkspace": "Selecciona un espacio de trabajo para abrir terminal" + }, "restoreLogsDialog": { "title": "Confirmar Reintento", "historyChange": { diff --git a/frontend/src/i18n/locales/ja/common.json b/frontend/src/i18n/locales/ja/common.json index 540577bf..7b0e4cd9 100644 --- a/frontend/src/i18n/locales/ja/common.json +++ b/frontend/src/i18n/locales/ja/common.json @@ -165,7 +165,8 @@ "workingBranch": "作業ブランチ", "recent": "最近", "other": "その他", - "devServerPreview": "開発サーバープレビュー" + "devServerPreview": "開発サーバープレビュー", + "terminal": "ターミナル" }, "repos": { "loading": "リポジトリを読み込み中...", diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index 54f1408a..2d9ba9bc 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -586,6 +586,9 @@ "discardChanges": "変更を破棄" } }, + "terminal": { + "selectWorkspace": "ターミナルを開くにはワークスペースを選択してください" + }, "restoreLogsDialog": { "title": "リトライを確認", "historyChange": { diff --git a/frontend/src/i18n/locales/ko/common.json b/frontend/src/i18n/locales/ko/common.json index 699a8dc9..3b67155f 100644 --- a/frontend/src/i18n/locales/ko/common.json +++ b/frontend/src/i18n/locales/ko/common.json @@ -165,7 +165,8 @@ "workingBranch": "작업 브랜치", "recent": "최근", "other": "기타", - "devServerPreview": "개발 서버 미리보기" + "devServerPreview": "개발 서버 미리보기", + "terminal": "터미널" }, "repos": { "loading": "저장소 로딩 중...", diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index ec4a5a1c..ea130314 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -586,6 +586,9 @@ "discardChanges": "변경사항 버리기" } }, + "terminal": { + "selectWorkspace": "터미널을 열려면 작업 공간을 선택하세요" + }, "restoreLogsDialog": { "title": "재시도 확인", "historyChange": { diff --git a/frontend/src/i18n/locales/zh-Hans/common.json b/frontend/src/i18n/locales/zh-Hans/common.json index c9e0d62d..af6def48 100644 --- a/frontend/src/i18n/locales/zh-Hans/common.json +++ b/frontend/src/i18n/locales/zh-Hans/common.json @@ -165,7 +165,8 @@ "workingBranch": "工作分支", "recent": "最近", "other": "其他", - "devServerPreview": "开发服务器预览" + "devServerPreview": "开发服务器预览", + "terminal": "终端" }, "repos": { "loading": "正在加载仓库...", diff --git a/frontend/src/i18n/locales/zh-Hans/tasks.json b/frontend/src/i18n/locales/zh-Hans/tasks.json index eff8316f..79b43b2f 100644 --- a/frontend/src/i18n/locales/zh-Hans/tasks.json +++ b/frontend/src/i18n/locales/zh-Hans/tasks.json @@ -586,6 +586,9 @@ "discardChanges": "放弃更改" } }, + "terminal": { + "selectWorkspace": "选择一个工作区以打开终端" + }, "restoreLogsDialog": { "title": "确认重试", "historyChange": { diff --git a/frontend/src/i18n/locales/zh-Hant/common.json b/frontend/src/i18n/locales/zh-Hant/common.json index 499504d5..cb0241f7 100644 --- a/frontend/src/i18n/locales/zh-Hant/common.json +++ b/frontend/src/i18n/locales/zh-Hant/common.json @@ -165,7 +165,8 @@ "workingBranch": "工作分支", "recent": "最近", "other": "其他", - "devServerPreview": "開發伺服器預覽" + "devServerPreview": "開發伺服器預覽", + "terminal": "終端機" }, "repos": { "loading": "正在載入儲存庫...", diff --git a/frontend/src/i18n/locales/zh-Hant/tasks.json b/frontend/src/i18n/locales/zh-Hant/tasks.json index 675d529d..2f338774 100644 --- a/frontend/src/i18n/locales/zh-Hant/tasks.json +++ b/frontend/src/i18n/locales/zh-Hant/tasks.json @@ -586,6 +586,9 @@ "discardChanges": "放棄變更" } }, + "terminal": { + "selectWorkspace": "選擇一個工作區以開啟終端機" + }, "restoreLogsDialog": { "title": "確認重試", "historyChange": { diff --git a/frontend/src/stores/useUiPreferencesStore.ts b/frontend/src/stores/useUiPreferencesStore.ts index 5a53b117..87181c1e 100644 --- a/frontend/src/stores/useUiPreferencesStore.ts +++ b/frontend/src/stores/useUiPreferencesStore.ts @@ -35,17 +35,21 @@ const DEFAULT_WORKSPACE_PANEL_STATE: WorkspacePanelState = { export const PERSIST_KEYS = { // Sidebar sections workspacesSidebarArchived: 'workspaces-sidebar-archived', - // Git panel sections + // Right panel sections gitAdvancedSettings: 'git-advanced-settings', gitPanelRepositories: 'git-panel-repositories', gitPanelProject: 'git-panel-project', gitPanelAddRepositories: 'git-panel-add-repositories', + rightPanelprocesses: 'right-panel-processes', + rightPanelPreview: 'right-panel-preview', // Process panel sections processesSection: 'processes-section', // Changes panel sections changesSection: 'changes-section', // Preview panel sections devServerSection: 'dev-server-section', + // Terminal panel section + terminalSection: 'terminal-section', // GitHub comments toggle showGitHubComments: 'show-github-comments', // Panel sizes @@ -66,8 +70,11 @@ export type PersistKey = | typeof PERSIST_KEYS.processesSection | typeof PERSIST_KEYS.changesSection | typeof PERSIST_KEYS.devServerSection + | typeof PERSIST_KEYS.terminalSection | typeof PERSIST_KEYS.showGitHubComments | typeof PERSIST_KEYS.rightMainPanel + | typeof PERSIST_KEYS.rightPanelprocesses + | typeof PERSIST_KEYS.rightPanelPreview | `repo-card-${string}` | `diff:${string}` | `edit:${string}` @@ -90,6 +97,7 @@ type State = { // Global layout state (applies across all workspaces) isLeftSidebarVisible: boolean; isRightSidebarVisible: boolean; + isTerminalVisible: boolean; previewRefreshKey: number; // Workspace-specific panel state @@ -108,6 +116,8 @@ type State = { toggleLeftSidebar: () => void; toggleLeftMainPanel: (workspaceId?: string) => void; toggleRightSidebar: () => void; + toggleTerminal: () => void; + setTerminalVisible: (value: boolean) => void; toggleRightMainPanelMode: ( mode: RightMainPanelMode, workspaceId?: string @@ -141,6 +151,7 @@ export const useUiPreferencesStore = create()( // Global layout state isLeftSidebarVisible: true, isRightSidebarVisible: true, + isTerminalVisible: true, previewRefreshKey: 0, // Workspace-specific panel state @@ -201,6 +212,11 @@ export const useUiPreferencesStore = create()( toggleRightSidebar: () => set((s) => ({ isRightSidebarVisible: !s.isRightSidebarVisible })), + toggleTerminal: () => + set((s) => ({ isTerminalVisible: !s.isTerminalVisible })), + + setTerminalVisible: (value) => set({ isTerminalVisible: value }), + toggleRightMainPanelMode: (mode, workspaceId) => { if (!workspaceId) return; const state = get(); @@ -306,6 +322,7 @@ export const useUiPreferencesStore = create()( // Global layout (persist sidebar visibility) isLeftSidebarVisible: state.isLeftSidebarVisible, isRightSidebarVisible: state.isRightSidebarVisible, + isTerminalVisible: state.isTerminalVisible, // Workspace-specific panel state (persisted) workspacePanelStates: state.workspacePanelStates, }), @@ -408,6 +425,7 @@ export function useWorkspacePanelState(workspaceId: string | undefined) { const isRightSidebarVisible = useUiPreferencesStore( (s) => s.isRightSidebarVisible ); + const isTerminalVisible = useUiPreferencesStore((s) => s.isTerminalVisible); // Actions from store const toggleRightMainPanelMode = useUiPreferencesStore( @@ -445,9 +463,10 @@ export function useWorkspacePanelState(workspaceId: string | undefined) { rightMainPanelMode: wsState.rightMainPanelMode, isLeftMainPanelVisible: wsState.isLeftMainPanelVisible, - // Global state (sidebars) + // Global state (sidebars and terminal) isLeftSidebarVisible, isRightSidebarVisible, + isTerminalVisible, // Workspace-specific actions toggleRightMainPanelMode: toggleRightMainPanelModeForWorkspace, diff --git a/frontend/src/styles/new/index.css b/frontend/src/styles/new/index.css index 57fc6f16..518b918e 100644 --- a/frontend/src/styles/new/index.css +++ b/frontend/src/styles/new/index.css @@ -275,81 +275,82 @@ } } -/* ANSI color classes for fancy-ansi */ +/* ANSI color classes for fancy-ansi - optimized for both light and dark modes */ @layer components { - .ansi-red { - @apply text-red-500; + /* Light mode: use darker shades for contrast on light backgrounds */ + .new-design .ansi-red { + @apply text-red-700 dark:text-red-400; } - .ansi-green { - @apply text-green-500; + .new-design .ansi-green { + @apply text-green-700 dark:text-green-400; } - .ansi-yellow { - @apply text-yellow-500; + .new-design .ansi-yellow { + @apply text-yellow-700 dark:text-yellow-400; } - .ansi-blue { - @apply text-blue-500; + .new-design .ansi-blue { + @apply text-blue-700 dark:text-blue-400; } - .ansi-magenta { - @apply text-purple-500; + .new-design .ansi-magenta { + @apply text-purple-700 dark:text-purple-400; } - .ansi-cyan { - @apply text-cyan-500; + .new-design .ansi-cyan { + @apply text-cyan-700 dark:text-cyan-400; } - .ansi-white { - @apply text-white; + .new-design .ansi-white { + @apply text-gray-600 dark:text-gray-200; } - .ansi-black { - @apply text-black; + .new-design .ansi-black { + @apply text-black dark:text-gray-900; } - .ansi-bright-red { - @apply text-red-400; + .new-design .ansi-bright-red { + @apply text-red-600 dark:text-red-300; } - .ansi-bright-green { - @apply text-green-400; + .new-design .ansi-bright-green { + @apply text-green-600 dark:text-green-300; } - .ansi-bright-yellow { - @apply text-yellow-400; + .new-design .ansi-bright-yellow { + @apply text-amber-600 dark:text-yellow-300; } - .ansi-bright-blue { - @apply text-blue-400; + .new-design .ansi-bright-blue { + @apply text-blue-600 dark:text-blue-300; } - .ansi-bright-magenta { - @apply text-purple-400; + .new-design .ansi-bright-magenta { + @apply text-purple-600 dark:text-purple-300; } - .ansi-bright-cyan { - @apply text-cyan-400; + .new-design .ansi-bright-cyan { + @apply text-cyan-600 dark:text-cyan-300; } - .ansi-bright-white { - @apply text-gray-200; + .new-design .ansi-bright-white { + @apply text-gray-500 dark:text-gray-100; } - .ansi-bright-black { - @apply text-gray-700; + .new-design .ansi-bright-black { + @apply text-gray-600 dark:text-gray-500; } - .ansi-bold { + .new-design .ansi-bold { @apply font-bold; } - .ansi-italic { + .new-design .ansi-italic { @apply italic; } - .ansi-underline { + .new-design .ansi-underline { @apply underline; } } @@ -359,6 +360,7 @@ 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } diff --git a/frontend/src/utils/terminalTheme.ts b/frontend/src/utils/terminalTheme.ts new file mode 100644 index 00000000..11d30272 --- /dev/null +++ b/frontend/src/utils/terminalTheme.ts @@ -0,0 +1,139 @@ +import type { ITheme } from '@xterm/xterm'; + +/** + * Convert HSL CSS variable value (e.g., "210 40% 98%") to hex color. + */ +function hslToHex(hslValue: string): string { + const trimmed = hslValue.trim(); + if (!trimmed) return '#000000'; + + // Parse "H S% L%" format (space-separated, S and L have % suffix) + const parts = trimmed.split(/\s+/); + if (parts.length < 3) return '#000000'; + + const h = parseFloat(parts[0]) / 360; + const s = parseFloat(parts[1]) / 100; + const l = parseFloat(parts[2]) / 100; + + // HSL to RGB conversion + let r: number, g: number, b: number; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + + const toHex = (x: number) => { + const hex = Math.round(x * 255).toString(16); + return hex.length === 1 ? '0' + hex : hex; + }; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +/** + * Get the CSS variable value from the computed styles. + * Looks for the variable on .new-design element first, then falls back to :root. + */ +function getCssVariable(name: string): string { + // Try to get from .new-design element first (where theme variables are scoped) + const newDesignEl = document.querySelector('.new-design'); + if (newDesignEl) { + const value = getComputedStyle(newDesignEl).getPropertyValue(name).trim(); + if (value) return value; + } + // Fall back to document element + return getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); +} + +/** + * Build an xterm.js theme from CSS variables defined in index.css. + * Uses --console-background and --console-foreground as the main colors, + * and derives ANSI colors from a combination of theme-appropriate defaults. + */ +export function getTerminalTheme(): ITheme { + const background = getCssVariable('--bg-secondary'); + const foreground = getCssVariable('--text-high'); + const success = getCssVariable('--console-success'); + const error = getCssVariable('--console-error'); + + // Detect if we're in dark mode by checking the class on html element + const isDark = document.documentElement.classList.contains('dark'); + + // Convert the main colors + const bgHex = hslToHex(background); + const fgHex = hslToHex(foreground); + const greenHex = hslToHex(success); + const redHex = hslToHex(error); + + // Define ANSI palette based on light/dark mode + // These are carefully chosen to be readable on the respective backgrounds + if (isDark) { + return { + background: bgHex, + foreground: fgHex, + cursor: fgHex, + cursorAccent: bgHex, + selectionBackground: '#3d4966', + selectionForeground: fgHex, + black: '#1a1a1a', + red: redHex, + green: greenHex, + yellow: '#e0af68', + blue: '#7aa2f7', + magenta: '#bb9af7', + cyan: '#7dcfff', + white: '#c0caf5', + brightBlack: '#545c7e', + brightRed: redHex, + brightGreen: greenHex, + brightYellow: '#e0af68', + brightBlue: '#7aa2f7', + brightMagenta: '#bb9af7', + brightCyan: '#7dcfff', + brightWhite: fgHex, + }; + } else { + // Light mode colors + return { + background: bgHex, + foreground: fgHex, + cursor: fgHex, + cursorAccent: bgHex, + selectionBackground: '#accef7', + selectionForeground: '#1a1a1a', + black: '#1a1a1a', + red: redHex, + green: greenHex, + yellow: '#946800', + blue: '#0550ae', + magenta: '#a626a4', + cyan: '#0e7490', + white: '#57606a', + brightBlack: '#4b5563', + brightRed: redHex, + brightGreen: greenHex, + brightYellow: '#7c5800', + brightBlue: '#0969da', + brightMagenta: '#8250df', + brightCyan: '#0891b2', + brightWhite: fgHex, + }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26ad1d0c..313abfae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,15 @@ importers: '@virtuoso.dev/message-list': specifier: ^1.13.3 version: 1.13.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/addon-web-links': + specifier: ^0.11.0 + version: 0.11.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 class-variance-authority: specifier: ^0.7.0 version: 0.7.1 @@ -191,6 +200,9 @@ importers: react-router-dom: specifier: ^6.8.1 version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-use-websocket: + specifier: ^4.13.0 + version: 4.13.0 react-virtuoso: specifier: ^4.14.0 version: 4.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2090,6 +2102,19 @@ packages: '@vue/shared@3.5.18': resolution: {integrity: sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==} + '@xterm/addon-fit@0.10.0': + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/addon-web-links@0.11.0': + resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3201,6 +3226,9 @@ packages: '@types/react': optional: true + react-use-websocket@4.13.0: + resolution: {integrity: sha512-anMuVoV//g2N76Wxqvqjjo1X48r9Np3y1/gMl7arX84tAPXdy5R7sB5lO5hvCzQRYjqXwV8XMAiEBOUbyrZFrw==} + react-virtuoso@4.14.0: resolution: {integrity: sha512-fR+eiCvirSNIRvvCD7ueJPRsacGQvUbjkwgWzBZXVq+yWypoH7mRUvWJzGHIdoRaCZCT+6mMMMwIG2S1BW3uwA==} peerDependencies: @@ -5556,6 +5584,16 @@ snapshots: '@vue/shared@3.5.18': {} + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -6625,6 +6663,8 @@ snapshots: optionalDependencies: '@types/react': 18.3.23 + react-use-websocket@4.13.0: {} + react-virtuoso@4.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1