From e6dad9ca875e771d16bec3793a7eeb60ec8ba550 Mon Sep 17 00:00:00 2001 From: iptoux Date: Fri, 22 May 2026 23:40:59 +0200 Subject: [PATCH 1/3] feat(terminal): implement drag-and-drop functionality for terminal slots Added styles and internationalization keys for drag-and-drop interactions within terminal slots. Introduced new methods for reordering terminal slots and growing the workspace grid to accommodate additional slots. Enhanced user experience by allowing users to drag and drop terminal slots, with visual feedback during the drag operation. Updated multiple locale files to support new drag-and-drop features across 13 languages. --- src/i18n/keys.rs | 3 + src/i18n/locales/de_de.rs | 3 + src/i18n/locales/en_us.rs | 3 + src/i18n/locales/es_es.rs | 3 + src/i18n/locales/fr_fr.rs | 3 + src/i18n/locales/hu_hu.rs | 3 + src/i18n/locales/it_it.rs | 3 + src/i18n/locales/ja_jp.rs | 3 + src/i18n/locales/ko_kr.rs | 3 + src/i18n/locales/pl_pl.rs | 3 + src/i18n/locales/pt_br.rs | 3 + src/i18n/locales/ru_ru.rs | 3 + src/i18n/locales/zh_cn.rs | 3 + src/i18n/locales/zh_tw.rs | 3 + src/workbench/mod.rs | 1 + src/workbench/state.rs | 99 +++++++++++++++ src/workbench/terminal_cell.rs | 66 +++++++++- src/workbench/terminal_slot_dnd.rs | 105 ++++++++++++++++ src/workbench/workspace_panel.rs | 188 ++++++++++++++++++++++++++++- styles.css | 73 +++++++++++ 20 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 src/workbench/terminal_slot_dnd.rs diff --git a/src/i18n/keys.rs b/src/i18n/keys.rs index 7c12bcb..595782b 100644 --- a/src/i18n/keys.rs +++ b/src/i18n/keys.rs @@ -598,6 +598,9 @@ pub enum I18nKey { WsTermRestoreSize, WsTermPaneTitleSingle, WsTermPaneTitleMulti, + WsTermDragHandleAria, + WsTermDropCreateNewAria, + WsTermDragMaxSlots, WsTermBootstrapFailed, // Voice subsystem diff --git a/src/i18n/locales/de_de.rs b/src/i18n/locales/de_de.rs index 235edc4..d657645 100644 --- a/src/i18n/locales/de_de.rs +++ b/src/i18n/locales/de_de.rs @@ -592,6 +592,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "Terminalgröße wiederherstellen", I18nKey::WsTermPaneTitleSingle => "{Rolle} · {Begriff} {n}", I18nKey::WsTermPaneTitleMulti => "{role} · {term} {slot}.{pane}", + I18nKey::WsTermDragHandleAria => "Terminal zum Umsortieren ziehen", + I18nKey::WsTermDropCreateNewAria => "Ablegen, um Terminal-Slot hinzuzufügen", + I18nKey::WsTermDragMaxSlots => "Maximale Terminal-Slots erreicht (16)", I18nKey::WsTermBootstrapFailed => { "Die Terminal-Benutzeroberfläche konnte nicht geladen werden. Überprüfen Sie die Browserkonsole. Das Bootstrap-Skript oder das xterm-CDN sind möglicherweise blockiert." } diff --git a/src/i18n/locales/en_us.rs b/src/i18n/locales/en_us.rs index 80561ce..e461370 100644 --- a/src/i18n/locales/en_us.rs +++ b/src/i18n/locales/en_us.rs @@ -628,6 +628,9 @@ Classic: Ctrl+O quick open, Ctrl+` new terminal, Ctrl+Shift+P palette." I18nKey::WsTermRestoreSize => "Restore terminal size", I18nKey::WsTermPaneTitleSingle => "{role} · {term} {n}", I18nKey::WsTermPaneTitleMulti => "{role} · {term} {slot}.{pane}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => { "Terminal UI failed to load. Check the browser console; the bootstrap script or xterm CDN may be blocked." } diff --git a/src/i18n/locales/es_es.rs b/src/i18n/locales/es_es.rs index 40551a2..41995c6 100644 --- a/src/i18n/locales/es_es.rs +++ b/src/i18n/locales/es_es.rs @@ -594,6 +594,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "Restaurar el tamaño del terminal", I18nKey::WsTermPaneTitleSingle => "{rol} · {término} {n}", I18nKey::WsTermPaneTitleMulti => "{rol} · {término} {espacio}.{panel}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => { "La interfaz de usuario del terminal no se pudo cargar. Verifique la consola del navegador; Es posible que el script de arranque o la CDN de xterm estén bloqueados." } diff --git a/src/i18n/locales/fr_fr.rs b/src/i18n/locales/fr_fr.rs index bb7a0b4..15d0f4b 100644 --- a/src/i18n/locales/fr_fr.rs +++ b/src/i18n/locales/fr_fr.rs @@ -596,6 +596,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "Restaurer la taille du terminal", I18nKey::WsTermPaneTitleSingle => "{rôle} · {terme} {n}", I18nKey::WsTermPaneTitleMulti => "{rôle} · {terme} {emplacement}.{volet}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => { "L'interface utilisateur du terminal n'a pas pu se charger. Vérifiez la console du navigateur ; le script d'amorçage ou le CDN xterm peut être bloqué." } diff --git a/src/i18n/locales/hu_hu.rs b/src/i18n/locales/hu_hu.rs index 83b58ba..2cb3c0d 100644 --- a/src/i18n/locales/hu_hu.rs +++ b/src/i18n/locales/hu_hu.rs @@ -592,6 +592,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "A terminál méretének visszaállítása", I18nKey::WsTermPaneTitleSingle => "{role} · {term} {n}", I18nKey::WsTermPaneTitleMulti => "{role} · {term} {slot}.{pane}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => { "Nem sikerült betölteni a terminál felhasználói felületét. Ellenőrizze a böngésző konzolját; a bootstrap szkript vagy az xterm CDN blokkolva lehet." } diff --git a/src/i18n/locales/it_it.rs b/src/i18n/locales/it_it.rs index d0396be..d46827e 100644 --- a/src/i18n/locales/it_it.rs +++ b/src/i18n/locales/it_it.rs @@ -590,6 +590,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "Ripristina le dimensioni del terminale", I18nKey::WsTermPaneTitleSingle => "{ruolo} · {termine} {n}", I18nKey::WsTermPaneTitleMulti => "{ruolo} · {termine} {slot}.{riquadro}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => { "Impossibile caricare l'interfaccia utente del terminale. Controlla la console del browser; lo script bootstrap o il CDN xterm potrebbero essere bloccati." } diff --git a/src/i18n/locales/ja_jp.rs b/src/i18n/locales/ja_jp.rs index 5bd5f8e..a1ea6cb 100644 --- a/src/i18n/locales/ja_jp.rs +++ b/src/i18n/locales/ja_jp.rs @@ -582,6 +582,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "端末サイズを復元する", I18nKey::WsTermPaneTitleSingle => "{役割} · {用語} {n}", I18nKey::WsTermPaneTitleMulti => "{役割} · {用語} {スロット}.{ペイン}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => "ターミナル UI のロードに失敗しました。ブラウザコンソールを確認してください。ブートストラップ スクリプトまたは xterm CDN がブロックされる可能性があります。", I18nKey::EulaAccepted => "承認されました", I18nKey::EulaUnknown => "未知", diff --git a/src/i18n/locales/ko_kr.rs b/src/i18n/locales/ko_kr.rs index f873122..7b5ba79 100644 --- a/src/i18n/locales/ko_kr.rs +++ b/src/i18n/locales/ko_kr.rs @@ -580,6 +580,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "터미널 크기 복원", I18nKey::WsTermPaneTitleSingle => "{역할} · {용어} {n}", I18nKey::WsTermPaneTitleMulti => "{역할} · {용어} {슬롯}.{창}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => "터미널 UI를 로드하지 못했습니다. 브라우저 콘솔을 확인하세요. 부트스트랩 스크립트 또는 xterm CDN이 차단되었을 수 있습니다.", I18nKey::EulaAccepted => "수락됨", I18nKey::EulaUnknown => "알려지지 않은", diff --git a/src/i18n/locales/pl_pl.rs b/src/i18n/locales/pl_pl.rs index a2725e0..9fe51c8 100644 --- a/src/i18n/locales/pl_pl.rs +++ b/src/i18n/locales/pl_pl.rs @@ -590,6 +590,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "Przywróć rozmiar terminala", I18nKey::WsTermPaneTitleSingle => "{rola} · {termin} {n.}", I18nKey::WsTermPaneTitleMulti => "{rola} · {termin} {slot}.{panel}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => { "Nie udało się załadować interfejsu terminala. Sprawdź konsolę przeglądarki; skrypt startowy lub xterm CDN mogą być zablokowane." } diff --git a/src/i18n/locales/pt_br.rs b/src/i18n/locales/pt_br.rs index dc9fb93..678ebdb 100644 --- a/src/i18n/locales/pt_br.rs +++ b/src/i18n/locales/pt_br.rs @@ -592,6 +592,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "Restaurar tamanho do terminal", I18nKey::WsTermPaneTitleSingle => "{função} · {termo} {n}", I18nKey::WsTermPaneTitleMulti => "{role} · {term} {slot}.{painel}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => { "Falha ao carregar a interface do terminal. Verifique o console do navegador; o script de inicialização ou CDN xterm pode estar bloqueado." } diff --git a/src/i18n/locales/ru_ru.rs b/src/i18n/locales/ru_ru.rs index 3f93293..de90281 100644 --- a/src/i18n/locales/ru_ru.rs +++ b/src/i18n/locales/ru_ru.rs @@ -590,6 +590,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "Восстановить размер терминала", I18nKey::WsTermPaneTitleSingle => "{роль} · {термин} {n}", I18nKey::WsTermPaneTitleMulti => "{роль} · {термин} {слот}.{панель}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => { "Не удалось загрузить пользовательский интерфейс терминала. Проверьте консоль браузера; сценарий начальной загрузки или xterm CDN могут быть заблокированы." } diff --git a/src/i18n/locales/zh_cn.rs b/src/i18n/locales/zh_cn.rs index 52533f3..9e17742 100644 --- a/src/i18n/locales/zh_cn.rs +++ b/src/i18n/locales/zh_cn.rs @@ -578,6 +578,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "恢复终端大小", I18nKey::WsTermPaneTitleSingle => "{角色} · {术语} {n}", I18nKey::WsTermPaneTitleMulti => "{角色} · {术语} {插槽}.{窗格}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => "终端 UI 无法加载。检查浏览器控制台;引导脚本或 xterm CDN 可能被阻止。", I18nKey::EulaAccepted => "公认", I18nKey::EulaUnknown => "未知", diff --git a/src/i18n/locales/zh_tw.rs b/src/i18n/locales/zh_tw.rs index 9d899d5..64f5de6 100644 --- a/src/i18n/locales/zh_tw.rs +++ b/src/i18n/locales/zh_tw.rs @@ -578,6 +578,9 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::WsTermRestoreSize => "恢復終端大小", I18nKey::WsTermPaneTitleSingle => "{角色} · {術語} {n}", I18nKey::WsTermPaneTitleMulti => "{角色} · {術語} {插槽}.{窗格}", + I18nKey::WsTermDragHandleAria => "Drag to reorder terminal", + I18nKey::WsTermDropCreateNewAria => "Drop to add terminal slot", + I18nKey::WsTermDragMaxSlots => "Maximum terminal slots reached (16)", I18nKey::WsTermBootstrapFailed => "終端 UI 無法載入。檢查瀏覽器控制台;引導腳本或 xterm CDN 可能會被封鎖。", I18nKey::EulaAccepted => "公認", I18nKey::EulaUnknown => "未知", diff --git a/src/workbench/mod.rs b/src/workbench/mod.rs index 2ac3f83..928ae96 100644 --- a/src/workbench/mod.rs +++ b/src/workbench/mod.rs @@ -34,6 +34,7 @@ pub mod state; mod voice_app_controls; mod terminal_cell; mod terminal_glue; +mod terminal_slot_dnd; mod theme_service; mod toast; mod update_dialog; diff --git a/src/workbench/state.rs b/src/workbench/state.rs index d936fc0..f8cbdf3 100644 --- a/src/workbench/state.rs +++ b/src/workbench/state.rs @@ -2067,6 +2067,18 @@ impl WorkbenchService { } } + /// Reorders terminal slots within a workspace. `to_index` is the + /// insertion position in the list **after** removing `from_index` + /// (same semantics as [`Self::reorder_workspaces`]). + pub fn reorder_terminal_slots(&self, workspace_id: u64, from_index: usize, to_index: usize) { + self.workspaces.update(|workspaces| { + let Some(workspace) = workspaces.iter_mut().find(|w| w.id == workspace_id) else { + return; + }; + reorder_workspace_slots(workspace, from_index, to_index); + }); + } + #[must_use] pub fn sidebar_collapsed(&self) -> RwSignal { self.sidebar_collapsed @@ -3300,6 +3312,29 @@ pub fn derive_workspace_name(path: &str) -> Option { Some(last.to_string()) } +fn reorder_workspace_slots(workspace: &mut WorkspaceEntry, from_index: usize, to_index: usize) { + if from_index == to_index { + return; + } + let n = workspace.slot_ids.len(); + if from_index >= n || to_index >= n { + return; + } + let id = workspace.slot_ids.remove(from_index); + let label = workspace.slot_agent_labels.remove(from_index); + let pane = if from_index < workspace.slot_pane_states.len() { + workspace.slot_pane_states.remove(from_index) + } else { + SlotPaneState::default_for_slot(id) + }; + let insert_at = to_index.min(workspace.slot_ids.len()); + workspace.slot_ids.insert(insert_at, id); + workspace.slot_agent_labels.insert(insert_at, label); + workspace + .slot_pane_states + .insert(insert_at.min(workspace.slot_pane_states.len()), pane); +} + #[cfg(test)] mod center_tab_tests { use super::*; @@ -3386,3 +3421,67 @@ mod center_tab_tests { assert!(!is_shell_workspace(&ws)); } } + +#[cfg(test)] +mod terminal_slot_tests { + use super::*; + + fn mk_slots(n: u8) -> WorkspaceEntry { + let slot_ids: Vec = (1..=n as u64).collect(); + let slot_pane_states: Vec = slot_ids + .iter() + .copied() + .map(SlotPaneState::default_for_slot) + .collect(); + let (grid_rows, grid_cols) = WorkspaceEntry::grid_dims_for_count(n); + WorkspaceEntry { + id: 1, + storage_key: "ws-storage".into(), + title: "Test".into(), + color: "#7dd3fc".into(), + cwd: "/tmp".into(), + terminal_count: n, + grid_rows, + grid_cols, + next_terminal_id: n as u64 + 1, + slot_ids, + slot_agent_labels: (0..n as usize).map(|i| format!("label{i}")).collect(), + slot_pane_states, + configuring: false, + agent_timeline: Vec::new(), + agent_compose_draft: String::new(), + agent_image_mode: false, + agent_context_items: Vec::new(), + memory_category_settings: HashMap::new(), + agent_chat_usage: ChatUsageStats::default(), + sidebar_explorer_open: true, + sidebar_graph_open: true, + sidebar_explorer_expanded_paths: Vec::new(), + center_tabs: default_center_tabs(), + center_active_tab_id: default_center_active_tab_id(), + center_next_tab_id: default_center_next_tab_id(), + } + } + + #[test] + fn reorder_permutes_parallel_vectors() { + let mut ws = mk_slots(3); + reorder_workspace_slots(&mut ws, 1, 2); + assert_eq!(ws.slot_ids, vec![1, 3, 2]); + assert_eq!(ws.slot_agent_labels, vec!["label0", "label2", "label1"]); + } + + #[test] + fn reorder_noop_on_same_index() { + let mut ws = mk_slots(2); + reorder_workspace_slots(&mut ws, 0, 0); + assert_eq!(ws.slot_ids, vec![1, 2]); + } + + #[test] + fn reorder_moves_first_slot_to_second() { + let mut ws = mk_slots(2); + reorder_workspace_slots(&mut ws, 0, 1); + assert_eq!(ws.slot_ids, vec![2, 1]); + } +} diff --git a/src/workbench/terminal_cell.rs b/src/workbench/terminal_cell.rs index e148c46..e12225b 100644 --- a/src/workbench/terminal_cell.rs +++ b/src/workbench/terminal_cell.rs @@ -7,6 +7,9 @@ use crate::tauri_bridge::{ }; use crate::workbench::agent_accent::agent_accent_class; use crate::workbench::agent_context_handoff::TerminalSlotHandoffButton; +use crate::workbench::terminal_slot_dnd::{ + set_drag_payload, TerminalDragMeta, TerminalSlotDragPayload, TerminalSlotDragService, +}; use crate::workbench::terminal_glue::{ terminal_create, terminal_dispose, terminal_fit, terminal_request_fit, terminal_set_stdin_enabled, terminal_show_fallback, terminal_size_from_js, @@ -19,7 +22,7 @@ use leptos::prelude::*; use leptos_icons::Icon as LxIcon; use std::sync::{Arc, Mutex}; use wasm_bindgen::JsCast; -use web_sys::HtmlElement; +use web_sys::{DragEvent, HtmlElement}; #[derive(Clone)] struct AgentLaunchPending { @@ -76,9 +79,11 @@ pub fn WorkspaceTerminalCell( /// Hides the close (`×`) button when removing this cell is a no-op /// (e.g. last remaining terminal in the workspace with a single pane). can_close: Signal, + slot_drag_enabled: Signal, ) -> impl IntoView { let i18n = expect_context::(); let wb = expect_context::(); + let slot_dnd = use_context::(); let load_failed = RwSignal::new(false); let node_ref = NodeRef::::new(); let terminal_key_focus = terminal_key.clone(); @@ -433,6 +438,65 @@ pub fn WorkspaceTerminalCell( move |_| wb.focus_terminal(terminal_key.clone()) } > + + + + + {move || dynamic_title.get()} diff --git a/src/workbench/terminal_slot_dnd.rs b/src/workbench/terminal_slot_dnd.rs new file mode 100644 index 0000000..33f5221 --- /dev/null +++ b/src/workbench/terminal_slot_dnd.rs @@ -0,0 +1,105 @@ +//! Drag-and-drop helpers for reordering terminal slots within a workspace grid. + +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; +use web_sys::DataTransfer; + +pub const TERMINAL_SLOT_MIME: &str = "application/x-blxcode-terminal-slot"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TerminalSlotDragPayload { + pub workspace_id: u64, + pub slot_id: u64, + pub from_index: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GhostPos { + pub index: usize, + pub rows: u8, + pub cols: u8, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalDragMeta { + pub workspace_id: u64, + pub slot_id: u64, + pub from_index: usize, + pub title: String, + pub agent_label: String, +} + +#[derive(Clone, Copy)] +pub struct TerminalSlotDragService { + /// Non-reactive: set on dragstart so dragover works without re-rendering. + session: StoredValue, + pub active: RwSignal>, + pub ghost: RwSignal>, +} + +impl TerminalSlotDragService { + #[must_use] + pub fn new() -> Self { + Self { + session: StoredValue::new(false), + active: RwSignal::new(None), + ghost: RwSignal::new(None), + } + } + + pub fn begin_session(&self) { + self.session.set_value(true); + } + + pub fn session_active(&self) -> bool { + self.session.get_value() + } + + pub fn clear(&self) { + self.session.set_value(false); + self.active.set(None); + self.ghost.set(None); + } +} + +pub fn set_drag_payload(dt: &DataTransfer, payload: &TerminalSlotDragPayload) { + if let Ok(json) = serde_json::to_string(payload) { + let _ = dt.set_data(TERMINAL_SLOT_MIME, &json); + let _ = dt.set_data("text/plain", &payload.slot_id.to_string()); + let _ = dt.set_effect_allowed("move"); + } +} + +pub fn read_drag_payload(dt: &DataTransfer) -> Option { + dt.get_data(TERMINAL_SLOT_MIME) + .ok() + .and_then(|json| serde_json::from_str(&json).ok()) +} + +pub fn is_terminal_drag(dt: &DataTransfer) -> bool { + let types = dt.types(); + for i in 0..types.length() { + if types.get(i) == TERMINAL_SLOT_MIME { + return true; + } + } + false +} + +pub fn ghost_style(pos: &GhostPos) -> String { + let rows = pos.rows.max(1) as f64; + let cols = pos.cols.max(1) as f64; + let row = (pos.index as f64 / cols).floor(); + let col = pos.index as f64 % cols; + format!( + "left:{:.4}%;top:{:.4}%;width:{:.4}%;height:{:.4}%;", + (col / cols) * 100.0, + (row / rows) * 100.0, + 100.0 / cols, + 100.0 / rows, + ) +} + +pub fn drag_event_data_transfer(ev: &web_sys::DragEvent) -> Option { + ev.data_transfer() +} diff --git a/src/workbench/workspace_panel.rs b/src/workbench/workspace_panel.rs index f46acd7..fa073c8 100644 --- a/src/workbench/workspace_panel.rs +++ b/src/workbench/workspace_panel.rs @@ -16,6 +16,10 @@ use crate::workbench::terminal_cell::WorkspaceTerminalCell; use crate::workbench::terminal_glue::{ terminal_observe_workspace_grid, terminal_unobserve_workspace_grid, }; +use crate::workbench::terminal_slot_dnd::{ + drag_event_data_transfer, ghost_style, is_terminal_drag, read_drag_payload, GhostPos, + TerminalSlotDragService, +}; use crate::workbench::WorkbenchService; use gloo_timers::future::TimeoutFuture; use leptos::callback::Callback; @@ -23,7 +27,7 @@ use leptos::html; use leptos::prelude::*; use leptos_icons::Icon as LxIcon; use wasm_bindgen::JsCast; -use web_sys::{HtmlElement, MouseEvent}; +use web_sys::{DragEvent, HtmlElement, MouseEvent}; #[derive(Clone, Copy)] enum GridResizeAxis { @@ -197,6 +201,14 @@ fn WorkspaceSurface(workspace_id: u64) -> impl IntoView { let is_configuring = Memo::new(move |_| workspace.get().map(|w| w.configuring).unwrap_or(false)); + let slot_dnd = TerminalSlotDragService::new(); + provide_context(slot_dnd); + + let slot_drag_enabled = Memo::new(move |_| { + !is_configuring.get() + && !wb.sidebar_collapsed().get() + && full_size_terminal.get().is_none() + }); let term_grid_ref = NodeRef::::new(); Effect::new({ @@ -264,7 +276,13 @@ fn WorkspaceSurface(workspace_id: u64) -> impl IntoView { class:workspace-center-panel--hidden=move || active_center_tab_id.get() != CENTER_TERMINALS_TAB_ID >
impl IntoView { index=index cwd=cwd agent_slug=slug + slot_drag_enabled=slot_drag_enabled is_workspace_active=Signal::derive(move || { wb.active_id().get() == Some(workspace_id) && active_center_tab_id.get() == CENTER_TERMINALS_TAB_ID @@ -337,6 +356,9 @@ fn WorkspaceSurface(workspace_id: u64) -> impl IntoView { } } /> + + + , is_workspace_active: Signal, hidden: Signal, is_full_size: Signal, @@ -820,6 +843,7 @@ fn TerminalSlotSurface( ) -> impl IntoView { let wb = expect_context::(); let i18n = expect_context::(); + let slot_dnd = expect_context::(); // Hydrate split layout from persisted workspace state so a restart // preserves the user's exact pane grid. let persisted = wb.slot_panes(workspace_id, slot_id); @@ -846,8 +870,123 @@ fn TerminalSlotSurface( if hidden.get() { class.push_str(" ws-term-slot--hidden"); } + if slot_dnd + .active + .get() + .is_some_and(|meta| meta.slot_id == slot_id) + { + class.push_str(" ws-term-slot--drag-source"); + } + if slot_dnd + .ghost + .get() + .is_some_and(|g| g.index == index) + && slot_dnd + .active + .get() + .is_some_and(|m| m.slot_id != slot_id) + { + class.push_str(" ws-term-slot--drag-over"); + } class } + on:dragenter=move |ev| { + if !slot_drag_enabled.get_untracked() { + return; + } + let Some(de) = ev.dyn_ref::() else { + return; + }; + let Some(dt) = drag_event_data_transfer(de) else { + return; + }; + if is_terminal_drag(&dt) + || slot_dnd.session_active() + || slot_dnd.active.get_untracked().is_some() + { + de.prevent_default(); + } + } + on:dragover=move |ev| { + if !slot_drag_enabled.get_untracked() { + return; + } + let Some(de) = ev.dyn_ref::() else { + return; + }; + let Some(dt) = drag_event_data_transfer(de) else { + return; + }; + if !is_terminal_drag(&dt) + && !slot_dnd.session_active() + && slot_dnd.active.get_untracked().is_none() + { + return; + } + de.prevent_default(); + let _ = dt.set_drop_effect("move"); + let payload = read_drag_payload(&dt).or_else(|| { + slot_dnd.active.get().map(|meta| { + crate::workbench::terminal_slot_dnd::TerminalSlotDragPayload { + workspace_id: meta.workspace_id, + slot_id: meta.slot_id, + from_index: meta.from_index, + } + }) + }); + let Some(payload) = payload else { + return; + }; + if payload.workspace_id != workspace_id || payload.slot_id == slot_id { + return; + } + let (rows, cols) = wb.workspaces().with_untracked(|list| { + list.iter() + .find(|w| w.id == workspace_id) + .map(|w| (w.grid_rows, w.grid_cols)) + .unwrap_or((1, 1)) + }); + slot_dnd.ghost.set(Some(GhostPos { + index, + rows, + cols, + })); + } + on:drop=move |ev| { + ev.prevent_default(); + ev.stop_propagation(); + if !slot_drag_enabled.get_untracked() { + slot_dnd.clear(); + return; + } + let payload = ev + .dyn_ref::() + .and_then(|de| de.data_transfer()) + .and_then(|dt| read_drag_payload(&dt)) + .or_else(|| { + slot_dnd.active.get().map(|meta| { + crate::workbench::terminal_slot_dnd::TerminalSlotDragPayload { + workspace_id: meta.workspace_id, + slot_id: meta.slot_id, + from_index: meta.from_index, + } + }) + }); + if let Some(payload) = payload { + if payload.workspace_id == workspace_id && payload.slot_id != slot_id { + let from_index = wb.workspaces().with_untracked(|list| { + list.iter() + .find(|w| w.id == workspace_id) + .and_then(|w| w.slot_ids.iter().position(|&id| id == payload.slot_id)) + .unwrap_or(payload.from_index) + }); + if from_index != index { + wb.reorder_terminal_slots(workspace_id, from_index, index); + } + } + } + slot_dnd.clear(); + } >
} } @@ -983,6 +1123,50 @@ fn pane_grid_style(axis: TerminalSplitAxis, count: usize) -> String { } } +#[component] +fn TerminalSlotGhost(slot_dnd: TerminalSlotDragService) -> impl IntoView { + view! { + +
+ + {move || { + slot_dnd + .active + .get() + .map(|m| m.title) + .unwrap_or_default() + }} + + + + {move || { + slot_dnd + .active + .get() + .map(|m| m.agent_label) + .unwrap_or_default() + }} + + +
+
+ } +} + fn terminal_slots(workspace: &WorkspaceEntry) -> Vec { workspace .slot_ids diff --git a/styles.css b/styles.css index be6f934..39fe985 100644 --- a/styles.css +++ b/styles.css @@ -5952,6 +5952,79 @@ button { display: none; } +.ws-term-slot--drag-source { + opacity: 0.55; +} + +.ws-term-slot--drag-over { + box-shadow: inset 0 0 0 2px var(--accent); +} + +.ws-term-grid--drag-active .ws-term-slot:not(.ws-term-slot--drag-source) .ws-term-cell__xterm { + pointer-events: none; +} + +.ws-term-grid--drag-active { + overflow: visible; +} + +.ws-term-slot-ghost { + position: absolute; + pointer-events: none; + opacity: 0.55; + border: 1px solid var(--accent); + background: color-mix(in srgb, var(--accent) 18%, var(--bg-raised)); + border-radius: 4px; + padding: 6px 8px; + transition: + left 60ms ease-out, + top 60ms ease-out, + width 60ms ease-out, + height 60ms ease-out; + z-index: 5; + display: flex; + align-items: center; + gap: 0.35rem; + overflow: hidden; + box-sizing: border-box; +} + +.ws-term-slot-ghost__title { + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ws-term-slot-ghost__badge { + flex-shrink: 0; + font-size: 0.62rem; + font-weight: 700; + color: var(--accent); +} + +.ws-term-cell__drag-handle { + cursor: grab; + display: inline-flex; + align-items: center; + flex-shrink: 0; + padding: 0 4px; + margin-right: 2px; + opacity: 0.55; + color: var(--text-muted); +} + +.ws-term-cell__drag-handle:hover { + opacity: 0.9; + color: var(--text); +} + +.ws-term-cell__drag-handle:active { + cursor: grabbing; +} + .ws-term-cell { position: relative; flex: 1; From 918ecc0b5a3838098853c7ede45edb82c5eb3656 Mon Sep 17 00:00:00 2001 From: iptoux Date: Fri, 22 May 2026 23:50:41 +0200 Subject: [PATCH 2/3] feat(terminal): enhance drag-and-drop functionality and improve terminal slot management Updated styles for drag-and-drop interactions, replacing box-shadow with an outline for better visibility. Introduced a new method to retrieve the live index of terminal slots in the workspace grid, improving the drag-and-drop experience. Refactored drag session management to prevent stale UI updates and ensure accurate slot reordering during drag operations. --- src/workbench/state.rs | 9 +++++++ src/workbench/terminal_cell.rs | 9 ++----- src/workbench/terminal_slot_dnd.rs | 27 +++++++++++++------ src/workbench/workspace_panel.rs | 42 ++++++++++++++++++------------ styles.css | 5 ++-- 5 files changed, 58 insertions(+), 34 deletions(-) diff --git a/src/workbench/state.rs b/src/workbench/state.rs index f8cbdf3..958a734 100644 --- a/src/workbench/state.rs +++ b/src/workbench/state.rs @@ -2079,6 +2079,15 @@ impl WorkbenchService { }); } + /// Live index of `slot_id` in the workspace grid (after any reorders). + pub fn terminal_slot_index(&self, workspace_id: u64, slot_id: u64) -> Option { + self.workspaces.with_untracked(|list| { + list.iter() + .find(|w| w.id == workspace_id) + .and_then(|w| w.slot_ids.iter().position(|&id| id == slot_id)) + }) + } + #[must_use] pub fn sidebar_collapsed(&self) -> RwSignal { self.sidebar_collapsed diff --git a/src/workbench/terminal_cell.rs b/src/workbench/terminal_cell.rs index e12225b..6538445 100644 --- a/src/workbench/terminal_cell.rs +++ b/src/workbench/terminal_cell.rs @@ -448,7 +448,6 @@ pub fn WorkspaceTerminalCell( on:dragstart={ let workspace_id = workspace_id; let slot_id = slot_id; - let grid_index = grid_index; let title_snap = dynamic_title; let agent_snap = agent_label; move |ev: DragEvent| { @@ -466,22 +465,18 @@ pub fn WorkspaceTerminalCell( let payload = TerminalSlotDragPayload { workspace_id, slot_id, - from_index: grid_index, }; set_drag_payload(&dt, &payload); - dnd.begin_session(); - // Defer ghost/active UI so the drag session is - // established before Leptos re-renders the grid. + let gen = dnd.begin_session(); let meta = TerminalDragMeta { workspace_id, slot_id, - from_index: grid_index, title: title_snap.get_untracked(), agent_label: agent_snap.get_untracked(), }; leptos::task::spawn_local(async move { TimeoutFuture::new(0).await; - dnd.active.set(Some(meta)); + dnd.try_set_active(gen, meta); }); } } diff --git a/src/workbench/terminal_slot_dnd.rs b/src/workbench/terminal_slot_dnd.rs index 33f5221..602cc66 100644 --- a/src/workbench/terminal_slot_dnd.rs +++ b/src/workbench/terminal_slot_dnd.rs @@ -10,12 +10,11 @@ pub const TERMINAL_SLOT_MIME: &str = "application/x-blxcode-terminal-slot"; pub struct TerminalSlotDragPayload { pub workspace_id: u64, pub slot_id: u64, - pub from_index: usize, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct GhostPos { - pub index: usize, + pub target_slot_id: u64, pub rows: u8, pub cols: u8, } @@ -24,15 +23,15 @@ pub struct GhostPos { pub struct TerminalDragMeta { pub workspace_id: u64, pub slot_id: u64, - pub from_index: usize, pub title: String, pub agent_label: String, } #[derive(Clone, Copy)] pub struct TerminalSlotDragService { - /// Non-reactive: set on dragstart so dragover works without re-rendering. session: StoredValue, + /// Bumped on begin/clear so deferred UI updates cannot resurrect stale drags. + session_gen: StoredValue, pub active: RwSignal>, pub ghost: RwSignal>, } @@ -42,21 +41,33 @@ impl TerminalSlotDragService { pub fn new() -> Self { Self { session: StoredValue::new(false), + session_gen: StoredValue::new(0), active: RwSignal::new(None), ghost: RwSignal::new(None), } } - pub fn begin_session(&self) { + pub fn begin_session(&self) -> u64 { self.session.set_value(true); + let gen = self.session_gen.get_value().wrapping_add(1); + self.session_gen.set_value(gen); + gen } pub fn session_active(&self) -> bool { self.session.get_value() } + pub fn try_set_active(&self, gen: u64, meta: TerminalDragMeta) { + if self.session.get_value() && self.session_gen.get_value() == gen { + self.active.set(Some(meta)); + } + } + pub fn clear(&self) { self.session.set_value(false); + self.session_gen + .set_value(self.session_gen.get_value().wrapping_add(1)); self.active.set(None); self.ghost.set(None); } @@ -86,11 +97,11 @@ pub fn is_terminal_drag(dt: &DataTransfer) -> bool { false } -pub fn ghost_style(pos: &GhostPos) -> String { +pub fn ghost_style(pos: &GhostPos, target_index: usize) -> String { let rows = pos.rows.max(1) as f64; let cols = pos.cols.max(1) as f64; - let row = (pos.index as f64 / cols).floor(); - let col = pos.index as f64 % cols; + let row = (target_index as f64 / cols).floor(); + let col = target_index as f64 % cols; format!( "left:{:.4}%;top:{:.4}%;width:{:.4}%;height:{:.4}%;", (col / cols) * 100.0, diff --git a/src/workbench/workspace_panel.rs b/src/workbench/workspace_panel.rs index fa073c8..67af028 100644 --- a/src/workbench/workspace_panel.rs +++ b/src/workbench/workspace_panel.rs @@ -880,7 +880,7 @@ fn TerminalSlotSurface( if slot_dnd .ghost .get() - .is_some_and(|g| g.index == index) + .is_some_and(|g| g.target_slot_id == slot_id) && slot_dnd .active .get() @@ -930,7 +930,6 @@ fn TerminalSlotSurface( crate::workbench::terminal_slot_dnd::TerminalSlotDragPayload { workspace_id: meta.workspace_id, slot_id: meta.slot_id, - from_index: meta.from_index, } }) }); @@ -947,7 +946,7 @@ fn TerminalSlotSurface( .unwrap_or((1, 1)) }); slot_dnd.ghost.set(Some(GhostPos { - index, + target_slot_id: slot_id, rows, cols, })); @@ -968,20 +967,23 @@ fn TerminalSlotSurface( crate::workbench::terminal_slot_dnd::TerminalSlotDragPayload { workspace_id: meta.workspace_id, slot_id: meta.slot_id, - from_index: meta.from_index, } }) }); if let Some(payload) = payload { if payload.workspace_id == workspace_id && payload.slot_id != slot_id { - let from_index = wb.workspaces().with_untracked(|list| { - list.iter() - .find(|w| w.id == workspace_id) - .and_then(|w| w.slot_ids.iter().position(|&id| id == payload.slot_id)) - .unwrap_or(payload.from_index) - }); - if from_index != index { - wb.reorder_terminal_slots(workspace_id, from_index, index); + let Some(from_index) = + wb.terminal_slot_index(workspace_id, payload.slot_id) + else { + slot_dnd.clear(); + return; + }; + let Some(to_index) = wb.terminal_slot_index(workspace_id, slot_id) else { + slot_dnd.clear(); + return; + }; + if from_index != to_index { + wb.reorder_terminal_slots(workspace_id, from_index, to_index); } } } @@ -1125,16 +1127,22 @@ fn pane_grid_style(axis: TerminalSplitAxis, count: usize) -> String { #[component] fn TerminalSlotGhost(slot_dnd: TerminalSlotDragService) -> impl IntoView { + let wb = expect_context::(); view! {
diff --git a/styles.css b/styles.css index 39fe985..4203acd 100644 --- a/styles.css +++ b/styles.css @@ -5957,7 +5957,8 @@ button { } .ws-term-slot--drag-over { - box-shadow: inset 0 0 0 2px var(--accent); + outline: 3px dashed var(--accent); + outline-offset: -3px; } .ws-term-grid--drag-active .ws-term-slot:not(.ws-term-slot--drag-source) .ws-term-cell__xterm { @@ -5972,7 +5973,7 @@ button { position: absolute; pointer-events: none; opacity: 0.55; - border: 1px solid var(--accent); + border: 2px dashed var(--accent); background: color-mix(in srgb, var(--accent) 18%, var(--bg-raised)); border-radius: 4px; padding: 6px 8px; From 419a3a6012119544e27e0feb857a0cc0105861b2 Mon Sep 17 00:00:00 2001 From: iptoux Date: Sat, 23 May 2026 00:00:24 +0200 Subject: [PATCH 3/3] feat(terminal): implement drag-and-drop reordering for terminal slots Added functionality to reorder terminal slots using a drag-and-drop interface, allowing users to swap positions of slots within the same workspace grid. Introduced visual feedback during the drag operation, including opacity changes and outlines for better user experience. Updated documentation to reflect the new drag-and-drop feature and its usage, along with internationalization support for new UI elements across 13 locales. --- CHANGELOG.md | 2 ++ docs/user/workspaces.md | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 289a984..974c687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Terminal slot drag & drop reorder**: terminal slots can now be reordered by dragging a dedicated **grip handle** (`LuGripVertical`) in the slot titlebar onto any other slot in the same workspace grid. New module `src/workbench/terminal_slot_dnd.rs` defines the `application/x-blxcode-terminal-slot` MIME payload, the `TerminalSlotDragService` Leptos context (active drag meta + ghost position, non-reactive `StoredValue` session flag with generation guard against stale deferred updates), and helpers `set_drag_payload` / `read_drag_payload` / `is_terminal_drag` / `ghost_style`. Drag source and drop target are resolved live by `slot_id` (not array index) via the new `WorkbenchService::terminal_slot_index(workspace_id, slot_id)` accessor, so repeated reorders compose without stale indices. The grid uses HTML5 native drag & drop (`dragstart` / `dragover` / `drop`), with the deferred `try_set_active(gen, meta)` pattern so Leptos doesn't re-render mid-`dragstart` (which would cancel the drag on WebKitGTK / Wry). Permutation runs in pure-Rust `reorder_workspace_slots` against `slot_ids`, `slot_agent_labels`, and `slot_pane_states` in parallel; PTYs are not unmounted because the `For` key is `slot.id` and `terminal_key` stays stable across the reorder. Drag-disabled states: configurator open, full-size slot active, collapsed sidebar. Drag visuals: source slot at `opacity: 0.55`, target slot with `3px dashed var(--accent)` outline (inset via `outline-offset: -3px`), and a transluscent ghost-preview overlay (`.ws-term-slot-ghost`, `2px dashed` border) at the drop target's grid cell — sized via percentage `grid_template` math so it tracks the live grid dims. To keep `dragover` reaching the slot wrapper while xterm canvases would otherwise capture pointer events, `.ws-term-grid--drag-active .ws-term-slot:not(--drag-source) .ws-term-cell__xterm { pointer-events: none }` is applied while a drag is in flight. New i18n key `WsTermDragHandleAria` (handle aria-label / tooltip) authored in all 13 locales. 3 new unit tests in `state.rs` (`reorder_permutes_parallel_vectors`, `reorder_noop_on_same_index`, `reorder_moves_first_slot_to_second`) pin the pure-Rust permutation semantics. Cross-workspace transfer, drag of individual split panes, and a custom HTML5 drag-image (`setDragImage`) are **out of scope** — `setDragImage` with live or cloned DOM proved to crash WebKitGTK in the Tauri webview under Wry; the current accent-dashed target outline + transluscent source slot is the stable Linux-safe UX. + - **Code preview drag-range selection + right-click handoff to terminals and agent**: the new `CodeView` now supports **drag-based line range selection** — press the left mouse button on a line and drag up or down to extend the selection (`code-view__row--selected` highlight follows continuously). The selection model moved from `Option` to an ordered `Option<(usize, usize)>` 1-based inclusive range; single-clicks still toggle a single line, and `Escape` clears nothing implicitly (it closes the new context menu only). A window-level `mouseup` listener (installed once per `CodeView` mount, cleaned up via `on_cleanup`) ends drags even when the cursor leaves the gutter. `.code-view` gained `user-select: none` so accidental text selection no longer fights the row drag. A new **right-click context menu** (`src/workbench/file_preview/code_context_menu.rs`) opens at the click position whenever the user invokes `contextmenu` on a row; if the click lands outside the current range the selection is first replaced with that single line, otherwise the existing range stays. The menu is grouped into four sections: **Snippet → Insert into terminal** (lists every terminal in every workspace via the new cross-workspace enumerator `list_terminal_targets_all_workspaces` — the preview's own workspace is moved to the front with a localized "current" badge), **Full context block → Insert into terminal** (wraps the snippet in a `⟪ BLXCode attached context ⟫` envelope so the receiving terminal CLI parses it just like a workspace handoff), **Snippet → Attach to agent** (per-workspace `upsert_workspace_agent_context` against the new `AgentContextKind::FileSnippet` kind), and **Clipboard** (`Copy snippet`, `Copy line range`, `Copy raw text` via `navigator.clipboard.writeText` with per-action toast feedback). Cross-workspace inserts and agent attaches add a `source workspace:` line to the snippet/envelope header so the model can disambiguate when the file lives in a different workspace than the target. New helper `build_file_snippet_block` in `src/workbench/file_preview/util.rs` produces fenced markdown blocks (clamped to in-range indices, UTF-8 safe; 6 unit tests cover single-line, range, cross-workspace header, no-language fence, UTF-8 codepoints, and out-of-range clamping). New `render_file_snippet_envelope` in `src/workbench/agent_context_handoff.rs` emits the BLXCode-style handoff envelope without the heavy memory/plans/images sections (2 unit tests cover the in-workspace and cross-workspace variants). `AgentContextItem` gained an optional `content: Option` field on both `src/agent_wire.rs` and `src-tauri/src/agent/protocol.rs` so file-snippet items can ship their fenced block inline; the existing six `AgentContextItem` constructors across the codebase (memory/learning notes + categories, plans, terminal session, "Memory" fallback) now pass `content: None` explicitly. Backend `render_context_prompt` in `src-tauri/src/agent/session_orchestrator.rs` partitions `FileSnippet` items into a dedicated `Attached file snippets (verbatim, line-numbered headers):` prompt section that renders each item's inline content; `render_agent_context_block` in `agent_context_handoff.rs` mirrors this with an `## Attached file snippets` section (memory/plans filters now skip snippet items). Toasts use `WorkbenchService`'s existing `ToastService`. A global `mousedown` window listener closes the menu on any outside click; `Escape` closes too. Drag selection works on both `Code` and `Text` files since both route through the same `CodeView`. 22 new i18n keys (`CodeViewMenuAria`, `CodeViewMenuSectionSnippetTerminal/SnippetAgent/EnvelopeTerminal/Clipboard`, `CodeViewMenuWorkspaceGroup`, `CodeViewMenuTerminalSlotLabel`, `CodeViewMenuAttachAgentLabel`, `CodeViewMenuNoTerminals`, `CodeViewMenuCopySnippet/Range/Raw`, `CodeViewMenuPreviewWorkspaceBadge`, and six `CodeViewToast*` variants for success/failure) were authored with real translations in all 13 locales (en, de, fr, es, it, pt_br, pl, hu, ru, ja, ko, zh_cn, zh_tw) and use `{workspace}`, `{terminal}`, `{slot}`, `{agent}`, `{error}` placeholders. CSS adds `.code-context-menu` and friends with sections, workspace-group headers, slot sublists, accent-pill `code-context-menu__badge`, and themed hover via `--overlay-2`. The `WorkspaceTerminalGroup` helper in `agent_context_handoff.rs` filters shell workspaces via the existing `is_shell_workspace` predicate. - **Closeable Terminals tab + Settings without workspace**: every center tab — including the pinned Terminals tab — now exposes a close button. Clicking the Terminals close button raises a new confirmation overlay (`src/workbench/close_terminals_tab_dialog/`) with a 3-second countdown that keeps the primary button disabled; confirming routes through `WorkbenchService::close_center_terminals_tab`, which saves the workspace, terminates its PTYs, and pushes it onto the recent list (same path as the sidebar close). Closing the last non-Terminals tab in a real workspace likewise triggers `close_workspace` so the welcome screen reappears when no workspaces remain. **Settings** can now open without an active workspace: `open_center_settings_tab` lazily provisions an ephemeral "shell workspace" (empty `cwd`, `configuring: false`, no terminal slots) that hosts only the Settings tab and is hidden from the sidebar via the new `is_shell_workspace` filter; closing the shell's Settings tab disposes the shell automatically. `ensure_center_tabs` was renamed to `repair_center_tab_state` and no longer auto-reinserts a Terminals tab; `open_center_terminals_tab` is the explicit reopener and `open_new_terminal` calls it before appending a slot. `HarnessUiService` gained a `close_terminals_confirm` signal + generation guard (so rapid re-opens cancel stale timers); the harness keyboard handler swallows shortcuts while the dialog is up and routes `Escape` to dismiss. New command palette entry **Terminals** (`PaletteAction::OpenTerminalsTab`, `CmdTermTitle/Sub`) reopens the Terminals tab without spawning a new PTY. `finalize_workspace_close` centralizes wizard-draft cleanup (`workspace_drafts` + `workspace_config_steps`) so every close path drops state. 7 new i18n keys (`CmdTermTitle`, `CmdTermSub`, `CenterTabCloseTerminalsTitle/Body/Confirm/Cancel`, `CenterTabCloseAria`) localized into all 13 locales. 4 new unit tests (`center_tab_tests`) cover the repair semantics, empty-tabs branch, dangling active-id repair, and shell-workspace predicate. - **Code preview with line numbers, syntax highlighting & row selection**: the file preview now ships a dedicated `CodeView` (`src/workbench/file_preview/code_view.rs`) that handles every source-code file with a real two-column layout — a sticky line-number gutter (right-aligned, tabular numerals, hairline divider) and an `hljs`-colored code column — instead of the prior plain `
` fallback. Clicking any row toggles a selection highlight (accent-soft background + accent-color left bar, click again to clear) so users can mark and refer back to a specific line. Backend `classify_kind` in `src-tauri/src/fs_entries.rs` now distinguishes `FileKind::Code` (Rust/TS/JS/JSX/TSX/MJS/CJS, Python, Go, Java/Kotlin/Scala/Groovy/Gradle, Swift/Obj-C, C/C++/C#/F#/VB, Ruby/PHP/Lua/Perl/Dart/R/Julia, Clojure/Elixir/Erlang/Haskell/Elm/Nim/Zig/OCaml, HTML/Vue/Svelte/CSS/SCSS/Sass/Less, JSON/JSON5/JSONC/TOML/YAML/XML/Plist, Shell/Bash/Zsh/Fish/PowerShell/Bat/Cmd, SQL/GraphQL/Protobuf/Thrift/HCL/Terraform/Nix, Dockerfile/Makefile/CMake, diff/patch) from plain `FileKind::Text` (txt/log/ini/conf/env/properties/csv/tsv/gitignore/editorconfig); both kinds route through `CodeView`, but only `Code` gets syntax highlighting. The 11-test fs_entries unit suite now asserts the new mapping including ts/tsx/js/py/go/html/json → Code and txt/log/env/gitignore → Text. The frontend bridge (`tauri_bridge.rs`) mirrors the new `FileKind::Code` variant. New module `src/workbench/file_preview/hljs_glue.rs` lazy-loads the vendored highlight.js 11.11 common bundle (`public/vendor/highlight/highlight.min.js`, ~127 KiB, 38 languages) via a `