mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
refactor: complete client-rendered shell cutover
This commit is contained in:
@@ -1712,6 +1712,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"selection": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/schemas/request/$defs/PaneSelectionReadParams"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Client-owned selection coordinates, validated against the pane's content revision."
|
||||
},
|
||||
"tab_id": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -2699,6 +2710,47 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PaneLinkActivateParams": {
|
||||
"properties": {
|
||||
"col": {
|
||||
"format": "uint16",
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"content_revision": {
|
||||
"format": "uint64",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"offset_from_bottom": {
|
||||
"format": "uint64",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"pane_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"viewport_row": {
|
||||
"format": "uint16",
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pane_id",
|
||||
"viewport_row",
|
||||
"col"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PaneListParams": {
|
||||
"properties": {
|
||||
"workspace_id": {
|
||||
@@ -5771,6 +5823,22 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"const": "pane.link.activate",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/schemas/request/$defs/PaneLinkActivateParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -10278,6 +10346,28 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"handled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": {
|
||||
"const": "pane_link_activated",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"handled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"logs": {
|
||||
|
||||
@@ -181,6 +181,8 @@ pub enum Method {
|
||||
PaneFocus(PaneTarget),
|
||||
#[serde(rename = "pane.input.set")]
|
||||
PaneInputSet(PaneInputSetParams),
|
||||
#[serde(rename = "pane.link.activate")]
|
||||
PaneLinkActivate(PaneLinkActivateParams),
|
||||
#[serde(rename = "pane.rename")]
|
||||
PaneRename(PaneRenameParams),
|
||||
#[serde(rename = "pane.send_text")]
|
||||
|
||||
@@ -10,4 +10,7 @@ pub struct CommandInvokeParams {
|
||||
pub tab_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pane_id: Option<String>,
|
||||
/// Client-owned selection coordinates, validated against the pane's content revision.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selection: Option<super::PaneSelectionReadParams>,
|
||||
}
|
||||
|
||||
@@ -120,14 +120,6 @@ impl NotificationShowSound {
|
||||
pub fn is_none(&self) -> bool {
|
||||
matches!(self, Self::None)
|
||||
}
|
||||
|
||||
pub fn to_sound(self) -> Option<crate::sound::Sound> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Done => Some(crate::sound::Sound::Done),
|
||||
Self::Request => Some(crate::sound::Sound::Request),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
|
||||
@@ -48,6 +48,17 @@ pub struct PaneInputSetParams {
|
||||
pub right_click: PaneRightClickTarget,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct PaneLinkActivateParams {
|
||||
pub pane_id: String,
|
||||
pub viewport_row: u16,
|
||||
pub col: u16,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content_revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offset_from_bottom: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PaneDirection {
|
||||
|
||||
@@ -274,6 +274,11 @@ pub enum ResponseResult {
|
||||
context: PluginInvocationContext,
|
||||
log: PluginCommandLogInfo,
|
||||
},
|
||||
PaneLinkActivated {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
handled: bool,
|
||||
},
|
||||
PluginLogList {
|
||||
logs: Vec<PluginCommandLogInfo>,
|
||||
},
|
||||
|
||||
@@ -313,6 +313,7 @@ fn command_invoke_request_round_trips_without_command_text() {
|
||||
workspace_id: Some("w1".into()),
|
||||
tab_id: Some("w1:t1".into()),
|
||||
pane_id: Some("w1:p1".into()),
|
||||
selection: None,
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
@@ -1311,6 +1312,32 @@ fn event_wait_parses_typed_match() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_link_activate_round_trips() {
|
||||
let request = Request {
|
||||
id: "req_pane_link".into(),
|
||||
method: Method::PaneLinkActivate(PaneLinkActivateParams {
|
||||
pane_id: "w1:p1".into(),
|
||||
viewport_row: 3,
|
||||
col: 7,
|
||||
content_revision: Some(42),
|
||||
offset_from_bottom: Some(5),
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(json["method"], "pane.link.activate");
|
||||
let restored: Request = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored, request);
|
||||
|
||||
let response = ResponseResult::PaneLinkActivated {
|
||||
url: Some("https://example.test".into()),
|
||||
handled: false,
|
||||
};
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
let restored: ResponseResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_action_list_and_invoke_round_trips() {
|
||||
let list = Request {
|
||||
|
||||
@@ -441,6 +441,7 @@ fn api_method_name(method: &Method) -> &'static str {
|
||||
Method::PaneGet(_) => "pane.get",
|
||||
Method::PaneFocus(_) => "pane.focus",
|
||||
Method::PaneInputSet(_) => "pane.input.set",
|
||||
Method::PaneLinkActivate(_) => "pane.link.activate",
|
||||
Method::PaneRename(_) => "pane.rename",
|
||||
Method::PaneSendText(_) => "pane.send_text",
|
||||
Method::PaneSendKeys(_) => "pane.send_keys",
|
||||
|
||||
+91
-2037
File diff suppressed because it is too large
Load Diff
@@ -357,7 +357,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
|
||||
+13
-14
@@ -7,7 +7,7 @@ use crate::api::schema::{
|
||||
};
|
||||
use crate::ui::AgentPanelEntry;
|
||||
|
||||
use super::{AppState, Mode};
|
||||
use super::AppState;
|
||||
|
||||
const MAX_FILTER_DEPTH: usize = 8;
|
||||
const MAX_FILTER_NODES: usize = 64;
|
||||
@@ -78,11 +78,7 @@ pub(crate) fn apply_agent_view(app: &AppState, entries: &mut Vec<AgentPanelEntry
|
||||
}
|
||||
|
||||
pub(crate) fn presented_workspace_idx(app: &AppState) -> Option<usize> {
|
||||
if app.mode == Mode::Navigate {
|
||||
app.workspaces.get(app.selected).map(|_| app.selected)
|
||||
} else {
|
||||
app.active
|
||||
}
|
||||
app.active
|
||||
}
|
||||
|
||||
fn normalize_source(source: &str) -> Result<String, String> {
|
||||
@@ -449,6 +445,10 @@ mod tests {
|
||||
state
|
||||
}
|
||||
|
||||
fn projected_entries(state: &AppState) -> Vec<crate::ui::AgentPanelEntry> {
|
||||
crate::ui::agent_panel_entries_from(state, &crate::terminal::TerminalRuntimeRegistry::new())
|
||||
}
|
||||
|
||||
fn current_workspace_view() -> AgentViewSetParams {
|
||||
AgentViewSetParams {
|
||||
source: "example.views".to_string(),
|
||||
@@ -468,16 +468,15 @@ mod tests {
|
||||
let mut state = state_with_agents();
|
||||
state.agent_view_override = Some(current_workspace_view());
|
||||
|
||||
assert_eq!(crate::ui::agent_panel_entries(&state)[0].ws_idx, 0);
|
||||
assert_eq!(projected_entries(&state)[0].ws_idx, 0);
|
||||
|
||||
state.mode = Mode::Navigate;
|
||||
state.selected = 1;
|
||||
let entries = crate::ui::agent_panel_entries(&state);
|
||||
state.active = Some(1);
|
||||
let entries = projected_entries(&state);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].ws_idx, 1);
|
||||
|
||||
state.mode = Mode::Settings;
|
||||
let entries = crate::ui::agent_panel_entries(&state);
|
||||
state.active = Some(0);
|
||||
let entries = projected_entries(&state);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].ws_idx, 0);
|
||||
}
|
||||
@@ -513,7 +512,7 @@ mod tests {
|
||||
}],
|
||||
});
|
||||
|
||||
let entries = crate::ui::agent_panel_entries(&state);
|
||||
let entries = projected_entries(&state);
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].ws_idx, 1);
|
||||
assert_eq!(entries[1].ws_idx, 0);
|
||||
@@ -547,7 +546,7 @@ mod tests {
|
||||
sort: Vec::new(),
|
||||
});
|
||||
|
||||
let entries = crate::ui::agent_panel_entries(&state);
|
||||
let entries = projected_entries(&state);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].agent_kind_label.as_deref(), Some("custom-agent"));
|
||||
}
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ impl App {
|
||||
self.state
|
||||
.focus_pane_in_workspace(resolved.ws_idx, resolved.pane_id);
|
||||
self.state.mark_active_tab_seen();
|
||||
self.state.settle_terminal_mode_after_focus();
|
||||
self.state.mode = crate::app::Mode::Terminal;
|
||||
self.agent_info(resolved.ws_idx, resolved.pane_id)
|
||||
.ok_or_else(|| TerminalTargetError::NotFound {
|
||||
target: target.to_string(),
|
||||
|
||||
+28
-68
@@ -28,36 +28,6 @@ enum RuntimeExitAction {
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn dispatch_api_request(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
method: crate::api::schema::Method,
|
||||
) -> String {
|
||||
self.handle_api_request(crate::api::schema::Request {
|
||||
id: id.to_string(),
|
||||
method,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_deferred_api_request(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
method: crate::api::schema::Method,
|
||||
) -> Option<String> {
|
||||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||||
if !self.handle_deferred_worktree_api_request(
|
||||
crate::api::schema::Request {
|
||||
id: id.to_string(),
|
||||
method,
|
||||
},
|
||||
respond_to,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
response_rx.try_recv().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn handle_internal_event_with_render_impact(&mut self, ev: AppEvent) -> bool {
|
||||
match ev {
|
||||
AppEvent::GitStatusRefreshed {
|
||||
@@ -115,9 +85,7 @@ impl App {
|
||||
) -> Vec<crate::app::actions::PaneStateUpdate> {
|
||||
if matches!(
|
||||
&ev,
|
||||
AppEvent::TerminalBell { .. }
|
||||
| AppEvent::ClipboardWrite { .. }
|
||||
| AppEvent::PrefixInputSource { .. }
|
||||
AppEvent::TerminalBell { .. } | AppEvent::ClipboardWrite { .. }
|
||||
) {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -173,12 +141,12 @@ impl App {
|
||||
}
|
||||
|
||||
if let AppEvent::WorktreeAddFinished(result) = ev {
|
||||
self.handle_worktree_add_finished(*result);
|
||||
self.handle_api_worktree_add_finished(*result);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if let AppEvent::WorktreeRemoveFinished(result) = ev {
|
||||
self.handle_worktree_remove_finished(*result);
|
||||
self.handle_api_worktree_remove_finished(*result);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
@@ -421,18 +389,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_clipboard_feedback(&mut self) {
|
||||
if !self.state.toast_config.clipboard.enabled {
|
||||
self.state.copy_feedback = None;
|
||||
self.copy_feedback_deadline = None;
|
||||
return;
|
||||
}
|
||||
self.state.copy_feedback = Some(crate::app::state::CopyFeedback {
|
||||
message: "copied to clipboard".to_string(),
|
||||
});
|
||||
self.copy_feedback_deadline = Some(Instant::now() + super::COPY_FEEDBACK_DURATION);
|
||||
}
|
||||
|
||||
fn restore_overlay_after_exit(
|
||||
&mut self,
|
||||
overlay: OverlayPaneState,
|
||||
@@ -701,7 +657,7 @@ impl App {
|
||||
self.sync_focus_events_with_outer_event(None);
|
||||
}
|
||||
|
||||
pub(super) fn send_outer_focus_event(&mut self, event: crate::ghostty::FocusEvent) {
|
||||
pub(crate) fn send_outer_focus_event(&mut self, event: crate::ghostty::FocusEvent) {
|
||||
self.sync_focus_events_with_outer_event(Some(event));
|
||||
}
|
||||
|
||||
@@ -780,6 +736,7 @@ impl App {
|
||||
runtime.try_send_focus_event(event);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String {
|
||||
self.drain_all_internal_events();
|
||||
self.handle_api_request_after_internal_events_drained(request)
|
||||
@@ -1015,6 +972,9 @@ impl App {
|
||||
Method::PaneGet(target) => return self.handle_pane_get(request.id, target),
|
||||
Method::PaneFocus(target) => return self.handle_pane_focus(request.id, target),
|
||||
Method::PaneInputSet(params) => return self.handle_pane_input_set(request.id, params),
|
||||
Method::PaneLinkActivate(params) => {
|
||||
return self.handle_pane_link_activate(request.id, params);
|
||||
}
|
||||
Method::PaneRename(params) => return self.handle_pane_rename(request.id, params),
|
||||
Method::PaneRead(params) => return self.handle_pane_read(request.id, params),
|
||||
Method::PaneGraphicsSet(params) => {
|
||||
@@ -1283,7 +1243,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1310,7 +1270,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1349,7 +1309,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1395,7 +1355,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1435,7 +1395,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1476,7 +1436,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1519,7 +1479,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1564,7 +1524,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1606,7 +1566,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1640,7 +1600,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1676,7 +1636,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1769,7 +1729,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1894,7 +1854,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
@@ -1931,7 +1891,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
@@ -1975,7 +1935,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
@@ -2026,7 +1986,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
@@ -2112,7 +2072,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -2162,7 +2122,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -2196,7 +2156,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -2219,7 +2179,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
|
||||
@@ -87,8 +87,6 @@ impl App {
|
||||
|
||||
fn replace_agent_view_override(&mut self, view: Option<AgentViewSetParams>) {
|
||||
self.state.agent_view_override = view;
|
||||
self.state.agent_panel_scroll = 0;
|
||||
self.state.mobile_switcher_scroll = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +101,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
|
||||
@@ -319,7 +319,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
|
||||
@@ -39,6 +39,7 @@ impl App {
|
||||
Ok(messages) => messages,
|
||||
Err(err) => return encode_error(id, "integration_install_failed", err.to_string()),
|
||||
};
|
||||
self.state.integration_recommendations = crate::integration::integration_recommendations();
|
||||
|
||||
encode_success(
|
||||
id,
|
||||
@@ -59,6 +60,7 @@ impl App {
|
||||
Ok(messages) => messages,
|
||||
Err(err) => return encode_error(id, "integration_uninstall_failed", err.to_string()),
|
||||
};
|
||||
self.state.integration_recommendations = crate::integration::integration_recommendations();
|
||||
|
||||
encode_success(
|
||||
id,
|
||||
|
||||
@@ -608,7 +608,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
|
||||
@@ -18,17 +18,7 @@ impl App {
|
||||
/// workspaces/tabs and panes hidden by zoom are not placeable. Short-lived UI modes do not
|
||||
/// suspend the producer because the pane becomes visible again without a layout event.
|
||||
fn pane_graphics_visible(&self, ws_idx: usize, pane_id: PaneId) -> bool {
|
||||
if self.state.active != Some(ws_idx) {
|
||||
return false;
|
||||
}
|
||||
let Some(tab) = self.state.workspaces[ws_idx].active_tab() else {
|
||||
return false;
|
||||
};
|
||||
if tab.zoomed {
|
||||
tab.layout.focused() == pane_id
|
||||
} else {
|
||||
tab.layout.pane_ids().contains(&pane_id)
|
||||
}
|
||||
self.state.pane_visible_on_active_surface(ws_idx, pane_id)
|
||||
}
|
||||
|
||||
pub(super) fn handle_pane_graphics_info(
|
||||
@@ -566,7 +556,7 @@ mod tests {
|
||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
rx,
|
||||
crate::api::EventHub::default(),
|
||||
|
||||
+85
-267
@@ -114,7 +114,7 @@ impl App {
|
||||
self.state.switch_workspace_tab(ws_idx, target_tab_idx);
|
||||
self.state
|
||||
.record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id);
|
||||
self.state.settle_terminal_mode_after_focus();
|
||||
self.state.mode = crate::app::Mode::Terminal;
|
||||
}
|
||||
self.terminal_runtimes
|
||||
.insert(new_pane.terminal.id.clone(), new_pane.runtime);
|
||||
@@ -206,26 +206,31 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_pane_selection_read(
|
||||
&mut self,
|
||||
id: String,
|
||||
params: PaneSelectionReadParams,
|
||||
) -> String {
|
||||
pub(crate) fn pane_selection_text(
|
||||
&self,
|
||||
params: &PaneSelectionReadParams,
|
||||
) -> Result<String, (&'static str, String)> {
|
||||
let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else {
|
||||
return pane_not_found(id, ¶ms.pane_id);
|
||||
return Err((
|
||||
"pane_not_found",
|
||||
format!("pane not found: {}", params.pane_id),
|
||||
));
|
||||
};
|
||||
let Some(runtime) =
|
||||
self.state
|
||||
.runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
|
||||
else {
|
||||
return pane_not_found(id, ¶ms.pane_id);
|
||||
return Err((
|
||||
"pane_not_found",
|
||||
format!("pane not found: {}", params.pane_id),
|
||||
));
|
||||
};
|
||||
let before = runtime.content_seq();
|
||||
if params
|
||||
.content_revision
|
||||
.is_some_and(|revision| revision != before || !before.is_multiple_of(2))
|
||||
{
|
||||
return encode_error(id, "stale_content", "pane content changed");
|
||||
return Err(("stale_content", "pane content changed".to_owned()));
|
||||
}
|
||||
let selection = crate::selection::Selection::absolute_range(
|
||||
pane_id,
|
||||
@@ -233,18 +238,32 @@ impl App {
|
||||
(params.cursor.row, params.cursor.col),
|
||||
);
|
||||
let Some(text) = runtime.extract_selection(&selection) else {
|
||||
return encode_error(id, "selection_unavailable", "selection text is unavailable");
|
||||
return Err((
|
||||
"selection_unavailable",
|
||||
"selection text is unavailable".to_owned(),
|
||||
));
|
||||
};
|
||||
if params.content_revision.is_some() && runtime.content_seq() != before {
|
||||
return encode_error(id, "stale_content", "pane content changed");
|
||||
return Err(("stale_content", "pane content changed".to_owned()));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub(super) fn handle_pane_selection_read(
|
||||
&mut self,
|
||||
id: String,
|
||||
params: PaneSelectionReadParams,
|
||||
) -> String {
|
||||
match self.pane_selection_text(¶ms) {
|
||||
Ok(text) => encode_success(
|
||||
id,
|
||||
ResponseResult::PaneSelection {
|
||||
pane_id: params.pane_id,
|
||||
text,
|
||||
},
|
||||
),
|
||||
Err((code, message)) => encode_error(id, code, message),
|
||||
}
|
||||
encode_success(
|
||||
id,
|
||||
ResponseResult::PaneSelection {
|
||||
pane_id: params.pane_id,
|
||||
text,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn handle_pane_copy_motion(
|
||||
@@ -287,10 +306,10 @@ impl App {
|
||||
};
|
||||
let col = match params.motion {
|
||||
PaneCopyMotion::LineEnd => {
|
||||
crate::app::input::copy_mode::last_character_col(&text).unwrap_or(0)
|
||||
crate::copy_mode::last_character_col(&text).unwrap_or(0)
|
||||
}
|
||||
PaneCopyMotion::FirstNonBlank => {
|
||||
crate::app::input::copy_mode::first_non_blank_col(&text).unwrap_or(0)
|
||||
crate::copy_mode::first_non_blank_col(&text).unwrap_or(0)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
@@ -456,7 +475,7 @@ impl App {
|
||||
|
||||
self.state.focus_pane_in_workspace(ws_idx, pane_id);
|
||||
self.state.mark_active_tab_seen();
|
||||
self.state.settle_terminal_mode_after_focus();
|
||||
self.state.mode = crate::app::Mode::Terminal;
|
||||
|
||||
let Some(pane) = self.pane_info(ws_idx, pane_id) else {
|
||||
return pane_not_found(id, &target.pane_id);
|
||||
@@ -648,7 +667,7 @@ impl App {
|
||||
if let Some(target_pane_id) = target {
|
||||
self.state.focus_pane_in_workspace(ws_idx, target_pane_id);
|
||||
self.state.switch_workspace_tab(ws_idx, tab_idx);
|
||||
self.state.settle_terminal_mode_after_focus();
|
||||
self.state.mode = crate::app::Mode::Terminal;
|
||||
}
|
||||
let focused_pane_id = self
|
||||
.state
|
||||
@@ -1231,7 +1250,7 @@ impl App {
|
||||
.switch_workspace_tab(target_ws_idx, target_tab_idx);
|
||||
self.state
|
||||
.record_pane_focus_change(previous_focus, target_ws_idx, moved_pane_id);
|
||||
self.state.settle_terminal_mode_after_focus();
|
||||
self.state.mode = crate::app::Mode::Terminal;
|
||||
}
|
||||
let created_workspace = created_workspace.then(|| self.workspace_info(target_ws_idx));
|
||||
let created_tab = if created_tab {
|
||||
@@ -1388,7 +1407,7 @@ impl App {
|
||||
if outcome.changed || outcome.focus_changed {
|
||||
self.schedule_session_save();
|
||||
}
|
||||
self.state.settle_terminal_mode_after_focus();
|
||||
self.state.mode = crate::app::Mode::Terminal;
|
||||
let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
|
||||
return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
|
||||
};
|
||||
@@ -2193,7 +2212,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -2802,7 +2821,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -2852,7 +2871,6 @@ mod tests {
|
||||
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
assert_eq!(success.id, "req");
|
||||
assert_eq!(app.state.request_remove_linked_worktree, None);
|
||||
assert!(app.state.workspaces.is_empty());
|
||||
}
|
||||
|
||||
@@ -2962,7 +2980,11 @@ mod tests {
|
||||
let source = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
|
||||
app.state.workspaces[0].tabs[0].layout.focus_pane(source);
|
||||
crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
||||
crate::ui::compute_view_with_runtime_registry(
|
||||
&mut app.state,
|
||||
&crate::terminal::TerminalRuntimeRegistry::new(),
|
||||
ratatui::layout::Rect::new(0, 0, 100, 20),
|
||||
);
|
||||
let source_public = app.public_pane_id(0, source).unwrap();
|
||||
let target_public = app.public_pane_id(0, target).unwrap();
|
||||
|
||||
@@ -2989,46 +3011,16 @@ mod tests {
|
||||
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_pane_swap_unfocused_source_updates_last_pane_history() {
|
||||
let mut app = app_with_linked_worktree();
|
||||
let source = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let focused = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
|
||||
let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Vertical);
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.workspaces[0].tabs[0].layout.focus_pane(focused);
|
||||
crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
||||
let source_public = app.public_pane_id(0, source).unwrap();
|
||||
let target_public = app.public_pane_id(0, target).unwrap();
|
||||
|
||||
let response = app.handle_pane_swap(
|
||||
"req".into(),
|
||||
PaneSwapParams {
|
||||
source_pane_id: Some(source_public),
|
||||
target_pane_id: Some(target_public),
|
||||
..PaneSwapParams::default()
|
||||
},
|
||||
);
|
||||
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
let ResponseResult::PaneSwap { swap } = success.result else {
|
||||
panic!("expected pane swap response");
|
||||
};
|
||||
assert!(swap.changed);
|
||||
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source));
|
||||
|
||||
app.state.last_pane();
|
||||
|
||||
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(focused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_pane_swap_direction_no_neighbor_returns_unchanged_layout() {
|
||||
let mut app = app_with_linked_worktree();
|
||||
let source = app.state.workspaces[0].tabs[0].root_pane;
|
||||
app.state.workspaces[0].tabs[0].layout.focus_pane(source);
|
||||
crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
||||
crate::ui::compute_view_with_runtime_registry(
|
||||
&mut app.state,
|
||||
&crate::terminal::TerminalRuntimeRegistry::new(),
|
||||
ratatui::layout::Rect::new(0, 0, 100, 20),
|
||||
);
|
||||
let source_public = app.public_pane_id(0, source).unwrap();
|
||||
|
||||
let response = app.handle_pane_swap(
|
||||
@@ -3184,124 +3176,6 @@ mod tests {
|
||||
Some(&source_terminal)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_pane_move_focuses_copy_mode_pane_back_into_copy_mode() {
|
||||
let mut app = app_with_linked_worktree();
|
||||
let source = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let target_tab = app.state.workspaces[0].test_add_tab(Some("target"));
|
||||
let target = app.state.workspaces[0].tabs[target_tab].root_pane;
|
||||
seed_terminal_states(&mut app);
|
||||
app.state.copy_mode = Some(crate::app::state::CopyModeState {
|
||||
pane_id: source,
|
||||
cursor_row: 0,
|
||||
cursor_col: 0,
|
||||
entry_offset_from_bottom: 0,
|
||||
selection: None,
|
||||
search: Default::default(),
|
||||
});
|
||||
let source_public = app.public_pane_id(0, source).unwrap();
|
||||
let target_public = app.public_pane_id(0, target).unwrap();
|
||||
let target_tab_public = app.public_tab_id(0, target_tab).unwrap();
|
||||
|
||||
let response = app.handle_pane_move(
|
||||
"req".into(),
|
||||
PaneMoveParams {
|
||||
pane_id: source_public,
|
||||
destination: PaneMoveDestination::Tab {
|
||||
tab_id: target_tab_public,
|
||||
target_pane_id: Some(target_public),
|
||||
split: SplitDirection::Right,
|
||||
ratio: None,
|
||||
},
|
||||
focus: true,
|
||||
},
|
||||
);
|
||||
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
let ResponseResult::PaneMove { move_result } = success.result else {
|
||||
panic!("expected pane move response");
|
||||
};
|
||||
assert!(move_result.changed);
|
||||
assert_eq!(app.state.mode, Mode::Copy);
|
||||
assert_eq!(app.state.copy_mode.expect("copy mode").pane_id, source);
|
||||
assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_release_follows_pane_moved_across_workspaces() {
|
||||
let mut app = app_with_linked_worktree();
|
||||
let source = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let source_terminal_id = app.state.workspaces[0].tabs[0]
|
||||
.terminal_id(source)
|
||||
.unwrap()
|
||||
.clone();
|
||||
let (runtime, mut rx) =
|
||||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||||
80,
|
||||
24,
|
||||
0,
|
||||
b"\x1b[>15u",
|
||||
2,
|
||||
);
|
||||
app.terminal_runtimes.insert(source_terminal_id, runtime);
|
||||
app.state.workspaces.push(Workspace::test_new("other"));
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
let source_public = app.public_pane_id(0, source).unwrap();
|
||||
let target = app.state.workspaces[1].tabs[0].root_pane;
|
||||
let target_tab_id = app.public_tab_id(1, 0).unwrap();
|
||||
let target_pane_id = app.public_pane_id(1, target).unwrap();
|
||||
|
||||
app.route_client_events_from(
|
||||
42,
|
||||
vec![crate::raw_input::RawInputEvent::Key(
|
||||
crate::input::TerminalKey::new(
|
||||
crossterm::event::KeyCode::Char('j'),
|
||||
crossterm::event::KeyModifiers::empty(),
|
||||
),
|
||||
)],
|
||||
false,
|
||||
);
|
||||
let response = app.handle_pane_move(
|
||||
"req".into(),
|
||||
PaneMoveParams {
|
||||
pane_id: source_public,
|
||||
destination: PaneMoveDestination::Tab {
|
||||
tab_id: target_tab_id,
|
||||
target_pane_id: Some(target_pane_id),
|
||||
split: SplitDirection::Down,
|
||||
ratio: None,
|
||||
},
|
||||
focus: false,
|
||||
},
|
||||
);
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
assert!(matches!(success.result, ResponseResult::PaneMove { .. }));
|
||||
app.route_client_events_from(
|
||||
42,
|
||||
vec![crate::raw_input::RawInputEvent::Key(
|
||||
crate::input::TerminalKey::new(
|
||||
crossterm::event::KeyCode::Char('j'),
|
||||
crossterm::event::KeyModifiers::empty(),
|
||||
)
|
||||
.with_kind(crossterm::event::KeyEventKind::Release),
|
||||
)],
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("forwarded press"),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:1u")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("forwarded release after pane move"),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:3u")
|
||||
);
|
||||
assert!(app.input_leases.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_id() {
|
||||
let mut app = app_with_linked_worktree();
|
||||
@@ -3862,86 +3736,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_pane_zoom_explicit_background_pane_updates_focus_history() {
|
||||
let mut app = app_with_linked_worktree();
|
||||
app.state.workspaces.push(Workspace::test_new("other"));
|
||||
let first = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let target = app.state.workspaces[1].tabs[0].root_pane;
|
||||
let _other = app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal);
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.workspaces[0].tabs[0].layout.focus_pane(first);
|
||||
let target_public = app.public_pane_id(1, target).unwrap();
|
||||
|
||||
let response = app.handle_pane_zoom(
|
||||
"req".into(),
|
||||
PaneZoomParams {
|
||||
pane_id: Some(target_public.clone()),
|
||||
mode: PaneZoomMode::On,
|
||||
},
|
||||
);
|
||||
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
let ResponseResult::PaneZoom { zoom } = success.result else {
|
||||
panic!("expected pane zoom response");
|
||||
};
|
||||
assert!(zoom.changed);
|
||||
assert!(zoom.zoom_changed);
|
||||
assert!(zoom.focus_changed);
|
||||
assert_eq!(zoom.pane_id, target_public);
|
||||
assert_eq!(app.state.active, Some(1));
|
||||
assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target));
|
||||
assert!(app.state.workspaces[1].tabs[0].zoomed);
|
||||
|
||||
app.state.last_pane();
|
||||
|
||||
assert_eq!(app.state.active, Some(0));
|
||||
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_pane_zoom_focuses_copy_mode_pane_back_into_copy_mode() {
|
||||
let mut app = app_with_linked_worktree();
|
||||
app.state.workspaces.push(Workspace::test_new("other"));
|
||||
let source = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let target = app.state.workspaces[1].tabs[0].root_pane;
|
||||
let _other = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
|
||||
let _target_other =
|
||||
app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal);
|
||||
app.state.workspaces[1].tabs[0].layout.focus_pane(target);
|
||||
app.state.active = Some(1);
|
||||
app.state.selected = 1;
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.state.copy_mode = Some(crate::app::state::CopyModeState {
|
||||
pane_id: source,
|
||||
cursor_row: 0,
|
||||
cursor_col: 0,
|
||||
entry_offset_from_bottom: 0,
|
||||
selection: None,
|
||||
search: Default::default(),
|
||||
});
|
||||
let source_public = app.public_pane_id(0, source).unwrap();
|
||||
|
||||
let response = app.handle_pane_zoom(
|
||||
"req".into(),
|
||||
PaneZoomParams {
|
||||
pane_id: Some(source_public),
|
||||
mode: PaneZoomMode::On,
|
||||
},
|
||||
);
|
||||
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
let ResponseResult::PaneZoom { zoom } = success.result else {
|
||||
panic!("expected pane zoom response");
|
||||
};
|
||||
assert!(zoom.focus_changed);
|
||||
assert_eq!(app.state.active, Some(0));
|
||||
assert_eq!(app.state.mode, Mode::Copy);
|
||||
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source));
|
||||
assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_pane_zoom_single_pane_returns_noop() {
|
||||
let mut app = app_with_linked_worktree();
|
||||
@@ -4112,7 +3906,11 @@ mod tests {
|
||||
let root = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
|
||||
app.state.workspaces[0].tabs[0].layout.focus_pane(root);
|
||||
crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
||||
crate::ui::compute_view_with_runtime_registry(
|
||||
&mut app.state,
|
||||
&crate::terminal::TerminalRuntimeRegistry::new(),
|
||||
ratatui::layout::Rect::new(0, 0, 100, 20),
|
||||
);
|
||||
let root_public = app.public_pane_id(0, root).unwrap();
|
||||
let right_public = app.public_pane_id(0, right).unwrap();
|
||||
|
||||
@@ -4143,7 +3941,11 @@ mod tests {
|
||||
let root = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
|
||||
app.state.workspaces[0].tabs[0].layout.focus_pane(root);
|
||||
crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
||||
crate::ui::compute_view_with_runtime_registry(
|
||||
&mut app.state,
|
||||
&crate::terminal::TerminalRuntimeRegistry::new(),
|
||||
ratatui::layout::Rect::new(0, 0, 100, 20),
|
||||
);
|
||||
let root_public = app.public_pane_id(0, root).unwrap();
|
||||
let right_public = app.public_pane_id(0, right).unwrap();
|
||||
|
||||
@@ -4170,7 +3972,11 @@ mod tests {
|
||||
let root = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
|
||||
app.state.workspaces[0].tabs[0].layout.focus_pane(root);
|
||||
crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
||||
crate::ui::compute_view_with_runtime_registry(
|
||||
&mut app.state,
|
||||
&crate::terminal::TerminalRuntimeRegistry::new(),
|
||||
ratatui::layout::Rect::new(0, 0, 100, 20),
|
||||
);
|
||||
let right_public = app.public_pane_id(0, right).unwrap();
|
||||
|
||||
let response = app.handle_pane_edges(
|
||||
@@ -4197,7 +4003,11 @@ mod tests {
|
||||
let root = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
|
||||
app.state.workspaces[0].tabs[0].layout.focus_pane(right);
|
||||
crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
||||
crate::ui::compute_view_with_runtime_registry(
|
||||
&mut app.state,
|
||||
&crate::terminal::TerminalRuntimeRegistry::new(),
|
||||
ratatui::layout::Rect::new(0, 0, 100, 20),
|
||||
);
|
||||
let root_public = app.public_pane_id(0, root).unwrap();
|
||||
let right_public = app.public_pane_id(0, right).unwrap();
|
||||
|
||||
@@ -4235,7 +4045,11 @@ mod tests {
|
||||
let root = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
|
||||
app.state.workspaces[0].tabs[0].layout.focus_pane(root);
|
||||
crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
||||
crate::ui::compute_view_with_runtime_registry(
|
||||
&mut app.state,
|
||||
&crate::terminal::TerminalRuntimeRegistry::new(),
|
||||
ratatui::layout::Rect::new(0, 0, 100, 20),
|
||||
);
|
||||
let root_public = app.public_pane_id(0, root).unwrap();
|
||||
let right_public = app.public_pane_id(0, right).unwrap();
|
||||
|
||||
@@ -4343,7 +4157,11 @@ mod tests {
|
||||
let mut app = app_with_linked_worktree();
|
||||
let root = app.state.workspaces[0].tabs[0].root_pane;
|
||||
app.state.workspaces[0].tabs[0].layout.focus_pane(root);
|
||||
crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
|
||||
crate::ui::compute_view_with_runtime_registry(
|
||||
&mut app.state,
|
||||
&crate::terminal::TerminalRuntimeRegistry::new(),
|
||||
ratatui::layout::Rect::new(0, 0, 100, 20),
|
||||
);
|
||||
let root_public = app.public_pane_id(0, root).unwrap();
|
||||
|
||||
let response = app.handle_pane_focus_direction(
|
||||
|
||||
@@ -357,10 +357,6 @@ impl App {
|
||||
.as_ref()
|
||||
.and_then(|pane| pane.cwd.clone())
|
||||
.or_else(|| Some(self.default_cwd_for_workspace(ws_idx).display().to_string()));
|
||||
let selected_text = focused_pane
|
||||
.as_ref()
|
||||
.and_then(|pane| self.parse_pane_id(&pane.pane_id))
|
||||
.and_then(|(_, pane_id)| self.selected_text_for_pane(pane_id));
|
||||
PluginInvocationContext {
|
||||
workspace_id: Some(workspace.workspace_id),
|
||||
workspace_label: Some(workspace.label),
|
||||
@@ -372,7 +368,9 @@ impl App {
|
||||
focused_pane_cwd: focused_pane.as_ref().and_then(|pane| pane.cwd.clone()),
|
||||
focused_pane_agent: focused_pane.as_ref().and_then(|pane| pane.agent.clone()),
|
||||
focused_pane_status: focused_pane.as_ref().map(|pane| pane.agent_status),
|
||||
selected_text,
|
||||
// Selection is client presentation state. Client keybindings provide
|
||||
// revision-validated coordinates; API callers can provide explicit context.
|
||||
selected_text: None,
|
||||
invocation_source: Some("api".to_string()),
|
||||
correlation_id: Some(correlation_id.to_string()),
|
||||
clicked_url: None,
|
||||
@@ -380,22 +378,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_text_for_pane(&self, pane_id: crate::layout::PaneId) -> Option<String> {
|
||||
let selection = self.state.selection.as_ref()?;
|
||||
if selection.pane_id != pane_id || !selection.is_visible() {
|
||||
return None;
|
||||
}
|
||||
let terminal_id = self
|
||||
.state
|
||||
.workspaces
|
||||
.iter()
|
||||
.find_map(|workspace| workspace.terminal_id(pane_id))?;
|
||||
self.terminal_runtimes
|
||||
.get(terminal_id)
|
||||
.and_then(|runtime| runtime.extract_selection(selection))
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
fn default_cwd_for_workspace(&self, ws_idx: usize) -> std::path::PathBuf {
|
||||
self.state
|
||||
.workspaces
|
||||
|
||||
+104
-92
@@ -6,11 +6,11 @@ mod runtime;
|
||||
|
||||
use super::responses::{encode_error, encode_success};
|
||||
use crate::api::schema::{
|
||||
InstalledPluginInfo, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams,
|
||||
PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction,
|
||||
PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo,
|
||||
PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, PluginUnlinkParams,
|
||||
ResponseResult,
|
||||
InstalledPluginInfo, PaneLinkActivateParams, PluginActionInfo, PluginActionInvokeParams,
|
||||
PluginActionListParams, PluginLinkParams, PluginListParams, PluginLogListParams,
|
||||
PluginManifestAction, PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams,
|
||||
PluginPaneInfo, PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams,
|
||||
PluginUnlinkParams, ResponseResult,
|
||||
};
|
||||
use crate::app::App;
|
||||
pub(super) use manifest::normalize_plugin_id;
|
||||
@@ -37,7 +37,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn refresh_installed_plugins(&mut self) -> std::io::Result<()> {
|
||||
if self.no_session {
|
||||
if !self.policy.persist_plugin_registry {
|
||||
return Ok(());
|
||||
}
|
||||
let entries = crate::persist::plugin_registry::try_load()?;
|
||||
@@ -49,7 +49,7 @@ impl App {
|
||||
&mut self,
|
||||
mutation: impl FnOnce(&mut crate::app::state::InstalledPluginRegistry) -> T,
|
||||
) -> std::io::Result<T> {
|
||||
if self.no_session {
|
||||
if !self.policy.persist_plugin_registry {
|
||||
return Ok(mutation(&mut self.state.installed_plugins));
|
||||
}
|
||||
let (result, entries) = crate::persist::plugin_registry::update(|entries| {
|
||||
@@ -225,6 +225,7 @@ impl App {
|
||||
pub(crate) fn invoke_plugin_action_from_keybind(
|
||||
&mut self,
|
||||
action_id: String,
|
||||
selected_text: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
self.refresh_installed_plugins()
|
||||
.map_err(|err| format!("failed to load plugin registry: {err}"))?;
|
||||
@@ -241,6 +242,7 @@ impl App {
|
||||
.map_err(|(_, message)| message)?;
|
||||
let mut context = self.current_plugin_context("keybinding");
|
||||
context.invocation_source = Some("keybinding".to_string());
|
||||
context.selected_text = selected_text;
|
||||
self.start_plugin_command(
|
||||
&plugin,
|
||||
Some(action.action_id),
|
||||
@@ -253,6 +255,80 @@ impl App {
|
||||
.map_err(|(_, message)| message)
|
||||
}
|
||||
|
||||
pub(super) fn handle_pane_link_activate(
|
||||
&mut self,
|
||||
id: String,
|
||||
params: PaneLinkActivateParams,
|
||||
) -> String {
|
||||
let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else {
|
||||
return encode_error(id, "pane_not_found", "pane not found");
|
||||
};
|
||||
if !self.state.pane_visible_on_active_surface(ws_idx, pane_id) {
|
||||
return encode_error(id, "stale_target", "pane is no longer visible");
|
||||
}
|
||||
let Some(runtime) =
|
||||
self.state
|
||||
.runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
|
||||
else {
|
||||
return encode_error(id, "pane_not_found", "pane runtime not found");
|
||||
};
|
||||
let current_offset = runtime
|
||||
.scroll_metrics()
|
||||
.map(|metrics| metrics.offset_from_bottom as u64);
|
||||
if params
|
||||
.offset_from_bottom
|
||||
.is_some_and(|expected| current_offset != Some(expected))
|
||||
{
|
||||
return encode_error(
|
||||
id,
|
||||
"stale_content",
|
||||
"pane viewport changed before link activation",
|
||||
);
|
||||
}
|
||||
let content_revision = runtime.content_seq();
|
||||
if content_revision % 2 != 0
|
||||
|| params
|
||||
.content_revision
|
||||
.is_some_and(|expected| expected != content_revision)
|
||||
{
|
||||
return encode_error(
|
||||
id,
|
||||
"stale_content",
|
||||
"pane content changed before link activation",
|
||||
);
|
||||
}
|
||||
let url = self.state.url_at_pane_surface_cell(
|
||||
&self.terminal_runtimes,
|
||||
ws_idx,
|
||||
pane_id,
|
||||
params.viewport_row,
|
||||
params.col,
|
||||
);
|
||||
if runtime.content_seq() != content_revision
|
||||
|| runtime
|
||||
.scroll_metrics()
|
||||
.map(|metrics| metrics.offset_from_bottom as u64)
|
||||
!= current_offset
|
||||
{
|
||||
return encode_error(
|
||||
id,
|
||||
"stale_content",
|
||||
"pane content or viewport changed during link activation",
|
||||
);
|
||||
}
|
||||
let handled = match url.as_deref() {
|
||||
Some(url) => match self.invoke_plugin_link_handler_for_url(url, pane_id) {
|
||||
Ok(handled) => handled,
|
||||
Err(err) => {
|
||||
tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler");
|
||||
false
|
||||
}
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
encode_success(id, ResponseResult::PaneLinkActivated { url, handled })
|
||||
}
|
||||
|
||||
pub(crate) fn invoke_plugin_link_handler_for_url(
|
||||
&mut self,
|
||||
url: &str,
|
||||
@@ -392,13 +468,8 @@ impl App {
|
||||
"width and height are only supported when placement is popup",
|
||||
);
|
||||
}
|
||||
if placement == PluginPanePlacement::Popup && self.state.mode != crate::app::Mode::Terminal
|
||||
{
|
||||
return encode_error(
|
||||
id,
|
||||
"ui_busy",
|
||||
"popup panes can only open from the normal workspace view",
|
||||
);
|
||||
if placement == PluginPanePlacement::Popup && self.state.popup_pane.is_some() {
|
||||
return encode_error(id, "ui_busy", "a popup pane is already open");
|
||||
}
|
||||
match placement {
|
||||
PluginPanePlacement::Overlay | PluginPanePlacement::Popup => {
|
||||
@@ -457,7 +528,7 @@ impl App {
|
||||
return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
|
||||
}
|
||||
self.state.focus_pane_in_workspace(ws_idx, pane_id);
|
||||
self.state.settle_terminal_mode_after_focus();
|
||||
self.state.mode = crate::app::Mode::Terminal;
|
||||
let Some(record) = self.state.plugin_panes.get(&pane_id).cloned() else {
|
||||
return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
|
||||
};
|
||||
@@ -716,7 +787,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -1395,69 +1466,6 @@ platforms = ["linux", "macos"]
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_pane_open_popup_preserves_existing_ui_modes() {
|
||||
let mut app = test_app();
|
||||
app.state.workspaces = vec![crate::workspace::Workspace::test_new("modal")];
|
||||
app.state.ensure_test_terminals();
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
let root_pane = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let root = unique_temp_path("plugin-popup-ui-busy");
|
||||
write_manifest(&root);
|
||||
link_manifest(&mut app, &root);
|
||||
|
||||
let open_popup = |app: &mut App, id: &str| {
|
||||
app.handle_api_request(Request {
|
||||
id: id.into(),
|
||||
method: Method::PluginPaneOpen(PluginPaneOpenParams {
|
||||
plugin_id: "example.worktree-bootstrap".into(),
|
||||
entrypoint: "board".into(),
|
||||
placement: Some(PluginPanePlacement::Popup),
|
||||
width: None,
|
||||
height: None,
|
||||
workspace_id: None,
|
||||
target_pane_id: None,
|
||||
direction: None,
|
||||
cwd: None,
|
||||
focus: true,
|
||||
env: std::collections::HashMap::new(),
|
||||
}),
|
||||
})
|
||||
};
|
||||
|
||||
app.state.mode = crate::app::Mode::Settings;
|
||||
app.state.settings.original_theme = Some("settings-theme".into());
|
||||
let settings_response = open_popup(&mut app, "settings-popup");
|
||||
let settings_error: serde_json::Value = serde_json::from_str(&settings_response).unwrap();
|
||||
assert_eq!(settings_error["error"]["code"], "ui_busy");
|
||||
assert_eq!(app.state.mode, crate::app::Mode::Settings);
|
||||
assert_eq!(
|
||||
app.state.settings.original_theme.as_deref(),
|
||||
Some("settings-theme")
|
||||
);
|
||||
assert!(app.state.popup_pane.is_none());
|
||||
|
||||
let copy_mode = crate::app::state::CopyModeState {
|
||||
pane_id: root_pane,
|
||||
cursor_row: 2,
|
||||
cursor_col: 3,
|
||||
entry_offset_from_bottom: 4,
|
||||
selection: None,
|
||||
search: crate::app::state::CopyModeSearchState::default(),
|
||||
};
|
||||
app.state.mode = crate::app::Mode::Copy;
|
||||
app.state.copy_mode = Some(copy_mode.clone());
|
||||
let copy_response = open_popup(&mut app, "copy-popup");
|
||||
let copy_error: serde_json::Value = serde_json::from_str(©_response).unwrap();
|
||||
assert_eq!(copy_error["error"]["code"], "ui_busy");
|
||||
assert_eq!(app.state.mode, crate::app::Mode::Copy);
|
||||
assert_eq!(app.state.copy_mode, Some(copy_mode));
|
||||
assert!(app.state.popup_pane.is_none());
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() {
|
||||
@@ -1681,7 +1689,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PL
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
@@ -1764,7 +1772,7 @@ command = ["sh", "-c", "sleep 1"]
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
@@ -1843,7 +1851,7 @@ command = ["sh", "-c", "sleep 1"]
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
@@ -1922,7 +1930,7 @@ command = ["sh", "-c", "sleep 1"]
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
@@ -1958,8 +1966,8 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"]
|
||||
write_manifest_content(&root, &manifest);
|
||||
link_manifest(&mut app, &root);
|
||||
|
||||
let open = app.handle_api_request(Request {
|
||||
id: "pane-open-popup".into(),
|
||||
let popup_request = |id: &str| Request {
|
||||
id: id.into(),
|
||||
method: Method::PluginPaneOpen(PluginPaneOpenParams {
|
||||
plugin_id: "example.popup".into(),
|
||||
entrypoint: "board".into(),
|
||||
@@ -1973,8 +1981,14 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"]
|
||||
focus: true,
|
||||
env: std::collections::HashMap::new(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
app.state.mode = crate::app::Mode::Navigate;
|
||||
let open = app.handle_api_request(popup_request("pane-open-popup"));
|
||||
assert_eq!(response_result(&open), ResponseResult::Ok {});
|
||||
let duplicate = app.handle_api_request(popup_request("pane-open-popup-duplicate"));
|
||||
let duplicate: crate::api::schema::ErrorResponse =
|
||||
serde_json::from_str(&duplicate).unwrap();
|
||||
assert_eq!(duplicate.error.code, "ui_busy");
|
||||
assert_eq!(
|
||||
read_capture_when_ready(&env_capture, || {
|
||||
app.drain_internal_events();
|
||||
@@ -2184,7 +2198,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"]
|
||||
.unwrap();
|
||||
|
||||
let mut app = test_app();
|
||||
app.no_session = false;
|
||||
app.policy.persist_plugin_registry = true;
|
||||
let workspace = crate::workspace::Workspace::test_new("plugin-refresh");
|
||||
let pane_id = workspace.tabs[0].root_pane;
|
||||
app.state.workspaces = vec![workspace];
|
||||
@@ -2202,7 +2216,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"]
|
||||
|
||||
make_stale(&mut app);
|
||||
assert!(app
|
||||
.invoke_plugin_action_from_keybind("bootstrap".into())
|
||||
.invoke_plugin_action_from_keybind("bootstrap".into(), None)
|
||||
.unwrap_err()
|
||||
.contains("disabled"));
|
||||
|
||||
@@ -2419,7 +2433,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_plugin_context_includes_selected_text_for_focused_pane() {
|
||||
async fn current_plugin_context_leaves_client_owned_selection_empty() {
|
||||
let mut app = test_app();
|
||||
let workspace = crate::workspace::Workspace::test_new("plugin-selection");
|
||||
let pane_id = workspace.tabs[0].root_pane;
|
||||
@@ -2433,11 +2447,9 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG
|
||||
terminal_id,
|
||||
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hello plugin\n"),
|
||||
);
|
||||
app.state.selection = Some(crate::selection::Selection::range(pane_id, 0, 0, 4, None));
|
||||
|
||||
let context = app.current_plugin_context("selection-test");
|
||||
|
||||
assert_eq!(context.selected_text.as_deref(), Some("hello"));
|
||||
assert_eq!(context.selected_text, None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -66,7 +66,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = crate::app::App::new(
|
||||
&Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
|
||||
+21
-45
@@ -157,13 +157,6 @@ impl App {
|
||||
};
|
||||
tab.set_custom_name(params.label.clone());
|
||||
crate::logging::tab_renamed(&workspace_id, &tab_id);
|
||||
if self.state.active == Some(ws_idx) {
|
||||
// Reflow the tab bar so the new label width takes effect immediately.
|
||||
// The tab bar renders into cached hit areas; without this refresh the
|
||||
// old geometry lingers until the next refresh (e.g. a tab switch),
|
||||
// leaving the visible label stale. Mirrors handle_tab_move.
|
||||
self.state.refresh_tab_bar_view();
|
||||
}
|
||||
self.schedule_session_save();
|
||||
self.emit_event(EventEnvelope {
|
||||
event: EventKind::TabRenamed,
|
||||
@@ -206,10 +199,6 @@ impl App {
|
||||
let tabs = self.tab_list_info(ws_idx);
|
||||
if moved {
|
||||
self.schedule_session_save();
|
||||
if self.state.active == Some(ws_idx) {
|
||||
self.state.tab_scroll_follow_active = true;
|
||||
self.state.refresh_tab_bar_view();
|
||||
}
|
||||
self.emit_event(EventEnvelope {
|
||||
event: EventKind::TabMoved,
|
||||
data: EventData::TabMoved {
|
||||
@@ -337,7 +326,13 @@ mod tests {
|
||||
fn api_tab_close_last_tab_closes_workspace_and_emits_both_events() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
);
|
||||
app.state.workspaces = vec![Workspace::test_new("tabs")];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
@@ -384,7 +379,13 @@ mod tests {
|
||||
fn api_tab_move_reorders_tabs_in_target_workspace() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
);
|
||||
let mut workspace = Workspace::test_new("tabs");
|
||||
workspace.test_add_tab(Some("two"));
|
||||
workspace.test_add_tab(Some("three"));
|
||||
@@ -424,42 +425,17 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_tab_rename_reflows_active_tab_bar() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
|
||||
let workspace = Workspace::test_new("tabs");
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.view.tab_bar_rect = ratatui::layout::Rect::new(0, 0, 60, 1);
|
||||
app.state.refresh_tab_bar_view();
|
||||
|
||||
let tab_id = app.public_tab_id(0, 0).unwrap();
|
||||
let width_before = app.state.view.tab_hit_areas[0].width;
|
||||
|
||||
app.handle_tab_rename(
|
||||
"req".into(),
|
||||
TabRenameParams {
|
||||
tab_id,
|
||||
label: "a much longer custom tab label".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let width_after = app.state.view.tab_hit_areas[0].width;
|
||||
assert!(
|
||||
width_after > width_before,
|
||||
"tab bar should reflow to the new label width immediately: \
|
||||
before={width_before}, after={width_after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tab_create_follows_cached_focused_pane_cwd_without_runtime() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub,
|
||||
);
|
||||
app.state.default_shell = exiting_test_command().into();
|
||||
app.state.shell_mode = ShellModeConfig::NonLogin;
|
||||
let workspace = Workspace::test_new("tabs");
|
||||
|
||||
+45
-10
@@ -390,7 +390,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -470,7 +470,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -554,7 +554,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -710,7 +710,6 @@ mod tests {
|
||||
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
assert_eq!(success.id, "req");
|
||||
assert_eq!(app.state.request_remove_linked_worktree, None);
|
||||
assert_eq!(app.state.workspaces.len(), 1);
|
||||
assert_eq!(app.state.workspaces[0].display_name(), "parent");
|
||||
}
|
||||
@@ -719,7 +718,13 @@ mod tests {
|
||||
fn api_workspace_close_event_includes_final_worktree_snapshot() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
);
|
||||
app.state.workspaces = app_with_linked_worktree().state.workspaces;
|
||||
let workspace_id = app.state.workspaces[0].id.clone();
|
||||
|
||||
@@ -753,7 +758,13 @@ mod tests {
|
||||
fn workspace_metadata_tokens_patch_clear_and_emit_snapshot() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
);
|
||||
app.state.workspaces = vec![Workspace::test_new("one")];
|
||||
let workspace_id = app.public_workspace_id(0);
|
||||
|
||||
@@ -805,7 +816,13 @@ mod tests {
|
||||
fn workspace_token_ttl_expires_through_runtime_and_emits_update() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
);
|
||||
app.state.workspaces = vec![Workspace::test_new("one")];
|
||||
let workspace_id = app.public_workspace_id(0);
|
||||
let response = app.handle_workspace_report_metadata(
|
||||
@@ -837,7 +854,13 @@ mod tests {
|
||||
fn api_workspace_move_reorders_workspaces() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
);
|
||||
app.state.workspaces = vec![
|
||||
Workspace::test_new("one"),
|
||||
Workspace::test_new("two"),
|
||||
@@ -879,7 +902,13 @@ mod tests {
|
||||
fn api_workspace_move_block_reorders_atomically() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
);
|
||||
app.state.workspaces = vec![
|
||||
Workspace::test_new("child"),
|
||||
Workspace::test_new("normal"),
|
||||
@@ -932,7 +961,13 @@ mod tests {
|
||||
fn api_workspace_move_noop_does_not_emit_event() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
);
|
||||
app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
|
||||
let moved_id = app.public_workspace_id(0);
|
||||
|
||||
|
||||
@@ -638,14 +638,6 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn emit_worktree_opened_for_workspace(&mut self, ws_idx: usize, already_open: bool) {
|
||||
let Some(worktree) = self.worktree_info_for_workspace(ws_idx) else {
|
||||
return;
|
||||
};
|
||||
self.emit_worktree_opened_event(ws_idx, worktree, already_open);
|
||||
}
|
||||
|
||||
fn emit_worktree_opened_event(
|
||||
&mut self,
|
||||
ws_idx: usize,
|
||||
@@ -802,7 +794,13 @@ mod tests {
|
||||
|
||||
fn test_app_with_event_hub(event_hub: crate::api::EventHub) -> App {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
App::new(&Config::default(), true, None, api_rx, event_hub)
|
||||
App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -936,6 +934,7 @@ mod tests {
|
||||
assert_eq!(tab.workspace_id, workspace.workspace_id);
|
||||
assert_eq!(root_pane.workspace_id, workspace.workspace_id);
|
||||
assert_eq!(worktree.branch.as_deref(), Some("worktree/api-create"));
|
||||
assert!(Path::new(&worktree.path).starts_with(&worktree_root));
|
||||
assert!(Path::new(&worktree.path).join("README.md").exists());
|
||||
assert_eq!(app.state.workspaces.len(), 2);
|
||||
assert!(
|
||||
|
||||
@@ -377,12 +377,6 @@ impl App {
|
||||
self.pending_api_worktree_creates.remove(&checkout_key);
|
||||
|
||||
if let Err(err) = result.result {
|
||||
if let Some(create) = &mut self.state.worktree_create {
|
||||
if create.checkout_path == result.path {
|
||||
create.creating = false;
|
||||
create.error = Some(err.clone());
|
||||
}
|
||||
}
|
||||
Self::send_api_response(
|
||||
api.respond_to,
|
||||
encode_error(api.id, "worktree_create_failed", err),
|
||||
@@ -438,17 +432,6 @@ impl App {
|
||||
ws.set_custom_name(label);
|
||||
}
|
||||
}
|
||||
if self
|
||||
.state
|
||||
.worktree_create
|
||||
.as_ref()
|
||||
.is_some_and(|create| create.checkout_path == result.path)
|
||||
{
|
||||
self.state.worktree_create = None;
|
||||
self.state.name_input.clear();
|
||||
self.state.name_input_replace_on_type = false;
|
||||
self.state.mode = crate::app::Mode::Terminal;
|
||||
}
|
||||
self.state.mark_session_dirty();
|
||||
if created_workspace {
|
||||
self.emit_workspace_open_events(ws_idx);
|
||||
@@ -520,17 +503,6 @@ impl App {
|
||||
} else {
|
||||
"worktree_remove_failed"
|
||||
};
|
||||
if let Some(remove) = &mut self.state.worktree_remove {
|
||||
if remove.workspace_id == result.workspace_id && remove.path == result.path {
|
||||
remove.removing = false;
|
||||
if code == "dirty_worktree_requires_force" && !remove.force_confirmation {
|
||||
remove.force_confirmation = true;
|
||||
remove.error = None;
|
||||
} else {
|
||||
remove.error = Some(message.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::send_api_response(api.respond_to, encode_error(api.id, code, message));
|
||||
return;
|
||||
}
|
||||
@@ -592,16 +564,6 @@ impl App {
|
||||
worktree,
|
||||
result.forced,
|
||||
);
|
||||
if self.state.worktree_remove.as_ref().is_some_and(|remove| {
|
||||
remove.workspace_id == result.workspace_id && remove.path == result.path
|
||||
}) {
|
||||
self.state.worktree_remove = None;
|
||||
self.state.mode = if self.state.active.is_some() {
|
||||
crate::app::Mode::Terminal
|
||||
} else {
|
||||
crate::app::Mode::Navigate
|
||||
};
|
||||
}
|
||||
let response = encode_success(
|
||||
api.id,
|
||||
ResponseResult::WorktreeRemoved {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
pub(super) fn update_config_file<F>(&mut self, error_context: &str, update: F) -> bool
|
||||
where
|
||||
F: FnOnce(&str) -> String,
|
||||
{
|
||||
#[cfg(test)]
|
||||
if std::env::var_os(crate::config::CONFIG_PATH_ENV_VAR).is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Err(err) = crate::config::update_file(error_context, update) {
|
||||
let path = crate::config::config_path();
|
||||
crate::logging::config_write_failed(&path, error_context, &err);
|
||||
self.state.config_diagnostic = Some(err);
|
||||
self.config_diagnostic_deadline =
|
||||
Some(std::time::Instant::now() + std::time::Duration::from_secs(5));
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn mark_onboarding_complete(&mut self) {
|
||||
self.update_config_file("onboarding setting", |content| {
|
||||
crate::config::upsert_top_level_bool(content, "onboarding", false)
|
||||
});
|
||||
}
|
||||
|
||||
fn save_config_edit(&mut self, edit: crate::config::ConfigEdit<'_>) {
|
||||
if self.update_config_file(edit.description(), |content| edit.apply(content)) {
|
||||
self.apply_config_from_disk(false);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn save_theme(&mut self, name: &str) {
|
||||
self.save_config_edit(crate::config::ConfigEdit::Theme(name));
|
||||
}
|
||||
|
||||
pub(super) fn save_status_indicators(&mut self, style: crate::config::StatusIndicatorStyle) {
|
||||
self.save_config_edit(crate::config::ConfigEdit::StatusIndicators(style));
|
||||
}
|
||||
|
||||
pub(super) fn save_sound(&mut self, enabled: bool) {
|
||||
self.save_config_edit(crate::config::ConfigEdit::Sound(enabled));
|
||||
}
|
||||
|
||||
pub(super) fn save_toast_delivery(&mut self, delivery: crate::config::ToastDelivery) {
|
||||
self.save_config_edit(crate::config::ConfigEdit::ToastDelivery(delivery));
|
||||
}
|
||||
|
||||
pub(super) fn save_agent_border_labels(&mut self, enabled: bool) {
|
||||
self.save_config_edit(crate::config::ConfigEdit::AgentBorderLabels(enabled));
|
||||
}
|
||||
|
||||
pub(super) fn save_agent_panel_sort(&mut self, sort: crate::app::state::AgentPanelSort) {
|
||||
let sort = match sort {
|
||||
crate::app::state::AgentPanelSort::Spaces => {
|
||||
crate::config::AgentPanelSortConfig::Spaces
|
||||
}
|
||||
crate::app::state::AgentPanelSort::Priority => {
|
||||
crate::config::AgentPanelSortConfig::Priority
|
||||
}
|
||||
};
|
||||
self.save_config_edit(crate::config::ConfigEdit::AgentPanelSort(sort));
|
||||
}
|
||||
}
|
||||
+1
-138
@@ -1,13 +1,10 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::api::schema::{EventData, EventEnvelope, EventKind};
|
||||
#[cfg(test)]
|
||||
use tracing::error;
|
||||
|
||||
use super::{
|
||||
api_helpers::{pane_agent_status, tab_attention_priority},
|
||||
App, Mode,
|
||||
};
|
||||
use crate::api::schema::{EventData, EventEnvelope, EventKind};
|
||||
use crate::{config::NewTerminalCwdConfig, workspace::Workspace};
|
||||
|
||||
pub(crate) fn resolve_new_terminal_cwd(
|
||||
@@ -103,129 +100,6 @@ impl App {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn begin_tui_workspace_create(&mut self, request_id: &'static str) {
|
||||
if self.state.prompt_new_workspace_name {
|
||||
let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| {
|
||||
self.focused_pane_cwd_in_workspace(ws_idx)
|
||||
.or_else(|| self.seed_cwd_from_workspace(ws_idx))
|
||||
});
|
||||
let cwd = self.resolve_new_terminal_cwd(follow_cwd);
|
||||
super::input::open_new_workspace_dialog(&mut self.state, cwd);
|
||||
return;
|
||||
}
|
||||
|
||||
self.runtime_workspace_create(
|
||||
request_id,
|
||||
crate::api::schema::WorkspaceCreateParams {
|
||||
source_workspace_id: None,
|
||||
cwd: None,
|
||||
focus: true,
|
||||
label: None,
|
||||
env: Default::default(),
|
||||
},
|
||||
);
|
||||
self.state.mode = if self.state.active.is_some() {
|
||||
Mode::Terminal
|
||||
} else {
|
||||
Mode::Navigate
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a workspace with a real PTY (needs event_tx).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn create_workspace(&mut self) {
|
||||
let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| {
|
||||
self.focused_pane_cwd_in_workspace(ws_idx)
|
||||
.or_else(|| self.seed_cwd_from_workspace(ws_idx))
|
||||
});
|
||||
let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd);
|
||||
if let Err(e) = self.create_workspace_with_events(initial_cwd, true) {
|
||||
error!(err = %e, "failed to create workspace");
|
||||
self.state.mode = Mode::Navigate;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn create_tab(&mut self) {
|
||||
let custom_name = self.state.requested_new_tab_name.take();
|
||||
let active_before = self.state.active;
|
||||
let follow_cwd = self.state.active.and_then(|ws_idx| {
|
||||
self.focused_pane_cwd_in_workspace(ws_idx)
|
||||
.or_else(|| self.seed_cwd_from_workspace(ws_idx))
|
||||
});
|
||||
let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd);
|
||||
match self.create_tab_with_options(initial_cwd, true) {
|
||||
Ok(created_idx) => {
|
||||
let created_workspace = active_before.is_none();
|
||||
let ws_idx = if created_workspace {
|
||||
Some(created_idx)
|
||||
} else {
|
||||
self.state.active
|
||||
};
|
||||
let tab_idx = if created_workspace { 0 } else { created_idx };
|
||||
if let Some(name) = custom_name {
|
||||
if let Some(ws) =
|
||||
ws_idx.and_then(|ws_idx| self.state.workspaces.get_mut(ws_idx))
|
||||
{
|
||||
if let Some(tab) = ws.tabs.get_mut(tab_idx) {
|
||||
tab.set_custom_name(name);
|
||||
}
|
||||
self.schedule_session_save();
|
||||
}
|
||||
}
|
||||
if let Some(ws_idx) = ws_idx {
|
||||
if created_workspace {
|
||||
self.emit_workspace_open_events(ws_idx);
|
||||
} else {
|
||||
self.emit_tab_created_events(ws_idx, tab_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(err = %e, "failed to create tab");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn create_tab_with_options(
|
||||
&mut self,
|
||||
initial_cwd: PathBuf,
|
||||
focus: bool,
|
||||
) -> std::io::Result<usize> {
|
||||
let Some(ws_idx) = self.state.active else {
|
||||
return self.create_workspace_with_options(initial_cwd, focus);
|
||||
};
|
||||
let (rows, cols) = self.state.estimate_pane_size();
|
||||
let ws = &mut self.state.workspaces[ws_idx];
|
||||
let (idx, terminal, runtime) = ws.create_tab(
|
||||
rows,
|
||||
cols,
|
||||
initial_cwd,
|
||||
self.state.pane_scrollback_limit_bytes,
|
||||
self.state.host_terminal_theme,
|
||||
self.state.host_terminal_appearance,
|
||||
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
|
||||
Vec::new(),
|
||||
)?;
|
||||
let root_pane = ws.tabs[idx].root_pane;
|
||||
self.terminal_runtimes.insert(terminal.id.clone(), runtime);
|
||||
self.state.terminals.insert(terminal.id.clone(), terminal);
|
||||
self.state.remove_alias_shadowed_by_new_pane(root_pane);
|
||||
if focus {
|
||||
self.state.switch_workspace_tab(ws_idx, idx);
|
||||
self.state.mode = Mode::Terminal;
|
||||
}
|
||||
let workspace_id = self.state.workspaces[ws_idx].id.clone();
|
||||
let tab_id = self
|
||||
.public_tab_id(ws_idx, idx)
|
||||
.unwrap_or_else(|| crate::workspace::public_tab_id_for_number(&workspace_id, idx + 1));
|
||||
let root_pane = self.state.workspaces[ws_idx].tabs[idx].root_pane.raw();
|
||||
crate::logging::tab_created(&workspace_id, &tab_id, root_pane);
|
||||
self.schedule_session_save();
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
pub(crate) fn create_workspace_with_options(
|
||||
&mut self,
|
||||
initial_cwd: PathBuf,
|
||||
@@ -234,17 +108,6 @@ impl App {
|
||||
self.create_workspace_with_launch_env(initial_cwd, focus, Vec::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn create_workspace_with_events(
|
||||
&mut self,
|
||||
initial_cwd: PathBuf,
|
||||
focus: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let ws_idx = self.create_workspace_with_options(initial_cwd, focus)?;
|
||||
self.emit_workspace_open_events(ws_idx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn create_workspace_with_launch_env(
|
||||
&mut self,
|
||||
initial_cwd: PathBuf,
|
||||
|
||||
+439
-3
@@ -1,7 +1,12 @@
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::App;
|
||||
use ratatui::layout::Direction;
|
||||
|
||||
use super::{App, Mode};
|
||||
|
||||
static NEXT_COMMAND_NAMESPACE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
@@ -87,7 +92,36 @@ impl App {
|
||||
if let Err((code, message)) = self.focus_client_shell_command_target(¶ms) {
|
||||
return crate::app::api::responses::encode_error(id, code, message);
|
||||
}
|
||||
match self.execute_custom_command_binding(&binding) {
|
||||
let selected_text = if binding.action == crate::config::CustomCommandAction::PluginAction {
|
||||
let Some(selection) = params.selection.as_ref() else {
|
||||
return self.execute_custom_command_response(id, &binding, None);
|
||||
};
|
||||
if params.pane_id.as_deref() != Some(selection.pane_id.as_str()) {
|
||||
return crate::app::api::responses::encode_error(
|
||||
id,
|
||||
"command_target_mismatch",
|
||||
"command selection does not belong to the requested pane",
|
||||
);
|
||||
}
|
||||
match self.pane_selection_text(selection) {
|
||||
Ok(text) => Some(text),
|
||||
Err((code, message)) => {
|
||||
return crate::app::api::responses::encode_error(id, code, message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.execute_custom_command_response(id, &binding, selected_text)
|
||||
}
|
||||
|
||||
fn execute_custom_command_response(
|
||||
&mut self,
|
||||
id: String,
|
||||
binding: &crate::config::CustomCommandKeybind,
|
||||
selected_text: Option<String>,
|
||||
) -> String {
|
||||
match self.execute_custom_command_binding(binding, selected_text) {
|
||||
Ok(()) => crate::app::api::responses::encode_success(
|
||||
id,
|
||||
crate::api::schema::ResponseResult::Ok {},
|
||||
@@ -175,6 +209,363 @@ impl App {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn execute_custom_command_binding(
|
||||
&mut self,
|
||||
binding: &crate::config::CustomCommandKeybind,
|
||||
selected_text: Option<String>,
|
||||
) -> io::Result<()> {
|
||||
match binding.action {
|
||||
crate::config::CustomCommandAction::Shell => self.spawn_custom_command(binding),
|
||||
crate::config::CustomCommandAction::Pane => {
|
||||
self.spawn_pane_command(&binding.command, Vec::new())
|
||||
}
|
||||
crate::config::CustomCommandAction::Popup => self.spawn_custom_popup_command(binding),
|
||||
crate::config::CustomCommandAction::PluginAction => self
|
||||
.invoke_plugin_action_from_keybind(binding.command.clone(), selected_text)
|
||||
.map_err(io::Error::other),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_custom_popup_command(
|
||||
&mut self,
|
||||
binding: &crate::config::CustomCommandKeybind,
|
||||
) -> io::Result<()> {
|
||||
self.spawn_popup_shell_command(
|
||||
&binding.command,
|
||||
None,
|
||||
self.custom_command_env().0,
|
||||
crate::app::popup::PopupGeometry {
|
||||
width: binding.width,
|
||||
height: binding.height,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) {
|
||||
let mut env = vec![(
|
||||
crate::api::SOCKET_PATH_ENV_VAR.to_string(),
|
||||
crate::api::socket_path().display().to_string(),
|
||||
)];
|
||||
if let Ok(current_exe) = std::env::current_exe() {
|
||||
env.push((
|
||||
"HERDR_BIN_PATH".to_string(),
|
||||
current_exe.display().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut cwd = None;
|
||||
if let Some(ws_idx) = self.state.active {
|
||||
env.push((
|
||||
"HERDR_ACTIVE_WORKSPACE_ID".to_string(),
|
||||
self.public_workspace_id(ws_idx),
|
||||
));
|
||||
if let Some(workspace) = self.state.workspaces.get(ws_idx) {
|
||||
let tab_idx = workspace.active_tab_index();
|
||||
if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) {
|
||||
env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id));
|
||||
}
|
||||
if let Some(pane_id) = workspace.focused_pane_id() {
|
||||
if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) {
|
||||
env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id));
|
||||
}
|
||||
if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| {
|
||||
tab.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes)
|
||||
}) {
|
||||
env.push((
|
||||
"HERDR_ACTIVE_PANE_CWD".to_string(),
|
||||
pane_cwd.display().to_string(),
|
||||
));
|
||||
if pane_cwd.is_dir() {
|
||||
cwd = Some(pane_cwd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(env, cwd)
|
||||
}
|
||||
|
||||
fn spawn_custom_command(
|
||||
&mut self,
|
||||
binding: &crate::config::CustomCommandKeybind,
|
||||
) -> std::io::Result<()> {
|
||||
let mut command = crate::platform::detached_custom_command_process(&binding.command);
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let (env, cwd) = self.custom_command_env();
|
||||
command.envs(env);
|
||||
if let Some(cwd) = cwd {
|
||||
command.current_dir(cwd);
|
||||
}
|
||||
let child = command.spawn()?;
|
||||
self.detached_process_children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn open_focused_scrollback_in_editor(&mut self) -> std::io::Result<()> {
|
||||
let ws_idx = self
|
||||
.state
|
||||
.active
|
||||
.ok_or_else(|| std::io::Error::other("no active workspace"))?;
|
||||
let ws = self
|
||||
.state
|
||||
.workspaces
|
||||
.get(ws_idx)
|
||||
.ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
|
||||
let pane_id = ws
|
||||
.focused_pane_id()
|
||||
.ok_or_else(|| std::io::Error::other("no focused pane"))?;
|
||||
let scrollback = self
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
|
||||
.ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))?
|
||||
.recent_unwrapped_text_snapshot(usize::MAX)
|
||||
.text;
|
||||
|
||||
let path = write_scrollback_temp_file(&scrollback)?;
|
||||
|
||||
let argv = match crate::platform::scrollback_editor_argv(&path) {
|
||||
Ok(argv) => argv,
|
||||
Err(err) => {
|
||||
let _ = fs::remove_file(&path);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let (env, _) = self.custom_command_env();
|
||||
let new_pane = match self.spawn_overlay_argv_command(&argv, None, env, vec![path.clone()]) {
|
||||
Ok((_, new_pane)) => new_pane,
|
||||
Err(err) => {
|
||||
let _ = fs::remove_file(&path);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let terminal_id = new_pane.terminal.id.clone();
|
||||
self.terminal_runtimes
|
||||
.insert(terminal_id.clone(), new_pane.runtime);
|
||||
self.state
|
||||
.remove_alias_shadowed_by_new_pane(new_pane.pane_id);
|
||||
self.state.terminals.insert(terminal_id, new_pane.terminal);
|
||||
|
||||
if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) {
|
||||
self.state.toast = Some(crate::app::state::ToastNotification {
|
||||
kind: crate::app::state::ToastKind::Finished,
|
||||
title: "opened scrollback".to_string(),
|
||||
context: format!("focused pane {public_pane_id}"),
|
||||
position: None,
|
||||
target: None,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_pane_command(
|
||||
&mut self,
|
||||
command: &str,
|
||||
temp_files: Vec<std::path::PathBuf>,
|
||||
) -> std::io::Result<()> {
|
||||
let Some(ws_idx) = self.state.active else {
|
||||
return Err(std::io::Error::other("no active workspace"));
|
||||
};
|
||||
let previous_focus_target = self.state.current_pane_focus_target();
|
||||
let (rows, cols) = self.state.estimate_pane_size();
|
||||
let new_rows = rows.max(4);
|
||||
let new_cols = cols.max(10);
|
||||
let (env, _) = self.custom_command_env();
|
||||
|
||||
let ws = self
|
||||
.state
|
||||
.workspaces
|
||||
.get_mut(ws_idx)
|
||||
.ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
|
||||
let tab_idx = ws.active_tab_index();
|
||||
let previous_focus = ws
|
||||
.focused_pane_id()
|
||||
.ok_or_else(|| std::io::Error::other("no focused pane"))?;
|
||||
let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false);
|
||||
let cwd = ws.active_tab().and_then(|tab| {
|
||||
tab.cwd_for_pane(
|
||||
previous_focus,
|
||||
&self.state.terminals,
|
||||
&self.terminal_runtimes,
|
||||
)
|
||||
});
|
||||
let new_pane = ws.split_focused_command(
|
||||
Direction::Horizontal,
|
||||
new_rows,
|
||||
new_cols,
|
||||
cwd,
|
||||
command,
|
||||
env,
|
||||
self.state.pane_scrollback_limit_bytes,
|
||||
self.state.host_terminal_theme,
|
||||
self.state.host_terminal_appearance,
|
||||
)?;
|
||||
let new_pane_id = new_pane.pane_id;
|
||||
self.terminal_runtimes
|
||||
.insert(new_pane.terminal.id.clone(), new_pane.runtime);
|
||||
self.state
|
||||
.terminals
|
||||
.insert(new_pane.terminal.id.clone(), new_pane.terminal);
|
||||
let new_focus_target = crate::app::state::PaneFocusTarget {
|
||||
workspace_id: ws.id.clone(),
|
||||
pane_id: new_pane_id,
|
||||
};
|
||||
if previous_focus_target.as_ref() != Some(&new_focus_target) {
|
||||
self.state.previous_pane_focus = previous_focus_target;
|
||||
}
|
||||
ws.active_tab_mut()
|
||||
.expect("workspace must have an active tab")
|
||||
.layout
|
||||
.focus_pane(new_pane_id);
|
||||
ws.active_tab_mut()
|
||||
.expect("workspace must have an active tab")
|
||||
.zoomed = true;
|
||||
self.overlay_panes.insert(
|
||||
new_pane_id,
|
||||
super::OverlayPaneState {
|
||||
ws_idx,
|
||||
tab_idx,
|
||||
previous_focus,
|
||||
previous_zoomed,
|
||||
temp_files,
|
||||
},
|
||||
);
|
||||
self.state.remove_alias_shadowed_by_new_pane(new_pane_id);
|
||||
self.state.mode = Mode::Terminal;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_overlay_argv_command(
|
||||
&mut self,
|
||||
argv: &[String],
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
extra_env: Vec<(String, String)>,
|
||||
temp_files: Vec<std::path::PathBuf>,
|
||||
) -> std::io::Result<(usize, crate::workspace::NewPane)> {
|
||||
let Some(ws_idx) = self.state.active else {
|
||||
return Err(std::io::Error::other("no active workspace"));
|
||||
};
|
||||
let previous_focus_target = self.state.current_pane_focus_target();
|
||||
let (rows, cols) = self.state.estimate_pane_size();
|
||||
let new_rows = rows.max(4);
|
||||
let new_cols = cols.max(10);
|
||||
|
||||
let ws = self
|
||||
.state
|
||||
.workspaces
|
||||
.get(ws_idx)
|
||||
.ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
|
||||
let previous_focus = ws
|
||||
.focused_pane_id()
|
||||
.ok_or_else(|| std::io::Error::other("no focused pane"))?;
|
||||
let cwd = cwd.or_else(|| {
|
||||
ws.active_tab().and_then(|tab| {
|
||||
tab.cwd_for_pane(
|
||||
previous_focus,
|
||||
&self.state.terminals,
|
||||
&self.terminal_runtimes,
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
let (tab_idx, new_pane, workspace_id) = {
|
||||
let ws = self
|
||||
.state
|
||||
.workspaces
|
||||
.get_mut(ws_idx)
|
||||
.ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
|
||||
let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false);
|
||||
let result = ws.split_pane_argv_command(
|
||||
previous_focus,
|
||||
Direction::Horizontal,
|
||||
new_rows,
|
||||
new_cols,
|
||||
cwd,
|
||||
argv,
|
||||
extra_env,
|
||||
self.state.pane_scrollback_limit_bytes,
|
||||
self.state.host_terminal_theme,
|
||||
self.state.host_terminal_appearance,
|
||||
true,
|
||||
);
|
||||
let (tab_idx, new_pane) = match result {
|
||||
Some(Ok(result)) => result,
|
||||
Some(Err(err)) => return Err(err),
|
||||
None => return Err(std::io::Error::other("focused pane disappeared")),
|
||||
};
|
||||
ws.tabs
|
||||
.get_mut(tab_idx)
|
||||
.ok_or_else(|| std::io::Error::other("plugin overlay tab disappeared"))?
|
||||
.zoomed = true;
|
||||
self.overlay_panes.insert(
|
||||
new_pane.pane_id,
|
||||
super::OverlayPaneState {
|
||||
ws_idx,
|
||||
tab_idx,
|
||||
previous_focus,
|
||||
previous_zoomed,
|
||||
temp_files,
|
||||
},
|
||||
);
|
||||
(tab_idx, new_pane, ws.id.clone())
|
||||
};
|
||||
|
||||
let new_focus_target = crate::app::state::PaneFocusTarget {
|
||||
workspace_id,
|
||||
pane_id: new_pane.pane_id,
|
||||
};
|
||||
if previous_focus_target.as_ref() != Some(&new_focus_target) {
|
||||
self.state.previous_pane_focus = previous_focus_target;
|
||||
}
|
||||
self.state.switch_workspace_tab(ws_idx, tab_idx);
|
||||
self.state.mode = Mode::Terminal;
|
||||
Ok((ws_idx, new_pane))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_scrollback_temp_file(content: &str) -> io::Result<std::path::PathBuf> {
|
||||
let mut last_collision = None;
|
||||
for attempt in 0..16 {
|
||||
let path = unique_scrollback_path(attempt);
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(content.as_bytes())?;
|
||||
return Ok(path);
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
|
||||
last_collision = Some(err);
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_collision.unwrap_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"failed to create unique scrollback temp file",
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn unique_scrollback_path(attempt: u32) -> std::path::PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0);
|
||||
std::env::temp_dir().join(format!(
|
||||
"herdr-scrollback-{}-{nanos}-{attempt}.txt",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -183,7 +574,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
crate::app::App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -239,6 +630,7 @@ mod tests {
|
||||
workspace_id: None,
|
||||
tab_id: None,
|
||||
pane_id: None,
|
||||
selection: None,
|
||||
},
|
||||
);
|
||||
let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
|
||||
@@ -287,12 +679,55 @@ mod tests {
|
||||
workspace_id: None,
|
||||
tab_id: None,
|
||||
pane_id: None,
|
||||
selection: None,
|
||||
},
|
||||
);
|
||||
let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
|
||||
assert_eq!(error.error.code, "command_not_found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_command_rejects_stale_client_selection_before_invocation() {
|
||||
let mut app = test_app();
|
||||
let workspace = crate::workspace::Workspace::test_new("plugin-selection");
|
||||
let pane_id = workspace.tabs[0].root_pane;
|
||||
let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.ensure_test_terminals();
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.terminal_runtimes.insert(
|
||||
terminal_id,
|
||||
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"selected text\n"),
|
||||
);
|
||||
let mut plugin = binding(crate::config::CustomCommandAction::PluginAction);
|
||||
plugin.command = "missing.plugin-action".into();
|
||||
install(&mut app, plugin);
|
||||
let command_id = app.client_shell_command_manifest()[0].command_id.clone();
|
||||
let workspace_id = app.public_workspace_id(0);
|
||||
let tab_id = app.public_tab_id(0, 0).unwrap();
|
||||
let pane_id = app.public_pane_id(0, pane_id).unwrap();
|
||||
|
||||
let response = app.handle_command_invoke(
|
||||
"request-selection".into(),
|
||||
crate::api::schema::CommandInvokeParams {
|
||||
command_id,
|
||||
workspace_id: Some(workspace_id),
|
||||
tab_id: Some(tab_id),
|
||||
pane_id: Some(pane_id.clone()),
|
||||
selection: Some(crate::api::schema::PaneSelectionReadParams {
|
||||
pane_id,
|
||||
anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 },
|
||||
cursor: crate::api::schema::PaneTextPoint { row: 0, col: 7 },
|
||||
content_revision: Some(u64::MAX),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
|
||||
assert_eq!(error.error.code, "stale_content");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn shell_command_invocation_executes_endpoint_owned_definition() {
|
||||
@@ -318,6 +753,7 @@ mod tests {
|
||||
workspace_id: None,
|
||||
tab_id: None,
|
||||
pane_id: None,
|
||||
selection: None,
|
||||
},
|
||||
);
|
||||
let success: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
|
||||
@@ -529,7 +529,7 @@ mod tests {
|
||||
fn test_app(config: &crate::config::Config) -> super::super::App {
|
||||
super::super::App::new(
|
||||
config,
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
tokio::sync::mpsc::unbounded_channel().1,
|
||||
crate::api::EventHub::default(),
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
use crate::{
|
||||
app::{App, InputSourceId},
|
||||
input::TerminalKey,
|
||||
};
|
||||
|
||||
use super::{ConsumedInputLease, InputLeaseKey};
|
||||
|
||||
fn is_retained_selection_copy_key(key: &TerminalKey) -> bool {
|
||||
matches!(key.code, KeyCode::Char('c' | 'C'))
|
||||
&& matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER)
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn dispatch_pending_clipboard_write(&mut self) -> bool {
|
||||
let Some(content) = self.state.request_clipboard_write.take() else {
|
||||
return false;
|
||||
};
|
||||
if self
|
||||
.event_tx
|
||||
.try_send(crate::events::AppEvent::ClipboardWrite { content })
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("failed to queue clipboard write event");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn try_copy_retained_selection(
|
||||
&mut self,
|
||||
source_id: InputSourceId,
|
||||
key: TerminalKey,
|
||||
) -> bool {
|
||||
if self.state.copy_on_select
|
||||
|| !is_retained_selection_copy_key(&key)
|
||||
|| !self
|
||||
.state
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_some_and(crate::selection::Selection::is_visible)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.state.copy_selection(&self.terminal_runtimes);
|
||||
self.selection_autoscroll_deadline = None;
|
||||
if !self.dispatch_pending_clipboard_write() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.input_leases.insert_consumed(
|
||||
InputLeaseKey::new(source_id, &key),
|
||||
ConsumedInputLease::SuppressRepeats,
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bytes::Bytes;
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use super::super::{app_for_mouse_test, mouse};
|
||||
use super::*;
|
||||
use crate::{app::Mode, events::AppEvent, workspace::Workspace};
|
||||
|
||||
fn app_with_screen_bytes_and_input(
|
||||
bytes: &[u8],
|
||||
) -> (
|
||||
App,
|
||||
crate::layout::PaneInfo,
|
||||
tokio::sync::mpsc::Receiver<Bytes>,
|
||||
) {
|
||||
let mut app = app_for_mouse_test();
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let pane_id = ws.tabs[0].root_pane;
|
||||
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
|
||||
let info = pane_infos[0].clone();
|
||||
let (runtime, input_rx) =
|
||||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||||
info.inner_rect.width,
|
||||
info.inner_rect.height,
|
||||
0,
|
||||
bytes,
|
||||
4,
|
||||
);
|
||||
ws.insert_test_runtime(pane_id, runtime);
|
||||
|
||||
app.state.workspaces = vec![ws];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.state.view.pane_infos = pane_infos;
|
||||
(app, info, input_rx)
|
||||
}
|
||||
|
||||
fn drag_select_range(
|
||||
app: &mut App,
|
||||
info: &crate::layout::PaneInfo,
|
||||
start_col: u16,
|
||||
end_col: u16,
|
||||
) {
|
||||
let row = info.inner_rect.y;
|
||||
let start_col = info.inner_rect.x + start_col;
|
||||
let end_col = info.inner_rect.x + end_col;
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
start_col,
|
||||
row,
|
||||
));
|
||||
app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
|
||||
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
|
||||
}
|
||||
|
||||
fn clipboard_write_content(app: &mut App) -> Vec<u8> {
|
||||
match app.event_rx.try_recv().expect("clipboard write event") {
|
||||
AppEvent::ClipboardWrite { content } => content,
|
||||
event => panic!("unexpected event: {event:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_visible_selection(app: &App) {
|
||||
assert!(app
|
||||
.state
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_some_and(crate::selection::Selection::is_visible));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_on_select_disabled_ctrl_c_copies_and_clears_retained_selection() {
|
||||
let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta");
|
||||
app.state.copy_on_select = false;
|
||||
drag_select_range(&mut app, &info, 0, 4);
|
||||
assert_visible_selection(&app);
|
||||
assert!(app.event_rx.try_recv().is_err());
|
||||
|
||||
let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
|
||||
.with_windows_record(crate::input::WindowsKeyRecord {
|
||||
key_down: true,
|
||||
repeat_count: 1,
|
||||
virtual_key_code: 0x43,
|
||||
virtual_scan_code: 0x2e,
|
||||
unicode: 'c' as u16,
|
||||
control_key_state: 0x0008,
|
||||
});
|
||||
let source_id = 41;
|
||||
app.route_client_events_from(
|
||||
source_id,
|
||||
vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())],
|
||||
false,
|
||||
);
|
||||
|
||||
let content = clipboard_write_content(&mut app);
|
||||
assert_eq!(content, b"alpha");
|
||||
assert!(app.state.selection.is_none());
|
||||
assert!(input_rx.try_recv().is_err());
|
||||
|
||||
let _ = content;
|
||||
app.show_clipboard_feedback();
|
||||
assert_eq!(
|
||||
app.state
|
||||
.copy_feedback
|
||||
.as_ref()
|
||||
.map(|feedback| feedback.message.as_str()),
|
||||
Some("copied to clipboard")
|
||||
);
|
||||
|
||||
app.route_client_events_from(
|
||||
source_id,
|
||||
vec![
|
||||
crate::raw_input::RawInputEvent::Key(ctrl_c.clone()),
|
||||
crate::raw_input::RawInputEvent::Key(
|
||||
ctrl_c.clone().with_kind(KeyEventKind::Repeat),
|
||||
),
|
||||
],
|
||||
false,
|
||||
);
|
||||
assert_eq!(app.input_leases.len(), 1);
|
||||
assert!(app.event_rx.try_recv().is_err());
|
||||
assert!(input_rx.try_recv().is_err());
|
||||
|
||||
app.route_client_events_from(
|
||||
source_id,
|
||||
vec![crate::raw_input::RawInputEvent::Key(
|
||||
ctrl_c.clone().with_kind(KeyEventKind::Release),
|
||||
)],
|
||||
false,
|
||||
);
|
||||
assert!(app.input_leases.is_empty());
|
||||
app.route_client_events_from(
|
||||
source_id,
|
||||
vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())],
|
||||
false,
|
||||
);
|
||||
let expected = if cfg!(windows) {
|
||||
b"\x1b[67;46;99;1;8;1_".as_slice()
|
||||
} else {
|
||||
b"\x03".as_slice()
|
||||
};
|
||||
assert_eq!(
|
||||
input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(),
|
||||
expected
|
||||
);
|
||||
assert!(app.event_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_on_select_disabled_cmd_c_copies_retained_selection() {
|
||||
let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta");
|
||||
app.state.copy_on_select = false;
|
||||
drag_select_range(&mut app, &info, 0, 4);
|
||||
|
||||
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Char('c'), KeyModifiers::SUPER));
|
||||
|
||||
assert_eq!(clipboard_write_content(&mut app), b"alpha");
|
||||
assert!(app.state.selection.is_none());
|
||||
assert!(input_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_shortcut_before_delayed_mouse_up_copies_in_progress_selection() {
|
||||
let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta");
|
||||
app.state.copy_on_select = false;
|
||||
let source_id = 41;
|
||||
let row = info.inner_rect.y;
|
||||
let start_col = info.inner_rect.x;
|
||||
let end_col = info.inner_rect.x + 4;
|
||||
app.route_client_events_from(
|
||||
source_id,
|
||||
vec![
|
||||
crate::raw_input::RawInputEvent::Mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
start_col,
|
||||
row,
|
||||
)),
|
||||
crate::raw_input::RawInputEvent::Mouse(mouse(
|
||||
MouseEventKind::Drag(MouseButton::Left),
|
||||
end_col,
|
||||
row,
|
||||
)),
|
||||
],
|
||||
false,
|
||||
);
|
||||
assert_visible_selection(&app);
|
||||
assert!(app
|
||||
.state
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_some_and(crate::selection::Selection::is_in_progress));
|
||||
assert!(app.state.selection_autoscroll.is_some());
|
||||
assert!(app.selection_autoscroll_deadline.is_some());
|
||||
|
||||
let cmd_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::SUPER);
|
||||
app.route_client_events_from(
|
||||
source_id,
|
||||
vec![crate::raw_input::RawInputEvent::Key(cmd_c.clone())],
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(clipboard_write_content(&mut app), b"alpha");
|
||||
assert!(app.event_rx.try_recv().is_err());
|
||||
assert!(app.state.selection.is_none());
|
||||
assert!(app.state.selection_autoscroll.is_none());
|
||||
assert!(app.selection_autoscroll_deadline.is_none());
|
||||
assert!(app.selection_highlight_clear_deadline.is_none());
|
||||
assert_eq!(app.input_leases.len(), 1);
|
||||
assert!(input_rx.try_recv().is_err());
|
||||
|
||||
app.route_client_events_from(
|
||||
source_id,
|
||||
vec![crate::raw_input::RawInputEvent::Key(
|
||||
cmd_c.with_kind(KeyEventKind::Release),
|
||||
)],
|
||||
false,
|
||||
);
|
||||
assert!(app.input_leases.is_empty());
|
||||
|
||||
app.route_client_events_from(
|
||||
source_id,
|
||||
vec![crate::raw_input::RawInputEvent::Mouse(mouse(
|
||||
MouseEventKind::Up(MouseButton::Left),
|
||||
end_col,
|
||||
row,
|
||||
))],
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(app.state.selection.is_none());
|
||||
assert!(app.state.selection_autoscroll.is_none());
|
||||
assert!(app.selection_autoscroll_deadline.is_none());
|
||||
assert!(app.selection_highlight_clear_deadline.is_none());
|
||||
assert!(app.input_leases.is_empty());
|
||||
assert!(app.event_rx.try_recv().is_err());
|
||||
assert!(input_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_selection_copy_shortcut_is_disabled_with_copy_on_select() {
|
||||
let (mut app, _info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta");
|
||||
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let mut selection = crate::selection::Selection::range(
|
||||
pane_id,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
app.state
|
||||
.pane_scroll_metrics(&app.terminal_runtimes, pane_id),
|
||||
);
|
||||
assert!(selection.finish());
|
||||
app.state.selection = Some(selection);
|
||||
app.state.copy_on_select = true;
|
||||
|
||||
app.handle_terminal_key_headless(TerminalKey::new(
|
||||
KeyCode::Char('c'),
|
||||
KeyModifiers::CONTROL,
|
||||
));
|
||||
|
||||
assert!(app.state.selection.is_none());
|
||||
assert!(app.event_rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(),
|
||||
b"\x03"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_selection_copy_shortcut_forwards_when_selection_text_is_empty() {
|
||||
let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"");
|
||||
app.state.copy_on_select = false;
|
||||
drag_select_range(&mut app, &info, 0, 4);
|
||||
assert_visible_selection(&app);
|
||||
|
||||
app.handle_terminal_key_headless(TerminalKey::new(
|
||||
KeyCode::Char('c'),
|
||||
KeyModifiers::CONTROL,
|
||||
));
|
||||
|
||||
assert!(app.state.selection.is_none());
|
||||
assert!(app.event_rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(),
|
||||
b"\x03"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_selection_copy_shortcut_requires_exact_modifiers() {
|
||||
let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta");
|
||||
app.state.copy_on_select = false;
|
||||
drag_select_range(&mut app, &info, 0, 4);
|
||||
|
||||
app.handle_terminal_key_headless(TerminalKey::new(
|
||||
KeyCode::Char('C'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
));
|
||||
|
||||
assert!(app.state.selection.is_none());
|
||||
assert!(app.event_rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
input_rx
|
||||
.try_recv()
|
||||
.expect("forwarded Ctrl-Shift-C")
|
||||
.as_ref(),
|
||||
b"\x03"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,973 +0,0 @@
|
||||
//! Input handling — translates crossterm key/mouse events into state mutations.
|
||||
|
||||
use bytes::Bytes;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::app::PaneClickState;
|
||||
#[cfg(test)]
|
||||
use crate::input::TerminalKey;
|
||||
#[cfg(test)]
|
||||
use ratatui::layout::Direction;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ScrollbarClickTarget {
|
||||
Thumb { grab_row_offset: u16 },
|
||||
Track { offset_from_bottom: usize },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg(test)]
|
||||
enum WheelRouting {
|
||||
HostScroll,
|
||||
MouseReport,
|
||||
AlternateScroll,
|
||||
}
|
||||
|
||||
const WORKSPACE_DRAG_THRESHOLD: u16 = 1;
|
||||
const TAB_DRAG_THRESHOLD: u16 = 1;
|
||||
|
||||
fn modified_url_click_modifier() -> KeyModifiers {
|
||||
KeyModifiers::CONTROL
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn modified_url_click_modifier_matches_terminal_mouse_reporting() {
|
||||
assert_eq!(modified_url_click_modifier(), KeyModifiers::CONTROL);
|
||||
}
|
||||
|
||||
mod clipboard;
|
||||
pub(crate) mod copy_mode;
|
||||
mod modal;
|
||||
mod mouse;
|
||||
mod navigate;
|
||||
mod overlays;
|
||||
mod selection;
|
||||
mod settings;
|
||||
mod sidebar;
|
||||
mod terminal;
|
||||
|
||||
pub(crate) use self::{
|
||||
modal::{
|
||||
handle_global_menu_key, handle_keybind_help_key, handle_navigator_key,
|
||||
insert_keybind_help_query_text, insert_navigator_search_text, insert_rename_input_text,
|
||||
open_new_workspace_dialog,
|
||||
},
|
||||
navigate::{
|
||||
terminal_direct_indexed_navigation_action, terminal_direct_non_indexed_navigation_action,
|
||||
},
|
||||
settings::open_settings_at,
|
||||
};
|
||||
pub(crate) type ConsumedInputLease = crate::input::ConsumedInputLease<super::TerminalInputContext>;
|
||||
pub(crate) type ForwardedInputLease = crate::input::ForwardedInputLease<super::TerminalInputTarget>;
|
||||
pub(crate) type InputLeaseKey = crate::input::InputLeaseKey<super::InputSourceId>;
|
||||
pub(crate) type InputLeaseTable = crate::input::InputLeaseTable<
|
||||
super::InputSourceId,
|
||||
super::TerminalInputContext,
|
||||
super::TerminalInputTarget,
|
||||
>;
|
||||
pub(crate) type RepeatPlan =
|
||||
crate::input::RepeatPlan<super::TerminalInputContext, super::TerminalInputTarget>;
|
||||
use self::{
|
||||
modal::{
|
||||
modal_action_from_key, ModalAction, ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS,
|
||||
},
|
||||
mouse::MouseAction,
|
||||
settings::SettingsAction,
|
||||
};
|
||||
use super::state::{AppState, Mode};
|
||||
use super::App;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl App {
|
||||
#[cfg(test)]
|
||||
pub(super) async fn handle_key(
|
||||
&mut self,
|
||||
key: TerminalKey,
|
||||
) -> Option<super::TerminalInputTarget> {
|
||||
self.route_client_events(vec![crate::raw_input::RawInputEvent::Key(key)], true);
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn handle_text_commit_headless(&mut self, text: &str) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.state.popup_pane.is_some() {
|
||||
if let Some(runtime) = self.popup_runtime() {
|
||||
let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes()));
|
||||
} else {
|
||||
self.close_popup_pane();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.state.mode != Mode::Terminal {
|
||||
self.paste_into_active_text_input(text);
|
||||
return;
|
||||
}
|
||||
|
||||
self.state.clear_selection();
|
||||
self.selection_autoscroll_deadline = None;
|
||||
self.state.update_dismissed = true;
|
||||
if let Some(ws_idx) = self.state.active {
|
||||
if let Some(runtime) = self
|
||||
.state
|
||||
.focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
|
||||
{
|
||||
let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn handle_paste(&mut self, text: String) {
|
||||
self.route_client_events(vec![crate::raw_input::RawInputEvent::Paste(text)], true);
|
||||
}
|
||||
|
||||
pub(crate) fn paste_into_active_text_input(&mut self, text: &str) -> bool {
|
||||
match self.state.mode {
|
||||
Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
|
||||
insert_rename_input_text(&mut self.state, text);
|
||||
true
|
||||
}
|
||||
Mode::NewLinkedWorktree => {
|
||||
self.insert_worktree_create_text(text);
|
||||
true
|
||||
}
|
||||
Mode::OpenExistingWorktree => {
|
||||
if !self
|
||||
.state
|
||||
.worktree_open
|
||||
.as_ref()
|
||||
.is_some_and(|open| open.search_focused)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.insert_worktree_open_search_text(text);
|
||||
true
|
||||
}
|
||||
Mode::Navigator => {
|
||||
if !self.state.navigator.search_focused {
|
||||
return false;
|
||||
}
|
||||
insert_navigator_search_text(&mut self.state, &self.terminal_runtimes, text);
|
||||
true
|
||||
}
|
||||
Mode::KeybindHelp => {
|
||||
if !self.state.keybind_help.search_focused {
|
||||
return false;
|
||||
}
|
||||
insert_keybind_help_query_text(&mut self.state, text);
|
||||
true
|
||||
}
|
||||
Mode::Copy => {
|
||||
let Some(prompt) = self
|
||||
.state
|
||||
.copy_mode
|
||||
.as_mut()
|
||||
.and_then(|copy_mode| copy_mode.search.prompt.as_mut())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
prompt
|
||||
.query
|
||||
.extend(text.chars().filter(|ch| !ch.is_control()));
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_onboarding_key(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Right | KeyCode::Char('l') => self.open_settings_from_onboarding(),
|
||||
_ => {
|
||||
if let Some(ModalAction::Continue) =
|
||||
modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS)
|
||||
{
|
||||
self.open_settings_from_onboarding();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_release_notes_key(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1),
|
||||
KeyCode::PageUp => self.scroll_release_notes(-8),
|
||||
KeyCode::PageDown => self.scroll_release_notes(8),
|
||||
KeyCode::Home => {
|
||||
if let Some(notes) = &mut self.state.release_notes {
|
||||
notes.scroll = 0;
|
||||
}
|
||||
}
|
||||
KeyCode::End => {
|
||||
let max_scroll = self.state.release_notes_max_scroll();
|
||||
if let Some(notes) = &mut self.state.release_notes {
|
||||
notes.scroll = max_scroll;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(ModalAction::Close) = modal_action_from_key(&key, RELEASE_NOTES_ACTIONS)
|
||||
{
|
||||
self.dismiss_release_notes();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_product_announcement_key(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => self.scroll_product_announcement(-1),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.scroll_product_announcement(1),
|
||||
KeyCode::PageUp => self.scroll_product_announcement(-8),
|
||||
KeyCode::PageDown => self.scroll_product_announcement(8),
|
||||
KeyCode::Home => {
|
||||
if let Some(announcement) = &mut self.state.product_announcement {
|
||||
announcement.scroll = 0;
|
||||
}
|
||||
}
|
||||
KeyCode::End => {
|
||||
let max_scroll = self.state.product_announcement_max_scroll();
|
||||
if let Some(announcement) = &mut self.state.product_announcement {
|
||||
announcement.scroll = max_scroll;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(ModalAction::Close) = modal_action_from_key(&key, RELEASE_NOTES_ACTIONS)
|
||||
{
|
||||
self.dismiss_product_announcement();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) {
|
||||
self.handle_mouse_from_input_source(super::LOCAL_INPUT_SOURCE, mouse);
|
||||
}
|
||||
|
||||
pub(super) fn handle_mouse_from_input_source(
|
||||
&mut self,
|
||||
source_id: super::InputSourceId,
|
||||
mouse: MouseEvent,
|
||||
) {
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
self.pending_url_click_sources.remove(&source_id);
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left)
|
||||
if self.pending_url_click_sources.contains(&source_id) =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left)
|
||||
if self.pending_url_click_sources.remove(&source_id) =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if self.state.popup_pane.is_some() {
|
||||
self.handle_popup_mouse(mouse);
|
||||
return;
|
||||
}
|
||||
if self.handle_overlay_mouse(mouse) {
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
|
||||
&& self.state.on_sidebar_divider(mouse.column, mouse.row)
|
||||
{
|
||||
let now = std::time::Instant::now();
|
||||
let is_double_click = self
|
||||
.last_sidebar_divider_click
|
||||
.is_some_and(|last| now.duration_since(last) <= super::SIDEBAR_DOUBLE_CLICK_WINDOW);
|
||||
self.last_sidebar_divider_click = Some(now);
|
||||
|
||||
if is_double_click {
|
||||
self.state.sidebar_width = self.state.default_sidebar_width;
|
||||
self.state.sidebar_width_source =
|
||||
crate::app::state::SidebarWidthSource::ConfigDefault;
|
||||
self.state.sidebar_width_auto = false;
|
||||
self.state.mark_session_dirty();
|
||||
self.state.drag = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if self.handle_modified_url_click(source_id, mouse) {
|
||||
return;
|
||||
}
|
||||
|
||||
let handled_pane_double_click = self.handle_pane_double_click(mouse);
|
||||
if !handled_pane_double_click {
|
||||
self.focus_pane_before_mouse_press(mouse);
|
||||
}
|
||||
|
||||
let previous_agent_panel_sort = self.state.agent_panel_sort;
|
||||
let previous_settings_section = self.state.settings.section;
|
||||
if !handled_pane_double_click {
|
||||
if let Some(action) =
|
||||
self.state
|
||||
.handle_mouse(&mut self.terminal_runtimes, source_id, mouse)
|
||||
{
|
||||
match action {
|
||||
MouseAction::NewWorkspace => {
|
||||
self.begin_tui_workspace_create("tui.mouse.workspace.create")
|
||||
}
|
||||
MouseAction::Settings(action) => match action {
|
||||
SettingsAction::SaveTheme(name) => self.save_theme(&name),
|
||||
SettingsAction::SaveStatusIndicators(style) => {
|
||||
self.save_status_indicators(style)
|
||||
}
|
||||
SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
|
||||
SettingsAction::SaveToastDelivery(delivery) => {
|
||||
self.save_toast_delivery(delivery)
|
||||
}
|
||||
SettingsAction::SaveAgentBorderLabels(enabled) => {
|
||||
self.save_agent_border_labels(enabled)
|
||||
}
|
||||
SettingsAction::InstallRecommendedIntegrations => {
|
||||
self.install_recommended_integrations()
|
||||
}
|
||||
},
|
||||
MouseAction::FocusWorkspace { ws_idx } => {
|
||||
self.focus_workspace_idx_via_api(ws_idx)
|
||||
}
|
||||
MouseAction::FocusTab { tab_idx } => self.focus_tab_idx_via_api(tab_idx),
|
||||
MouseAction::FocusPane { ws_idx, pane_id } => {
|
||||
self.focus_pane_internal_via_api(ws_idx, pane_id)
|
||||
}
|
||||
MouseAction::FocusToastTarget => self.focus_toast_target_via_api(),
|
||||
MouseAction::MoveWorkspace {
|
||||
source_ws_idx,
|
||||
insert_idx,
|
||||
} => self.move_workspace_via_api(source_ws_idx, insert_idx),
|
||||
MouseAction::MoveWorkspaceBlock { params } => {
|
||||
self.move_workspace_block_via_api(params)
|
||||
}
|
||||
MouseAction::MoveTab {
|
||||
ws_idx,
|
||||
source_tab_idx,
|
||||
insert_idx,
|
||||
} => self.move_tab_via_api(ws_idx, source_tab_idx, insert_idx),
|
||||
MouseAction::SetSplitRatio { path, ratio } => {
|
||||
self.set_split_ratio_via_api(path, ratio)
|
||||
}
|
||||
MouseAction::RenameModal(action) => {
|
||||
self.apply_rename_mouse_action_via_api(action)
|
||||
}
|
||||
MouseAction::ConfirmCloseAccept => self.confirm_close_accept_via_api(),
|
||||
MouseAction::ContextMenu { menu, idx } => {
|
||||
self.apply_context_menu_action_via_api(menu, idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
|
||||
&& self
|
||||
.state
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_none_or(crate::selection::Selection::is_in_progress)
|
||||
{
|
||||
self.selection_highlight_clear_deadline = None;
|
||||
}
|
||||
}
|
||||
if previous_settings_section != crate::app::state::SettingsSection::Integrations
|
||||
&& self.state.settings.section == crate::app::state::SettingsSection::Integrations
|
||||
{
|
||||
self.refresh_integration_recommendations();
|
||||
}
|
||||
if self.state.agent_panel_sort != previous_agent_panel_sort {
|
||||
self.save_agent_panel_sort(self.state.agent_panel_sort);
|
||||
}
|
||||
|
||||
self.dispatch_pending_clipboard_write();
|
||||
|
||||
// Sync autoscroll deadline with state (mouse handler may have
|
||||
// set or cleared selection_autoscroll during handle_mouse).
|
||||
if self.state.selection_autoscroll.is_none() {
|
||||
self.selection_autoscroll_deadline = None;
|
||||
} else if self.selection_autoscroll_deadline.is_none() {
|
||||
self.selection_autoscroll_deadline =
|
||||
Some(std::time::Instant::now() + super::SELECTION_AUTOSCROLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_popup_mouse(&mut self, mouse: MouseEvent) {
|
||||
let Some((_outer, inner)) =
|
||||
crate::ui::popup_pane_rects(&self.state, self.state.view.terminal_area)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if mouse.column < inner.x
|
||||
|| mouse.column >= inner.x.saturating_add(inner.width)
|
||||
|| mouse.row < inner.y
|
||||
|| mouse.row >= inner.y.saturating_add(inner.height)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(rt) = self.popup_runtime() else {
|
||||
self.close_popup_pane();
|
||||
return;
|
||||
};
|
||||
let position = crate::input::mouse::Position::Cell {
|
||||
column: mouse.column.saturating_sub(inner.x),
|
||||
row: mouse.row.saturating_sub(inner.y),
|
||||
};
|
||||
let bytes = match mouse.kind {
|
||||
MouseEventKind::ScrollUp
|
||||
| MouseEventKind::ScrollDown
|
||||
| MouseEventKind::ScrollLeft
|
||||
| MouseEventKind::ScrollRight => match rt.wheel_routing() {
|
||||
Some(crate::pane::WheelRouting::MouseReport) => {
|
||||
rt.encode_mouse_wheel(mouse.kind, position, mouse.modifiers)
|
||||
}
|
||||
Some(crate::pane::WheelRouting::AlternateScroll) => {
|
||||
rt.encode_alternate_scroll(mouse.kind)
|
||||
}
|
||||
Some(crate::pane::WheelRouting::HostScroll) | None => {
|
||||
let lines_per_notch = self.state.mouse_scroll_lines;
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch),
|
||||
MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch),
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => {
|
||||
rt.encode_mouse_button(mouse.kind, position, mouse.modifiers)
|
||||
}
|
||||
MouseEventKind::Moved => rt.encode_mouse_motion(mouse.kind, position, mouse.modifiers),
|
||||
};
|
||||
let Some(bytes) = bytes else {
|
||||
return;
|
||||
};
|
||||
if !matches!(mouse.kind, MouseEventKind::Moved) {
|
||||
rt.scroll_reset();
|
||||
}
|
||||
if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
|
||||
warn!(err = %err, kind = ?mouse.kind, "failed to forward popup mouse event");
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_pane_before_mouse_press(&mut self, mouse: MouseEvent) {
|
||||
if !matches!(self.state.mode, Mode::Terminal | Mode::Resize)
|
||||
|| !matches!(
|
||||
mouse.kind,
|
||||
MouseEventKind::Down(MouseButton::Left | MouseButton::Middle)
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(pane_id) = self
|
||||
.state
|
||||
.pane_at(mouse.column, mouse.row)
|
||||
.map(|info| info.id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(ws_idx) = self.state.active else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Focus through the runtime API before an application can consume its press.
|
||||
self.focus_pane_internal_via_api(ws_idx, pane_id);
|
||||
}
|
||||
|
||||
fn handle_modified_url_click(
|
||||
&mut self,
|
||||
source_id: super::InputSourceId,
|
||||
mouse: MouseEvent,
|
||||
) -> bool {
|
||||
self.handle_modified_url_click_with(source_id, mouse, crate::platform::open_url)
|
||||
}
|
||||
|
||||
fn handle_modified_url_click_with(
|
||||
&mut self,
|
||||
source_id: super::InputSourceId,
|
||||
mouse: MouseEvent,
|
||||
open_url: impl FnOnce(&str) -> std::io::Result<Option<std::process::Child>>,
|
||||
) -> bool {
|
||||
if self.state.mode != Mode::Terminal
|
||||
|| !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
|
||||
|| !mouse.modifiers.contains(modified_url_click_modifier())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let viewport_row = mouse.row.saturating_sub(info.inner_rect.y);
|
||||
let col = mouse.column.saturating_sub(info.inner_rect.x);
|
||||
let Some(url) =
|
||||
self.state
|
||||
.url_at_pane_cell(&self.terminal_runtimes, info.id, viewport_row, col)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let plugin_handled = match self.invoke_plugin_link_handler_for_url(&url, info.id) {
|
||||
Ok(handled) => handled,
|
||||
Err(err) => {
|
||||
tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler");
|
||||
false
|
||||
}
|
||||
};
|
||||
if !plugin_handled && crate::app::actions::safe_web_url(&url).is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.last_pane_click = None;
|
||||
self.pending_url_click_sources.insert(source_id);
|
||||
if plugin_handled {
|
||||
return true;
|
||||
}
|
||||
match open_url(&url) {
|
||||
Ok(Some(child)) => self.detached_process_children.push(child),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(err = %err, url = %url, "failed to open pane URL");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_pane_double_click(&mut self, mouse: MouseEvent) -> bool {
|
||||
// A pane press stops being a double-click candidate once it becomes
|
||||
// a drag or completes as a real text selection.
|
||||
match mouse.kind {
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
self.last_pane_click = None;
|
||||
return false;
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left)
|
||||
if self
|
||||
.state
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| selection.is_visible()) =>
|
||||
{
|
||||
self.last_pane_click = None;
|
||||
return false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Only terminal-pane left-clicks can start this gesture; other clicks
|
||||
// should keep their existing mouse behavior and clear stale candidates.
|
||||
let Some(click) = self.pane_click_candidate(mouse) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Require the second click to land near the first click in the same pane
|
||||
// and within the double-click window so adjacent interactions do not select a word.
|
||||
if !self.take_pane_double_click(click) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.select_double_clicked_word(click)
|
||||
}
|
||||
|
||||
fn pane_click_candidate(&mut self, mouse: MouseEvent) -> Option<PaneClickState> {
|
||||
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !mouse.modifiers.is_empty() {
|
||||
self.last_pane_click = None;
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.state.mode != Mode::Terminal {
|
||||
self.last_pane_click = None;
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else {
|
||||
self.last_pane_click = None;
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(PaneClickState {
|
||||
pane_id: info.id,
|
||||
viewport_row: mouse.row - info.inner_rect.y,
|
||||
col: mouse.column - info.inner_rect.x,
|
||||
at: std::time::Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
fn take_pane_double_click(&mut self, click: PaneClickState) -> bool {
|
||||
if !self
|
||||
.last_pane_click
|
||||
.is_some_and(|last| last.is_double_click_for(click))
|
||||
{
|
||||
self.last_pane_click = Some(click);
|
||||
return false;
|
||||
}
|
||||
|
||||
self.last_pane_click = None;
|
||||
true
|
||||
}
|
||||
|
||||
fn select_double_clicked_word(&mut self, click: PaneClickState) -> bool {
|
||||
let selected = self.state.select_word_at_pane_cell(
|
||||
&self.terminal_runtimes,
|
||||
click.pane_id,
|
||||
click.viewport_row,
|
||||
click.col,
|
||||
);
|
||||
if selected {
|
||||
self.selection_highlight_clear_deadline = self
|
||||
.state
|
||||
.copy_on_select
|
||||
.then(|| std::time::Instant::now() + super::PANE_COPY_HIGHLIGHT_DURATION);
|
||||
}
|
||||
selected
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_modal_paste_shortcut(key: &KeyEvent) -> bool {
|
||||
if !matches!(key.code, KeyCode::Char('v' | 'V')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
key.modifiers.contains(KeyModifiers::SUPER) || key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn modal_paste_target_active(state: &AppState) -> bool {
|
||||
match state.mode {
|
||||
Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane | Mode::NewLinkedWorktree => {
|
||||
true
|
||||
}
|
||||
Mode::OpenExistingWorktree => state
|
||||
.worktree_open
|
||||
.as_ref()
|
||||
.is_some_and(|open| open.search_focused),
|
||||
Mode::Navigator => state.navigator.search_focused,
|
||||
Mode::KeybindHelp => state.keybind_help.search_focused,
|
||||
Mode::Copy => state
|
||||
.copy_mode
|
||||
.as_ref()
|
||||
.is_some_and(|copy_mode| copy_mode.search.prompt.is_some()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mouse handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App
|
||||
impl AppState {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn split_pane(
|
||||
&mut self,
|
||||
terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry,
|
||||
direction: Direction,
|
||||
) {
|
||||
// Actual PTY spawning happens in Workspace::split_focused
|
||||
// which needs events channel — this is called from navigate_key
|
||||
// where we don't have async context, so the workspace handles it
|
||||
let (rows, cols) = self.estimate_pane_size();
|
||||
let new_rows = (rows / 2).max(4);
|
||||
let new_cols = (cols / 2).max(10);
|
||||
|
||||
let follow_cwd = self
|
||||
.active
|
||||
.and_then(|i| self.workspaces.get(i))
|
||||
.and_then(|ws| {
|
||||
let tab = ws.active_tab()?;
|
||||
let terminal_id = tab.terminal_id(tab.layout.focused())?;
|
||||
super::creation::launch_cwd_for_terminal(
|
||||
terminal_id,
|
||||
&self.terminals,
|
||||
terminal_runtimes,
|
||||
)
|
||||
});
|
||||
let cwd = Some(super::creation::resolve_new_terminal_cwd(
|
||||
&self.new_terminal_cwd,
|
||||
follow_cwd,
|
||||
));
|
||||
|
||||
let previous_focus = self.current_pane_focus_target();
|
||||
if let Some(ws_idx) = self.active {
|
||||
let Some(ws) = self.workspaces.get_mut(ws_idx) else {
|
||||
return;
|
||||
};
|
||||
if let Ok(new_pane) = ws.split_focused(
|
||||
direction,
|
||||
new_rows,
|
||||
new_cols,
|
||||
cwd,
|
||||
self.pane_scrollback_limit_bytes,
|
||||
self.host_terminal_theme,
|
||||
self.host_terminal_appearance,
|
||||
crate::pane::PaneShellConfig::new(&self.default_shell, self.shell_mode),
|
||||
Vec::new(),
|
||||
) {
|
||||
let new_id = new_pane.pane_id;
|
||||
terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime);
|
||||
self.remove_alias_shadowed_by_new_pane(new_id);
|
||||
self.terminals
|
||||
.insert(new_pane.terminal.id.clone(), new_pane.terminal);
|
||||
self.record_pane_focus_change(previous_focus, ws_idx, new_id);
|
||||
self.mark_session_dirty();
|
||||
self.mode = Mode::Terminal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn state_with_workspaces(names: &[&str]) -> AppState {
|
||||
let mut state = AppState::test_new();
|
||||
state.workspaces = names
|
||||
.iter()
|
||||
.map(|name| crate::workspace::Workspace::test_new(name))
|
||||
.collect();
|
||||
if !state.workspaces.is_empty() {
|
||||
state.active = Some(0);
|
||||
state.selected = 0;
|
||||
state.mode = Mode::Navigate;
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn app_for_mouse_test() -> App {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
);
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.state.update_available = None;
|
||||
app.state.latest_release_notes_available = false;
|
||||
app.state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 26, 20);
|
||||
app.state.view.terminal_area = ratatui::layout::Rect::new(26, 0, 80, 20);
|
||||
app
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn mouse(
|
||||
kind: crossterm::event::MouseEventKind,
|
||||
col: u16,
|
||||
row: u16,
|
||||
) -> crossterm::event::MouseEvent {
|
||||
crossterm::event::MouseEvent {
|
||||
kind,
|
||||
column: col,
|
||||
row,
|
||||
modifiers: crossterm::event::KeyModifiers::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn numbered_lines_bytes(count: usize) -> Vec<u8> {
|
||||
(0..count)
|
||||
.map(|i| format!("{i:06}\r\n"))
|
||||
.collect::<String>()
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot {
|
||||
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
||||
crate::persist::capture(
|
||||
&state.workspaces,
|
||||
&state.terminals,
|
||||
&terminal_runtimes,
|
||||
state.active,
|
||||
state.selected,
|
||||
state.sidebar_width,
|
||||
state.sidebar_section_split,
|
||||
state.collapsed_space_keys.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option<f32> {
|
||||
match &snapshot.workspaces.first()?.tabs.first()?.layout {
|
||||
crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio),
|
||||
crate::persist::LayoutSnapshot::Pane(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn unique_temp_path(name: &str) -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(unix)]
|
||||
fn wait_for_file(path: &std::path::Path) -> String {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if let Ok(content) = std::fs::read_to_string(path) {
|
||||
if !content.is_empty() {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
panic!("timed out waiting for {}", path.display());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(unix)]
|
||||
async fn wait_for_detached_process_reap(app: &mut App, pid: u32) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
while crate::platform::process_exists(pid) && tokio::time::Instant::now() < deadline {
|
||||
app.reap_finished_detached_processes();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
app.reap_finished_detached_processes();
|
||||
!crate::platform::process_exists(pid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_app() -> App {
|
||||
App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
None,
|
||||
tokio::sync::mpsc::unbounded_channel().1,
|
||||
crate::api::EventHub::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paste_routes_to_rename_modal_input() {
|
||||
let mut app = test_app();
|
||||
app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::RenameTab;
|
||||
app.state.name_input = "2".into();
|
||||
app.state.name_input_replace_on_type = true;
|
||||
|
||||
app.handle_paste("feature/logs".into()).await;
|
||||
|
||||
assert_eq!(app.state.name_input, "feature/logs");
|
||||
assert!(!app.state.name_input_replace_on_type);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paste_routes_to_keybind_help_query_only_when_searching() {
|
||||
let mut app = test_app();
|
||||
app.state.mode = Mode::KeybindHelp;
|
||||
app.handle_paste("ignored".into()).await;
|
||||
assert!(app.state.keybind_help.query.is_empty());
|
||||
|
||||
app.state.keybind_help.search_focused = true;
|
||||
app.state.keybind_help.scroll = 3;
|
||||
app.handle_paste("work\nspace".into()).await;
|
||||
|
||||
assert_eq!(app.state.keybind_help.query, "workspace");
|
||||
assert_eq!(app.state.keybind_help.scroll, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paste_routes_to_new_linked_worktree_input() {
|
||||
let mut app = test_app();
|
||||
app.state.mode = Mode::NewLinkedWorktree;
|
||||
app.state.name_input = "generated-branch".into();
|
||||
app.state.name_input_replace_on_type = true;
|
||||
app.state.worktree_create = Some(crate::app::state::WorktreeCreateState {
|
||||
source_workspace_id: "source".into(),
|
||||
source_checkout_path: "/repo/herdr".into(),
|
||||
source_existing_membership: None,
|
||||
source_repo_root: "/repo/herdr".into(),
|
||||
repo_key: "repo-key".into(),
|
||||
repo_name: "herdr".into(),
|
||||
branch: "generated-branch".into(),
|
||||
checkout_path: "/repo/herdr-generated-branch".into(),
|
||||
error: None,
|
||||
creating: false,
|
||||
});
|
||||
|
||||
app.handle_paste("feature/linear-302".into()).await;
|
||||
|
||||
assert_eq!(app.state.name_input, "feature/linear-302");
|
||||
assert_eq!(
|
||||
app.state
|
||||
.worktree_create
|
||||
.as_ref()
|
||||
.map(|create| create.branch.as_str()),
|
||||
Some("feature/linear-302")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_paste_shortcut_matches_platform_primary_v() {
|
||||
#[cfg(target_os = "macos")]
|
||||
let modifiers = KeyModifiers::SUPER;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let modifiers = KeyModifiers::CONTROL;
|
||||
|
||||
assert!(is_modal_paste_shortcut(&KeyEvent::new(
|
||||
KeyCode::Char('v'),
|
||||
modifiers
|
||||
)));
|
||||
assert!(is_modal_paste_shortcut(&KeyEvent::new(
|
||||
KeyCode::Char('V'),
|
||||
modifiers | KeyModifiers::SHIFT
|
||||
)));
|
||||
assert!(!is_modal_paste_shortcut(&KeyEvent::new(
|
||||
KeyCode::Char('v'),
|
||||
KeyModifiers::ALT
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_paste_target_is_active_only_for_text_inputs() {
|
||||
let mut state = AppState::test_new();
|
||||
|
||||
state.mode = Mode::RenameTab;
|
||||
assert!(modal_paste_target_active(&state));
|
||||
|
||||
state.mode = Mode::Navigator;
|
||||
state.navigator.search_focused = false;
|
||||
assert!(!modal_paste_target_active(&state));
|
||||
state.navigator.search_focused = true;
|
||||
assert!(modal_paste_target_active(&state));
|
||||
|
||||
state.mode = Mode::KeybindHelp;
|
||||
state.keybind_help.search_focused = false;
|
||||
assert!(!modal_paste_target_active(&state));
|
||||
state.keybind_help.search_focused = true;
|
||||
assert!(modal_paste_target_active(&state));
|
||||
|
||||
state.mode = Mode::ConfirmClose;
|
||||
assert!(!modal_paste_target_active(&state));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,790 +0,0 @@
|
||||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
widgets::{Block, Borders},
|
||||
};
|
||||
|
||||
use crate::app::{
|
||||
state::{AppState, DragState, DragTarget, Mode, NavigatorTarget},
|
||||
App,
|
||||
};
|
||||
|
||||
use super::{
|
||||
modal::{keybind_help_back, leave_modal, modal_action_from_buttons, ModalAction},
|
||||
ScrollbarClickTarget,
|
||||
};
|
||||
|
||||
fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
|
||||
col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool {
|
||||
if self.state.mode == Mode::ReleaseNotes {
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left)
|
||||
if self
|
||||
.state
|
||||
.release_notes_close_button_at(mouse.column, mouse.row) =>
|
||||
{
|
||||
self.dismiss_release_notes();
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if let Some(target) = self
|
||||
.state
|
||||
.release_notes_scrollbar_target_at(mouse.column, mouse.row)
|
||||
{
|
||||
match target {
|
||||
ScrollbarClickTarget::Thumb { grab_row_offset } => {
|
||||
self.state.drag = Some(DragState {
|
||||
target: DragTarget::ReleaseNotesScrollbar { grab_row_offset },
|
||||
});
|
||||
}
|
||||
ScrollbarClickTarget::Track { offset_from_bottom } => {
|
||||
self.state
|
||||
.set_release_notes_offset_from_bottom(offset_from_bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
if let Some(DragState {
|
||||
target: DragTarget::ReleaseNotesScrollbar { grab_row_offset },
|
||||
}) = &self.state.drag
|
||||
{
|
||||
if let Some(offset_from_bottom) = self
|
||||
.state
|
||||
.release_notes_offset_for_drag_row(mouse.row, *grab_row_offset)
|
||||
{
|
||||
self.state
|
||||
.set_release_notes_offset_from_bottom(offset_from_bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
self.state.drag = None;
|
||||
}
|
||||
MouseEventKind::ScrollUp => self.scroll_release_notes(-3),
|
||||
MouseEventKind::ScrollDown => self.scroll_release_notes(3),
|
||||
_ => {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.state.mode == Mode::ProductAnnouncement {
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left)
|
||||
if self
|
||||
.state
|
||||
.product_announcement_close_button_at(mouse.column, mouse.row) =>
|
||||
{
|
||||
self.dismiss_product_announcement();
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if let Some(target) = self
|
||||
.state
|
||||
.product_announcement_scrollbar_target_at(mouse.column, mouse.row)
|
||||
{
|
||||
match target {
|
||||
ScrollbarClickTarget::Thumb { grab_row_offset } => {
|
||||
self.state.drag = Some(DragState {
|
||||
target: DragTarget::ProductAnnouncementScrollbar {
|
||||
grab_row_offset,
|
||||
},
|
||||
});
|
||||
}
|
||||
ScrollbarClickTarget::Track { offset_from_bottom } => self
|
||||
.state
|
||||
.set_product_announcement_offset_from_bottom(offset_from_bottom),
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
if let Some(DragState {
|
||||
target: DragTarget::ProductAnnouncementScrollbar { grab_row_offset },
|
||||
}) = &self.state.drag
|
||||
{
|
||||
if let Some(offset_from_bottom) = self
|
||||
.state
|
||||
.product_announcement_offset_for_drag_row(mouse.row, *grab_row_offset)
|
||||
{
|
||||
self.state
|
||||
.set_product_announcement_offset_from_bottom(offset_from_bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
self.state.drag = None;
|
||||
}
|
||||
MouseEventKind::ScrollUp => self.scroll_product_announcement(-3),
|
||||
MouseEventKind::ScrollDown => self.scroll_product_announcement(3),
|
||||
_ => {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.state.mode == Mode::Navigator {
|
||||
match mouse.kind {
|
||||
MouseEventKind::Moved => {
|
||||
if let Some(idx) = self.state.navigator_row_index_at_from(
|
||||
&self.terminal_runtimes,
|
||||
mouse.column,
|
||||
mouse.row,
|
||||
) {
|
||||
self.state.navigator.selected = idx;
|
||||
self.state
|
||||
.ensure_navigator_selection_visible_from(&self.terminal_runtimes);
|
||||
}
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if self
|
||||
.state
|
||||
.navigator_search_contains(mouse.column, mouse.row)
|
||||
{
|
||||
self.state.navigator.search_focused = true;
|
||||
} else if let Some(idx) = self.state.navigator_row_index_at_from(
|
||||
&self.terminal_runtimes,
|
||||
mouse.column,
|
||||
mouse.row,
|
||||
) {
|
||||
self.state.navigator.selected = idx;
|
||||
let target = self
|
||||
.state
|
||||
.navigator_rows_from(&self.terminal_runtimes)
|
||||
.get(idx)
|
||||
.map(|row| (row.target.clone(), row.is_workspace));
|
||||
if let Some((NavigatorTarget::Workspace { .. }, true)) = target {
|
||||
if self.state.navigator_row_caret_at(mouse.column) {
|
||||
self.state.toggle_selected_navigator_workspace_from(
|
||||
&self.terminal_runtimes,
|
||||
);
|
||||
} else {
|
||||
self.state
|
||||
.accept_navigator_selection_from(&self.terminal_runtimes);
|
||||
}
|
||||
} else {
|
||||
self.state
|
||||
.accept_navigator_selection_from(&self.terminal_runtimes);
|
||||
}
|
||||
} else if !self.state.navigator_popup_contains(mouse.column, mouse.row) {
|
||||
leave_modal(&mut self.state);
|
||||
}
|
||||
}
|
||||
MouseEventKind::ScrollUp => {
|
||||
self.state.navigator.scroll = self.state.navigator.scroll.saturating_sub(3);
|
||||
self.state
|
||||
.align_navigator_selection_to_scroll_from(&self.terminal_runtimes);
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
let viewport = self.state.navigator_body_rect().height as usize;
|
||||
let max = self
|
||||
.state
|
||||
.navigator_max_scroll_from(&self.terminal_runtimes, viewport);
|
||||
self.state.navigator.scroll =
|
||||
self.state.navigator.scroll.saturating_add(3).min(max);
|
||||
self.state
|
||||
.align_navigator_selection_to_scroll_from(&self.terminal_runtimes);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.state.mode == Mode::KeybindHelp {
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left)
|
||||
if self
|
||||
.state
|
||||
.keybind_help_close_button_at(mouse.column, mouse.row) =>
|
||||
{
|
||||
keybind_help_back(&mut self.state);
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if let Some(target) = self
|
||||
.state
|
||||
.keybind_help_scrollbar_target_at(mouse.column, mouse.row)
|
||||
{
|
||||
match target {
|
||||
ScrollbarClickTarget::Thumb { grab_row_offset } => {
|
||||
self.state.drag = Some(DragState {
|
||||
target: DragTarget::KeybindHelpScrollbar { grab_row_offset },
|
||||
});
|
||||
}
|
||||
ScrollbarClickTarget::Track { offset_from_bottom } => {
|
||||
self.state
|
||||
.set_keybind_help_offset_from_bottom(offset_from_bottom);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let rect = self.state.keybind_help_popup_rect();
|
||||
let inside = mouse.column >= rect.x
|
||||
&& mouse.column < rect.x + rect.width
|
||||
&& mouse.row >= rect.y
|
||||
&& mouse.row < rect.y + rect.height;
|
||||
if !inside {
|
||||
leave_modal(&mut self.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
if let Some(DragState {
|
||||
target: DragTarget::KeybindHelpScrollbar { grab_row_offset },
|
||||
}) = &self.state.drag
|
||||
{
|
||||
if let Some(offset_from_bottom) = self
|
||||
.state
|
||||
.keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset)
|
||||
{
|
||||
self.state
|
||||
.set_keybind_help_offset_from_bottom(offset_from_bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
self.state.drag = None;
|
||||
}
|
||||
MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3),
|
||||
MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3),
|
||||
_ => {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub(super) fn onboarding_full_area(&self) -> Rect {
|
||||
self.view.sidebar_rect.union(self.view.terminal_area)
|
||||
}
|
||||
|
||||
pub(crate) fn navigator_popup_rect(&self) -> Rect {
|
||||
let area = self.onboarding_full_area();
|
||||
let margin_x = (area.width / 16).max(2);
|
||||
let margin_y = (area.height / 10).max(1);
|
||||
let width = area.width.saturating_sub(margin_x.saturating_mul(2));
|
||||
let height = area.height.saturating_sub(margin_y.saturating_mul(2));
|
||||
Rect::new(
|
||||
area.x + margin_x,
|
||||
area.y + margin_y,
|
||||
width.max(4),
|
||||
height.max(4),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn navigator_inner_rect(&self) -> Rect {
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.inner(self.navigator_popup_rect())
|
||||
}
|
||||
|
||||
pub(crate) fn navigator_search_rect(&self) -> Rect {
|
||||
let inner = self.navigator_inner_rect();
|
||||
Rect::new(inner.x, inner.y, inner.width, inner.height.min(1))
|
||||
}
|
||||
|
||||
pub(crate) fn navigator_body_rect(&self) -> Rect {
|
||||
let inner = self.navigator_inner_rect();
|
||||
if inner.height <= 4 {
|
||||
return Rect::default();
|
||||
}
|
||||
Rect::new(
|
||||
inner.x,
|
||||
inner.y + 2,
|
||||
inner.width,
|
||||
inner.height.saturating_sub(4),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn navigator_detail_rect(&self) -> Rect {
|
||||
let inner = self.navigator_inner_rect();
|
||||
Rect::new(
|
||||
inner.x,
|
||||
inner.y + inner.height.saturating_sub(2),
|
||||
inner.width,
|
||||
inner.height.min(1),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn navigator_footer_rect(&self) -> Rect {
|
||||
let inner = self.navigator_inner_rect();
|
||||
Rect::new(
|
||||
inner.x,
|
||||
inner.y + inner.height.saturating_sub(1),
|
||||
inner.width,
|
||||
inner.height.min(1),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn navigator_popup_contains(&self, col: u16, row: u16) -> bool {
|
||||
rect_contains(self.navigator_popup_rect(), col, row)
|
||||
}
|
||||
|
||||
pub(crate) fn navigator_search_contains(&self, col: u16, row: u16) -> bool {
|
||||
rect_contains(self.navigator_search_rect(), col, row)
|
||||
}
|
||||
|
||||
pub(crate) fn navigator_row_index_at_from(
|
||||
&self,
|
||||
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
|
||||
col: u16,
|
||||
row: u16,
|
||||
) -> Option<usize> {
|
||||
let body = self.navigator_body_rect();
|
||||
if !rect_contains(body, col, row) {
|
||||
return None;
|
||||
}
|
||||
let line_idx = self
|
||||
.navigator
|
||||
.scroll
|
||||
.saturating_add(row.saturating_sub(body.y) as usize);
|
||||
let lines = crate::app::state::navigator_display_lines(
|
||||
&self.navigator_rows_from(terminal_runtimes),
|
||||
);
|
||||
match lines.get(line_idx) {
|
||||
Some(crate::app::state::NavigatorDisplayLine::Row(idx)) => Some(*idx),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn navigator_row_caret_at(&self, col: u16) -> bool {
|
||||
let body = self.navigator_body_rect();
|
||||
col <= body.x.saturating_add(3)
|
||||
}
|
||||
|
||||
pub(super) fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option<Rect> {
|
||||
let area = self.onboarding_full_area();
|
||||
let popup_w = popup_w.min(area.width.saturating_sub(4));
|
||||
let popup_h = popup_h.min(area.height.saturating_sub(2));
|
||||
if popup_w < 4 || popup_h < 4 {
|
||||
return None;
|
||||
}
|
||||
let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
|
||||
let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
|
||||
let popup = Rect::new(popup_x, popup_y, popup_w, popup_h);
|
||||
Some(Block::default().borders(Borders::ALL).inner(popup))
|
||||
}
|
||||
|
||||
fn release_notes_modal_inner(&self) -> Option<Rect> {
|
||||
self.onboarding_modal_inner(
|
||||
crate::ui::RELEASE_NOTES_MODAL_SIZE.0,
|
||||
crate::ui::RELEASE_NOTES_MODAL_SIZE.1,
|
||||
)
|
||||
}
|
||||
|
||||
fn product_announcement_modal_inner(&self) -> Option<Rect> {
|
||||
self.onboarding_modal_inner(
|
||||
crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0,
|
||||
crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1,
|
||||
)
|
||||
}
|
||||
|
||||
fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool {
|
||||
let Some(inner) = self.release_notes_modal_inner() else {
|
||||
return false;
|
||||
};
|
||||
if inner.height < 4 || inner.width < 12 {
|
||||
return false;
|
||||
}
|
||||
let button =
|
||||
crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
|
||||
col >= button.x
|
||||
&& col < button.x + button.width
|
||||
&& row >= button.y
|
||||
&& row < button.y + button.height
|
||||
}
|
||||
|
||||
pub(super) fn rename_modal_inner(&self) -> Option<Rect> {
|
||||
self.onboarding_modal_inner(56, 7)
|
||||
}
|
||||
|
||||
fn release_notes_body_rect(&self) -> Option<Rect> {
|
||||
let inner = self.release_notes_modal_inner()?;
|
||||
if inner.height < 8 || inner.width < 4 {
|
||||
return None;
|
||||
}
|
||||
Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content)
|
||||
}
|
||||
|
||||
fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
|
||||
Some(crate::ui::release_notes_scroll_metrics(
|
||||
self.release_notes.as_ref()?,
|
||||
&self.update_install_command,
|
||||
self.release_notes_body_rect()?,
|
||||
&self.palette,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn release_notes_max_scroll(&self) -> u16 {
|
||||
self.release_notes_scroll_metrics()
|
||||
.map(|metrics| metrics.max_offset_from_bottom as u16)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn release_notes_scrollbar_target_at(
|
||||
&self,
|
||||
col: u16,
|
||||
row: u16,
|
||||
) -> Option<ScrollbarClickTarget> {
|
||||
let body = self.release_notes_body_rect()?;
|
||||
let metrics = self.release_notes_scroll_metrics()?;
|
||||
let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
|
||||
if !(col >= track.x
|
||||
&& col < track.x + track.width
|
||||
&& row >= track.y
|
||||
&& row < track.y + track.height)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
|
||||
Some(ScrollbarClickTarget::Thumb { grab_row_offset })
|
||||
} else {
|
||||
Some(ScrollbarClickTarget::Track {
|
||||
offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
|
||||
let body = self.release_notes_body_rect()?;
|
||||
let metrics = self.release_notes_scroll_metrics()?;
|
||||
let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
|
||||
Some(crate::ui::scrollbar_offset_from_drag_row(
|
||||
metrics,
|
||||
track,
|
||||
row,
|
||||
grab_row_offset,
|
||||
))
|
||||
}
|
||||
|
||||
fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) {
|
||||
let max_scroll = self.release_notes_max_scroll() as usize;
|
||||
if let Some(notes) = &mut self.release_notes {
|
||||
notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16;
|
||||
}
|
||||
}
|
||||
|
||||
fn product_announcement_close_button_at(&self, col: u16, row: u16) -> bool {
|
||||
let Some(inner) = self.product_announcement_modal_inner() else {
|
||||
return false;
|
||||
};
|
||||
if inner.height < 4 || inner.width < 12 {
|
||||
return false;
|
||||
}
|
||||
let button =
|
||||
crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
|
||||
col >= button.x
|
||||
&& col < button.x + button.width
|
||||
&& row >= button.y
|
||||
&& row < button.y + button.height
|
||||
}
|
||||
|
||||
fn product_announcement_body_rect(&self) -> Option<Rect> {
|
||||
let inner = self.product_announcement_modal_inner()?;
|
||||
if inner.height < 8 || inner.width < 4 {
|
||||
return None;
|
||||
}
|
||||
Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content)
|
||||
}
|
||||
|
||||
fn product_announcement_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
|
||||
Some(crate::ui::product_announcement_scroll_metrics(
|
||||
self.product_announcement.as_ref()?,
|
||||
self.product_announcement_body_rect()?,
|
||||
&self.palette,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn product_announcement_max_scroll(&self) -> u16 {
|
||||
self.product_announcement_scroll_metrics()
|
||||
.map(|metrics| metrics.max_offset_from_bottom as u16)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn product_announcement_scrollbar_target_at(
|
||||
&self,
|
||||
col: u16,
|
||||
row: u16,
|
||||
) -> Option<ScrollbarClickTarget> {
|
||||
let body = self.product_announcement_body_rect()?;
|
||||
let metrics = self.product_announcement_scroll_metrics()?;
|
||||
let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
|
||||
if !(col >= track.x
|
||||
&& col < track.x + track.width
|
||||
&& row >= track.y
|
||||
&& row < track.y + track.height)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
|
||||
Some(ScrollbarClickTarget::Thumb { grab_row_offset })
|
||||
} else {
|
||||
Some(ScrollbarClickTarget::Track {
|
||||
offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn product_announcement_offset_for_drag_row(
|
||||
&self,
|
||||
row: u16,
|
||||
grab_row_offset: u16,
|
||||
) -> Option<usize> {
|
||||
let body = self.product_announcement_body_rect()?;
|
||||
let metrics = self.product_announcement_scroll_metrics()?;
|
||||
let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
|
||||
Some(crate::ui::scrollbar_offset_from_drag_row(
|
||||
metrics,
|
||||
track,
|
||||
row,
|
||||
grab_row_offset,
|
||||
))
|
||||
}
|
||||
|
||||
fn set_product_announcement_offset_from_bottom(&mut self, offset_from_bottom: usize) {
|
||||
let max_scroll = self.product_announcement_max_scroll() as usize;
|
||||
if let Some(announcement) = &mut self.product_announcement {
|
||||
announcement.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) {
|
||||
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(inner) = self.onboarding_modal_inner(64, 16) else {
|
||||
return;
|
||||
};
|
||||
let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1)
|
||||
.actions
|
||||
.unwrap_or_default();
|
||||
let button = crate::ui::onboarding_welcome_continue_rect(actions);
|
||||
if modal_action_from_buttons(mouse.column, mouse.row, &[(button, ModalAction::Continue)])
|
||||
== Some(ModalAction::Continue)
|
||||
{
|
||||
self.request_complete_onboarding = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn keybind_help_popup_rect(&self) -> Rect {
|
||||
crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn keybind_help_modal_inner(&self) -> Option<Rect> {
|
||||
self.onboarding_modal_inner(76, 22)
|
||||
}
|
||||
|
||||
fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool {
|
||||
let Some(inner) = self.keybind_help_modal_inner() else {
|
||||
return false;
|
||||
};
|
||||
if inner.height < 4 || inner.width < 12 {
|
||||
return false;
|
||||
}
|
||||
let button =
|
||||
crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
|
||||
col >= button.x
|
||||
&& col < button.x + button.width
|
||||
&& row >= button.y
|
||||
&& row < button.y + button.height
|
||||
}
|
||||
|
||||
fn keybind_help_body_rect(&self) -> Option<Rect> {
|
||||
let inner = self.keybind_help_modal_inner()?;
|
||||
if inner.height < 6 || inner.width < 4 {
|
||||
return None;
|
||||
}
|
||||
Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content)
|
||||
}
|
||||
|
||||
fn keybind_help_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
|
||||
let body = self.keybind_help_body_rect()?;
|
||||
let viewport_rows = body.height.max(1) as usize;
|
||||
let wrap_width = body.width.max(1) as usize;
|
||||
let total_rows = crate::ui::keybind_help_lines(self)
|
||||
.into_iter()
|
||||
.map(|(width, _)| width.max(1).div_ceil(wrap_width))
|
||||
.sum::<usize>();
|
||||
let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
|
||||
Some(crate::pane::ScrollMetrics {
|
||||
offset_from_bottom: max_offset_from_bottom
|
||||
.saturating_sub(self.keybind_help.scroll as usize),
|
||||
max_offset_from_bottom,
|
||||
viewport_rows,
|
||||
})
|
||||
}
|
||||
|
||||
fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option<ScrollbarClickTarget> {
|
||||
let body = self.keybind_help_body_rect()?;
|
||||
let metrics = self.keybind_help_scroll_metrics()?;
|
||||
let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
|
||||
if !(col >= track.x
|
||||
&& col < track.x + track.width
|
||||
&& row >= track.y
|
||||
&& row < track.y + track.height)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
|
||||
Some(ScrollbarClickTarget::Thumb { grab_row_offset })
|
||||
} else {
|
||||
Some(ScrollbarClickTarget::Track {
|
||||
offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
|
||||
let body = self.keybind_help_body_rect()?;
|
||||
let metrics = self.keybind_help_scroll_metrics()?;
|
||||
let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
|
||||
Some(crate::ui::scrollbar_offset_from_drag_row(
|
||||
metrics,
|
||||
track,
|
||||
row,
|
||||
grab_row_offset,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn keybind_help_max_scroll(&self) -> u16 {
|
||||
self.keybind_help_scroll_metrics()
|
||||
.map(|metrics| metrics.max_offset_from_bottom as u16)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) {
|
||||
let max_scroll = self.keybind_help_max_scroll() as usize;
|
||||
self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16;
|
||||
}
|
||||
|
||||
pub(super) fn scroll_keybind_help(&mut self, delta: i16) {
|
||||
let max_scroll = self.keybind_help_max_scroll();
|
||||
let current = self.keybind_help.scroll as i16;
|
||||
self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crossterm::event::{MouseButton, MouseEventKind};
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use super::super::{app_for_mouse_test, mouse};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clicking_keybind_help_close_button_closes_overlay() {
|
||||
let mut app = app_for_mouse_test();
|
||||
app.state.mode = Mode::KeybindHelp;
|
||||
|
||||
let rect = app.state.keybind_help_popup_rect();
|
||||
let inner = Rect::new(
|
||||
rect.x + 1,
|
||||
rect.y + 1,
|
||||
rect.width.saturating_sub(2),
|
||||
rect.height.saturating_sub(2),
|
||||
);
|
||||
let close =
|
||||
crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
close.x,
|
||||
close.y,
|
||||
));
|
||||
|
||||
assert_eq!(app.state.mode, Mode::Navigate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_keybind_help_back_button_leaves_help_open() {
|
||||
let mut app = app_for_mouse_test();
|
||||
app.state.mode = Mode::KeybindHelp;
|
||||
app.state.keybind_help.search_focused = true;
|
||||
app.state.keybind_help.query = "work".into();
|
||||
|
||||
let rect = app.state.keybind_help_popup_rect();
|
||||
let inner = Rect::new(
|
||||
rect.x + 1,
|
||||
rect.y + 1,
|
||||
rect.width.saturating_sub(2),
|
||||
rect.height.saturating_sub(2),
|
||||
);
|
||||
let back =
|
||||
crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
back.x,
|
||||
back.y,
|
||||
));
|
||||
|
||||
assert_eq!(app.state.mode, Mode::KeybindHelp);
|
||||
assert!(!app.state.keybind_help.search_focused);
|
||||
assert!(app.state.keybind_help.query.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onboarding_hover_does_not_change_selection() {
|
||||
let mut app = app_for_mouse_test();
|
||||
app.state.mode = Mode::Onboarding;
|
||||
|
||||
let inner = app.state.onboarding_modal_inner(64, 16).unwrap();
|
||||
let content = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1).content;
|
||||
app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y));
|
||||
|
||||
assert!(!app.state.request_complete_onboarding);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onboarding_click_continue_requests_completion() {
|
||||
let mut app = app_for_mouse_test();
|
||||
app.state.mode = Mode::Onboarding;
|
||||
|
||||
let inner = app.state.onboarding_modal_inner(64, 16).unwrap();
|
||||
let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1)
|
||||
.actions
|
||||
.unwrap();
|
||||
let continue_rect = crate::ui::onboarding_welcome_continue_rect(actions);
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
continue_rect.x,
|
||||
continue_rect.y,
|
||||
));
|
||||
|
||||
assert!(app.state.request_complete_onboarding);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_notes_preview_scrollbar_uses_full_content_body() {
|
||||
let mut app = app_for_mouse_test();
|
||||
app.state.view.sidebar_rect = Rect::new(0, 0, 24, 16);
|
||||
app.state.view.terminal_area = Rect::new(24, 0, 96, 16);
|
||||
app.state.release_notes = Some(crate::app::state::ReleaseNotesState {
|
||||
version: "9.9.9".into(),
|
||||
body: "### Added\n- Custom command keybindings now accept an optional description field.\n\n### Fixed\n- Sidebar Git status refresh now deduplicates workspaces.\n- Large restored sessions no longer leave panes without shells after startup.\n- Pane shutdown no longer warns after the direct child has already exited.\n- Closing the last pane or tab in a parent worktree workspace now shows the existing confirmation before closing the whole worktree group.\n- Update prompts, toasts, and docs now distinguish installing a new binary from stopping or reattaching a running Herdr session to use it."
|
||||
.into(),
|
||||
scroll: 0,
|
||||
preview: true,
|
||||
});
|
||||
app.state.update_install_command = "brew update && brew upgrade herdr".into();
|
||||
|
||||
let inner = app.state.release_notes_modal_inner().unwrap();
|
||||
let expected_body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content;
|
||||
let body = app.state.release_notes_body_rect().unwrap();
|
||||
|
||||
assert_eq!(body, expected_body);
|
||||
|
||||
let metrics = app.state.release_notes_scroll_metrics().unwrap();
|
||||
assert_eq!(metrics.viewport_rows, body.height as usize);
|
||||
assert!(metrics.max_offset_from_bottom > 0);
|
||||
|
||||
let track = crate::ui::release_notes_scrollbar_rect(body, metrics).unwrap();
|
||||
assert_eq!(track.y, body.y);
|
||||
assert!(matches!(
|
||||
app.state
|
||||
.release_notes_scrollbar_target_at(track.x, track.y),
|
||||
Some(ScrollbarClickTarget::Thumb { .. } | ScrollbarClickTarget::Track { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
use crossterm::event::{MouseEvent, MouseEventKind};
|
||||
|
||||
use crate::{
|
||||
app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection},
|
||||
terminal::TerminalRuntimeRegistry,
|
||||
};
|
||||
|
||||
impl AppState {
|
||||
pub(crate) fn update_selection_cursor(
|
||||
&mut self,
|
||||
terminal_runtimes: &TerminalRuntimeRegistry,
|
||||
pane_id: crate::layout::PaneId,
|
||||
screen_col: u16,
|
||||
screen_row: u16,
|
||||
) {
|
||||
let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
|
||||
return;
|
||||
};
|
||||
let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id);
|
||||
if let Some(selection) = self.selection.as_mut() {
|
||||
selection.drag(screen_col, screen_row, info.inner_rect, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_edge_scroll_lines(distance: u16) -> usize {
|
||||
usize::from(distance).saturating_mul(3).clamp(3, 15)
|
||||
}
|
||||
|
||||
pub(super) fn update_selection_drag(
|
||||
&mut self,
|
||||
terminal_runtimes: &TerminalRuntimeRegistry,
|
||||
screen_col: u16,
|
||||
screen_row: u16,
|
||||
) {
|
||||
let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let top = info.inner_rect.y;
|
||||
let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1);
|
||||
|
||||
// Only activate autoscroll when the user is actively dragging.
|
||||
// An anchored click in the hot zone should not start the timer.
|
||||
// Check before advancing the cursor: if already Dragging from a prior
|
||||
// event, it stays true. If Anchored, the mouse must have moved away
|
||||
// from the anchor cell for this to count as a real drag.
|
||||
let was_dragging = self.selection.as_ref().is_some_and(|s| s.is_dragging());
|
||||
let anchor_differs_from_mouse = self.selection.as_ref().is_some_and(|s| {
|
||||
// Convert anchor to screen coords for comparison.
|
||||
// Anchor is stored in absolute row; for a simple screen
|
||||
// comparison, check whether the mouse is on a different
|
||||
// cell than the anchor's screen position.
|
||||
let (ar, ac) = s.anchor_screen_pos(
|
||||
info.inner_rect,
|
||||
self.pane_scroll_metrics(terminal_runtimes, s.pane_id),
|
||||
);
|
||||
ar != screen_row || ac != screen_col
|
||||
});
|
||||
let is_dragging = was_dragging || anchor_differs_from_mouse;
|
||||
|
||||
// Advance the selection cursor.
|
||||
self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row);
|
||||
|
||||
// If the mouse is on a different cell than the anchor but drag()
|
||||
// didn't transition (cursor clamped to edge == anchor), force
|
||||
// Dragging so the selection becomes visible and autoscroll can run.
|
||||
if is_dragging {
|
||||
if let Some(sel) = self.selection.as_mut() {
|
||||
if sel.is_just_click() {
|
||||
sel.force_dragging();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if screen_row < top {
|
||||
// Cursor above pane — immediate scroll + set autoscroll state
|
||||
if is_dragging {
|
||||
self.scroll_pane_up(
|
||||
terminal_runtimes,
|
||||
pane_id,
|
||||
Self::selection_edge_scroll_lines(top - screen_row),
|
||||
);
|
||||
// Re-advance cursor after scroll so it reflects the new viewport position
|
||||
self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row);
|
||||
self.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: SelectionAutoscrollDirection::Up,
|
||||
last_mouse_screen_col: screen_col,
|
||||
last_mouse_screen_row: screen_row,
|
||||
inner_rect: info.inner_rect,
|
||||
});
|
||||
}
|
||||
} else if screen_row > bottom {
|
||||
// Cursor below pane — immediate scroll + set autoscroll state
|
||||
if is_dragging {
|
||||
self.scroll_pane_down(
|
||||
terminal_runtimes,
|
||||
pane_id,
|
||||
Self::selection_edge_scroll_lines(screen_row - bottom),
|
||||
);
|
||||
// Re-advance cursor after scroll so it reflects the new viewport position
|
||||
self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row);
|
||||
self.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: screen_col,
|
||||
last_mouse_screen_row: screen_row,
|
||||
inner_rect: info.inner_rect,
|
||||
});
|
||||
}
|
||||
} else if screen_row == top {
|
||||
// Hot zone: top edge row — no immediate scroll, set autoscroll state
|
||||
if is_dragging {
|
||||
self.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: SelectionAutoscrollDirection::Up,
|
||||
last_mouse_screen_col: screen_col,
|
||||
last_mouse_screen_row: screen_row,
|
||||
inner_rect: info.inner_rect,
|
||||
});
|
||||
} else {
|
||||
self.selection_autoscroll = None;
|
||||
}
|
||||
} else if screen_row == bottom {
|
||||
// Hot zone: bottom edge row — no immediate scroll, set autoscroll state
|
||||
if is_dragging {
|
||||
self.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: screen_col,
|
||||
last_mouse_screen_row: screen_row,
|
||||
inner_rect: info.inner_rect,
|
||||
});
|
||||
} else {
|
||||
self.selection_autoscroll = None;
|
||||
}
|
||||
} else {
|
||||
// Safe zone: inside pane, not on edge rows — clear autoscroll
|
||||
self.selection_autoscroll = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn scroll_selection_with_wheel(
|
||||
&mut self,
|
||||
terminal_runtimes: &TerminalRuntimeRegistry,
|
||||
mouse: MouseEvent,
|
||||
) -> bool {
|
||||
let lines_per_notch = self.mouse_scroll_lines;
|
||||
|
||||
let Some(selection) = self.selection.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if !selection.is_in_progress() {
|
||||
return false;
|
||||
}
|
||||
let pane_id = selection.pane_id;
|
||||
self.focus_pane(pane_id);
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollUp => {
|
||||
self.scroll_pane_up(terminal_runtimes, pane_id, lines_per_notch)
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
self.scroll_pane_down(terminal_runtimes, pane_id, lines_per_notch)
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
self.update_selection_cursor(terminal_runtimes, pane_id, mouse.column, mouse.row);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod autoscroll_tests {
|
||||
use super::*;
|
||||
use crate::layout::PaneInfo;
|
||||
use crate::terminal::TerminalRuntimeRegistry;
|
||||
use crate::workspace::Workspace;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
/// Build an AppState with one workspace/pane and pane_infos populated
|
||||
/// so pane_info_by_id works. Returns (state, pane_id).
|
||||
fn make_state_with_pane() -> (AppState, crate::layout::PaneId) {
|
||||
let mut state = AppState::test_new();
|
||||
let ws = Workspace::test_new("test");
|
||||
let pane_id = ws.tabs[0].root_pane;
|
||||
state.workspaces.push(ws);
|
||||
state.active = Some(0);
|
||||
state.view.pane_infos.push(PaneInfo {
|
||||
id: pane_id,
|
||||
rect: Rect::new(0, 0, 80, 24),
|
||||
inner_rect: Rect::new(0, 0, 80, 24),
|
||||
scrollbar_rect: None,
|
||||
borders: ratatui::widgets::Borders::NONE,
|
||||
is_focused: true,
|
||||
});
|
||||
(state, pane_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn above_pane_sets_autoscroll_up() {
|
||||
// Build state with pane starting at row 5 so we can drag above it
|
||||
let mut state = AppState::test_new();
|
||||
let ws = Workspace::test_new("test");
|
||||
let pane_id = ws.tabs[0].root_pane;
|
||||
state.workspaces.push(ws);
|
||||
state.active = Some(0);
|
||||
state.view.pane_infos.push(PaneInfo {
|
||||
id: pane_id,
|
||||
rect: Rect::new(0, 5, 80, 24),
|
||||
inner_rect: Rect::new(0, 5, 80, 24),
|
||||
scrollbar_rect: None,
|
||||
borders: ratatui::widgets::Borders::NONE,
|
||||
is_focused: true,
|
||||
});
|
||||
// Anchor at (5, 10), drag to different cell above pane
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None);
|
||||
sel.drag(4, 5, Rect::new(0, 5, 80, 24), None);
|
||||
state.selection = Some(sel);
|
||||
let terminal_runtimes = TerminalRuntimeRegistry::new();
|
||||
state.update_selection_drag(&terminal_runtimes, 5, 4);
|
||||
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
|
||||
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_hot_zone_sets_autoscroll_up_on_drag() {
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
// Anchor at (5, 10), drag to top edge row (row 0) — different cell
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None);
|
||||
sel.drag(0, 0, Rect::new(0, 0, 80, 24), None);
|
||||
state.selection = Some(sel);
|
||||
let terminal_runtimes = TerminalRuntimeRegistry::new();
|
||||
state.update_selection_drag(&terminal_runtimes, 0, 0);
|
||||
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
|
||||
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_hot_zone_clears_autoscroll_on_click() {
|
||||
// An anchored click on the top edge row should NOT start autoscroll.
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None));
|
||||
// Same-cell drag on top edge row — still anchored
|
||||
let terminal_runtimes = TerminalRuntimeRegistry::new();
|
||||
state.update_selection_drag(&terminal_runtimes, 0, 0);
|
||||
assert!(state.selection_autoscroll.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_hot_zone_sets_autoscroll_down_on_drag() {
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
// Anchor at (0, 0), drag to bottom edge row (row 23) — different cell
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
|
||||
sel.drag(23, 0, Rect::new(0, 0, 80, 24), None);
|
||||
state.selection = Some(sel);
|
||||
let terminal_runtimes = TerminalRuntimeRegistry::new();
|
||||
state.update_selection_drag(&terminal_runtimes, 0, 23);
|
||||
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
|
||||
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_hot_zone_clears_autoscroll_on_click() {
|
||||
// An anchored click on the bottom edge row should NOT start autoscroll.
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
// Anchor at bottom edge row
|
||||
state.selection = Some(crate::selection::Selection::anchor(pane_id, 23, 0, None));
|
||||
// Same-cell drag — still anchored
|
||||
let terminal_runtimes = TerminalRuntimeRegistry::new();
|
||||
state.update_selection_drag(&terminal_runtimes, 0, 23);
|
||||
assert!(state.selection_autoscroll.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_pane_sets_autoscroll_down_on_drag() {
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
// Anchor at (0, 0), drag to different cell below pane
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
|
||||
sel.drag(5, 5, Rect::new(0, 0, 80, 24), None);
|
||||
state.selection = Some(sel);
|
||||
// Drag cursor one row below the pane bottom
|
||||
let terminal_runtimes = TerminalRuntimeRegistry::new();
|
||||
state.update_selection_drag(&terminal_runtimes, 0, 24);
|
||||
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
|
||||
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_zone_clears_autoscroll() {
|
||||
let (mut state, pane_id) = make_state_with_pane();
|
||||
// Anchor at (0, 0), drag to (5, 5) so it's truly dragging
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
|
||||
sel.drag(5, 5, Rect::new(0, 0, 80, 24), None);
|
||||
state.selection = Some(sel);
|
||||
// Set autoscroll first
|
||||
state.selection_autoscroll = Some(SelectionAutoscroll {
|
||||
direction: SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: 5,
|
||||
last_mouse_screen_row: 23,
|
||||
inner_rect: Rect::new(0, 0, 80, 24),
|
||||
});
|
||||
// Move cursor into safe zone (middle of pane, not on edge rows)
|
||||
let terminal_runtimes = TerminalRuntimeRegistry::new();
|
||||
state.update_selection_drag(&terminal_runtimes, 5, 12);
|
||||
assert!(state.selection_autoscroll.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,693 +0,0 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
state::{AppState, SettingsSection, THEME_NAMES},
|
||||
App, Mode,
|
||||
},
|
||||
config::{StatusIndicatorStyle, ToastDelivery},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
// The shared `Save` verb is semantic: these actions persist settings.
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub(super) enum SettingsAction {
|
||||
SaveTheme(String),
|
||||
SaveStatusIndicators(StatusIndicatorStyle),
|
||||
SaveSound(bool),
|
||||
SaveToastDelivery(ToastDelivery),
|
||||
SaveAgentBorderLabels(bool),
|
||||
InstallRecommendedIntegrations,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) {
|
||||
let previous_section = self.state.settings.section;
|
||||
if let Some(action) = update_settings_state(&mut self.state, key) {
|
||||
match action {
|
||||
SettingsAction::SaveTheme(name) => self.save_theme(&name),
|
||||
SettingsAction::SaveStatusIndicators(style) => self.save_status_indicators(style),
|
||||
SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
|
||||
SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery),
|
||||
SettingsAction::SaveAgentBorderLabels(enabled) => {
|
||||
self.save_agent_border_labels(enabled)
|
||||
}
|
||||
SettingsAction::InstallRecommendedIntegrations => {
|
||||
self.install_recommended_integrations()
|
||||
}
|
||||
}
|
||||
}
|
||||
if previous_section != SettingsSection::Integrations
|
||||
&& self.state.settings.section == SettingsSection::Integrations
|
||||
{
|
||||
self.refresh_integration_recommendations();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_theme_name(name: &str) -> String {
|
||||
name.to_lowercase().replace([' ', '_'], "-")
|
||||
}
|
||||
|
||||
fn current_theme_index(theme_name: &str) -> usize {
|
||||
let normalized = normalize_theme_name(theme_name);
|
||||
THEME_NAMES
|
||||
.iter()
|
||||
.position(|name| normalize_theme_name(name) == normalized)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn status_indicator_index(style: StatusIndicatorStyle) -> usize {
|
||||
match style {
|
||||
StatusIndicatorStyle::Dots => 0,
|
||||
StatusIndicatorStyle::Symbols => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_indicator_for_index(idx: usize) -> StatusIndicatorStyle {
|
||||
if idx == 0 {
|
||||
StatusIndicatorStyle::Dots
|
||||
} else {
|
||||
StatusIndicatorStyle::Symbols
|
||||
}
|
||||
}
|
||||
|
||||
fn toast_delivery_index(delivery: ToastDelivery) -> usize {
|
||||
match delivery {
|
||||
ToastDelivery::Off => 0,
|
||||
ToastDelivery::Herdr => 1,
|
||||
ToastDelivery::Terminal => 2,
|
||||
ToastDelivery::System => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn toast_delivery_for_index(idx: usize) -> ToastDelivery {
|
||||
match idx {
|
||||
0 => ToastDelivery::Off,
|
||||
1 => ToastDelivery::Herdr,
|
||||
2 => ToastDelivery::Terminal,
|
||||
_ => ToastDelivery::System,
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_selected_theme(state: &mut AppState) {
|
||||
use crate::app::state::Palette;
|
||||
|
||||
let name = THEME_NAMES[state.settings.list.selected];
|
||||
if let Some(mut palette) = Palette::from_name(name) {
|
||||
if let Some(custom) = &state.theme_runtime.custom {
|
||||
palette = palette.with_overrides(custom);
|
||||
}
|
||||
if let Some(accent) = &state.theme_runtime.legacy_accent {
|
||||
palette.accent = crate::config::parse_color(accent);
|
||||
}
|
||||
state.palette = palette;
|
||||
state.theme_name = name.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_settings(state: &mut AppState) {
|
||||
if let Some(palette) = state.settings.original_palette.take() {
|
||||
state.palette = palette;
|
||||
}
|
||||
if let Some(theme_name) = state.settings.original_theme.take() {
|
||||
state.theme_name = theme_name;
|
||||
}
|
||||
super::modal::leave_modal(state);
|
||||
}
|
||||
|
||||
fn integrations_need_install(state: &AppState) -> bool {
|
||||
state
|
||||
.integration_recommendations
|
||||
.iter()
|
||||
.any(crate::integration::IntegrationRecommendation::needs_install)
|
||||
}
|
||||
|
||||
fn apply_settings(state: &mut AppState) -> Option<SettingsAction> {
|
||||
match state.settings.section {
|
||||
SettingsSection::Theme => {
|
||||
let theme_name = state.theme_name.clone();
|
||||
state.settings.original_palette = None;
|
||||
state.settings.original_theme = None;
|
||||
super::modal::leave_modal(state);
|
||||
Some(SettingsAction::SaveTheme(theme_name))
|
||||
}
|
||||
SettingsSection::Integrations if integrations_need_install(state) => {
|
||||
Some(SettingsAction::InstallRecommendedIntegrations)
|
||||
}
|
||||
SettingsSection::Integrations => None,
|
||||
_ => {
|
||||
super::modal::leave_modal(state);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<SettingsAction> {
|
||||
match state.settings.section {
|
||||
SettingsSection::Theme => match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let previous = state.settings.list.selected;
|
||||
state.settings.list.move_prev();
|
||||
if state.settings.list.selected != previous {
|
||||
preview_selected_theme(state);
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let previous = state.settings.list.selected;
|
||||
state.settings.list.move_next(THEME_NAMES.len());
|
||||
if state.settings.list.selected != previous {
|
||||
preview_selected_theme(state);
|
||||
}
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||
state.settings.section = SettingsSection::Indicators;
|
||||
state.settings.list.selected = status_indicator_index(state.status_indicators);
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
||||
state.settings.section = SettingsSection::Integrations;
|
||||
state.settings.list.selected = 0;
|
||||
}
|
||||
_ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
|
||||
Some(super::modal::ModalAction::Apply) => return apply_settings(state),
|
||||
Some(super::modal::ModalAction::Close) => cancel_settings(state),
|
||||
_ => {}
|
||||
},
|
||||
},
|
||||
SettingsSection::Indicators => match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
|
||||
state.settings.list.selected = 1 - state.settings.list.selected.min(1);
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
let style = status_indicator_for_index(state.settings.list.selected);
|
||||
return Some(SettingsAction::SaveStatusIndicators(style));
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
||||
state.settings.section = SettingsSection::Theme;
|
||||
state.settings.list.selected = current_theme_index(&state.theme_name);
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||
state.settings.section = SettingsSection::Sound;
|
||||
state.settings.list.selected = usize::from(!state.sound_enabled());
|
||||
}
|
||||
_ => {
|
||||
if let Some(super::modal::ModalAction::Close) =
|
||||
super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS)
|
||||
{
|
||||
cancel_settings(state);
|
||||
}
|
||||
}
|
||||
},
|
||||
SettingsSection::Sound => match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
|
||||
state.settings.list.selected = 1 - state.settings.list.selected.min(1);
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
let enabled = state.settings.list.selected == 0;
|
||||
return Some(SettingsAction::SaveSound(enabled));
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||
state.settings.section = SettingsSection::Toast;
|
||||
state.settings.list.selected = toast_delivery_index(state.toast_delivery());
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
||||
state.settings.section = SettingsSection::Indicators;
|
||||
state.settings.list.selected = status_indicator_index(state.status_indicators);
|
||||
}
|
||||
_ => {
|
||||
if let Some(super::modal::ModalAction::Close) =
|
||||
super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS)
|
||||
{
|
||||
cancel_settings(state);
|
||||
}
|
||||
}
|
||||
},
|
||||
SettingsSection::Toast => match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => state.settings.list.move_prev(),
|
||||
KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(4),
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
let delivery = toast_delivery_for_index(state.settings.list.selected);
|
||||
return Some(SettingsAction::SaveToastDelivery(delivery));
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
||||
state.settings.section = SettingsSection::Sound;
|
||||
state.settings.list.selected = usize::from(!state.sound_enabled());
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||
state.settings.section = SettingsSection::PaneLabels;
|
||||
state.settings.list.selected = usize::from(!state.agent_border_labels_enabled());
|
||||
}
|
||||
_ => {
|
||||
if let Some(super::modal::ModalAction::Close) =
|
||||
super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS)
|
||||
{
|
||||
cancel_settings(state);
|
||||
}
|
||||
}
|
||||
},
|
||||
SettingsSection::PaneLabels => match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
|
||||
state.settings.list.selected = 1 - state.settings.list.selected.min(1);
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
let enabled = state.settings.list.selected == 0;
|
||||
return Some(SettingsAction::SaveAgentBorderLabels(enabled));
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
||||
state.settings.section = SettingsSection::Toast;
|
||||
state.settings.list.selected = toast_delivery_index(state.toast_delivery());
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||
state.settings.section = SettingsSection::Integrations;
|
||||
state.settings.list.selected = 0;
|
||||
}
|
||||
_ => {
|
||||
if let Some(super::modal::ModalAction::Close) =
|
||||
super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS)
|
||||
{
|
||||
cancel_settings(state);
|
||||
}
|
||||
}
|
||||
},
|
||||
SettingsSection::Integrations => match key.code {
|
||||
KeyCode::Enter | KeyCode::Char(' ') if integrations_need_install(state) => {
|
||||
return Some(SettingsAction::InstallRecommendedIntegrations);
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
||||
state.settings.section = SettingsSection::PaneLabels;
|
||||
state.settings.list.selected = usize::from(!state.agent_border_labels_enabled());
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||
state.settings.section = SettingsSection::Theme;
|
||||
state.settings.list.selected = current_theme_index(&state.theme_name);
|
||||
}
|
||||
_ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
|
||||
Some(super::modal::ModalAction::Apply) => return apply_settings(state),
|
||||
Some(super::modal::ModalAction::Close) => cancel_settings(state),
|
||||
_ => {}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn open_settings(state: &mut AppState) {
|
||||
open_settings_at(state, SettingsSection::Theme);
|
||||
}
|
||||
|
||||
pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) {
|
||||
state.integration_install_messages.clear();
|
||||
state.settings.original_palette = Some(state.palette.clone());
|
||||
state.settings.original_theme = Some(state.theme_name.clone());
|
||||
state.settings.section = section;
|
||||
state.settings.list.selected = match section {
|
||||
SettingsSection::Theme => current_theme_index(&state.theme_name),
|
||||
SettingsSection::Indicators => status_indicator_index(state.status_indicators),
|
||||
SettingsSection::Sound => usize::from(!state.sound_enabled()),
|
||||
SettingsSection::Toast => toast_delivery_index(state.toast_delivery()),
|
||||
SettingsSection::PaneLabels => usize::from(!state.agent_border_labels_enabled()),
|
||||
SettingsSection::Integrations => 0,
|
||||
};
|
||||
state.mode = Mode::Settings;
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn settings_popup_rect(&self) -> Rect {
|
||||
crate::ui::centered_popup_rect(
|
||||
self.screen_rect(),
|
||||
crate::ui::SETTINGS_POPUP_WIDTH,
|
||||
crate::ui::settings_popup_height(self),
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn settings_inner_rect(&self) -> Rect {
|
||||
let popup = self.settings_popup_rect();
|
||||
Rect::new(
|
||||
popup.x + 1,
|
||||
popup.y + 1,
|
||||
popup.width.saturating_sub(2),
|
||||
popup.height.saturating_sub(2),
|
||||
)
|
||||
}
|
||||
|
||||
fn settings_tab_at(&self, col: u16, row: u16) -> Option<SettingsSection> {
|
||||
let inner = self.settings_inner_rect();
|
||||
let tab_y = inner.y + 1;
|
||||
if row != tab_y {
|
||||
return None;
|
||||
}
|
||||
let mut x = inner.x;
|
||||
for section in SettingsSection::ALL {
|
||||
let badge_width = if self.settings_section_has_badge(*section) {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let width = section.label().len() as u16 + 2 + badge_width;
|
||||
if col >= x && col < x + width {
|
||||
return Some(*section);
|
||||
}
|
||||
x += width + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn settings_content_rect(&self) -> Rect {
|
||||
let inner = self.settings_inner_rect();
|
||||
crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content
|
||||
}
|
||||
|
||||
fn settings_list_index_at(&self, col: u16, row: u16) -> Option<usize> {
|
||||
let area = self.settings_content_rect();
|
||||
if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
match self.settings.section {
|
||||
SettingsSection::Theme => {
|
||||
let max_visible = area.height as usize;
|
||||
let scroll = if self.settings.list.selected >= max_visible {
|
||||
self.settings.list.selected - max_visible + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let idx = scroll + (row - area.y) as usize;
|
||||
(idx < THEME_NAMES.len()).then_some(idx)
|
||||
}
|
||||
SettingsSection::Indicators | SettingsSection::Sound => {
|
||||
let list_y = area.y + 3;
|
||||
if row >= list_y && row < list_y + 2 {
|
||||
Some((row - list_y) as usize)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
SettingsSection::Toast => {
|
||||
let list_y = area.y + 3;
|
||||
if row >= list_y && row < list_y + 8 {
|
||||
Some(((row - list_y) / 2) as usize)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
SettingsSection::PaneLabels => {
|
||||
let list_y = area.y + 3;
|
||||
if row >= list_y && row < list_y + 2 {
|
||||
Some((row - list_y) as usize)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
SettingsSection::Integrations => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) {
|
||||
self.settings.section = section;
|
||||
self.settings.list.select(match section {
|
||||
SettingsSection::Theme => current_theme_index(&self.theme_name),
|
||||
SettingsSection::Indicators => {
|
||||
status_indicator_index(self.status_indicators)
|
||||
}
|
||||
SettingsSection::Sound => usize::from(!self.sound_enabled()),
|
||||
SettingsSection::Toast => toast_delivery_index(self.toast_delivery()),
|
||||
SettingsSection::PaneLabels => {
|
||||
usize::from(!self.agent_border_labels_enabled())
|
||||
}
|
||||
SettingsSection::Integrations => 0,
|
||||
});
|
||||
return None;
|
||||
}
|
||||
if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) {
|
||||
self.settings.list.select(idx);
|
||||
return match self.settings.section {
|
||||
SettingsSection::Theme => {
|
||||
preview_selected_theme(self);
|
||||
None
|
||||
}
|
||||
SettingsSection::Indicators => Some(SettingsAction::SaveStatusIndicators(
|
||||
status_indicator_for_index(idx),
|
||||
)),
|
||||
SettingsSection::Sound => {
|
||||
let enabled = idx == 0;
|
||||
Some(SettingsAction::SaveSound(enabled))
|
||||
}
|
||||
SettingsSection::Toast => {
|
||||
let delivery = toast_delivery_for_index(idx);
|
||||
Some(SettingsAction::SaveToastDelivery(delivery))
|
||||
}
|
||||
SettingsSection::PaneLabels => {
|
||||
let enabled = idx == 0;
|
||||
Some(SettingsAction::SaveAgentBorderLabels(enabled))
|
||||
}
|
||||
SettingsSection::Integrations => None,
|
||||
};
|
||||
}
|
||||
|
||||
let inner = self.settings_inner_rect();
|
||||
let show_primary = crate::ui::settings_show_primary_action(self);
|
||||
let (apply, close) =
|
||||
crate::ui::settings_button_rects(inner, self.settings.section, show_primary);
|
||||
let mut buttons = vec![(close, super::modal::ModalAction::Close)];
|
||||
if let Some(apply) = apply {
|
||||
buttons.insert(0, (apply, super::modal::ModalAction::Apply));
|
||||
}
|
||||
match super::modal::modal_action_from_buttons(mouse.column, mouse.row, &buttons) {
|
||||
Some(super::modal::ModalAction::Apply) => apply_settings(self),
|
||||
Some(super::modal::ModalAction::Close) => {
|
||||
cancel_settings(self);
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
cancel_settings(self);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
|
||||
|
||||
use super::super::{app_for_mouse_test, mouse, state_with_workspaces};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_cancel_restores_previewed_theme_from_other_sections() {
|
||||
let mut state = state_with_workspaces(&["test"]);
|
||||
let original_palette = state.palette.clone();
|
||||
let original_theme = state.theme_name.clone();
|
||||
|
||||
open_settings(&mut state);
|
||||
update_settings_state(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
|
||||
);
|
||||
assert_ne!(state.theme_name, original_theme);
|
||||
|
||||
update_settings_state(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
|
||||
);
|
||||
assert_eq!(
|
||||
state.settings.section,
|
||||
crate::app::state::SettingsSection::Indicators
|
||||
);
|
||||
|
||||
update_settings_state(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
|
||||
);
|
||||
|
||||
assert_eq!(state.mode, Mode::Terminal);
|
||||
assert_eq!(state.theme_name, original_theme);
|
||||
assert_eq!(state.palette.accent, original_palette.accent);
|
||||
assert_eq!(state.palette.panel_bg, original_palette.panel_bg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_indicator_choice_returns_save_action() {
|
||||
let mut state = state_with_workspaces(&["test"]);
|
||||
open_settings_at(&mut state, SettingsSection::Indicators);
|
||||
state.settings.list.selected = 1;
|
||||
|
||||
let action = update_settings_state(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
Some(SettingsAction::SaveStatusIndicators(
|
||||
StatusIndicatorStyle::Symbols
|
||||
))
|
||||
);
|
||||
assert_eq!(state.status_indicators, StatusIndicatorStyle::Dots);
|
||||
assert_eq!(state.mode, Mode::Settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_sound_toggle_returns_save_action() {
|
||||
let mut state = state_with_workspaces(&["test"]);
|
||||
open_settings(&mut state);
|
||||
state.settings.section = crate::app::state::SettingsSection::Sound;
|
||||
state.settings.list.selected = 0;
|
||||
|
||||
let action = update_settings_state(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
|
||||
);
|
||||
|
||||
assert_eq!(action, Some(SettingsAction::SaveSound(true)));
|
||||
assert!(!state.sound.enabled);
|
||||
assert_eq!(state.mode, Mode::Settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_tab_cycle_wraps_after_integrations() {
|
||||
let mut state = state_with_workspaces(&["test"]);
|
||||
open_settings_at(&mut state, SettingsSection::PaneLabels);
|
||||
|
||||
update_settings_state(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
|
||||
);
|
||||
assert_eq!(state.settings.section, SettingsSection::Integrations);
|
||||
|
||||
update_settings_state(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
|
||||
);
|
||||
assert_eq!(state.settings.section, SettingsSection::Theme);
|
||||
|
||||
update_settings_state(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()),
|
||||
);
|
||||
assert_eq!(state.settings.section, SettingsSection::Integrations);
|
||||
|
||||
update_settings_state(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()),
|
||||
);
|
||||
assert_eq!(state.settings.section, SettingsSection::PaneLabels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrations_enter_does_nothing_when_nothing_needs_install() {
|
||||
let mut state = state_with_workspaces(&["test"]);
|
||||
open_settings_at(&mut state, SettingsSection::Integrations);
|
||||
|
||||
let enter_action = update_settings_state(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
|
||||
);
|
||||
assert_eq!(enter_action, None);
|
||||
|
||||
let space_action = update_settings_state(
|
||||
&mut state,
|
||||
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::empty()),
|
||||
);
|
||||
assert_eq!(space_action, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_hover_does_not_change_selection() {
|
||||
let mut app = app_for_mouse_test();
|
||||
open_settings(&mut app.state);
|
||||
app.state.settings.list.select(0);
|
||||
|
||||
let area = app.state.settings_content_rect();
|
||||
app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2));
|
||||
|
||||
assert_eq!(app.state.settings.list.selected, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_update_badge_only_tracks_outdated_recommendations() {
|
||||
let mut state = state_with_workspaces(&["test"]);
|
||||
state.integration_recommendations = vec![integration_recommendation(
|
||||
crate::integration::IntegrationStatusKind::NotInstalled,
|
||||
true,
|
||||
)];
|
||||
assert!(!state.integration_updates_available());
|
||||
|
||||
state.integration_recommendations = vec![integration_recommendation(
|
||||
crate::integration::IntegrationStatusKind::NotInstalled,
|
||||
false,
|
||||
)];
|
||||
assert!(!state.integration_updates_available());
|
||||
|
||||
state.integration_recommendations = vec![integration_recommendation(
|
||||
crate::integration::IntegrationStatusKind::Current,
|
||||
true,
|
||||
)];
|
||||
assert!(!state.integration_updates_available());
|
||||
|
||||
state.integration_recommendations = vec![integration_recommendation(
|
||||
crate::integration::IntegrationStatusKind::Outdated,
|
||||
true,
|
||||
)];
|
||||
assert!(state.integration_updates_available());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_tab_hit_area_includes_integration_update_badge() {
|
||||
let mut state = state_with_workspaces(&["test"]);
|
||||
state.integration_recommendations = vec![integration_recommendation(
|
||||
crate::integration::IntegrationStatusKind::Outdated,
|
||||
true,
|
||||
)];
|
||||
open_settings(&mut state);
|
||||
|
||||
let inner = state.settings_inner_rect();
|
||||
let tab_y = inner.y + 1;
|
||||
let integrations_idx = SettingsSection::ALL
|
||||
.iter()
|
||||
.position(|section| *section == SettingsSection::Integrations)
|
||||
.expect("integrations section should be present");
|
||||
let integrations_x = inner.x
|
||||
+ SettingsSection::ALL[..integrations_idx]
|
||||
.iter()
|
||||
.map(|section| {
|
||||
let badge_width = if state.settings_section_has_badge(*section) {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
section.label().len() as u16 + 3 + badge_width
|
||||
})
|
||||
.sum::<u16>();
|
||||
let dotted_width = SettingsSection::Integrations.label().len() as u16 + 4;
|
||||
|
||||
assert_eq!(
|
||||
state.settings_tab_at(integrations_x + dotted_width - 1, tab_y),
|
||||
Some(SettingsSection::Integrations)
|
||||
);
|
||||
}
|
||||
|
||||
fn integration_recommendation(
|
||||
state: crate::integration::IntegrationStatusKind,
|
||||
available: bool,
|
||||
) -> crate::integration::IntegrationRecommendation {
|
||||
crate::integration::IntegrationRecommendation {
|
||||
target: crate::api::schema::IntegrationTarget::Claude,
|
||||
label: "claude",
|
||||
command: "claude",
|
||||
available,
|
||||
path: std::path::PathBuf::from("/tmp/herdr-test-integration"),
|
||||
state,
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+151
-3013
File diff suppressed because it is too large
Load Diff
+1
-13
@@ -37,18 +37,6 @@ impl App {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn try_route_paste_to_popup(&mut self, text: &str) -> bool {
|
||||
if self.state.popup_pane.is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(runtime) = self.popup_runtime() else {
|
||||
self.close_popup_pane();
|
||||
return true;
|
||||
};
|
||||
let _ = runtime.try_send_paste(text.to_owned());
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_popup_shell_command(
|
||||
&mut self,
|
||||
command: &str,
|
||||
@@ -218,7 +206,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
|
||||
+12
-269
@@ -5,7 +5,6 @@ use std::time::Duration;
|
||||
|
||||
use super::{
|
||||
background_update_check_enabled, App, AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL,
|
||||
SELECTION_AUTOSCROLL_INTERVAL,
|
||||
};
|
||||
fn retain_detached_process_after_wait(
|
||||
pid: u32,
|
||||
@@ -29,10 +28,6 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown_terminal_runtime(&mut self, terminal_id: crate::terminal::TerminalId) {
|
||||
let target = super::TerminalInputTarget {
|
||||
terminal_id: terminal_id.clone(),
|
||||
};
|
||||
self.release_input_target_headless(&target);
|
||||
if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) {
|
||||
runtime.shutdown();
|
||||
}
|
||||
@@ -45,28 +40,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears temporary copied-token highlights, such as after double-click copy.
|
||||
pub(crate) fn clear_due_selection_highlight(&mut self, now: Instant) -> bool {
|
||||
if self
|
||||
.selection_highlight_clear_deadline
|
||||
.is_none_or(|deadline| now < deadline)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.selection_highlight_clear_deadline = None;
|
||||
if self
|
||||
.state
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| !selection.is_in_progress())
|
||||
{
|
||||
self.state.clear_selection();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn sync_agent_metadata_deadline(&mut self) {
|
||||
self.agent_metadata_deadline = self.state.next_agent_metadata_expiry();
|
||||
}
|
||||
@@ -98,84 +71,6 @@ impl App {
|
||||
self.sync_agent_metadata_deadline();
|
||||
}
|
||||
|
||||
pub(crate) fn tick_selection_autoscroll(&mut self, now: Instant) {
|
||||
let Some(autoscroll) = self.state.selection_autoscroll.clone() else {
|
||||
// Self-heal: state cleared but deadline leaked
|
||||
self.selection_autoscroll_deadline = None;
|
||||
return;
|
||||
};
|
||||
|
||||
// Selection must still be in progress for autoscroll to continue
|
||||
let Some(pane_id) = self.state.selection.as_ref().map(|s| s.pane_id) else {
|
||||
self.stop_selection_autoscroll();
|
||||
return;
|
||||
};
|
||||
if !self
|
||||
.state
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_dragging())
|
||||
{
|
||||
self.stop_selection_autoscroll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Rect-change detection: if inner_rect changed since drag, stop
|
||||
let current_rect = self
|
||||
.state
|
||||
.pane_info_by_id(pane_id)
|
||||
.map(|info| info.inner_rect);
|
||||
if current_rect != Some(autoscroll.inner_rect) {
|
||||
self.stop_selection_autoscroll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Scrollback boundary detection via ScrollMetrics — fail-closed if unavailable
|
||||
let Some(metrics) = self
|
||||
.state
|
||||
.pane_scroll_metrics(&self.terminal_runtimes, pane_id)
|
||||
else {
|
||||
self.stop_selection_autoscroll();
|
||||
return;
|
||||
};
|
||||
match autoscroll.direction {
|
||||
crate::app::state::SelectionAutoscrollDirection::Up => {
|
||||
let at_top = metrics.offset_from_bottom >= metrics.max_offset_from_bottom;
|
||||
if at_top {
|
||||
self.stop_selection_autoscroll();
|
||||
return;
|
||||
}
|
||||
self.state
|
||||
.scroll_pane_up(&self.terminal_runtimes, pane_id, 1);
|
||||
}
|
||||
crate::app::state::SelectionAutoscrollDirection::Down => {
|
||||
let at_bottom = metrics.offset_from_bottom == 0;
|
||||
if at_bottom {
|
||||
self.stop_selection_autoscroll();
|
||||
return;
|
||||
}
|
||||
self.state
|
||||
.scroll_pane_down(&self.terminal_runtimes, pane_id, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Extend selection cursor to last known mouse position
|
||||
self.state.update_selection_cursor(
|
||||
&self.terminal_runtimes,
|
||||
pane_id,
|
||||
autoscroll.last_mouse_screen_col,
|
||||
autoscroll.last_mouse_screen_row,
|
||||
);
|
||||
|
||||
// Reschedule
|
||||
self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL);
|
||||
}
|
||||
|
||||
pub(crate) fn stop_selection_autoscroll(&mut self) {
|
||||
self.state.stop_selection_autoscroll_state();
|
||||
self.selection_autoscroll_deadline = None;
|
||||
}
|
||||
|
||||
pub(crate) fn can_render_now(&self, now: Instant) -> bool {
|
||||
match self.last_render_at {
|
||||
Some(last_render_at) => now.duration_since(last_render_at) >= MIN_RENDER_INTERVAL,
|
||||
@@ -200,7 +95,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn run_auto_update_check(&mut self) {
|
||||
if !background_update_check_enabled(self.no_session, self.update_version_check_enabled) {
|
||||
if !background_update_check_enabled(
|
||||
self.policy.background_updates,
|
||||
self.update_version_check_enabled,
|
||||
) {
|
||||
self.next_auto_update_check = None;
|
||||
return;
|
||||
}
|
||||
@@ -220,7 +118,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn run_agent_manifest_update_check(&mut self) {
|
||||
if !background_update_check_enabled(self.no_session, self.update_manifest_check_enabled) {
|
||||
if !background_update_check_enabled(
|
||||
self.policy.background_updates,
|
||||
self.update_manifest_check_enabled,
|
||||
) {
|
||||
self.next_agent_manifest_update_check = None;
|
||||
return;
|
||||
}
|
||||
@@ -250,7 +151,6 @@ impl App {
|
||||
self.toast_deadline,
|
||||
self.state.next_pending_agent_notification_deadline(),
|
||||
self.state.next_managed_agent_deadline(),
|
||||
self.copy_feedback_deadline,
|
||||
include_git_refresh
|
||||
.then(|| self.git_refresh_deadline())
|
||||
.flatten(),
|
||||
@@ -259,8 +159,6 @@ impl App {
|
||||
self.agent_metadata_deadline,
|
||||
self.pending_agent_resume_deadline,
|
||||
self.session_save_deadline,
|
||||
self.selection_autoscroll_deadline,
|
||||
self.selection_highlight_clear_deadline,
|
||||
self.next_tab_bar_status_deadline(),
|
||||
render_deadline,
|
||||
]
|
||||
@@ -275,6 +173,7 @@ impl App {
|
||||
.1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn drain_all_internal_events(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
loop {
|
||||
@@ -288,6 +187,7 @@ impl App {
|
||||
changed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn drain_internal_events_up_to(&mut self, limit: usize) -> (bool, bool) {
|
||||
let mut had_event = false;
|
||||
let mut changed = false;
|
||||
@@ -296,7 +196,7 @@ impl App {
|
||||
break;
|
||||
};
|
||||
had_event = true;
|
||||
changed |= self.handle_internal_event_with_prefix_sync(ev);
|
||||
changed |= self.handle_internal_event_with_render_impact(ev);
|
||||
}
|
||||
(had_event, changed)
|
||||
}
|
||||
@@ -305,7 +205,6 @@ impl App {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::state;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
#[test]
|
||||
@@ -332,7 +231,7 @@ mod tests {
|
||||
fn test_app_with_pane() -> (super::super::App, crate::layout::PaneId) {
|
||||
let mut app = super::super::App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
tokio::sync::mpsc::unbounded_channel().1,
|
||||
crate::api::EventHub::default(),
|
||||
@@ -351,160 +250,4 @@ mod tests {
|
||||
});
|
||||
(app, pane_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_selection_autoscroll_stops_when_metrics_unavailable() {
|
||||
// Without a runtime, pane_scroll_metrics returns None.
|
||||
// Fail-closed: stop autoscroll instead of rescheduling forever.
|
||||
let (mut app, pane_id) = test_app_with_pane();
|
||||
let now = Instant::now();
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
|
||||
// Drag to a different cell so it becomes Dragging
|
||||
sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None);
|
||||
app.state.selection = Some(sel);
|
||||
app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
|
||||
direction: state::SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: 5,
|
||||
last_mouse_screen_row: 23,
|
||||
inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
|
||||
});
|
||||
app.selection_autoscroll_deadline = Some(now);
|
||||
app.tick_selection_autoscroll(now);
|
||||
// Should stop because no runtime metrics available
|
||||
assert!(app.state.selection_autoscroll.is_none());
|
||||
assert!(app.selection_autoscroll_deadline.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_selection_autoscroll_stops_when_selection_done() {
|
||||
let (mut app, pane_id) = test_app_with_pane();
|
||||
let now = Instant::now();
|
||||
// Create a selection that is already finished (not in progress)
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
|
||||
// Drag to a different cell so it becomes visible, then finish
|
||||
sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None);
|
||||
sel.finish(); // now it's Done, not in progress
|
||||
app.state.selection = Some(sel);
|
||||
app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
|
||||
direction: state::SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: 0,
|
||||
last_mouse_screen_row: 23,
|
||||
inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
|
||||
});
|
||||
app.selection_autoscroll_deadline = Some(now);
|
||||
app.tick_selection_autoscroll(now);
|
||||
assert!(app.state.selection_autoscroll.is_none());
|
||||
assert!(app.selection_autoscroll_deadline.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_selection_autoscroll_stops_when_selection_cleared() {
|
||||
let (mut app, _pane_id) = test_app_with_pane();
|
||||
let now = Instant::now();
|
||||
app.state.selection = None;
|
||||
app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
|
||||
direction: state::SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: 0,
|
||||
last_mouse_screen_row: 23,
|
||||
inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
|
||||
});
|
||||
app.selection_autoscroll_deadline = Some(now);
|
||||
app.tick_selection_autoscroll(now);
|
||||
assert!(app.state.selection_autoscroll.is_none());
|
||||
assert!(app.selection_autoscroll_deadline.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_selection_autoscroll_stops_when_selection_anchored() {
|
||||
// Anchored (click, no drag) should not keep the timer running.
|
||||
let (mut app, pane_id) = test_app_with_pane();
|
||||
let now = Instant::now();
|
||||
app.state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None));
|
||||
app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
|
||||
direction: state::SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: 0,
|
||||
last_mouse_screen_row: 23,
|
||||
inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
|
||||
});
|
||||
app.selection_autoscroll_deadline = Some(now);
|
||||
app.tick_selection_autoscroll(now);
|
||||
assert!(app.state.selection_autoscroll.is_none());
|
||||
assert!(app.selection_autoscroll_deadline.is_none());
|
||||
}
|
||||
|
||||
/// Creates an app with a real TerminalRuntime (no PTY) so scroll_metrics
|
||||
/// returns meaningful data. Uses test_with_scrollback_bytes.
|
||||
fn test_app_with_runtime(
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
bytes: &[u8],
|
||||
) -> (super::super::App, crate::layout::PaneId) {
|
||||
let mut app = super::super::App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
None,
|
||||
tokio::sync::mpsc::unbounded_channel().1,
|
||||
crate::api::EventHub::default(),
|
||||
);
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let pane_id = ws.tabs[0].root_pane;
|
||||
let runtime =
|
||||
crate::terminal::TerminalRuntime::test_with_scrollback_bytes(cols, rows, 0, bytes);
|
||||
ws.tabs[0].runtimes.insert(pane_id, runtime);
|
||||
app.state.workspaces.push(ws);
|
||||
app.state.active = Some(0);
|
||||
app.state.view.pane_infos.push(crate::layout::PaneInfo {
|
||||
id: pane_id,
|
||||
rect: ratatui::layout::Rect::new(0, 0, cols, rows),
|
||||
inner_rect: ratatui::layout::Rect::new(0, 0, cols, rows),
|
||||
scrollbar_rect: None,
|
||||
borders: ratatui::widgets::Borders::NONE,
|
||||
is_focused: true,
|
||||
});
|
||||
(app, pane_id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tick_selection_autoscroll_stops_at_scrollback_top() {
|
||||
// Create a runtime with no scrollback content — we're already at
|
||||
// the top (offset_from_bottom == max_offset_from_bottom).
|
||||
let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]);
|
||||
let now = Instant::now();
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 5, 5, None);
|
||||
sel.drag(0, 0, ratatui::layout::Rect::new(0, 0, 80, 24), None);
|
||||
app.state.selection = Some(sel);
|
||||
app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
|
||||
direction: state::SelectionAutoscrollDirection::Up,
|
||||
last_mouse_screen_col: 0,
|
||||
last_mouse_screen_row: 0,
|
||||
inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
|
||||
});
|
||||
app.selection_autoscroll_deadline = Some(now);
|
||||
app.tick_selection_autoscroll(now);
|
||||
// At scrollback top, can't scroll further up — should stop
|
||||
assert!(app.state.selection_autoscroll.is_none());
|
||||
assert!(app.selection_autoscroll_deadline.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tick_selection_autoscroll_stops_at_scrollback_bottom() {
|
||||
// Create a runtime with no scrollback content — we're already at
|
||||
// the bottom (offset_from_bottom == 0).
|
||||
let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]);
|
||||
let now = Instant::now();
|
||||
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
|
||||
sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None);
|
||||
app.state.selection = Some(sel);
|
||||
app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
|
||||
direction: state::SelectionAutoscrollDirection::Down,
|
||||
last_mouse_screen_col: 5,
|
||||
last_mouse_screen_row: 23,
|
||||
inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
|
||||
});
|
||||
app.selection_autoscroll_deadline = Some(now);
|
||||
app.tick_selection_autoscroll(now);
|
||||
// At scrollback bottom, can't scroll further down — should stop
|
||||
assert!(app.state.selection_autoscroll.is_none());
|
||||
assert!(app.selection_autoscroll_deadline.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
use crate::api::schema::{
|
||||
EmptyParams, LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneInputSetParams,
|
||||
PaneRenameParams, PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget,
|
||||
PaneZoomParams, TabCreateParams, TabMoveParams, TabRenameParams, TabTarget,
|
||||
WorkspaceCloseParams, WorkspaceCreateParams, WorkspaceMoveBlockParams, WorkspaceMoveParams,
|
||||
WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, WorktreeOpenParams,
|
||||
WorktreeRemoveParams,
|
||||
};
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
pub(crate) fn dispatch_runtime_mutation(&mut self, id: &'static str, method: Method) -> String {
|
||||
self.dispatch_api_request(id, method)
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_deferred_runtime_mutation(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
method: Method,
|
||||
) -> Option<String> {
|
||||
self.dispatch_deferred_api_request(id, method)
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_workspace_focus(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
workspace_id: String,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::WorkspaceFocus(WorkspaceTarget { workspace_id }))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_workspace_create(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: WorkspaceCreateParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::WorkspaceCreate(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_workspace_rename(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: WorkspaceRenameParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::WorkspaceRename(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_workspace_move(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: WorkspaceMoveParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::WorkspaceMove(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_workspace_move_block(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: WorkspaceMoveBlockParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::WorkspaceMoveBlock(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_workspace_close_group(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
workspace_id: String,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(
|
||||
id,
|
||||
Method::WorkspaceClose(WorkspaceCloseParams {
|
||||
workspace_id,
|
||||
close_group: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_tab_create(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: TabCreateParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::TabCreate(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_tab_focus(&mut self, id: &'static str, tab_id: String) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::TabFocus(TabTarget { tab_id }))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_tab_rename(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: TabRenameParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::TabRename(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_tab_move(&mut self, id: &'static str, params: TabMoveParams) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::TabMove(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_tab_close(&mut self, id: &'static str, tab_id: String) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::TabClose(TabTarget { tab_id }))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_server_reload_config(&mut self, id: &'static str) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::ServerReloadConfig(EmptyParams::default()))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_pane_focus(&mut self, id: &'static str, pane_id: String) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::PaneFocus(PaneTarget { pane_id }))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_pane_close(&mut self, id: &'static str, pane_id: String) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::PaneClose(PaneTarget { pane_id }))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_pane_rename(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: PaneRenameParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::PaneRename(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_pane_input_set(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: PaneInputSetParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::PaneInputSet(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_pane_focus_direction(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: PaneFocusDirectionParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::PaneFocusDirection(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_pane_resize(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: PaneResizeParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::PaneResize(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_pane_swap(&mut self, id: &'static str, params: PaneSwapParams) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::PaneSwap(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_pane_split(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: PaneSplitParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::PaneSplit(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_pane_zoom(&mut self, id: &'static str, params: PaneZoomParams) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::PaneZoom(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_layout_set_split_ratio(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: LayoutSetSplitRatioParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::LayoutSetSplitRatio(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_worktree_create_deferred(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: WorktreeCreateParams,
|
||||
) -> Option<String> {
|
||||
self.dispatch_deferred_runtime_mutation(id, Method::WorktreeCreate(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_worktree_open(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: WorktreeOpenParams,
|
||||
) -> String {
|
||||
self.dispatch_runtime_mutation(id, Method::WorktreeOpen(params))
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_worktree_remove_deferred(
|
||||
&mut self,
|
||||
id: &'static str,
|
||||
params: WorktreeRemoveParams,
|
||||
) -> Option<String> {
|
||||
self.dispatch_deferred_runtime_mutation(id, Method::WorktreeRemove(params))
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -12,7 +12,7 @@ enum SessionSaveJob {
|
||||
|
||||
impl App {
|
||||
pub(super) fn schedule_session_save(&mut self) {
|
||||
if !self.no_session {
|
||||
if self.policy.persist_session {
|
||||
self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE);
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,6 @@ impl App {
|
||||
&self.terminal_runtimes,
|
||||
self.state.active,
|
||||
self.state.selected,
|
||||
self.state.sidebar_width,
|
||||
self.state.sidebar_section_split,
|
||||
self.state.collapsed_space_keys.clone(),
|
||||
);
|
||||
let history = self.persist_pane_history.then(|| {
|
||||
crate::persist::capture_history(&self.state.workspaces, &self.terminal_runtimes)
|
||||
@@ -58,7 +55,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn start_background_session_save(&mut self) {
|
||||
if self.no_session {
|
||||
if !self.policy.persist_session {
|
||||
self.session_save_deadline = None;
|
||||
return;
|
||||
}
|
||||
@@ -88,7 +85,7 @@ impl App {
|
||||
let _ = thread.join();
|
||||
}
|
||||
|
||||
if self.no_session {
|
||||
if !self.policy.persist_session {
|
||||
self.session_save_deadline = None;
|
||||
return;
|
||||
}
|
||||
|
||||
+39
-1188
File diff suppressed because it is too large
Load Diff
@@ -524,7 +524,7 @@ mod tests {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
App::new(
|
||||
&Config::default(),
|
||||
true,
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
|
||||
@@ -88,7 +88,13 @@ mod tests {
|
||||
async fn sync_keeps_latest_raw_title_and_emits_only_for_stripped_changes() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
);
|
||||
app.state.workspaces = vec![Workspace::test_new("one")];
|
||||
app.state.active = Some(0);
|
||||
app.state.ensure_test_terminals();
|
||||
@@ -161,7 +167,13 @@ mod tests {
|
||||
async fn syncing_pending_titles_preserves_sidebar_render_impact() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub,
|
||||
);
|
||||
app.state.workspaces = vec![Workspace::test_new("one")];
|
||||
app.state.active = Some(0);
|
||||
app.state.ensure_test_terminals();
|
||||
@@ -189,7 +201,13 @@ mod tests {
|
||||
fn sidebar_redraws_only_for_the_configured_title_form() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub,
|
||||
);
|
||||
app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]];
|
||||
app.state.sidebar_agents.rows_by_agent.insert(
|
||||
"claude".into(),
|
||||
|
||||
@@ -1,51 +1,6 @@
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
pub(super) fn update_host_terminal_theme(
|
||||
&mut self,
|
||||
kind: crate::terminal_theme::DefaultColorKind,
|
||||
color: crate::terminal_theme::RgbColor,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
if matches!(kind, crate::terminal_theme::DefaultColorKind::Background)
|
||||
&& !self.state.host_terminal_appearance_explicit
|
||||
{
|
||||
changed |= self.set_host_terminal_appearance(color.inferred_appearance(), false);
|
||||
}
|
||||
let next_theme = self.state.host_terminal_theme.with_color(kind, color);
|
||||
changed | self.set_host_terminal_theme(next_theme)
|
||||
}
|
||||
|
||||
pub(super) fn update_host_terminal_palette_colors(
|
||||
&mut self,
|
||||
colors: &[(u8, crate::terminal_theme::RgbColor)],
|
||||
) -> bool {
|
||||
let mut next_theme = self.state.host_terminal_theme;
|
||||
for &(index, color) in colors {
|
||||
next_theme = next_theme.with_palette_color(index, color);
|
||||
}
|
||||
self.set_host_terminal_theme(next_theme)
|
||||
}
|
||||
|
||||
pub(super) fn set_host_terminal_appearance(
|
||||
&mut self,
|
||||
appearance: crate::terminal_theme::HostAppearance,
|
||||
explicit: bool,
|
||||
) -> bool {
|
||||
if self.state.host_terminal_appearance == Some(appearance)
|
||||
&& self.state.host_terminal_appearance_explicit == explicit
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self.state.host_terminal_appearance_explicit && !explicit {
|
||||
return false;
|
||||
}
|
||||
self.state.host_terminal_appearance = Some(appearance);
|
||||
self.state.host_terminal_appearance_explicit = explicit;
|
||||
self.apply_host_terminal_appearance_to_panes();
|
||||
self.refresh_effective_app_theme()
|
||||
}
|
||||
|
||||
pub(crate) fn set_host_terminal_appearance_state(
|
||||
&mut self,
|
||||
appearance: Option<crate::terminal_theme::HostAppearance>,
|
||||
|
||||
@@ -107,7 +107,13 @@ mod tests {
|
||||
fn test_app() -> App {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
crate::app::AppPolicy::TEST,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub,
|
||||
);
|
||||
app.state.workspaces = vec![Workspace::test_new("herd")];
|
||||
app.state.active = Some(0);
|
||||
app.state.ensure_test_terminals();
|
||||
|
||||
+2
-2343
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,582 @@
|
||||
//! Direct terminal attach input parsing and semantic actions.
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::io;
|
||||
|
||||
#[cfg(unix)]
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
|
||||
|
||||
#[cfg(unix)]
|
||||
use super::write_to_server;
|
||||
#[cfg(unix)]
|
||||
use crate::ipc::LocalStream;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientMessage};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[cfg(windows)]
|
||||
pub(super) struct AttachEscapeState;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[cfg(unix)]
|
||||
pub(super) struct AttachEscapeState {
|
||||
pending_prefix: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[cfg(unix)]
|
||||
pub(super) enum AttachInputAction {
|
||||
Forward(Vec<u8>),
|
||||
ForwardPair(Vec<u8>, Vec<u8>),
|
||||
Semantic(AttachSemanticAction),
|
||||
ForwardThenSemantic(Vec<u8>, AttachSemanticAction),
|
||||
Detach,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[cfg(unix)]
|
||||
pub(super) enum AttachSemanticAction {
|
||||
Scroll {
|
||||
source: AttachScrollSource,
|
||||
direction: AttachScrollDirection,
|
||||
lines: u16,
|
||||
column: Option<u16>,
|
||||
row: Option<u16>,
|
||||
modifiers: u8,
|
||||
},
|
||||
Mouse {
|
||||
kind: crate::protocol::ClientMouseKind,
|
||||
position: crate::protocol::ClientMousePosition,
|
||||
modifiers: u8,
|
||||
},
|
||||
Ignore,
|
||||
}
|
||||
|
||||
impl AttachEscapeState {
|
||||
#[cfg(unix)]
|
||||
pub(super) fn filter_input(
|
||||
&mut self,
|
||||
data: Vec<u8>,
|
||||
viewport_rows: u16,
|
||||
mouse_scroll_lines: usize,
|
||||
) -> AttachInputAction {
|
||||
const PREFIX: u8 = 0x02; // Ctrl+B
|
||||
|
||||
if crate::raw_input::is_complete_text_bracketed_paste(&data) {
|
||||
return if let Some(prefix) = self.pending_prefix.take() {
|
||||
AttachInputAction::ForwardPair(prefix, data)
|
||||
} else {
|
||||
AttachInputAction::Forward(data)
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(key) = single_attach_key(&data) {
|
||||
let is_prefix = key.code == crossterm::event::KeyCode::Char('b')
|
||||
&& key.modifiers == crossterm::event::KeyModifiers::CONTROL;
|
||||
let is_quit = key.code == crossterm::event::KeyCode::Char('q')
|
||||
&& key.modifiers.is_empty()
|
||||
&& key.kind == crossterm::event::KeyEventKind::Press;
|
||||
|
||||
if let Some(mut prefix) = self.pending_prefix.take() {
|
||||
if is_prefix && key.kind != crossterm::event::KeyEventKind::Press {
|
||||
prefix.extend(data);
|
||||
self.pending_prefix = Some(prefix);
|
||||
return AttachInputAction::None;
|
||||
}
|
||||
if is_quit {
|
||||
return AttachInputAction::Detach;
|
||||
}
|
||||
if is_prefix {
|
||||
return AttachInputAction::Forward(data);
|
||||
}
|
||||
if let Some(action) = attach_scroll_action(&data, viewport_rows, mouse_scroll_lines)
|
||||
{
|
||||
return AttachInputAction::ForwardThenSemantic(prefix, action);
|
||||
}
|
||||
prefix.extend(data);
|
||||
return AttachInputAction::Forward(prefix);
|
||||
}
|
||||
|
||||
if is_prefix && key.kind == crossterm::event::KeyEventKind::Press {
|
||||
self.pending_prefix = Some(data);
|
||||
return AttachInputAction::None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(action) = attach_scroll_action(&data, viewport_rows, mouse_scroll_lines) {
|
||||
return if let Some(prefix) = self.pending_prefix.take() {
|
||||
AttachInputAction::ForwardThenSemantic(prefix, action)
|
||||
} else {
|
||||
AttachInputAction::Semantic(action)
|
||||
};
|
||||
}
|
||||
|
||||
// The host framer normally supplies one complete event. Preserve the legacy
|
||||
// byte path for coalesced plain input used by older terminals.
|
||||
let mut output = Vec::with_capacity(data.len());
|
||||
for byte in data {
|
||||
if let Some(mut prefix) = self.pending_prefix.take() {
|
||||
match byte {
|
||||
b'q' => return AttachInputAction::Detach,
|
||||
PREFIX => output.extend(prefix),
|
||||
other => {
|
||||
prefix.push(other);
|
||||
output.extend(prefix);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if byte == PREFIX {
|
||||
self.pending_prefix = Some(vec![PREFIX]);
|
||||
} else {
|
||||
output.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
if output.is_empty() {
|
||||
AttachInputAction::None
|
||||
} else if let Some(action) =
|
||||
attach_scroll_action(&output, viewport_rows, mouse_scroll_lines)
|
||||
{
|
||||
AttachInputAction::Semantic(action)
|
||||
} else {
|
||||
AttachInputAction::Forward(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn take_pending_prefix(&mut self) -> Option<Vec<u8>> {
|
||||
self.pending_prefix.take()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn single_attach_key(data: &[u8]) -> Option<crate::input::TerminalKey> {
|
||||
let mut events = crate::raw_input::parse_raw_input_bytes_sync(data);
|
||||
if events.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
match events.pop()? {
|
||||
crate::raw_input::RawInputEvent::Key(key) => Some(key),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn direct_attach_pixel_mouse(
|
||||
data: &[u8],
|
||||
geometry: crate::input::mouse::HostGeometry,
|
||||
) -> Option<(
|
||||
crate::protocol::ClientMouseKind,
|
||||
crate::protocol::ClientMousePosition,
|
||||
u8,
|
||||
)> {
|
||||
let (x, y) = crate::input::mouse::parse_report(data)?;
|
||||
let (column, row) = geometry.cell(x, y)?;
|
||||
let cell_report = crate::input::mouse::report_at_cell(data, column, row)?;
|
||||
let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report);
|
||||
if events.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let crate::raw_input::RawInputEvent::Mouse(mouse) = events.pop()? else {
|
||||
return None;
|
||||
};
|
||||
Some((
|
||||
crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?,
|
||||
crate::protocol::ClientMousePosition::Pixels { x, y, column, row },
|
||||
mouse.modifiers.bits(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn attach_scroll_action(
|
||||
data: &[u8],
|
||||
viewport_rows: u16,
|
||||
mouse_scroll_lines: usize,
|
||||
) -> Option<AttachSemanticAction> {
|
||||
let mut events = crate::raw_input::parse_raw_input_bytes_sync(data);
|
||||
if events.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
match events.pop()? {
|
||||
crate::raw_input::RawInputEvent::Mouse(mouse) => match mouse.kind {
|
||||
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
|
||||
let direction = if mouse.kind == MouseEventKind::ScrollUp {
|
||||
AttachScrollDirection::Up
|
||||
} else {
|
||||
AttachScrollDirection::Down
|
||||
};
|
||||
Some(AttachSemanticAction::Scroll {
|
||||
source: AttachScrollSource::Wheel,
|
||||
direction,
|
||||
lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16,
|
||||
column: Some(mouse.column),
|
||||
row: Some(mouse.row),
|
||||
modifiers: mouse.modifiers.bits(),
|
||||
})
|
||||
}
|
||||
kind => Some(AttachSemanticAction::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::from_crossterm(kind)?,
|
||||
position: crate::protocol::ClientMousePosition::Cell {
|
||||
column: mouse.column,
|
||||
row: mouse.row,
|
||||
},
|
||||
modifiers: mouse.modifiers.bits(),
|
||||
}),
|
||||
},
|
||||
crate::raw_input::RawInputEvent::Key(key)
|
||||
if key.modifiers.is_empty()
|
||||
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
|
||||
{
|
||||
let direction = match key.code {
|
||||
KeyCode::PageUp => AttachScrollDirection::Up,
|
||||
KeyCode::PageDown => AttachScrollDirection::Down,
|
||||
_ => return None,
|
||||
};
|
||||
Some(AttachSemanticAction::Scroll {
|
||||
source: AttachScrollSource::PageKey {
|
||||
input: data.to_vec(),
|
||||
},
|
||||
direction,
|
||||
lines: viewport_rows.saturating_sub(1).max(1),
|
||||
column: None,
|
||||
row: None,
|
||||
modifiers: KeyModifiers::empty().bits(),
|
||||
})
|
||||
}
|
||||
crate::raw_input::RawInputEvent::Key(key)
|
||||
if key.modifiers.is_empty()
|
||||
&& key.kind == KeyEventKind::Release
|
||||
&& matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) =>
|
||||
{
|
||||
Some(AttachSemanticAction::Ignore)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn write_attach_semantic_action(
|
||||
stream: &mut LocalStream,
|
||||
action: AttachSemanticAction,
|
||||
) -> io::Result<()> {
|
||||
let message = match action {
|
||||
AttachSemanticAction::Scroll {
|
||||
source,
|
||||
direction,
|
||||
lines,
|
||||
column,
|
||||
row,
|
||||
modifiers,
|
||||
} => ClientMessage::AttachScroll {
|
||||
source,
|
||||
direction,
|
||||
lines,
|
||||
column,
|
||||
row,
|
||||
modifiers,
|
||||
},
|
||||
AttachSemanticAction::Mouse {
|
||||
kind,
|
||||
position,
|
||||
modifiers,
|
||||
} => ClientMessage::AttachMouse {
|
||||
kind,
|
||||
position,
|
||||
geometry: None,
|
||||
modifiers,
|
||||
lines: 1,
|
||||
},
|
||||
AttachSemanticAction::Ignore => return Ok(()),
|
||||
};
|
||||
write_to_server(stream, &message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::{AttachScrollDirection, AttachScrollSource};
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_detaches_on_prefix_q() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![0x02], 24, 3),
|
||||
AttachInputAction::None
|
||||
));
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![b'q'], 24, 3),
|
||||
AttachInputAction::Detach
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_sends_literal_prefix_on_double_prefix() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![0x02], 24, 3),
|
||||
AttachInputAction::None
|
||||
));
|
||||
match escape.filter_input(vec![0x02], 24, 3) {
|
||||
AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]),
|
||||
other => panic!("expected forwarded prefix, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_detaches_on_kitty_encoded_prefix_q() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3),
|
||||
AttachInputAction::None
|
||||
));
|
||||
assert!(matches!(
|
||||
escape.filter_input(b"\x1b[98;5:3u".to_vec(), 24, 3),
|
||||
AttachInputAction::None
|
||||
));
|
||||
assert!(matches!(
|
||||
escape.filter_input(b"\x1b[113u".to_vec(), 24, 3),
|
||||
AttachInputAction::Detach
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_detaches_on_modify_other_keys_encoded_prefix() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(b"\x1b[27;5;98~".to_vec(), 24, 3),
|
||||
AttachInputAction::None
|
||||
));
|
||||
assert!(matches!(
|
||||
escape.filter_input(b"q".to_vec(), 24, 3),
|
||||
AttachInputAction::Detach
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_forwards_kitty_encoded_literal_prefix() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3),
|
||||
AttachInputAction::None
|
||||
));
|
||||
assert!(matches!(
|
||||
escape.filter_input(b"\x1b[98;5:3u".to_vec(), 24, 3),
|
||||
AttachInputAction::None
|
||||
));
|
||||
match escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3) {
|
||||
AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[98;5u"),
|
||||
other => panic!("expected Kitty-encoded prefix, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_does_not_interpret_bracketed_paste_contents() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
let paste = b"\x1b[200~one\x02q\ntwo\x1b[201~".to_vec();
|
||||
|
||||
match escape.filter_input(paste.clone(), 24, 3) {
|
||||
AttachInputAction::Forward(bytes) => assert_eq!(bytes, paste),
|
||||
other => panic!("expected opaque paste, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_flushes_pending_prefix_before_bracketed_paste() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
let paste = b"\x1b[200~one\ntwo\x1b[201~".to_vec();
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![0x02], 24, 3),
|
||||
AttachInputAction::None
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
escape.filter_input(paste.clone(), 24, 3),
|
||||
AttachInputAction::ForwardPair(prefix, bytes)
|
||||
if prefix == vec![0x02] && bytes == paste
|
||||
));
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![b'q'], 24, 3),
|
||||
AttachInputAction::Forward(bytes) if bytes == b"q"
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_forwards_prefix_before_non_escape_key() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![b'a', 0x02], 24, 3),
|
||||
AttachInputAction::Forward(bytes) if bytes == b"a"
|
||||
));
|
||||
match escape.filter_input(vec![b'x'], 24, 3) {
|
||||
AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']),
|
||||
other => panic!("expected forwarded bytes, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_turns_wheel_into_scroll_action() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) {
|
||||
AttachInputAction::Semantic(AttachSemanticAction::Scroll {
|
||||
source,
|
||||
direction,
|
||||
lines,
|
||||
column,
|
||||
row,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(source, AttachScrollSource::Wheel);
|
||||
assert_eq!(direction, AttachScrollDirection::Up);
|
||||
assert_eq!(lines, 7);
|
||||
assert_eq!(column, Some(10));
|
||||
assert_eq!(row, Some(5));
|
||||
}
|
||||
other => panic!("expected scroll action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_routes_non_wheel_mouse_reports_semantically() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7),
|
||||
AttachInputAction::Semantic(AttachSemanticAction::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::Down(
|
||||
crate::protocol::ClientMouseButton::Left
|
||||
),
|
||||
position: crate::protocol::ClientMousePosition::Cell { column: 10, row: 5 },
|
||||
modifiers: 0,
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_flushes_pending_prefix_before_cell_mouse() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![0x02], 24, 3),
|
||||
AttachInputAction::None
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7),
|
||||
AttachInputAction::ForwardThenSemantic(
|
||||
prefix,
|
||||
AttachSemanticAction::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::Down(
|
||||
crate::protocol::ClientMouseButton::Left
|
||||
),
|
||||
position: crate::protocol::ClientMousePosition::Cell {
|
||||
column: 10,
|
||||
row: 5
|
||||
},
|
||||
modifiers: 0,
|
||||
}
|
||||
) if prefix == vec![0x02]
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn direct_attach_pixel_mouse_keeps_pixels_and_semantic_kind() {
|
||||
let geometry = crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap();
|
||||
let (kind, position, modifiers) =
|
||||
direct_attach_pixel_mouse(b"\x1b[<0;21;22M", geometry).expect("pixel mouse");
|
||||
|
||||
assert_eq!(
|
||||
kind,
|
||||
crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left)
|
||||
);
|
||||
assert_eq!(
|
||||
position,
|
||||
crate::protocol::ClientMousePosition::Pixels {
|
||||
x: 21,
|
||||
y: 22,
|
||||
column: 2,
|
||||
row: 1,
|
||||
}
|
||||
);
|
||||
assert_eq!(modifiers, 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn pixel_mouse_flushes_pending_attach_prefix() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
assert!(matches!(
|
||||
escape.filter_input(vec![0x02], 24, 3),
|
||||
AttachInputAction::None
|
||||
));
|
||||
|
||||
assert_eq!(escape.take_pending_prefix(), Some(vec![0x02]));
|
||||
assert_eq!(escape.take_pending_prefix(), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_turns_plain_page_keys_into_scroll_actions() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) {
|
||||
AttachInputAction::Semantic(AttachSemanticAction::Scroll {
|
||||
source,
|
||||
direction,
|
||||
lines,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(
|
||||
source,
|
||||
AttachScrollSource::PageKey {
|
||||
input: b"\x1b[5~".to_vec()
|
||||
}
|
||||
);
|
||||
assert_eq!(direction, AttachScrollDirection::Up);
|
||||
assert_eq!(lines, 11);
|
||||
}
|
||||
other => panic!("expected page-up scroll action, got {other:?}"),
|
||||
}
|
||||
|
||||
match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) {
|
||||
AttachInputAction::Semantic(AttachSemanticAction::Scroll {
|
||||
source,
|
||||
direction,
|
||||
lines,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(
|
||||
source,
|
||||
AttachScrollSource::PageKey {
|
||||
input: b"\x1b[6~".to_vec()
|
||||
}
|
||||
);
|
||||
assert_eq!(direction, AttachScrollDirection::Down);
|
||||
assert_eq!(lines, 11);
|
||||
}
|
||||
other => panic!("expected page-down scroll action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_forwards_modified_page_key() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) {
|
||||
AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"),
|
||||
other => panic!("expected modified page key to forward, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::ipc::LocalStream;
|
||||
#[cfg(windows)]
|
||||
use crate::protocol::ClientInputEvent;
|
||||
use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD;
|
||||
use crate::protocol::{ClientClipboardImageTarget, ClientMessage};
|
||||
|
||||
use super::{is_remote_client_process, write_to_server, ClientError};
|
||||
|
||||
pub(super) fn write_remote_image_to_server(
|
||||
stream: &mut LocalStream,
|
||||
target: ClientClipboardImageTarget,
|
||||
image: crate::platform::ClipboardImage,
|
||||
source: &'static str,
|
||||
) -> Result<(), ClientError> {
|
||||
if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
|
||||
warn!(
|
||||
bytes = image.bytes.len(),
|
||||
max = MAX_CLIPBOARD_IMAGE_PAYLOAD,
|
||||
source,
|
||||
"local image is too large to bridge"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
bytes = image.bytes.len(),
|
||||
extension = image.extension,
|
||||
source,
|
||||
"bridging local image to remote server"
|
||||
);
|
||||
write_to_server(
|
||||
stream,
|
||||
&ClientMessage::ClipboardImage {
|
||||
target,
|
||||
extension: image.extension.to_owned(),
|
||||
data: image.bytes,
|
||||
},
|
||||
)
|
||||
.map_err(ClientError::ConnectionLost)
|
||||
}
|
||||
|
||||
pub(super) fn client_remote_image_paste_key(
|
||||
config: &crate::config::Config,
|
||||
) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> {
|
||||
if !is_remote_client_process() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match config.remote_image_paste_key() {
|
||||
Ok(key) => key,
|
||||
Err(diagnostic) => {
|
||||
warn!(diagnostic = %diagnostic, "local remote image paste key config diagnostic");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn should_bridge_clipboard_image_paste(
|
||||
data: &[u8],
|
||||
is_remote_client: bool,
|
||||
remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
|
||||
) -> bool {
|
||||
if data == b"\x1b[200~\x1b[201~" {
|
||||
return is_remote_client;
|
||||
}
|
||||
|
||||
let Some(remote_image_paste_key) = remote_image_paste_key else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let events = crate::raw_input::parse_raw_input_bytes_sync(data);
|
||||
matches!(
|
||||
events.as_slice(),
|
||||
[crate::raw_input::RawInputEvent::Key(key)]
|
||||
if key.kind == crossterm::event::KeyEventKind::Press
|
||||
&& crate::config::terminal_key_matches_combo(key, remote_image_paste_key)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn should_bridge_clipboard_image_events(
|
||||
events: &[ClientInputEvent],
|
||||
is_remote_client: bool,
|
||||
remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
|
||||
) -> bool {
|
||||
if !is_remote_client {
|
||||
return false;
|
||||
}
|
||||
if matches!(events, [ClientInputEvent::Paste { text }] if text.is_empty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(remote_image_paste_key) = remote_image_paste_key else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
events,
|
||||
[event]
|
||||
if matches!(
|
||||
event.to_raw_input_event(),
|
||||
crate::raw_input::RawInputEvent::Key(key)
|
||||
if key.kind == crossterm::event::KeyEventKind::Press
|
||||
&& crate::config::terminal_key_matches_combo(
|
||||
&key,
|
||||
remote_image_paste_key,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn read_image_file_from_terminal_drop(
|
||||
data: &[u8],
|
||||
is_remote_client: bool,
|
||||
) -> Option<crate::platform::ClipboardImage> {
|
||||
let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?;
|
||||
read_image_file(path, extension)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn read_image_file_from_client_events(
|
||||
events: &[ClientInputEvent],
|
||||
is_remote_client: bool,
|
||||
) -> Option<crate::platform::ClipboardImage> {
|
||||
let [ClientInputEvent::Paste { text }] = events else {
|
||||
return None;
|
||||
};
|
||||
let text = normalized_terminal_drop_text(text)?;
|
||||
let (path, extension) =
|
||||
image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?;
|
||||
read_image_file(path, extension)
|
||||
}
|
||||
|
||||
fn read_image_file(
|
||||
path: PathBuf,
|
||||
extension: &'static str,
|
||||
) -> Option<crate::platform::ClipboardImage> {
|
||||
let metadata = std::fs::metadata(&path).ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(&path).ok()?;
|
||||
let bytes =
|
||||
match crate::platform::read_limited_reader(file, MAX_CLIPBOARD_IMAGE_PAYLOAD).ok()? {
|
||||
crate::platform::LimitedRead::Complete(bytes) => bytes,
|
||||
crate::platform::LimitedRead::Empty => return None,
|
||||
crate::platform::LimitedRead::Oversized => {
|
||||
warn!(
|
||||
max = MAX_CLIPBOARD_IMAGE_PAYLOAD,
|
||||
"local image file drop is too large to bridge"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(crate::platform::ClipboardImage { bytes, extension })
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn image_path_from_terminal_drop(
|
||||
data: &[u8],
|
||||
is_remote_client: bool,
|
||||
) -> Option<(PathBuf, &'static str)> {
|
||||
let bytes = bracketed_paste_payload(data).unwrap_or(data);
|
||||
let text = std::str::from_utf8(bytes).ok()?;
|
||||
let text = normalized_terminal_drop_text(text)?;
|
||||
let text = unescape_terminal_drop_path(strip_matching_path_quotes(text));
|
||||
image_path_from_drop_text(&text, is_remote_client)
|
||||
}
|
||||
|
||||
fn normalized_terminal_drop_text(text: &str) -> Option<&str> {
|
||||
let text = text.trim_end_matches(['\r', '\n']);
|
||||
(!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text)
|
||||
}
|
||||
|
||||
fn image_path_from_drop_text(
|
||||
text: &str,
|
||||
is_remote_client: bool,
|
||||
) -> Option<(PathBuf, &'static str)> {
|
||||
if !is_remote_client {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(text);
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let extension = recognized_image_extension(path.extension()?.to_str()?)?;
|
||||
Some((path, extension))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> {
|
||||
const START: &[u8] = b"\x1b[200~";
|
||||
const END: &[u8] = b"\x1b[201~";
|
||||
data.strip_prefix(START)?.strip_suffix(END)
|
||||
}
|
||||
|
||||
fn strip_matching_path_quotes(text: &str) -> &str {
|
||||
if text.len() < 2 {
|
||||
return text;
|
||||
}
|
||||
|
||||
let bytes = text.as_bytes();
|
||||
match (bytes.first(), bytes.last()) {
|
||||
(Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"')) => &text[1..text.len() - 1],
|
||||
_ => text,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn unescape_terminal_drop_path(text: &str) -> String {
|
||||
let mut unescaped = String::with_capacity(text.len());
|
||||
let mut chars = text.chars();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\\' {
|
||||
if let Some(escaped) = chars.next() {
|
||||
unescaped.push(escaped);
|
||||
} else {
|
||||
unescaped.push(ch);
|
||||
}
|
||||
} else {
|
||||
unescaped.push(ch);
|
||||
}
|
||||
}
|
||||
unescaped
|
||||
}
|
||||
|
||||
fn recognized_image_extension(extension: &str) -> Option<&'static str> {
|
||||
if extension.eq_ignore_ascii_case("png") {
|
||||
Some("png")
|
||||
} else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") {
|
||||
Some("jpg")
|
||||
} else if extension.eq_ignore_ascii_case("gif") {
|
||||
Some("gif")
|
||||
} else if extension.eq_ignore_ascii_case("webp") {
|
||||
Some("webp")
|
||||
} else if extension.eq_ignore_ascii_case("bmp") {
|
||||
Some("bmp")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use std::io;
|
||||
|
||||
use crate::protocol;
|
||||
use crate::server::socket_paths::client_socket_path;
|
||||
|
||||
/// Errors that can occur during client operation.
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
/// Could not connect to the server's client socket.
|
||||
ConnectionFailed(io::Error),
|
||||
/// Server rejected our handshake.
|
||||
HandshakeRejected { version: u32, error: String },
|
||||
/// Server shut down.
|
||||
ServerShutdown { reason: Option<String> },
|
||||
/// Lost connection to the server.
|
||||
ConnectionLost(io::Error),
|
||||
/// Protocol error (framing, deserialization).
|
||||
Protocol(protocol::FramingError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ClientError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ClientError::ConnectionFailed(err) => {
|
||||
write!(f, "failed to connect to server: {err}")?;
|
||||
let path = client_socket_path();
|
||||
write!(
|
||||
f,
|
||||
"\nIs herdr server running? Start it with `herdr server`."
|
||||
)?;
|
||||
write!(f, "\nSocket path: {}", path.display())
|
||||
}
|
||||
ClientError::HandshakeRejected { version, error } => {
|
||||
write!(f, "server rejected handshake (version {version}): {error}")
|
||||
}
|
||||
ClientError::ServerShutdown { reason } => {
|
||||
match reason.as_deref() {
|
||||
Some("detached") => {
|
||||
if let Ok(reattach_command) =
|
||||
std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR)
|
||||
{
|
||||
write!(f, "detached from remote server")?;
|
||||
write!(f, "\nRun `{reattach_command}` to reattach")?;
|
||||
} else {
|
||||
write!(f, "detached from server")?;
|
||||
write!(
|
||||
f,
|
||||
"\nRun `{}` to reattach",
|
||||
crate::session::local_attach_command()
|
||||
)?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
write!(f, "server shut down")?;
|
||||
if let Some(reason) = reason {
|
||||
write!(f, ": {reason}")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ClientError::ConnectionLost(err) => {
|
||||
if let Ok(reattach_command) = std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR)
|
||||
{
|
||||
write!(f, "lost connection to remote Herdr: {err}")?;
|
||||
write!(f, "\nIf the remote server survived the SSH or network drop, its panes may still be running.")?;
|
||||
write!(f, "\nRun `{reattach_command}` to reattach")
|
||||
} else {
|
||||
write!(f, "lost connection to server: {err}")
|
||||
}
|
||||
}
|
||||
ClientError::Protocol(err) => write!(f, "protocol error: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ClientError::ConnectionFailed(err) => Some(err),
|
||||
ClientError::ConnectionLost(err) => Some(err),
|
||||
ClientError::Protocol(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<protocol::FramingError> for ClientError {
|
||||
fn from(err: protocol::FramingError) -> Self {
|
||||
match err {
|
||||
protocol::FramingError::UnexpectedEof => ClientError::ConnectionLost(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"server closed connection",
|
||||
)),
|
||||
protocol::FramingError::Io(err) => ClientError::ConnectionLost(err),
|
||||
err => ClientError::Protocol(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use crate::protocol::render_ansi;
|
||||
|
||||
static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
|
||||
|
||||
pub(super) fn write_encoded_frame_with_graphics(
|
||||
mut writer: impl io::Write,
|
||||
encoded: &[u8],
|
||||
graphics: &[u8],
|
||||
) -> io::Result<()> {
|
||||
if graphics.is_empty() {
|
||||
return writer.write_all(encoded);
|
||||
}
|
||||
|
||||
let insertion = render_ansi::final_sync_output_end(encoded).unwrap_or(encoded.len());
|
||||
|
||||
writer.write_all(&encoded[..insertion])?;
|
||||
record_received_kitty_graphics(graphics);
|
||||
writer.write_all(b"\x1b7")?;
|
||||
writer.write_all(graphics)?;
|
||||
writer.write_all(b"\x1b8")?;
|
||||
writer.write_all(&encoded[insertion..])
|
||||
}
|
||||
|
||||
pub(super) fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool {
|
||||
bytes.windows(3).any(|window| window == b"\x1b_G")
|
||||
}
|
||||
|
||||
pub(super) fn record_received_kitty_graphics(bytes: &[u8]) {
|
||||
let ids = kitty_graphics_image_ids(bytes);
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let set = RECEIVED_KITTY_GRAPHICS_IDS.get_or_init(|| Mutex::new(HashSet::new()));
|
||||
if let Ok(mut set) = set.lock() {
|
||||
set.extend(ids);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clear_received_kitty_graphics(mut writer: impl io::Write) -> io::Result<()> {
|
||||
let Some(set) = RECEIVED_KITTY_GRAPHICS_IDS.get() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(mut set) = set.lock() else {
|
||||
return Ok(());
|
||||
};
|
||||
for id in set.drain() {
|
||||
write!(writer, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")?;
|
||||
}
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
pub(super) fn kitty_graphics_image_ids(bytes: &[u8]) -> Vec<u32> {
|
||||
let mut ids = Vec::new();
|
||||
let mut index = 0usize;
|
||||
while let Some(start) = find_subslice(&bytes[index..], b"\x1b_G") {
|
||||
let command_start = index + start + 3;
|
||||
let Some(end) = find_subslice(&bytes[command_start..], b"\x1b\\") else {
|
||||
break;
|
||||
};
|
||||
let command = &bytes[command_start..command_start + end];
|
||||
if let Some(id) = kitty_graphics_command_image_id(command) {
|
||||
ids.push(id);
|
||||
}
|
||||
index = command_start + end + 2;
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn kitty_graphics_command_image_id(command: &[u8]) -> Option<u32> {
|
||||
let header_end = command
|
||||
.iter()
|
||||
.position(|byte| *byte == b';')
|
||||
.unwrap_or(command.len());
|
||||
for part in command[..header_end].split(|byte| *byte == b',') {
|
||||
let Some(value) = part.strip_prefix(b"i=") else {
|
||||
continue;
|
||||
};
|
||||
let text = std::str::from_utf8(value).ok()?;
|
||||
if let Ok(id) = text.parse::<u32>() {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
if needle.is_empty() || needle.len() > haystack.len() {
|
||||
return None;
|
||||
}
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use std::io;
|
||||
#[cfg(unix)]
|
||||
use std::io::IsTerminal as _;
|
||||
use std::time::Duration;
|
||||
|
||||
use interprocess::local_socket::traits::Stream as _;
|
||||
#[cfg(windows)]
|
||||
use tracing::debug;
|
||||
use tracing::info;
|
||||
|
||||
use crate::ipc::LocalStream;
|
||||
use crate::protocol::{
|
||||
self, ClientMessage, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION,
|
||||
};
|
||||
|
||||
use super::{shell, terminal_setup::is_ssh_session, ClientError};
|
||||
|
||||
/// Time to wait for the server's Welcome reply during the handshake.
|
||||
///
|
||||
/// A local client talks to an already-connected server, so 5s is plenty. The
|
||||
/// remote bridge client (`herdr --remote`) sits behind a fresh per-attach ssh
|
||||
/// connection whose cold-connect (TCP + key exchange + auth) happens inside this
|
||||
/// window; on a high-latency link that easily exceeds 5s, so it gets a far
|
||||
/// larger budget. See issue #753.
|
||||
pub(super) const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub(super) const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
pub(super) fn is_remote_client_process() -> bool {
|
||||
std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok()
|
||||
}
|
||||
|
||||
pub(super) fn client_shell_keybinding_source() -> shell::ClientShellKeybindingSource {
|
||||
match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR)
|
||||
.ok()
|
||||
.as_deref()
|
||||
{
|
||||
Some("server") => shell::ClientShellKeybindingSource::Endpoint,
|
||||
Some(_) => shell::ClientShellKeybindingSource::RemoteLocal,
|
||||
None => shell::ClientShellKeybindingSource::Local,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handshake_read_timeout() -> Duration {
|
||||
if is_remote_client_process() {
|
||||
return REMOTE_HANDSHAKE_READ_TIMEOUT;
|
||||
}
|
||||
LOCAL_HANDSHAKE_READ_TIMEOUT
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
pub(super) fn direct_graphics_profile_values(
|
||||
term_program: &str,
|
||||
term: &str,
|
||||
kitty_window: bool,
|
||||
blocked_transport: bool,
|
||||
terminals: bool,
|
||||
) -> bool {
|
||||
let supported = term_program.eq_ignore_ascii_case("ghostty")
|
||||
|| term_program.eq_ignore_ascii_case("wezterm")
|
||||
|| matches!(term, "xterm-ghostty" | "xterm-kitty" | "xterm-wezterm")
|
||||
|| kitty_window;
|
||||
supported && !blocked_transport && terminals
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn direct_graphics_profile_allowed() -> bool {
|
||||
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
|
||||
let term = std::env::var("TERM").unwrap_or_default();
|
||||
direct_graphics_profile_values(
|
||||
&term_program,
|
||||
&term,
|
||||
std::env::var_os("KITTY_WINDOW_ID").is_some(),
|
||||
is_remote_client_process()
|
||||
|| is_ssh_session()
|
||||
|| std::env::var_os("TMUX").is_some()
|
||||
|| std::env::var_os("STY").is_some(),
|
||||
io::stdin().is_terminal() && io::stdout().is_terminal(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn direct_graphics_profile_allowed() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn set_handshake_recv_timeout(
|
||||
stream: &LocalStream,
|
||||
timeout: Option<Duration>,
|
||||
context: &'static str,
|
||||
) -> Result<(), ClientError> {
|
||||
match stream.set_recv_timeout(timeout) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == io::ErrorKind::Unsupported => {
|
||||
debug!(err = %err, context, "client socket receive timeout unavailable");
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(ClientError::ConnectionFailed(err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn set_handshake_recv_timeout(
|
||||
stream: &LocalStream,
|
||||
timeout: Option<Duration>,
|
||||
_context: &'static str,
|
||||
) -> Result<(), ClientError> {
|
||||
stream
|
||||
.set_recv_timeout(timeout)
|
||||
.map_err(ClientError::ConnectionFailed)
|
||||
}
|
||||
|
||||
/// Performs the client→server handshake.
|
||||
///
|
||||
/// Sends TerminalHello (or ClientShellHello) with the terminal size and protocol
|
||||
/// version, then reads the Welcome response.
|
||||
pub(super) fn do_handshake(
|
||||
stream: &mut LocalStream,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
cell_width_px: u32,
|
||||
cell_height_px: u32,
|
||||
exact_cell_size: bool,
|
||||
shell_surface_size: Option<crate::protocol::ClientSurfaceSize>,
|
||||
endpoint_keybindings: bool,
|
||||
mouse_capture: bool,
|
||||
) -> Result<RenderEncoding, ClientError> {
|
||||
stream
|
||||
.set_nonblocking(false)
|
||||
.map_err(ClientError::ConnectionFailed)?;
|
||||
|
||||
let hello = if let Some(surface_size) = shell_surface_size {
|
||||
ClientMessage::ClientShellHello {
|
||||
version: PROTOCOL_VERSION,
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
surface_size,
|
||||
pixel_mouse: exact_cell_size && cfg!(unix),
|
||||
direct_graphics: exact_cell_size
|
||||
&& cell_width_px > 0
|
||||
&& cell_height_px > 0
|
||||
&& direct_graphics_profile_allowed(),
|
||||
endpoint_keybindings,
|
||||
mouse_capture,
|
||||
}
|
||||
} else {
|
||||
ClientMessage::TerminalHello {
|
||||
version: PROTOCOL_VERSION,
|
||||
cols,
|
||||
rows,
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
pixel_mouse: exact_cell_size && cfg!(unix),
|
||||
}
|
||||
};
|
||||
protocol::write_message(stream, &hello)
|
||||
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
|
||||
|
||||
set_handshake_recv_timeout(
|
||||
stream,
|
||||
Some(handshake_read_timeout()),
|
||||
"client handshake read timeout unavailable",
|
||||
)?;
|
||||
let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?;
|
||||
set_handshake_recv_timeout(
|
||||
stream,
|
||||
None,
|
||||
"failed to clear client handshake read timeout",
|
||||
)?;
|
||||
|
||||
match welcome {
|
||||
ServerMessage::Welcome {
|
||||
version,
|
||||
encoding,
|
||||
error,
|
||||
} => {
|
||||
if let Some(error) = error {
|
||||
return Err(ClientError::HandshakeRejected { version, error });
|
||||
}
|
||||
info!(version, ?encoding, "handshake succeeded");
|
||||
Ok(encoding)
|
||||
}
|
||||
_ => Err(ClientError::Protocol(protocol::FramingError::Io(
|
||||
io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"),
|
||||
))),
|
||||
}
|
||||
}
|
||||
+502
-2776
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
use std::io;
|
||||
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::protocol::NotifyKind;
|
||||
|
||||
use super::shell;
|
||||
|
||||
pub(super) fn handle_shell_notification_effects(
|
||||
effects: Vec<shell::ClientShellNotificationEffect>,
|
||||
sound_config: &crate::config::SoundConfig,
|
||||
) {
|
||||
for effect in effects {
|
||||
match effect {
|
||||
shell::ClientShellNotificationEffect::Sound { sound, agent } => {
|
||||
let agent = agent.as_deref().and_then(crate::detect::parse_agent_label);
|
||||
if sound_config.allows(agent) {
|
||||
crate::sound::play(sound, sound_config);
|
||||
}
|
||||
}
|
||||
shell::ClientShellNotificationEffect::Terminal { title, body } => {
|
||||
if let Err(err) = crate::terminal_notify::show_notification(&title, body.as_deref())
|
||||
{
|
||||
warn!(err = %err, "failed to emit terminal notification");
|
||||
}
|
||||
}
|
||||
shell::ClientShellNotificationEffect::System { title, body } => {
|
||||
if let Err(err) =
|
||||
crate::platform::show_desktop_notification(&title, body.as_deref())
|
||||
{
|
||||
warn!(err = %err, "failed to emit system notification");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_notify(
|
||||
kind: NotifyKind,
|
||||
message: &str,
|
||||
body: Option<&str>,
|
||||
sound_config: &crate::config::SoundConfig,
|
||||
) {
|
||||
handle_notify_with_notifiers(
|
||||
kind,
|
||||
message,
|
||||
body,
|
||||
sound_config,
|
||||
crate::terminal_notify::show_notification,
|
||||
crate::platform::show_desktop_notification,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_notify_with_notifiers(
|
||||
kind: NotifyKind,
|
||||
message: &str,
|
||||
body: Option<&str>,
|
||||
sound_config: &crate::config::SoundConfig,
|
||||
mut show_terminal_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>,
|
||||
mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>,
|
||||
) {
|
||||
match kind {
|
||||
NotifyKind::Sound => {
|
||||
let Some(sound) = sound_from_notify_message(message) else {
|
||||
warn!(
|
||||
message = message,
|
||||
"received unknown sound notification from server"
|
||||
);
|
||||
return;
|
||||
};
|
||||
if sound_config.enabled {
|
||||
crate::sound::play(sound, sound_config);
|
||||
}
|
||||
}
|
||||
NotifyKind::Toast => {
|
||||
debug!(
|
||||
message = message,
|
||||
"received terminal toast notification from server"
|
||||
);
|
||||
if let Err(err) = show_terminal_notification(message, body) {
|
||||
warn!(err = %err, "failed to emit terminal notification");
|
||||
}
|
||||
}
|
||||
NotifyKind::SystemToast => {
|
||||
debug!(
|
||||
message = message,
|
||||
"received system toast notification from server"
|
||||
);
|
||||
if let Err(err) = show_system_notification(message, body) {
|
||||
warn!(err = %err, "failed to emit system notification");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sound_from_notify_message(message: &str) -> Option<crate::sound::Sound> {
|
||||
match message {
|
||||
"agent done" => Some(crate::sound::Sound::Done),
|
||||
"agent attention" => Some(crate::sound::Sound::Request),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
+22
-6924
File diff suppressed because it is too large
Load Diff
+181
-42
@@ -186,11 +186,43 @@ impl ClientShellState {
|
||||
let Some(snapshot) = self.snapshot.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let selection = (action == crate::protocol::ClientShellCommandAction::PluginAction)
|
||||
.then(|| {
|
||||
let selection = self.selection.as_ref()?;
|
||||
if !selection.is_visible() {
|
||||
return None;
|
||||
}
|
||||
if snapshot.focused_pane_id.as_deref() != Some(selection.pane_id.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let content_revision = self
|
||||
.pane_surface
|
||||
.as_ref()?
|
||||
.panes
|
||||
.iter()
|
||||
.find(|pane| pane.pane_id == selection.pane_id)?
|
||||
.content_revision;
|
||||
let (anchor, cursor) = selection.ordered_cells();
|
||||
Some(crate::api::schema::PaneSelectionReadParams {
|
||||
pane_id: selection.pane_id.clone(),
|
||||
anchor: crate::api::schema::PaneTextPoint {
|
||||
row: anchor.0,
|
||||
col: anchor.1,
|
||||
},
|
||||
cursor: crate::api::schema::PaneTextPoint {
|
||||
row: cursor.0,
|
||||
col: cursor.1,
|
||||
},
|
||||
content_revision: Some(content_revision),
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let params = crate::api::schema::CommandInvokeParams {
|
||||
command_id,
|
||||
workspace_id: snapshot.focused_workspace_id.clone(),
|
||||
tab_id: snapshot.focused_tab_id.clone(),
|
||||
pane_id: snapshot.focused_pane_id.clone(),
|
||||
selection,
|
||||
};
|
||||
if action == crate::protocol::ClientShellCommandAction::Popup {
|
||||
self.popup_pending = true;
|
||||
@@ -211,6 +243,14 @@ impl ClientShellState {
|
||||
}
|
||||
|
||||
pub(super) fn request_selection_copy(&mut self, outcome: &mut ClientShellInput) {
|
||||
self.request_selection_copy_with_fallback(outcome, None);
|
||||
}
|
||||
|
||||
pub(super) fn request_selection_copy_with_fallback(
|
||||
&mut self,
|
||||
outcome: &mut ClientShellInput,
|
||||
fallback_key: Option<crate::input::TerminalKey>,
|
||||
) {
|
||||
let Some(selection) = self.selection.as_ref() else {
|
||||
return;
|
||||
};
|
||||
@@ -221,6 +261,27 @@ impl ClientShellState {
|
||||
.and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id))
|
||||
.map(|pane| pane.content_revision);
|
||||
let (anchor, cursor) = selection.ordered_cells();
|
||||
let fallback = fallback_key.and_then(|key| {
|
||||
let press = ClientPaneInputEvent::from_terminal_key(key.clone())?;
|
||||
let tracks_release = matches!(
|
||||
&press,
|
||||
ClientPaneInputEvent::Key {
|
||||
tracks_release: true,
|
||||
..
|
||||
}
|
||||
);
|
||||
let mut message =
|
||||
super::target_event_message(ClientInputTarget::Pane(pane_id.clone()), press);
|
||||
if tracks_release {
|
||||
let release = ClientPaneInputEvent::from_terminal_key(
|
||||
key.with_kind(crossterm::event::KeyEventKind::Release),
|
||||
)?;
|
||||
if let ClientMessage::ClientShellPaneInput { events, .. } = &mut message {
|
||||
events.push(release);
|
||||
}
|
||||
}
|
||||
Some(message)
|
||||
});
|
||||
self.push_endpoint_method_with_kind(
|
||||
crate::api::schema::Method::PaneSelectionRead(
|
||||
crate::api::schema::PaneSelectionReadParams {
|
||||
@@ -236,7 +297,7 @@ impl ClientShellState {
|
||||
content_revision,
|
||||
},
|
||||
),
|
||||
PendingEndpointKind::SelectionCopy,
|
||||
PendingEndpointKind::SelectionCopy { fallback },
|
||||
outcome,
|
||||
);
|
||||
}
|
||||
@@ -431,34 +492,34 @@ impl ClientShellState {
|
||||
let repaint = self.complete_pane_scroll(pane_id, serial, result, &mut outcome);
|
||||
return (repaint, outcome.actions);
|
||||
}
|
||||
PendingEndpointKind::SelectionCopy => {
|
||||
PendingEndpointKind::SelectionCopy { fallback } => {
|
||||
let fallback = || {
|
||||
fallback
|
||||
.map(ClientShellAction::Request)
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
return match result {
|
||||
Ok(crate::api::schema::ResponseResult::PaneSelection { text, .. })
|
||||
if !text.is_empty() =>
|
||||
{
|
||||
if self.config.clipboard_toast_enabled {
|
||||
self.copy_feedback = Some(crate::app::state::CopyFeedback {
|
||||
message: "copied to clipboard".to_owned(),
|
||||
});
|
||||
self.copy_feedback_deadline =
|
||||
Some(std::time::Instant::now() + std::time::Duration::from_secs(2));
|
||||
}
|
||||
let repaint = self.show_copy_feedback(std::time::Instant::now());
|
||||
(
|
||||
self.config.clipboard_toast_enabled,
|
||||
repaint,
|
||||
vec![ClientShellAction::ClipboardWrite(text.into_bytes())],
|
||||
)
|
||||
}
|
||||
Ok(crate::api::schema::ResponseResult::PaneSelection { .. }) => {
|
||||
(false, Vec::new())
|
||||
(false, fallback())
|
||||
}
|
||||
Ok(_) => {
|
||||
self.endpoint_error =
|
||||
Some("endpoint returned an unexpected selection result".to_owned());
|
||||
(true, Vec::new())
|
||||
(true, fallback())
|
||||
}
|
||||
Err(error) => {
|
||||
self.endpoint_error = Some(error.message);
|
||||
(true, Vec::new())
|
||||
(true, fallback())
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -521,6 +582,70 @@ impl ClientShellState {
|
||||
self.request_selection_copy(&mut outcome);
|
||||
return (true, outcome.actions);
|
||||
}
|
||||
PendingEndpointKind::PaneLinkActivate {
|
||||
pane_id,
|
||||
inner_rect,
|
||||
fallback_events,
|
||||
} => {
|
||||
let completed_before_release = !fallback_events.iter().any(|event| {
|
||||
event.kind
|
||||
== crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left)
|
||||
});
|
||||
let replay = (self.mode == ClientShellMode::Terminal
|
||||
&& self.overlay.is_none()
|
||||
&& self
|
||||
.hits
|
||||
.panes
|
||||
.iter()
|
||||
.any(|hit| hit.pane_id == pane_id && hit.inner_rect == inner_rect))
|
||||
.then_some(fallback_events);
|
||||
if replay.is_none() {
|
||||
self.url_click_consumes_until_up = completed_before_release;
|
||||
}
|
||||
let replay_action = |events: Option<Vec<crossterm::event::MouseEvent>>| {
|
||||
events
|
||||
.map(ClientShellAction::ReplayMouse)
|
||||
.into_iter()
|
||||
.collect()
|
||||
};
|
||||
return match result {
|
||||
Ok(crate::api::schema::ResponseResult::PaneLinkActivated {
|
||||
handled: true,
|
||||
..
|
||||
}) => {
|
||||
self.url_click_consumes_until_up = completed_before_release;
|
||||
(false, Vec::new())
|
||||
}
|
||||
Ok(crate::api::schema::ResponseResult::PaneLinkActivated {
|
||||
url: Some(url),
|
||||
handled: false,
|
||||
}) if crate::app::actions::safe_web_url(&url).is_some() => {
|
||||
self.url_click_consumes_until_up = completed_before_release;
|
||||
(false, vec![ClientShellAction::OpenSafeWebUrl(url)])
|
||||
}
|
||||
Ok(crate::api::schema::ResponseResult::PaneLinkActivated { .. }) => {
|
||||
(false, replay_action(replay))
|
||||
}
|
||||
Ok(_) => {
|
||||
self.endpoint_error =
|
||||
Some("endpoint returned an unexpected link result".to_owned());
|
||||
(true, replay_action(replay))
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.code.as_deref(),
|
||||
Some("stale_content" | "stale_target")
|
||||
) =>
|
||||
{
|
||||
self.url_click_consumes_until_up = completed_before_release;
|
||||
(false, Vec::new())
|
||||
}
|
||||
Err(error) => {
|
||||
self.endpoint_error = Some(error.message);
|
||||
(true, replay_action(replay))
|
||||
}
|
||||
};
|
||||
}
|
||||
PendingEndpointKind::CopyMotion {
|
||||
pane_id,
|
||||
origin,
|
||||
@@ -662,7 +787,7 @@ impl ClientShellState {
|
||||
}
|
||||
|
||||
pub(super) fn endpoint_method_for_action(
|
||||
&self,
|
||||
&mut self,
|
||||
action: crate::input::KeybindAction,
|
||||
) -> Option<crate::api::schema::Method> {
|
||||
use crate::api::schema::{
|
||||
@@ -710,47 +835,61 @@ impl ClientShellState {
|
||||
if agents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let current = agents
|
||||
.iter()
|
||||
.position(|pane_id| {
|
||||
Some(pane_id.as_str()) == snapshot.focused_pane_id.as_deref()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let next = if action == KeybindAction::PreviousAgent {
|
||||
(current + agents.len() - 1) % agents.len()
|
||||
} else {
|
||||
(current + 1) % agents.len()
|
||||
let current = agents.iter().position(|pane_id| {
|
||||
Some(pane_id.as_str()) == snapshot.focused_pane_id.as_deref()
|
||||
});
|
||||
let next = match (current, action) {
|
||||
(Some(current), KeybindAction::PreviousAgent) => {
|
||||
(current + agents.len() - 1) % agents.len()
|
||||
}
|
||||
(Some(current), KeybindAction::NextAgent) => (current + 1) % agents.len(),
|
||||
(None, KeybindAction::PreviousAgent) => agents.len() - 1,
|
||||
(None, KeybindAction::NextAgent) => 0,
|
||||
_ => unreachable!("relative agent action"),
|
||||
};
|
||||
Some(Method::PaneFocus(PaneTarget {
|
||||
pane_id: agents[next].clone(),
|
||||
}))
|
||||
let pane_id = agents[next].clone();
|
||||
if !self
|
||||
.hits
|
||||
.agents
|
||||
.iter()
|
||||
.any(|(_, visible_pane_id)| visible_pane_id == &pane_id)
|
||||
{
|
||||
self.agent_scroll = next.min(self.hits.agent_max_scroll);
|
||||
}
|
||||
Some(Method::PaneFocus(PaneTarget { pane_id }))
|
||||
}
|
||||
KeybindAction::SwitchWorkspace(index) => {
|
||||
let entries = self.navigation_workspace_entries(snapshot);
|
||||
Some(Method::WorkspaceFocus(WorkspaceTarget {
|
||||
workspace_id: snapshot
|
||||
.workspaces
|
||||
.get(entries.get(index)?.index)?
|
||||
.workspace_id
|
||||
.clone(),
|
||||
}))
|
||||
let workspace_id = snapshot
|
||||
.workspaces
|
||||
.get(entries.get(index)?.index)?
|
||||
.workspace_id
|
||||
.clone();
|
||||
self.reveal_workspace(&workspace_id);
|
||||
Some(Method::WorkspaceFocus(WorkspaceTarget { workspace_id }))
|
||||
}
|
||||
KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace => {
|
||||
let entries = self.navigation_workspace_entries(snapshot);
|
||||
let current = entries.iter().position(|entry| {
|
||||
snapshot.workspaces[entry.index].workspace_id == focused_workspace
|
||||
})?;
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let current = entries
|
||||
.iter()
|
||||
.position(|entry| {
|
||||
snapshot.workspaces[entry.index].workspace_id == focused_workspace
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let delta = if action == KeybindAction::PreviousWorkspace {
|
||||
-1
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let next = (current as isize + delta).rem_euclid(entries.len() as isize) as usize;
|
||||
Some(Method::WorkspaceFocus(WorkspaceTarget {
|
||||
workspace_id: snapshot.workspaces[entries[next].index]
|
||||
.workspace_id
|
||||
.clone(),
|
||||
}))
|
||||
let workspace_id = snapshot.workspaces[entries[next].index]
|
||||
.workspace_id
|
||||
.clone();
|
||||
self.reveal_workspace(&workspace_id);
|
||||
Some(Method::WorkspaceFocus(WorkspaceTarget { workspace_id }))
|
||||
}
|
||||
KeybindAction::SwitchTab(index) => {
|
||||
let tabs = snapshot
|
||||
|
||||
+20
-18
@@ -30,6 +30,8 @@ impl ClientShellState {
|
||||
let Some(path) = self.config.preferences_path.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let mut collapsed_groups = self.collapsed_groups.iter().cloned().collect::<Vec<_>>();
|
||||
collapsed_groups.sort();
|
||||
let preferences = preferences::ClientChromePreferences {
|
||||
sidebar_width: self.sidebar_width_manual.then_some(self.sidebar_width),
|
||||
sidebar_section_split: self
|
||||
@@ -41,6 +43,7 @@ impl ClientShellState {
|
||||
agent_panel_sort: self
|
||||
.agent_panel_sort_manual
|
||||
.then_some(self.config.agent_panel_sort),
|
||||
collapsed_groups,
|
||||
};
|
||||
if let Err(error) = preferences::store(path, preferences) {
|
||||
self.endpoint_error = Some(error);
|
||||
@@ -57,6 +60,12 @@ impl ClientShellState {
|
||||
&loaded.diagnostics,
|
||||
&loaded.invalid_sections,
|
||||
);
|
||||
if let Some(appearance) = self.host_appearance {
|
||||
self.config.palette = crate::app::client_palette_for_appearance(
|
||||
&self.config.theme_runtime,
|
||||
appearance,
|
||||
);
|
||||
}
|
||||
if !self.sidebar_width_manual {
|
||||
self.sidebar_width = self.config.sidebar_width;
|
||||
}
|
||||
@@ -79,6 +88,7 @@ impl ClientShellState {
|
||||
self.set_local_config_diagnostic(self.config.local_config_diagnostic(&diagnostics));
|
||||
}
|
||||
}
|
||||
self.reconcile_input_source();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,9 +133,11 @@ impl ClientShellConfig {
|
||||
mouse_capture: config.ui.mouse_capture,
|
||||
mouse_scroll_lines: config.ui.mouse_scroll_lines(),
|
||||
right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(),
|
||||
worktree_directory: crate::worktree::expand_tilde_absolute_path(
|
||||
&config.worktrees.directory,
|
||||
),
|
||||
redraw_on_focus_gained: config.ui.redraw_on_focus_gained,
|
||||
switch_ascii_input_source_in_prefix: config
|
||||
.experimental
|
||||
.switch_ascii_input_source_in_prefix,
|
||||
local_config_path: crate::config::config_path(),
|
||||
preferences_path: None,
|
||||
preferences: preferences::ClientChromePreferences::default(),
|
||||
startup_config_diagnostic: None,
|
||||
@@ -304,6 +316,7 @@ impl ClientShellConfig {
|
||||
self.mouse_capture = ui.mouse_capture;
|
||||
self.mouse_scroll_lines = ui.mouse_scroll_lines();
|
||||
self.right_click_passthrough_modifiers = ui.right_click_passthrough_modifiers();
|
||||
self.redraw_on_focus_gained = ui.redraw_on_focus_gained;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,9 +325,9 @@ impl ClientShellConfig {
|
||||
self.theme_name = self.theme_runtime.manual_name.clone();
|
||||
self.palette = crate::app::client_palette_from_config(config);
|
||||
}
|
||||
if !invalid_section("worktrees") {
|
||||
self.worktree_directory =
|
||||
crate::worktree::expand_tilde_absolute_path(&config.worktrees.directory);
|
||||
if !invalid_section("experimental") {
|
||||
self.switch_ascii_input_source_in_prefix =
|
||||
config.experimental.switch_ascii_input_source_in_prefix;
|
||||
}
|
||||
|
||||
diagnostics
|
||||
@@ -423,7 +436,6 @@ mod tests {
|
||||
next.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols;
|
||||
next.ui.sidebar.agents.row_gap = 2;
|
||||
next.keys.prefix = "ctrl+a".to_owned();
|
||||
next.worktrees.directory = "/var/tmp/herdr-reloaded-worktrees".to_owned();
|
||||
|
||||
let diagnostics = shell.apply_live_config(&next, &[], &[]);
|
||||
|
||||
@@ -443,10 +455,6 @@ mod tests {
|
||||
shell.keybinds.prefix,
|
||||
(KeyCode::Char('a'), KeyModifiers::CONTROL)
|
||||
);
|
||||
assert_eq!(
|
||||
shell.worktree_directory,
|
||||
std::path::PathBuf::from("/var/tmp/herdr-reloaded-worktrees")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -478,14 +486,12 @@ mod tests {
|
||||
let mut initial = Config::default();
|
||||
initial.ui.sidebar_width = 29;
|
||||
initial.keys.prefix = "ctrl+x".to_owned();
|
||||
initial.worktrees.directory = "/var/tmp/herdr-current-worktrees".to_owned();
|
||||
let mut shell = ClientShellConfig::from_config(&initial);
|
||||
|
||||
let mut invalid = Config::default();
|
||||
invalid.ui.sidebar_width = 35;
|
||||
invalid.keys.prefix = "ctrl+a".to_owned();
|
||||
invalid.worktrees.directory = "/var/tmp/herdr-invalid-worktrees".to_owned();
|
||||
let invalid_sections = vec!["ui".to_owned(), "keys".to_owned(), "worktrees".to_owned()];
|
||||
let invalid_sections = vec!["ui".to_owned(), "keys".to_owned()];
|
||||
shell.apply_live_config(&invalid, &[], &invalid_sections);
|
||||
|
||||
assert_eq!(shell.sidebar_width, 29);
|
||||
@@ -493,9 +499,5 @@ mod tests {
|
||||
shell.keybinds.prefix,
|
||||
(KeyCode::Char('x'), KeyModifiers::CONTROL)
|
||||
);
|
||||
assert_eq!(
|
||||
shell.worktree_directory,
|
||||
std::path::PathBuf::from("/var/tmp/herdr-current-worktrees")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +282,7 @@ impl ClientShellState {
|
||||
if !self.collapsed_groups.remove(&key) {
|
||||
self.collapsed_groups.insert(key);
|
||||
}
|
||||
self.persist_chrome_preferences(outcome);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -196,8 +196,7 @@ impl ClientShellState {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let Some(command) = crate::app::input::copy_mode::copy_mode_command_char(key.clone())
|
||||
else {
|
||||
let Some(command) = crate::copy_mode::copy_mode_command_char(key.clone()) else {
|
||||
return;
|
||||
};
|
||||
match command {
|
||||
@@ -304,8 +303,7 @@ impl ClientShellState {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(ch) = crate::app::input::copy_mode::copy_mode_command_char(key.clone())
|
||||
{
|
||||
if let Some(ch) = crate::copy_mode::copy_mode_command_char(key.clone()) {
|
||||
if let Some(prompt) = self
|
||||
.copy_mode
|
||||
.as_mut()
|
||||
@@ -331,7 +329,9 @@ impl ClientShellState {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
prompt.query.push_str(text);
|
||||
prompt
|
||||
.query
|
||||
.extend(text.chars().filter(|character| !character.is_control()));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -560,8 +560,7 @@ impl ClientShellState {
|
||||
let Some(hit) = self.copy_hit() else {
|
||||
return;
|
||||
};
|
||||
let lines =
|
||||
crate::app::input::copy_mode::copy_mode_page_lines(hit.inner_rect.height, half_page);
|
||||
let lines = crate::copy_mode::copy_mode_page_lines(hit.inner_rect.height, half_page);
|
||||
let Some((pane_id, next_offset)) = self.copy_mode.as_mut().map(|copy_mode| {
|
||||
if direction < 0 {
|
||||
copy_mode.cursor.row = copy_mode.cursor.row.saturating_sub(lines as u32);
|
||||
|
||||
+254
-9
@@ -3,12 +3,69 @@ use crate::protocol::ClientPaneInputEvent;
|
||||
use crate::raw_input::RawInputEvent;
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
|
||||
const LOCAL_INPUT_SOURCE: u8 = 0;
|
||||
|
||||
fn is_retained_selection_copy_key(key: &crate::input::TerminalKey) -> bool {
|
||||
matches!(key.code, KeyCode::Char('c' | 'C'))
|
||||
&& matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER)
|
||||
}
|
||||
|
||||
pub(super) fn is_modal_paste_shortcut_for_platform(
|
||||
key: &crate::input::TerminalKey,
|
||||
macos: bool,
|
||||
) -> bool {
|
||||
matches!(key.code, KeyCode::Char('v' | 'V'))
|
||||
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
|| macos && key.modifiers.contains(KeyModifiers::SUPER))
|
||||
}
|
||||
|
||||
fn is_modal_paste_shortcut(key: &crate::input::TerminalKey) -> bool {
|
||||
is_modal_paste_shortcut_for_platform(key, cfg!(target_os = "macos"))
|
||||
}
|
||||
|
||||
fn host_theme_update(event: &RawInputEvent) -> Option<crate::protocol::ClientHostThemeUpdate> {
|
||||
use crate::protocol::{
|
||||
ClientHostAppearance, ClientHostDefaultColorKind, ClientHostThemeUpdate,
|
||||
};
|
||||
|
||||
match event {
|
||||
RawInputEvent::HostDefaultColor { kind, color } => {
|
||||
Some(ClientHostThemeUpdate::DefaultColor {
|
||||
kind: match kind {
|
||||
crate::terminal_theme::DefaultColorKind::Foreground => {
|
||||
ClientHostDefaultColorKind::Foreground
|
||||
}
|
||||
crate::terminal_theme::DefaultColorKind::Background => {
|
||||
ClientHostDefaultColorKind::Background
|
||||
}
|
||||
},
|
||||
color: (*color).into(),
|
||||
})
|
||||
}
|
||||
RawInputEvent::HostPaletteColors { colors } => Some(ClientHostThemeUpdate::PaletteColors(
|
||||
colors
|
||||
.iter()
|
||||
.map(|(index, color)| (*index, (*color).into()))
|
||||
.collect(),
|
||||
)),
|
||||
RawInputEvent::HostColorSchemeChanged(appearance) => {
|
||||
Some(ClientHostThemeUpdate::Appearance(match appearance {
|
||||
crate::terminal_theme::HostAppearance::Dark => ClientHostAppearance::Dark,
|
||||
crate::terminal_theme::HostAppearance::Light => ClientHostAppearance::Light,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientShellState {
|
||||
pub(crate) fn host_keyboard_report_all_requested(&self) -> bool {
|
||||
matches!(
|
||||
self.mode,
|
||||
ClientShellMode::Prefix | ClientShellMode::Navigate
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
pub(crate) fn handle_input_bytes(&mut self, data: &[u8]) -> ClientShellInput {
|
||||
self.handle_raw_events(crate::raw_input::parse_raw_input_bytes_sync(data))
|
||||
@@ -67,12 +124,28 @@ impl ClientShellState {
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn replay_mouse_events(
|
||||
&mut self,
|
||||
events: Vec<crossterm::event::MouseEvent>,
|
||||
) -> ClientShellInput {
|
||||
self.replaying_url_click = true;
|
||||
let outcome =
|
||||
self.handle_raw_events(events.into_iter().map(RawInputEvent::Mouse).collect());
|
||||
self.replaying_url_click = false;
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(super) fn handle_raw_events(&mut self, events: Vec<RawInputEvent>) -> ClientShellInput {
|
||||
let mut outcome = ClientShellInput::default();
|
||||
if !events.is_empty() && self.endpoint_error.take().is_some() {
|
||||
outcome.repaint = true;
|
||||
}
|
||||
for event in events {
|
||||
if let Some(update) = host_theme_update(&event) {
|
||||
outcome
|
||||
.requests
|
||||
.push(ClientMessage::ClientShellHostTheme { update });
|
||||
}
|
||||
match event {
|
||||
RawInputEvent::Key(key) => self.handle_key(key, &mut outcome),
|
||||
RawInputEvent::Text(text) => {
|
||||
@@ -85,9 +158,11 @@ impl ClientShellState {
|
||||
| ClientShellOverlay::ReleaseNotes(_)
|
||||
)
|
||||
) {
|
||||
self.reconcile_input_source();
|
||||
continue;
|
||||
}
|
||||
if self.prepare_committed_text(&text, &mut outcome) {
|
||||
self.reconcile_input_source();
|
||||
continue;
|
||||
}
|
||||
if let Some(target) = self.popup_input_target() {
|
||||
@@ -116,9 +191,11 @@ impl ClientShellState {
|
||||
| ClientShellOverlay::ReleaseNotes(_)
|
||||
)
|
||||
) {
|
||||
self.reconcile_input_source();
|
||||
continue;
|
||||
}
|
||||
if self.prepare_committed_text(&text, &mut outcome) {
|
||||
self.reconcile_input_source();
|
||||
continue;
|
||||
}
|
||||
if let Some(target) = self.popup_input_target() {
|
||||
@@ -142,9 +219,36 @@ impl ClientShellState {
|
||||
RawInputEvent::OuterFocusGained => {
|
||||
self.outer_focused = Some(true);
|
||||
outcome.query_host_appearance = true;
|
||||
outcome.repaint |= self.config.redraw_on_focus_gained;
|
||||
outcome
|
||||
.requests
|
||||
.push(ClientMessage::ClientShellFocus { focused: true });
|
||||
}
|
||||
RawInputEvent::OuterFocusLost => {
|
||||
self.outer_focused = Some(false);
|
||||
self.release_input_leases(&mut outcome);
|
||||
outcome
|
||||
.requests
|
||||
.push(ClientMessage::ClientShellFocus { focused: false });
|
||||
}
|
||||
RawInputEvent::OuterFocusLost => self.outer_focused = Some(false),
|
||||
RawInputEvent::HostColorSchemeChanged(appearance) => {
|
||||
self.host_appearance = Some(appearance);
|
||||
self.host_appearance_explicit = true;
|
||||
outcome.query_host_theme = true;
|
||||
if self.config.theme_runtime.auto_switch {
|
||||
self.config.palette = crate::app::client_palette_for_appearance(
|
||||
&self.config.theme_runtime,
|
||||
appearance,
|
||||
);
|
||||
outcome.repaint = true;
|
||||
}
|
||||
}
|
||||
RawInputEvent::HostDefaultColor {
|
||||
kind: crate::terminal_theme::DefaultColorKind::Background,
|
||||
color,
|
||||
} if !self.host_appearance_explicit => {
|
||||
let appearance = color.inferred_appearance();
|
||||
self.host_appearance = Some(appearance);
|
||||
if self.config.theme_runtime.auto_switch {
|
||||
self.config.palette = crate::app::client_palette_for_appearance(
|
||||
&self.config.theme_runtime,
|
||||
@@ -158,6 +262,7 @@ impl ClientShellState {
|
||||
| RawInputEvent::HostCellSizeReport { .. }
|
||||
| RawInputEvent::Unsupported => {}
|
||||
}
|
||||
self.reconcile_input_source();
|
||||
}
|
||||
outcome.repaint |= self.resume_mobile_switcher_if_ready();
|
||||
outcome
|
||||
@@ -172,7 +277,6 @@ impl ClientShellState {
|
||||
self.copy_input_queue.push_back(key);
|
||||
return;
|
||||
}
|
||||
const LOCAL_INPUT_SOURCE: u8 = 0;
|
||||
let lease_key = crate::input::InputLeaseKey::new(LOCAL_INPUT_SOURCE, &key);
|
||||
let key = self.input_leases.normalize_press(&lease_key, key);
|
||||
match key.kind {
|
||||
@@ -201,7 +305,11 @@ impl ClientShellState {
|
||||
}
|
||||
KeyEventKind::Release => {
|
||||
if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) {
|
||||
self.push_pane_key(lease.target, key, outcome);
|
||||
let release = lease
|
||||
.key
|
||||
.with_modifiers(key.modifiers)
|
||||
.with_kind(KeyEventKind::Release);
|
||||
self.push_pane_key(lease.target, release, outcome);
|
||||
} else {
|
||||
let _ = self.input_leases.remove(&lease_key);
|
||||
}
|
||||
@@ -209,6 +317,51 @@ impl ClientShellState {
|
||||
}
|
||||
}
|
||||
|
||||
fn release_input_leases(&mut self, outcome: &mut ClientShellInput) {
|
||||
for lease in self.input_leases.remove_source(LOCAL_INPUT_SOURCE) {
|
||||
self.push_pane_key(
|
||||
lease.target,
|
||||
lease.key.with_kind(KeyEventKind::Release),
|
||||
outcome,
|
||||
);
|
||||
}
|
||||
if let Some(gesture) = self.pane_mouse_gesture.take() {
|
||||
let modifiers = gesture
|
||||
.last_event
|
||||
.modifiers
|
||||
.difference(gesture.stripped_modifiers);
|
||||
let geometry = matches!(
|
||||
gesture.last_position,
|
||||
crate::protocol::ClientMousePosition::Pixels { .. }
|
||||
)
|
||||
.then_some(crate::protocol::ClientMouseGeometry {
|
||||
cols: gesture.hit.inner_rect.width,
|
||||
rows: gesture.hit.inner_rect.height,
|
||||
width_px: gesture.hit.pixel_width,
|
||||
height_px: gesture.hit.pixel_height,
|
||||
});
|
||||
let target = if gesture.hit.popup {
|
||||
ClientInputTarget::Popup(gesture.hit.pane_id)
|
||||
} else {
|
||||
ClientInputTarget::Pane(gesture.hit.pane_id)
|
||||
};
|
||||
super::push_target_event(
|
||||
target,
|
||||
ClientPaneInputEvent::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::Up(
|
||||
crate::protocol::ClientMouseButton::from_crossterm(gesture.button),
|
||||
),
|
||||
position: gesture.last_position,
|
||||
geometry,
|
||||
modifiers: modifiers.bits(),
|
||||
lines: self.config.mouse_scroll_lines.min(u16::MAX as usize) as u16,
|
||||
},
|
||||
outcome,
|
||||
);
|
||||
}
|
||||
self.copy_input_queue.clear();
|
||||
}
|
||||
|
||||
fn execute_repeat_plan(
|
||||
&mut self,
|
||||
lease_key: crate::input::InputLeaseKey<u8>,
|
||||
@@ -252,11 +405,69 @@ impl ClientShellState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn modal_paste_target_active(&self) -> bool {
|
||||
if self.popup_pending || self.popup_input_target().is_some() {
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.copy_mode
|
||||
.as_ref()
|
||||
.is_some_and(|copy_mode| copy_mode.search_prompt.is_some())
|
||||
{
|
||||
return self.overlay.is_none();
|
||||
}
|
||||
matches!(
|
||||
self.overlay.as_ref(),
|
||||
Some(ClientShellOverlay::Rename(_))
|
||||
| Some(ClientShellOverlay::WorktreeCreate(
|
||||
ClientWorktreeCreateOverlay {
|
||||
creating: false,
|
||||
..
|
||||
}
|
||||
))
|
||||
| Some(ClientShellOverlay::WorktreeOpen(
|
||||
ClientWorktreeOpenOverlay {
|
||||
search_focused: true,
|
||||
opening: false,
|
||||
..
|
||||
}
|
||||
))
|
||||
| Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay {
|
||||
search_focused: true,
|
||||
..
|
||||
}))
|
||||
| Some(ClientShellOverlay::Help(ClientHelpOverlay {
|
||||
search_focused: true,
|
||||
..
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn handle_modal_paste_shortcut_with(
|
||||
&mut self,
|
||||
key: &crate::input::TerminalKey,
|
||||
outcome: &mut ClientShellInput,
|
||||
read_clipboard_text: impl FnOnce() -> Option<String>,
|
||||
) -> bool {
|
||||
if !is_modal_paste_shortcut(key) || !self.modal_paste_target_active() {
|
||||
return false;
|
||||
}
|
||||
if let Some(text) = read_clipboard_text() {
|
||||
let inserted = self.insert_copy_search_text(&text) || self.insert_overlay_text(&text);
|
||||
outcome.repaint |= inserted;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn route_key_press(
|
||||
&mut self,
|
||||
key: &crate::input::TerminalKey,
|
||||
outcome: &mut ClientShellInput,
|
||||
) -> Option<ClientInputTarget> {
|
||||
if self.handle_modal_paste_shortcut_with(key, outcome, crate::platform::read_clipboard_text)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
self.overlay,
|
||||
Some(
|
||||
@@ -293,7 +504,7 @@ impl ClientShellState {
|
||||
.as_ref()
|
||||
.is_some_and(crate::selection::Selection::is_visible)
|
||||
{
|
||||
self.request_selection_copy(outcome);
|
||||
self.request_selection_copy_with_fallback(outcome, Some(key.clone()));
|
||||
self.selection = None;
|
||||
self.stop_selection_autoscroll();
|
||||
self.selection_highlight_clear_deadline = None;
|
||||
@@ -649,12 +860,14 @@ impl ClientShellState {
|
||||
} else {
|
||||
(current as isize + delta).rem_euclid(entries.len() as isize) as usize
|
||||
};
|
||||
self.navigate_workspace_id = Some(
|
||||
snapshot.workspaces[entries[next].index]
|
||||
.workspace_id
|
||||
.clone(),
|
||||
);
|
||||
let workspace_id = snapshot.workspaces[entries[next].index]
|
||||
.workspace_id
|
||||
.clone();
|
||||
self.navigate_workspace_id = Some(workspace_id.clone());
|
||||
self.reveal_mobile_workspace = mobile;
|
||||
if !mobile {
|
||||
self.reveal_workspace(&workspace_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_pane(&mut self, reverse: bool, outcome: &mut ClientShellInput) {
|
||||
@@ -741,6 +954,38 @@ impl ClientShellState {
|
||||
.and_then(|snapshot| snapshot.focused_pane_id.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn clipboard_image_target(
|
||||
&self,
|
||||
) -> Option<crate::protocol::ClientClipboardImageTarget> {
|
||||
if matches!(
|
||||
self.overlay,
|
||||
Some(
|
||||
ClientShellOverlay::Onboarding
|
||||
| ClientShellOverlay::ProductAnnouncement(_)
|
||||
| ClientShellOverlay::ReleaseNotes(_)
|
||||
)
|
||||
) || self
|
||||
.copy_mode
|
||||
.as_ref()
|
||||
.is_some_and(|copy_mode| copy_mode.search_prompt.is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(terminal_id) = self.popup_input_target().and_then(|target| match target {
|
||||
ClientInputTarget::Popup(terminal_id) => Some(terminal_id),
|
||||
ClientInputTarget::Pane(_) => None,
|
||||
}) {
|
||||
return Some(crate::protocol::ClientClipboardImageTarget::Popup(
|
||||
terminal_id,
|
||||
));
|
||||
}
|
||||
if self.popup_pending || self.overlay.is_some() || self.mode != ClientShellMode::Terminal {
|
||||
return None;
|
||||
}
|
||||
self.focused_pane_id()
|
||||
.map(crate::protocol::ClientClipboardImageTarget::Pane)
|
||||
}
|
||||
|
||||
fn popup_input_target(&self) -> Option<ClientInputTarget> {
|
||||
self.popup_terminal_id
|
||||
.as_ref()
|
||||
|
||||
+131
-13
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
|
||||
const SELECTION_AUTOSCROLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(30);
|
||||
|
||||
impl ClientShellState {
|
||||
fn set_sidebar_width_from_column(&mut self, column: u16, outcome: &mut ClientShellInput) {
|
||||
let (min, max) = crate::config::validated_sidebar_bounds(
|
||||
@@ -249,7 +251,7 @@ impl ClientShellState {
|
||||
max_offset_from_bottom: metrics.max_offset_from_bottom,
|
||||
});
|
||||
self.selection_autoscroll_deadline =
|
||||
Some(std::time::Instant::now() + crate::app::SELECTION_AUTOSCROLL_INTERVAL);
|
||||
Some(std::time::Instant::now() + SELECTION_AUTOSCROLL_INTERVAL);
|
||||
}
|
||||
|
||||
fn scroll_in_progress_selection(
|
||||
@@ -368,7 +370,7 @@ impl ClientShellState {
|
||||
);
|
||||
self.push_pane_scroll_offset(autoscroll.pane_id.clone(), next_offset, &mut outcome);
|
||||
self.selection_autoscroll = Some(autoscroll);
|
||||
self.selection_autoscroll_deadline = Some(now + crate::app::SELECTION_AUTOSCROLL_INTERVAL);
|
||||
self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL);
|
||||
outcome.repaint = true;
|
||||
outcome
|
||||
}
|
||||
@@ -726,6 +728,44 @@ impl ClientShellState {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.url_click_consumes_until_up {
|
||||
match mouse.kind {
|
||||
MouseEventKind::Drag(MouseButton::Left) => return,
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
self.url_click_consumes_until_up = false;
|
||||
return;
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
self.url_click_consumes_until_up = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !self.replaying_url_click
|
||||
&& matches!(
|
||||
mouse.kind,
|
||||
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
|
||||
)
|
||||
{
|
||||
if let Some(fallback_events) =
|
||||
self.pending_requests
|
||||
.values_mut()
|
||||
.find_map(|pending| match &mut pending.kind {
|
||||
PendingEndpointKind::PaneLinkActivate {
|
||||
fallback_events, ..
|
||||
} if !fallback_events
|
||||
.iter()
|
||||
.any(|event| event.kind == MouseEventKind::Up(MouseButton::Left)) =>
|
||||
{
|
||||
Some(fallback_events)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
fallback_events.push(mouse);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(gesture) = self.pane_mouse_gesture.as_ref() {
|
||||
let gesture_event = matches!(
|
||||
mouse.kind,
|
||||
@@ -749,6 +789,11 @@ impl ClientShellState {
|
||||
.cloned()
|
||||
}
|
||||
.unwrap_or_else(|| gesture.hit.clone());
|
||||
let position = self.pane_mouse_position(&hit, mouse);
|
||||
if let Some(gesture) = self.pane_mouse_gesture.as_mut() {
|
||||
gesture.last_event = mouse;
|
||||
gesture.last_position = position;
|
||||
}
|
||||
self.push_pane_mouse_event(&hit, mouse, modifiers, outcome);
|
||||
if mouse.kind == MouseEventKind::Up(button) {
|
||||
self.pane_mouse_gesture = None;
|
||||
@@ -772,9 +817,11 @@ impl ClientShellState {
|
||||
self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome);
|
||||
if hit.mouse_reporting {
|
||||
self.pane_mouse_gesture = Some(ClientPaneMouseGesture {
|
||||
last_position: self.pane_mouse_position(&hit, mouse),
|
||||
hit,
|
||||
button,
|
||||
stripped_modifiers: crossterm::event::KeyModifiers::empty(),
|
||||
last_event: mouse,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -795,6 +842,57 @@ impl ClientShellState {
|
||||
if self.popup_terminal_id.is_some() {
|
||||
return;
|
||||
}
|
||||
if !self.replaying_url_click
|
||||
&& self.overlay.is_none()
|
||||
&& self.mode == ClientShellMode::Terminal
|
||||
&& mouse.kind == MouseEventKind::Down(MouseButton::Left)
|
||||
&& mouse
|
||||
.modifiers
|
||||
.contains(crossterm::event::KeyModifiers::CONTROL)
|
||||
{
|
||||
if let Some(hit) = self
|
||||
.hits
|
||||
.panes
|
||||
.iter()
|
||||
.find(|hit| super::contains(hit.inner_rect, point))
|
||||
.cloned()
|
||||
{
|
||||
let viewport_row = mouse.row.saturating_sub(hit.inner_rect.y);
|
||||
let col = mouse.column.saturating_sub(hit.inner_rect.x);
|
||||
let content_revision = self
|
||||
.pane_surface
|
||||
.as_ref()
|
||||
.and_then(|surface| {
|
||||
surface
|
||||
.panes
|
||||
.iter()
|
||||
.find(|pane| pane.pane_id == hit.pane_id)
|
||||
})
|
||||
.map(|pane| pane.content_revision);
|
||||
self.last_pane_click = None;
|
||||
let pane_id = hit.pane_id.clone();
|
||||
self.push_endpoint_method_with_kind(
|
||||
crate::api::schema::Method::PaneLinkActivate(
|
||||
crate::api::schema::PaneLinkActivateParams {
|
||||
pane_id: pane_id.clone(),
|
||||
viewport_row,
|
||||
col,
|
||||
content_revision,
|
||||
offset_from_bottom: hit
|
||||
.scroll
|
||||
.map(|metrics| metrics.offset_from_bottom as u64),
|
||||
},
|
||||
),
|
||||
PendingEndpointKind::PaneLinkActivate {
|
||||
pane_id,
|
||||
inner_rect: hit.inner_rect,
|
||||
fallback_events: vec![mouse],
|
||||
},
|
||||
outcome,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.overlay.is_none()
|
||||
&& self.mode == ClientShellMode::Terminal
|
||||
&& self
|
||||
@@ -1625,9 +1723,11 @@ impl ClientShellState {
|
||||
outcome,
|
||||
);
|
||||
self.pane_mouse_gesture = Some(ClientPaneMouseGesture {
|
||||
last_position: self.pane_mouse_position(&hit, mouse),
|
||||
hit,
|
||||
button: MouseButton::Right,
|
||||
stripped_modifiers,
|
||||
last_event: mouse,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1899,6 +1999,7 @@ impl ClientShellState {
|
||||
self.collapsed_groups.insert(key.clone());
|
||||
}
|
||||
outcome.repaint = true;
|
||||
self.persist_chrome_preferences(outcome);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2035,9 +2136,11 @@ impl ClientShellState {
|
||||
if hit.mouse_reporting && super::contains(hit.inner_rect, point) {
|
||||
self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome);
|
||||
self.pane_mouse_gesture = Some(ClientPaneMouseGesture {
|
||||
last_position: self.pane_mouse_position(&hit, mouse),
|
||||
hit: hit.clone(),
|
||||
button: MouseButton::Left,
|
||||
stripped_modifiers: crossterm::event::KeyModifiers::empty(),
|
||||
last_event: mouse,
|
||||
});
|
||||
} else if super::contains(hit.inner_rect, point) {
|
||||
let click = ClientPaneClick {
|
||||
@@ -2087,9 +2190,11 @@ impl ClientShellState {
|
||||
{
|
||||
self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome);
|
||||
self.pane_mouse_gesture = Some(ClientPaneMouseGesture {
|
||||
last_position: self.pane_mouse_position(&hit, mouse),
|
||||
hit,
|
||||
button: MouseButton::Middle,
|
||||
stripped_modifiers: crossterm::event::KeyModifiers::empty(),
|
||||
last_event: mouse,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2132,21 +2237,12 @@ impl ClientShellState {
|
||||
}
|
||||
}
|
||||
|
||||
fn push_pane_mouse_event(
|
||||
&self,
|
||||
hit: &PaneHit,
|
||||
mouse: MouseEvent,
|
||||
modifiers: crossterm::event::KeyModifiers,
|
||||
outcome: &mut ClientShellInput,
|
||||
) {
|
||||
let Some(kind) = crate::protocol::ClientMouseKind::from_crossterm(mouse.kind) else {
|
||||
return;
|
||||
};
|
||||
fn pane_mouse_position(&self, hit: &PaneHit, mouse: MouseEvent) -> ClientMousePosition {
|
||||
let cell = ClientMousePosition::Cell {
|
||||
column: mouse.column.saturating_sub(hit.inner_rect.x),
|
||||
row: mouse.row.saturating_sub(hit.inner_rect.y),
|
||||
};
|
||||
let position = if hit.sgr_pixel_mouse && hit.pixel_width > 0 && hit.pixel_height > 0 {
|
||||
if hit.sgr_pixel_mouse && hit.pixel_width > 0 && hit.pixel_height > 0 {
|
||||
self.host_mouse_pixels
|
||||
.and_then(|pixels| {
|
||||
pixels
|
||||
@@ -2166,7 +2262,28 @@ impl ClientShellState {
|
||||
.unwrap_or(cell)
|
||||
} else {
|
||||
cell
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push_pane_mouse_event(
|
||||
&self,
|
||||
hit: &PaneHit,
|
||||
mouse: MouseEvent,
|
||||
modifiers: crossterm::event::KeyModifiers,
|
||||
outcome: &mut ClientShellInput,
|
||||
) {
|
||||
let Some(kind) = crate::protocol::ClientMouseKind::from_crossterm(mouse.kind) else {
|
||||
return;
|
||||
};
|
||||
let position = self.pane_mouse_position(hit, mouse);
|
||||
let geometry = matches!(position, ClientMousePosition::Pixels { .. }).then_some(
|
||||
crate::protocol::ClientMouseGeometry {
|
||||
cols: hit.inner_rect.width,
|
||||
rows: hit.inner_rect.height,
|
||||
width_px: hit.pixel_width,
|
||||
height_px: hit.pixel_height,
|
||||
},
|
||||
);
|
||||
let target = if hit.popup {
|
||||
ClientInputTarget::Popup(hit.pane_id.clone())
|
||||
} else {
|
||||
@@ -2177,6 +2294,7 @@ impl ClientShellState {
|
||||
ClientPaneInputEvent::Mouse {
|
||||
kind,
|
||||
position,
|
||||
geometry,
|
||||
modifiers: modifiers.bits(),
|
||||
lines: self.config.mouse_scroll_lines.min(u16::MAX as usize) as u16,
|
||||
},
|
||||
|
||||
@@ -178,9 +178,11 @@ impl ClientShellState {
|
||||
if self.snapshot.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Err(error) = crate::config::update_file("onboarding setting", |content| {
|
||||
crate::config::upsert_top_level_bool(content, "onboarding", false)
|
||||
}) {
|
||||
if let Err(error) = crate::config::update_file_at(
|
||||
&self.config.local_config_path,
|
||||
"onboarding setting",
|
||||
|content| crate::config::upsert_top_level_bool(content, "onboarding", false),
|
||||
) {
|
||||
self.set_local_config_diagnostic(Some(error));
|
||||
}
|
||||
self.config.startup_onboarding = false;
|
||||
@@ -423,7 +425,8 @@ impl ClientShellState {
|
||||
true
|
||||
}
|
||||
Some(ClientShellOverlay::Help(help)) if help.search_focused => {
|
||||
help.query.push_str(text);
|
||||
help.query
|
||||
.extend(text.chars().filter(|character| !character.is_control()));
|
||||
help.scroll = 0;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct ClientChromePreferences {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) sidebar_width: Option<u16>,
|
||||
@@ -17,6 +17,8 @@ pub(super) struct ClientChromePreferences {
|
||||
pub(super) sidebar_collapsed: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) agent_panel_sort: Option<crate::config::AgentPanelSortConfig>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub(super) collapsed_groups: Vec<String>,
|
||||
}
|
||||
|
||||
pub(super) fn path_for_local_endpoint(socket_path: &Path) -> PathBuf {
|
||||
|
||||
+107
-5
@@ -43,7 +43,9 @@ pub(crate) struct ClientShellConfig {
|
||||
pub(super) mouse_capture: bool,
|
||||
pub(super) mouse_scroll_lines: usize,
|
||||
pub(super) right_click_passthrough_modifiers: Option<crossterm::event::KeyModifiers>,
|
||||
pub(super) worktree_directory: std::path::PathBuf,
|
||||
pub(super) redraw_on_focus_gained: bool,
|
||||
pub(super) switch_ascii_input_source_in_prefix: bool,
|
||||
pub(super) local_config_path: std::path::PathBuf,
|
||||
pub(super) preferences_path: Option<std::path::PathBuf>,
|
||||
pub(super) preferences: preferences::ClientChromePreferences,
|
||||
pub(super) startup_config_diagnostic: Option<String>,
|
||||
@@ -151,6 +153,8 @@ pub(super) struct ClientPaneMouseGesture {
|
||||
pub(super) hit: PaneHit,
|
||||
pub(super) button: crossterm::event::MouseButton,
|
||||
pub(super) stripped_modifiers: crossterm::event::KeyModifiers,
|
||||
pub(super) last_event: crossterm::event::MouseEvent,
|
||||
pub(super) last_position: crate::protocol::ClientMousePosition,
|
||||
}
|
||||
|
||||
pub(super) struct ClientWorkspacePress {
|
||||
@@ -222,6 +226,9 @@ pub(crate) enum ClientShellAction {
|
||||
request: Box<crate::api::schema::Request>,
|
||||
},
|
||||
ClipboardWrite(Vec<u8>),
|
||||
Request(ClientMessage),
|
||||
OpenSafeWebUrl(String),
|
||||
ReplayMouse(Vec<crossterm::event::MouseEvent>),
|
||||
Keybind(crate::input::KeybindAction),
|
||||
}
|
||||
|
||||
@@ -231,6 +238,7 @@ pub(crate) struct ClientShellInput {
|
||||
pub repaint: bool,
|
||||
pub resize: bool,
|
||||
pub query_host_appearance: bool,
|
||||
pub query_host_theme: bool,
|
||||
pub requests: Vec<ClientMessage>,
|
||||
pub actions: Vec<ClientShellAction>,
|
||||
}
|
||||
@@ -592,7 +600,9 @@ pub(super) enum PendingEndpointKind {
|
||||
WorktreeRemove {
|
||||
forced: bool,
|
||||
},
|
||||
SelectionCopy,
|
||||
SelectionCopy {
|
||||
fallback: Option<ClientMessage>,
|
||||
},
|
||||
PaneScroll {
|
||||
pane_id: String,
|
||||
serial: u64,
|
||||
@@ -603,6 +613,11 @@ pub(super) enum PendingEndpointKind {
|
||||
col: u16,
|
||||
generation: u64,
|
||||
},
|
||||
PaneLinkActivate {
|
||||
pane_id: String,
|
||||
inner_rect: Rect,
|
||||
fallback_events: Vec<crossterm::event::MouseEvent>,
|
||||
},
|
||||
CopyMotion {
|
||||
pane_id: String,
|
||||
origin: crate::api::schema::PaneTextPoint,
|
||||
@@ -796,6 +811,8 @@ pub(crate) struct ClientShellState {
|
||||
pub(super) overlay: Option<ClientShellOverlay>,
|
||||
pub(super) previous_pane_id: Option<String>,
|
||||
pub(super) pane_mouse_gesture: Option<ClientPaneMouseGesture>,
|
||||
pub(super) url_click_consumes_until_up: bool,
|
||||
pub(super) replaying_url_click: bool,
|
||||
pub(super) selection: Option<crate::selection::Selection<String>>,
|
||||
pub(super) last_pane_click: Option<ClientPaneClick>,
|
||||
pub(super) selection_autoscroll: Option<ClientSelectionAutoscroll>,
|
||||
@@ -824,6 +841,10 @@ pub(crate) struct ClientShellState {
|
||||
pub(super) pending_notifications: Vec<ClientPendingNotification>,
|
||||
pub(super) visible_notification: Option<ClientVisibleNotification>,
|
||||
pub(super) outer_focused: Option<bool>,
|
||||
pub(super) ascii_input_source_active: bool,
|
||||
pub(super) pending_input_source_changes: Vec<bool>,
|
||||
pub(super) host_appearance: Option<crate::terminal_theme::HostAppearance>,
|
||||
pub(super) host_appearance_explicit: bool,
|
||||
pub(super) local_config_diagnostic: Option<String>,
|
||||
pub(super) config_diagnostic: Option<String>,
|
||||
pub(super) endpoint_error: Option<String>,
|
||||
@@ -863,7 +884,7 @@ pub(super) struct WorkspaceEntry {
|
||||
|
||||
impl ClientShellState {
|
||||
pub(crate) fn new(mut config: ClientShellConfig) -> Self {
|
||||
let preferences = config.preferences;
|
||||
let preferences = config.preferences.clone();
|
||||
let local_config_diagnostic = config.startup_config_diagnostic.take();
|
||||
let overlay = config
|
||||
.startup_onboarding
|
||||
@@ -909,7 +930,7 @@ impl ClientShellState {
|
||||
chrome_drag: None,
|
||||
workspace_press: None,
|
||||
tab_press: None,
|
||||
collapsed_groups: HashSet::new(),
|
||||
collapsed_groups: preferences.collapsed_groups.into_iter().collect(),
|
||||
workspace_scroll: 0,
|
||||
agent_scroll: 0,
|
||||
tab_scroll: 0,
|
||||
@@ -925,6 +946,8 @@ impl ClientShellState {
|
||||
overlay,
|
||||
previous_pane_id: None,
|
||||
pane_mouse_gesture: None,
|
||||
url_click_consumes_until_up: false,
|
||||
replaying_url_click: false,
|
||||
selection: None,
|
||||
last_pane_click: None,
|
||||
selection_autoscroll: None,
|
||||
@@ -953,6 +976,10 @@ impl ClientShellState {
|
||||
pending_notifications: Vec::new(),
|
||||
visible_notification: None,
|
||||
outer_focused: None,
|
||||
ascii_input_source_active: false,
|
||||
pending_input_source_changes: Vec::new(),
|
||||
host_appearance: None,
|
||||
host_appearance_explicit: false,
|
||||
config_diagnostic: local_config_diagnostic.clone(),
|
||||
local_config_diagnostic,
|
||||
endpoint_error: None,
|
||||
@@ -1008,6 +1035,25 @@ impl ClientShellState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reveal_workspace(&mut self, workspace_id: &str) {
|
||||
if self
|
||||
.hits
|
||||
.workspaces
|
||||
.iter()
|
||||
.any(|hit| hit.workspace_id == workspace_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let target = self.snapshot.as_deref().and_then(|snapshot| {
|
||||
self.navigation_workspace_entries(snapshot)
|
||||
.iter()
|
||||
.position(|entry| snapshot.workspaces[entry.index].workspace_id == workspace_id)
|
||||
});
|
||||
if let Some(target) = target {
|
||||
self.workspace_scroll = target.min(self.hits.workspace_max_scroll);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn layout(&self, cols: u16, rows: u16) -> ClientShellLayout {
|
||||
self.config.layout(
|
||||
cols,
|
||||
@@ -1090,7 +1136,6 @@ impl ClientShellState {
|
||||
self.chrome_drag = None;
|
||||
self.workspace_press = None;
|
||||
self.tab_press = None;
|
||||
self.collapsed_groups.clear();
|
||||
self.workspace_scroll = 0;
|
||||
self.agent_scroll = 0;
|
||||
self.tab_scroll = 0;
|
||||
@@ -1117,6 +1162,8 @@ impl ClientShellState {
|
||||
.then_some(ClientShellOverlay::Onboarding);
|
||||
self.previous_pane_id = None;
|
||||
self.pane_mouse_gesture = None;
|
||||
self.url_click_consumes_until_up = false;
|
||||
self.replaying_url_click = false;
|
||||
self.selection = None;
|
||||
self.last_pane_click = None;
|
||||
self.selection_autoscroll = None;
|
||||
@@ -1313,6 +1360,7 @@ impl ClientShellState {
|
||||
}
|
||||
self.snapshot = Some(snapshot);
|
||||
self.resume_mobile_switcher_if_ready();
|
||||
self.reconcile_input_source();
|
||||
}
|
||||
|
||||
pub(crate) fn set_pane_surface(&mut self, mut surface: PaneSurfaceFrame) {
|
||||
@@ -1341,6 +1389,10 @@ impl ClientShellState {
|
||||
.as_deref()
|
||||
.map(|popup| popup.terminal_id.clone());
|
||||
if previous_popup != next_popup {
|
||||
if next_popup.is_some() && matches!(self.overlay, Some(ClientShellOverlay::Settings(_)))
|
||||
{
|
||||
self.cancel_settings_overlay();
|
||||
}
|
||||
if let Some(terminal_id) = previous_popup.as_ref() {
|
||||
self.input_leases
|
||||
.remove_target(&ClientInputTarget::Popup(terminal_id.clone()));
|
||||
@@ -1461,6 +1513,7 @@ impl ClientShellState {
|
||||
.set_scene(std::mem::take(&mut surface.graphics));
|
||||
self.pane_surface = Some(surface);
|
||||
self.resume_mobile_switcher_if_ready();
|
||||
self.reconcile_input_source();
|
||||
}
|
||||
|
||||
pub(crate) fn tick_popup_pending(&mut self, now: std::time::Instant) {
|
||||
@@ -1473,6 +1526,17 @@ impl ClientShellState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_copy_feedback(&mut self, now: std::time::Instant) -> bool {
|
||||
if !self.config.clipboard_toast_enabled {
|
||||
return false;
|
||||
}
|
||||
self.copy_feedback = Some(crate::app::state::CopyFeedback {
|
||||
message: "copied to clipboard".to_owned(),
|
||||
});
|
||||
self.copy_feedback_deadline = Some(now + std::time::Duration::from_secs(2));
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn tick_copy_feedback(&mut self, now: std::time::Instant) -> bool {
|
||||
let mut repaint = false;
|
||||
if self
|
||||
@@ -1506,4 +1570,42 @@ impl ClientShellState {
|
||||
self.hits = ShellHitMap::default();
|
||||
self.host_mouse_pixels = None;
|
||||
}
|
||||
|
||||
fn wants_ascii_input(&self) -> bool {
|
||||
if let Some(overlay) = self.overlay.as_ref() {
|
||||
return matches!(
|
||||
overlay,
|
||||
ClientShellOverlay::ConfirmClose(_)
|
||||
| ClientShellOverlay::Help(_)
|
||||
| ClientShellOverlay::Navigator(_)
|
||||
| ClientShellOverlay::WorktreeRemove(_)
|
||||
| ClientShellOverlay::ContextMenu(_)
|
||||
| ClientShellOverlay::GlobalMenu(_)
|
||||
);
|
||||
}
|
||||
matches!(
|
||||
self.mode,
|
||||
ClientShellMode::Prefix
|
||||
| ClientShellMode::Navigate
|
||||
| ClientShellMode::Resize
|
||||
| ClientShellMode::Copy
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_input_source(&mut self) {
|
||||
// Keep the platform restore token while another window has focus. Restoring
|
||||
// through a global key injection is only safe after this client regains focus.
|
||||
if self.outer_focused == Some(false) {
|
||||
return;
|
||||
}
|
||||
let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input();
|
||||
if desired != self.ascii_input_source_active {
|
||||
self.ascii_input_source_active = desired;
|
||||
self.pending_input_source_changes.push(desired);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> {
|
||||
std::mem::take(&mut self.pending_input_source_changes)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,339 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tab_overflow_controls_scroll_the_client_owned_tab_bar() {
|
||||
let mut snapshot = snapshot();
|
||||
snapshot.tabs.extend((2..=8).map(|number| ClientShellTab {
|
||||
tab_id: format!("tab_{number}"),
|
||||
workspace_id: "ws_1".into(),
|
||||
number,
|
||||
label: number.to_string(),
|
||||
custom_label: false,
|
||||
zoomed: false,
|
||||
focused: false,
|
||||
agent_status: AgentStatus::Idle,
|
||||
}));
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(80, 20).expect("overflow tab bar");
|
||||
|
||||
assert!(state.hits.tab_scroll_right.width > 0);
|
||||
let scroll_right = state.hits.tab_scroll_right;
|
||||
let outcome =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: scroll_right.x + 1,
|
||||
row: scroll_right.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(outcome.repaint);
|
||||
assert_eq!(state.tab_scroll, 1);
|
||||
|
||||
let mut update = state.snapshot.as_deref().expect("snapshot").clone();
|
||||
update.focused_tab_id = Some("tab_8".into());
|
||||
for tab in &mut update.tabs {
|
||||
tab.focused = tab.tab_id == "tab_8";
|
||||
}
|
||||
state.set_snapshot(Box::new(update));
|
||||
state.compose(80, 20).expect("focused overflow tab");
|
||||
assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8"));
|
||||
|
||||
state.compose(300, 20).expect("tabs without overflow");
|
||||
assert_eq!(state.tab_scroll, 0);
|
||||
assert_eq!(state.hits.tabs.len(), 8);
|
||||
state.compose(80, 20).expect("focused tab after narrowing");
|
||||
assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_owned_sidebar_dividers_resize_live() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 30).expect("expanded sidebar");
|
||||
let workspace_body = state.hits.workspace_body;
|
||||
let needless_scroll =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::ScrollDown,
|
||||
column: workspace_body.x,
|
||||
row: workspace_body.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert_eq!(state.hits.workspace_max_scroll, 0);
|
||||
assert_eq!(state.workspace_scroll, 0);
|
||||
assert!(!needless_scroll.repaint);
|
||||
let width_divider = state.hits.sidebar_divider;
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: width_divider.x,
|
||||
row: width_divider.y + 2,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
let resize =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: 31,
|
||||
row: width_divider.y + 2,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert_eq!(state.sidebar_width, 32);
|
||||
assert!(state.sidebar_width_manual);
|
||||
assert!(resize.repaint);
|
||||
assert!(resize.resize);
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
column: 31,
|
||||
row: width_divider.y + 2,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 30).expect("resized sidebar");
|
||||
let section_divider = state.hits.sidebar_section_divider;
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: section_divider.x + 2,
|
||||
row: section_divider.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
let split = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: section_divider.x + 2,
|
||||
row: 20,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(state.sidebar_section_split > 0.6);
|
||||
assert!(split.repaint);
|
||||
assert!(!split.resize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_menus_capture_stable_targets_and_route_actions() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 20).expect("composed frame");
|
||||
|
||||
let workspace = state.hits.workspaces[0].rect;
|
||||
let open_workspace_menu =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Right),
|
||||
column: workspace.x + 2,
|
||||
row: workspace.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(open_workspace_menu.actions.is_empty());
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay {
|
||||
target: ClientContextMenuTarget::Workspace { ref workspace_id, .. },
|
||||
..
|
||||
})) if workspace_id == "ws_1"
|
||||
));
|
||||
let workspace_items = match state.overlay.as_ref() {
|
||||
Some(ClientShellOverlay::ContextMenu(menu)) => menu.items(),
|
||||
_ => panic!("workspace context menu"),
|
||||
};
|
||||
assert!(workspace_items
|
||||
.iter()
|
||||
.any(|item| item.action == ClientContextMenuAction::NewWorktree));
|
||||
state.compose(106, 20).expect("workspace context menu");
|
||||
let rename = state.hits.context_menu_rows[0].0;
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: rename.x + 1,
|
||||
row: rename.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::Rename(ClientRenameOverlay {
|
||||
target: ClientRenameTarget::Workspace { ref workspace_id },
|
||||
..
|
||||
})) if workspace_id == "ws_1"
|
||||
));
|
||||
|
||||
state.overlay = None;
|
||||
state.compose(106, 20).expect("composed frame");
|
||||
let pane = state.hits.panes[0].rect;
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Right),
|
||||
column: pane.x + 1,
|
||||
row: pane.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
state.compose(106, 20).expect("pane context menu");
|
||||
let split_index = match state.overlay.as_ref() {
|
||||
Some(ClientShellOverlay::ContextMenu(menu)) => menu
|
||||
.items()
|
||||
.iter()
|
||||
.position(|item| item.action == ClientContextMenuAction::SplitRight)
|
||||
.expect("split right item"),
|
||||
_ => panic!("pane context menu"),
|
||||
};
|
||||
let split = state.hits.context_menu_rows[split_index].0;
|
||||
let outcome =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: split.x + 1,
|
||||
row: split.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else {
|
||||
panic!("pane split context action should use endpoint API");
|
||||
};
|
||||
assert!(matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::PaneSplit(params)
|
||||
if params.target_pane_id.as_deref() == Some("pane_1")
|
||||
&& params.direction == crate::api::schema::SplitDirection::Right
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_menu_opens_from_sidebar_and_routes_client_actions() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 30).expect("shell frame");
|
||||
let launcher = state.hits.global_launcher;
|
||||
assert_ne!(launcher, Rect::default());
|
||||
|
||||
let open = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: launcher.x,
|
||||
row: launcher.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(open.repaint);
|
||||
let menu = state.compose(106, 30).expect("global menu");
|
||||
let text = menu
|
||||
.cells
|
||||
.chunks(menu.width as usize)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("settings"));
|
||||
assert!(text.contains("keybinds"));
|
||||
assert!(text.contains("reload config"));
|
||||
assert!(text.contains("detach"));
|
||||
|
||||
let keybinds = state.hits.global_menu_rows[1].0;
|
||||
let help = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: keybinds.x,
|
||||
row: keybinds.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(help.actions.is_empty());
|
||||
assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_))));
|
||||
|
||||
state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay {
|
||||
highlighted: 3,
|
||||
}));
|
||||
let detach = state.handle_input_bytes(b"\r");
|
||||
assert!(detach.detach);
|
||||
assert!(state.overlay.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_tab_overlay_owns_text_cursor_and_submits_public_api_request() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
let mut open = ClientShellInput::default();
|
||||
state.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab),
|
||||
&mut open,
|
||||
);
|
||||
assert!(open.actions.is_empty());
|
||||
let frame = state.compose(106, 20).expect("new tab overlay");
|
||||
let text = frame
|
||||
.cells
|
||||
.chunks(frame.width as usize)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("new tab"));
|
||||
assert!(text.contains("save"));
|
||||
let restored = frame.to_ratatui_buffer().expect("overlay frame");
|
||||
assert!(!restored
|
||||
.cell((26, 7))
|
||||
.expect("overlay title cell")
|
||||
.modifier
|
||||
.contains(Modifier::DIM));
|
||||
assert!(frame.cursor.as_ref().is_some_and(|cursor| cursor.visible));
|
||||
|
||||
assert!(state.handle_input_bytes(b"logs").actions.is_empty());
|
||||
let create = state.handle_input_bytes(b"\r");
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else {
|
||||
panic!("new tab save should use endpoint API");
|
||||
};
|
||||
assert!(matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::TabCreate(params)
|
||||
if params.workspace_id.as_deref() == Some("ws_1")
|
||||
&& params.label.as_deref() == Some("logs")
|
||||
));
|
||||
assert!(state.overlay.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_confirmation_error_becomes_client_owned_overlay_and_stable_group_close() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
let mut close = ClientShellInput::default();
|
||||
state.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::ClosePane),
|
||||
&mut close,
|
||||
);
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &close.actions[..] else {
|
||||
panic!("pane close should use endpoint API");
|
||||
};
|
||||
let request_id = request.id.clone();
|
||||
assert!(
|
||||
state
|
||||
.handle_endpoint_result(
|
||||
"boot-1",
|
||||
&request_id,
|
||||
Err(ClientShellEndpointError {
|
||||
code: Some("confirmation_required".into()),
|
||||
message: "confirmation required".into(),
|
||||
}),
|
||||
)
|
||||
.0
|
||||
);
|
||||
let frame = state.compose(106, 20).expect("confirmation overlay");
|
||||
let text = frame
|
||||
.cells
|
||||
.chunks(frame.width as usize)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("Close workspace?"));
|
||||
assert!(text.contains("1 pane"));
|
||||
|
||||
let confirm = state.handle_input_bytes(b"\r");
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &confirm.actions[..] else {
|
||||
panic!("confirmation should use endpoint API");
|
||||
};
|
||||
assert!(matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::WorkspaceClose(params)
|
||||
if params.workspace_id == "ws_1" && params.close_group
|
||||
));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,609 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn host_appearance_prefers_explicit_reports_over_background_inference() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.config.theme_runtime.auto_switch = true;
|
||||
|
||||
let light = crate::app::client_palette_for_appearance(
|
||||
&state.config.theme_runtime,
|
||||
crate::terminal_theme::HostAppearance::Light,
|
||||
);
|
||||
let dark = crate::app::client_palette_for_appearance(
|
||||
&state.config.theme_runtime,
|
||||
crate::terminal_theme::HostAppearance::Dark,
|
||||
);
|
||||
|
||||
let inferred = state.handle_raw_events(vec![RawInputEvent::HostDefaultColor {
|
||||
kind: crate::terminal_theme::DefaultColorKind::Background,
|
||||
color: crate::terminal_theme::RgbColor {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
},
|
||||
}]);
|
||||
assert!(inferred.repaint);
|
||||
assert!(matches!(
|
||||
inferred.requests.as_slice(),
|
||||
[ClientMessage::ClientShellHostTheme {
|
||||
update: crate::protocol::ClientHostThemeUpdate::DefaultColor {
|
||||
kind: crate::protocol::ClientHostDefaultColorKind::Background,
|
||||
..
|
||||
}
|
||||
}]
|
||||
));
|
||||
assert_eq!(
|
||||
state.host_appearance,
|
||||
Some(crate::terminal_theme::HostAppearance::Light)
|
||||
);
|
||||
assert!(!state.host_appearance_explicit);
|
||||
assert_eq!(state.config.palette, light);
|
||||
|
||||
let explicit = state.handle_raw_events(vec![RawInputEvent::HostColorSchemeChanged(
|
||||
crate::terminal_theme::HostAppearance::Dark,
|
||||
)]);
|
||||
assert!(explicit.repaint);
|
||||
assert!(explicit.query_host_theme);
|
||||
assert!(matches!(
|
||||
explicit.requests.as_slice(),
|
||||
[ClientMessage::ClientShellHostTheme {
|
||||
update: crate::protocol::ClientHostThemeUpdate::Appearance(
|
||||
crate::protocol::ClientHostAppearance::Dark
|
||||
)
|
||||
}]
|
||||
));
|
||||
assert_eq!(
|
||||
state.host_appearance,
|
||||
Some(crate::terminal_theme::HostAppearance::Dark)
|
||||
);
|
||||
assert!(state.host_appearance_explicit);
|
||||
assert_eq!(state.config.palette, dark);
|
||||
|
||||
let ignored = state.handle_raw_events(vec![RawInputEvent::HostDefaultColor {
|
||||
kind: crate::terminal_theme::DefaultColorKind::Background,
|
||||
color: crate::terminal_theme::RgbColor {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
},
|
||||
}]);
|
||||
assert!(!ignored.repaint);
|
||||
assert_eq!(ignored.requests.len(), 1);
|
||||
assert_eq!(
|
||||
state.host_appearance,
|
||||
Some(crate::terminal_theme::HostAppearance::Dark)
|
||||
);
|
||||
assert_eq!(state.config.palette, dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_paste_shortcut_modifiers_are_platform_specific() {
|
||||
let key = |code, modifiers| crate::input::TerminalKey::new(code, modifiers);
|
||||
|
||||
assert!(input::is_modal_paste_shortcut_for_platform(
|
||||
&key(KeyCode::Char('v'), KeyModifiers::CONTROL),
|
||||
false
|
||||
));
|
||||
assert!(input::is_modal_paste_shortcut_for_platform(
|
||||
&key(
|
||||
KeyCode::Char('V'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT
|
||||
),
|
||||
false
|
||||
));
|
||||
assert!(!input::is_modal_paste_shortcut_for_platform(
|
||||
&key(KeyCode::Char('v'), KeyModifiers::SUPER),
|
||||
false
|
||||
));
|
||||
assert!(input::is_modal_paste_shortcut_for_platform(
|
||||
&key(KeyCode::Char('v'), KeyModifiers::CONTROL),
|
||||
true
|
||||
));
|
||||
assert!(input::is_modal_paste_shortcut_for_platform(
|
||||
&key(KeyCode::Char('v'), KeyModifiers::SUPER),
|
||||
true
|
||||
));
|
||||
assert!(!input::is_modal_paste_shortcut_for_platform(
|
||||
&key(KeyCode::Char('v'), KeyModifiers::ALT),
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_paste_inserts_clipboard_text_through_overlay_text_path() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
|
||||
title: "rename pane",
|
||||
input: "replace me".into(),
|
||||
replace_on_type: true,
|
||||
target: ClientRenameTarget::Pane {
|
||||
pane_id: "pane_1".into(),
|
||||
},
|
||||
}));
|
||||
let mut outcome = ClientShellInput::default();
|
||||
let key = crate::input::TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
|
||||
|
||||
assert!(
|
||||
state.handle_modal_paste_shortcut_with(&key, &mut outcome, || {
|
||||
Some("feature/pasted".into())
|
||||
})
|
||||
);
|
||||
assert!(outcome.repaint);
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::Rename(ClientRenameOverlay { ref input, replace_on_type: false, .. }))
|
||||
if input == "feature/pasted"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_shell_graphics_follow_final_shell_origin_and_local_overlay_visibility() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
let mut pane_surface = surface();
|
||||
let key = crate::protocol::SurfaceGraphicsAssetKey {
|
||||
source: crate::protocol::SurfaceGraphicsSource::Terminal {
|
||||
target: crate::protocol::SurfaceGraphicsTarget::Pane {
|
||||
pane_id: "pane_1".into(),
|
||||
},
|
||||
image_id: 1,
|
||||
},
|
||||
image_width: 1,
|
||||
image_height: 1,
|
||||
format: crate::protocol::SurfaceGraphicsFormat::Rgba,
|
||||
data_len: 4,
|
||||
data_fingerprint: 17,
|
||||
};
|
||||
pane_surface.graphics = crate::protocol::SurfaceGraphicsScene {
|
||||
assets: vec![crate::protocol::SurfaceGraphicsAsset {
|
||||
key: key.clone(),
|
||||
data: vec![1, 2, 3, 4],
|
||||
}],
|
||||
placements: vec![crate::protocol::SurfaceGraphicsPlacement {
|
||||
asset: key,
|
||||
logical_placement_id: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
source_x: 0,
|
||||
source_y: 0,
|
||||
source_width: 1,
|
||||
source_height: 1,
|
||||
x_offset: 0,
|
||||
y_offset: 0,
|
||||
z: 0,
|
||||
scrollback_offset: 0,
|
||||
}],
|
||||
retained_assets: Vec::new(),
|
||||
};
|
||||
state.set_pane_surface(pane_surface);
|
||||
|
||||
let visible = state.compose(106, 20).expect("visible graphics frame");
|
||||
let visible = String::from_utf8_lossy(&visible.graphics);
|
||||
assert!(visible.contains("a=t,t=d"));
|
||||
assert!(visible.contains("\u{1b}[2;27H"));
|
||||
|
||||
state.overlay = Some(ClientShellOverlay::Onboarding);
|
||||
let hidden = state.compose(106, 20).expect("overlay frame");
|
||||
assert!(String::from_utf8_lossy(&hidden.graphics).contains("a=d,d=i"));
|
||||
|
||||
state.overlay = None;
|
||||
let restored = state.compose(106, 20).expect("restored graphics frame");
|
||||
let restored = String::from_utf8_lossy(&restored.graphics);
|
||||
assert!(restored.contains("a=p"));
|
||||
assert!(!restored.contains("a=t,t=d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_link_fallback_does_not_replay_against_changed_geometry() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 20).expect("pane frame");
|
||||
let pane = state.hits.panes[0].clone();
|
||||
let down = MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: pane.inner_rect.x + 2,
|
||||
row: pane.inner_rect.y + 1,
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
};
|
||||
let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]);
|
||||
let request_id = match &activate.actions[..] {
|
||||
[ClientShellAction::Endpoint { request, .. }] => request.id.clone(),
|
||||
_ => panic!("expected link activation request"),
|
||||
};
|
||||
state.hits.panes[0].inner_rect.x = state.hits.panes[0].inner_rect.x.saturating_add(1);
|
||||
|
||||
let (_, actions) = state.handle_endpoint_result(
|
||||
"boot-1",
|
||||
&request_id,
|
||||
Ok(crate::api::schema::ResponseResult::PaneLinkActivated {
|
||||
url: None,
|
||||
handled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(actions.is_empty());
|
||||
assert!(state.url_click_consumes_until_up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_experimental_reload_keeps_input_source_preference() {
|
||||
let mut shell = ClientShellConfig::from_config(&Config::default());
|
||||
shell.switch_ascii_input_source_in_prefix = true;
|
||||
let config = Config::default();
|
||||
shell.apply_live_config(&config, &[], &["experimental".to_owned()]);
|
||||
assert!(shell.switch_ascii_input_source_in_prefix);
|
||||
shell.apply_live_config(&config, &[], &[]);
|
||||
assert!(!shell.switch_ascii_input_source_in_prefix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_release_uses_the_leased_press_code_with_current_modifiers() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
let press = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())
|
||||
.with_windows_record(crate::input::WindowsKeyRecord {
|
||||
key_down: true,
|
||||
repeat_count: 1,
|
||||
virtual_key_code: 0x58,
|
||||
virtual_scan_code: 0x2d,
|
||||
unicode: 0,
|
||||
control_key_state: 0,
|
||||
});
|
||||
state.handle_raw_events(vec![RawInputEvent::Key(press)]);
|
||||
let release = crate::input::TerminalKey::new(KeyCode::Char('z'), KeyModifiers::SHIFT)
|
||||
.with_kind(crossterm::event::KeyEventKind::Release)
|
||||
.with_windows_record(crate::input::WindowsKeyRecord {
|
||||
key_down: false,
|
||||
repeat_count: 1,
|
||||
virtual_key_code: 0x5a,
|
||||
virtual_scan_code: 0x2d,
|
||||
unicode: 0,
|
||||
control_key_state: 0x0010,
|
||||
});
|
||||
|
||||
let outcome = state.handle_raw_events(vec![RawInputEvent::Key(release)]);
|
||||
|
||||
assert!(matches!(
|
||||
&outcome.requests[..],
|
||||
[ClientMessage::ClientShellPaneInput { events, .. }]
|
||||
if matches!(
|
||||
&events[..],
|
||||
[ClientPaneInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Char('x'),
|
||||
modifiers,
|
||||
kind: crate::protocol::ClientKeyKind::Release,
|
||||
physical_key_id: Some(0x2d),
|
||||
..
|
||||
}] if *modifiers == KeyModifiers::SHIFT.bits()
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_search_match_copies_after_in_flight_repeat() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
let mut pane_surface = surface();
|
||||
pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
|
||||
offset_from_bottom: 0,
|
||||
max_offset_from_bottom: 20,
|
||||
viewport_rows: 2,
|
||||
});
|
||||
state.set_pane_surface(pane_surface);
|
||||
state.compose(106, 20).expect("composed frame");
|
||||
let mut enter = ClientShellInput::default();
|
||||
state.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode),
|
||||
&mut enter,
|
||||
);
|
||||
let matches = vec![
|
||||
crate::api::schema::PaneTextRange {
|
||||
start: crate::api::schema::PaneTextPoint { row: 5, col: 2 },
|
||||
end: crate::api::schema::PaneTextPoint { row: 5, col: 7 },
|
||||
},
|
||||
crate::api::schema::PaneTextRange {
|
||||
start: crate::api::schema::PaneTextPoint { row: 15, col: 1 },
|
||||
end: crate::api::schema::PaneTextPoint { row: 15, col: 6 },
|
||||
},
|
||||
];
|
||||
|
||||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||||
KeyCode::Char('/'),
|
||||
KeyModifiers::empty(),
|
||||
))]);
|
||||
state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new(
|
||||
"needle",
|
||||
))]);
|
||||
let initial = state.handle_raw_events(vec![RawInputEvent::Key(
|
||||
crate::input::TerminalKey::new(KeyCode::Enter, KeyModifiers::empty()),
|
||||
)]);
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &initial.actions[..] else {
|
||||
panic!("initial search request");
|
||||
};
|
||||
state.handle_endpoint_result(
|
||||
"boot-1",
|
||||
&request.id,
|
||||
Ok(copy_search_result(matches.clone(), Some(0))),
|
||||
);
|
||||
let repeat = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||||
KeyCode::Char('n'),
|
||||
KeyModifiers::empty(),
|
||||
))]);
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &repeat.actions[..] else {
|
||||
panic!("repeat search request");
|
||||
};
|
||||
let repeat_id = request.id.clone();
|
||||
|
||||
let early_copy = state.handle_raw_events(vec![RawInputEvent::Key(
|
||||
crate::input::TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()),
|
||||
)]);
|
||||
assert!(early_copy.actions.is_empty());
|
||||
assert_eq!(state.mode, ClientShellMode::Copy);
|
||||
|
||||
let (_, actions) = state.handle_endpoint_result(
|
||||
"boot-1",
|
||||
&repeat_id,
|
||||
Ok(copy_search_result(matches, Some(1))),
|
||||
);
|
||||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||||
let selection_request_id = actions
|
||||
.iter()
|
||||
.find_map(|action| match action {
|
||||
ClientShellAction::Endpoint { request, .. }
|
||||
if matches!(
|
||||
request.method,
|
||||
crate::api::schema::Method::PaneSelectionRead(_)
|
||||
) =>
|
||||
{
|
||||
Some(request.id.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("deferred selection read");
|
||||
let (_, clipboard) = state.handle_endpoint_result(
|
||||
"boot-1",
|
||||
&selection_request_id,
|
||||
Ok(crate::api::schema::ResponseResult::PaneSelection {
|
||||
pane_id: "pane_1".into(),
|
||||
text: "needle".into(),
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
&clipboard[..],
|
||||
[ClientShellAction::ClipboardWrite(bytes)] if bytes == b"needle"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_host_reports_use_cells_without_target_pixel_mode_and_release_outside() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
let mut pane_surface = surface();
|
||||
pane_surface.panes[0].mouse_reporting = true;
|
||||
state.set_pane_surface(pane_surface);
|
||||
state.compose(106, 20).expect("composed frame");
|
||||
let pane = state.hits.panes[0].clone();
|
||||
let geometry =
|
||||
crate::input::mouse::HostGeometry::new(106, 20, 1060, 400).expect("host geometry");
|
||||
let x = u32::from(pane.inner_rect.x) * 10 + 21;
|
||||
let y = u32::from(pane.inner_rect.y) * 20 + 21;
|
||||
|
||||
let down = state.handle_pixel_mouse(format!("\x1b[<0;{x};{y}M").as_bytes(), geometry);
|
||||
assert!(matches!(
|
||||
&down.requests[..],
|
||||
[ClientMessage::ClientShellPaneInput { events, .. }]
|
||||
if matches!(
|
||||
&events[..],
|
||||
[ClientPaneInputEvent::Mouse {
|
||||
position: ClientMousePosition::Cell { column: 2, row: 1 },
|
||||
..
|
||||
}]
|
||||
)
|
||||
));
|
||||
|
||||
state.hits.panes.clear();
|
||||
let release = state.handle_pixel_mouse(b"\x1b[<0;1;1m", geometry);
|
||||
assert!(matches!(
|
||||
&release.requests[..],
|
||||
[ClientMessage::ClientShellPaneInput { pane_id, events }]
|
||||
if pane_id == "pane_1"
|
||||
&& matches!(
|
||||
&events[..],
|
||||
[ClientPaneInputEvent::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::Up(
|
||||
crate::protocol::ClientMouseButton::Left
|
||||
),
|
||||
position: ClientMousePosition::Cell { .. },
|
||||
..
|
||||
}]
|
||||
)
|
||||
));
|
||||
assert!(state.pane_mouse_gesture.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_targets_unconsumed_input_and_keeps_prefix_local() {
|
||||
let config = ClientShellConfig::from_config(&Config::default());
|
||||
let mut state = ClientShellState::new(config);
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
|
||||
let text = state.handle_input_bytes(b"hello");
|
||||
assert_eq!(text.requests.len(), 1);
|
||||
let ClientMessage::ClientShellPaneInput { pane_id, events } = &text.requests[0] else {
|
||||
panic!("expected targeted pane input");
|
||||
};
|
||||
assert_eq!(pane_id, "pane_1");
|
||||
assert_eq!(events.len(), 5);
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
ClientPaneInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Char('h'),
|
||||
generated_text: Some(text),
|
||||
..
|
||||
} if text == "h"
|
||||
));
|
||||
|
||||
let interrupt = state.handle_input_bytes(b"\x1b[99;5u");
|
||||
assert_eq!(interrupt.requests.len(), 1);
|
||||
let ClientMessage::ClientShellPaneInput { events, .. } = &interrupt.requests[0] else {
|
||||
panic!("expected semantic interrupt");
|
||||
};
|
||||
assert!(matches!(
|
||||
&events[..],
|
||||
[ClientPaneInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Char('c'),
|
||||
modifiers,
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
..
|
||||
}] if *modifiers == KeyModifiers::CONTROL.bits()
|
||||
));
|
||||
|
||||
let alt = state.handle_input_bytes(b"\x1b[120;3u");
|
||||
let ClientMessage::ClientShellPaneInput { events, .. } = &alt.requests[0] else {
|
||||
panic!("expected semantic alt key");
|
||||
};
|
||||
assert!(matches!(
|
||||
&events[..],
|
||||
[ClientPaneInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Char('x'),
|
||||
modifiers,
|
||||
..
|
||||
}] if *modifiers == KeyModifiers::ALT.bits()
|
||||
));
|
||||
assert!(!state.handle_input_bytes(&[0x02]).detach);
|
||||
let detach = state.handle_input_bytes(b"q");
|
||||
assert!(detach.detach);
|
||||
assert!(detach.requests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_key_release_keeps_the_press_target() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
|
||||
let press = state.handle_input_bytes(b"\x1b[99;5u");
|
||||
let release = state.handle_input_bytes(b"\x1b[99;5:3u");
|
||||
let ClientMessage::ClientShellPaneInput {
|
||||
pane_id: press_target,
|
||||
..
|
||||
} = &press.requests[0]
|
||||
else {
|
||||
panic!("expected targeted press");
|
||||
};
|
||||
let ClientMessage::ClientShellPaneInput {
|
||||
pane_id: release_target,
|
||||
events,
|
||||
} = &release.requests[0]
|
||||
else {
|
||||
panic!("expected targeted release");
|
||||
};
|
||||
assert_eq!(release_target, press_target);
|
||||
assert!(matches!(
|
||||
&events[..],
|
||||
[ClientPaneInputEvent::Key {
|
||||
kind: crate::protocol::ClientKeyKind::Release,
|
||||
..
|
||||
}]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_overlay_uses_live_keymap_and_owns_filter_state() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
let mut open = ClientShellInput::default();
|
||||
state.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help),
|
||||
&mut open,
|
||||
);
|
||||
let initial = state.compose(106, 30).expect("help overlay");
|
||||
let text = initial
|
||||
.cells
|
||||
.chunks(initial.width as usize)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("keybinds"));
|
||||
assert!(text.contains("prefix mode"));
|
||||
|
||||
assert!(state.handle_input_bytes(b"/").actions.is_empty());
|
||||
assert!(state.handle_input_bytes(b"workspace").actions.is_empty());
|
||||
let filtered = state.compose(106, 30).expect("filtered help");
|
||||
let text = filtered
|
||||
.cells
|
||||
.chunks(filtered.width as usize)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("workspace navigation"));
|
||||
assert!(!text.contains("prefix mode"));
|
||||
assert!(filtered
|
||||
.cursor
|
||||
.as_ref()
|
||||
.is_some_and(|cursor| cursor.visible));
|
||||
|
||||
assert!(state.handle_input_bytes(b"\x1b").repaint);
|
||||
assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_))));
|
||||
assert!(state.handle_input_bytes(b"\x1b").repaint);
|
||||
assert!(state.overlay.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_pane_empty_value_is_preserved_as_a_clear_request() {
|
||||
let mut snapshot = snapshot();
|
||||
snapshot.panes[0].label = Some("build".into());
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot));
|
||||
let mut open = ClientShellInput::default();
|
||||
state.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::RenamePane),
|
||||
&mut open,
|
||||
);
|
||||
assert!(state.handle_input_bytes(&[0x15]).actions.is_empty());
|
||||
let save = state.handle_input_bytes(b"\r");
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &save.actions[..] else {
|
||||
panic!("pane rename should use endpoint API");
|
||||
};
|
||||
assert!(matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::PaneRename(params)
|
||||
if params.pane_id == "pane_1" && params.label.as_deref() == Some("")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_client_composition_preserves_pane_hyperlinks() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
let mut pane_surface = surface();
|
||||
let linked = Buffer::with_lines(["LIVE", "PANE"]);
|
||||
pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks(
|
||||
&linked,
|
||||
None,
|
||||
&[((0, 0), "L".into(), "https://example.test".into())],
|
||||
);
|
||||
state.set_pane_surface(pane_surface);
|
||||
let mut selection =
|
||||
crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1));
|
||||
assert!(selection.finish());
|
||||
state.selection = Some(selection);
|
||||
let frame = state.compose(106, 20).expect("composed frame");
|
||||
let hit = &state.hits.panes[0];
|
||||
let index =
|
||||
usize::from(hit.inner_rect.y) * usize::from(frame.width) + usize::from(hit.inner_rect.x);
|
||||
let link = frame.cells[index].hyperlink.expect("linked cell") as usize;
|
||||
assert_eq!(frame.hyperlinks[link], "https://example.test");
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shell_new_controls_use_the_same_client_action_routes_as_keybinds() {
|
||||
let mut config = Config::default();
|
||||
config.ui.prompt_new_workspace_name = false;
|
||||
config.ui.prompt_new_tab_name = true;
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 20).expect("composed frame");
|
||||
|
||||
let new_workspace = state.hits.new_workspace;
|
||||
let create_workspace =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: new_workspace.x + 1,
|
||||
row: new_workspace.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &create_workspace.actions[..] else {
|
||||
panic!("new workspace click should use the endpoint API");
|
||||
};
|
||||
assert!(matches!(
|
||||
request.method,
|
||||
crate::api::schema::Method::WorkspaceCreate(_)
|
||||
));
|
||||
|
||||
let new_tab = state.hits.new_tab;
|
||||
let open_new_tab =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: new_tab.x + 1,
|
||||
row: new_tab.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(open_new_tab.actions.is_empty());
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::Rename(ClientRenameOverlay {
|
||||
target: ClientRenameTarget::NewTab { .. },
|
||||
..
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_client_chrome_preferences_round_trip_per_endpoint() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"herdr-client-shell-prefs-{}.json",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let config =
|
||||
ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone());
|
||||
let mut state = ClientShellState::new(config);
|
||||
state.sidebar_width = 31;
|
||||
state.sidebar_width_manual = true;
|
||||
state.sidebar_section_split = 0.7;
|
||||
state.sidebar_section_split_manual = true;
|
||||
state.sidebar_collapsed = true;
|
||||
state.sidebar_collapsed_manual = true;
|
||||
state.collapsed_groups.insert("repo-two".into());
|
||||
state.collapsed_groups.insert("repo-one".into());
|
||||
state.persist_chrome_preferences(&mut ClientShellInput::default());
|
||||
|
||||
let reloaded_config =
|
||||
ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone());
|
||||
let reloaded = ClientShellState::new(reloaded_config);
|
||||
assert_eq!(reloaded.sidebar_width, 31);
|
||||
assert!(reloaded.sidebar_width_manual);
|
||||
assert_eq!(reloaded.sidebar_section_split, 0.7);
|
||||
assert!(reloaded.sidebar_section_split_manual);
|
||||
assert!(reloaded.sidebar_collapsed);
|
||||
assert!(reloaded.sidebar_collapsed_manual);
|
||||
assert_eq!(
|
||||
reloaded.collapsed_groups,
|
||||
HashSet::from(["repo-one".to_string(), "repo-two".to_string()])
|
||||
);
|
||||
std::fs::remove_file(path).expect("remove client chrome preferences");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_bar_renders_endpoint_status_ellipses_and_clamps_to_useful_scroll() {
|
||||
let mut projected = snapshot();
|
||||
projected.tab_bar_right = vec![
|
||||
crate::protocol::ClientShellTabStatusSegment {
|
||||
text: "ZOOM".into(),
|
||||
accent: true,
|
||||
},
|
||||
crate::protocol::ClientShellTabStatusSegment {
|
||||
text: "host".into(),
|
||||
accent: false,
|
||||
},
|
||||
];
|
||||
projected.tab_bar_right_separator = " · ".into();
|
||||
for number in 2..=8 {
|
||||
projected.tabs.push(ClientShellTab {
|
||||
tab_id: format!("tab_{number}"),
|
||||
workspace_id: "ws_1".into(),
|
||||
number,
|
||||
label: number.to_string(),
|
||||
custom_label: false,
|
||||
zoomed: false,
|
||||
focused: false,
|
||||
agent_status: AgentStatus::Idle,
|
||||
});
|
||||
}
|
||||
let mut config = ClientShellConfig::from_config(&Config::default());
|
||||
config.mobile_width_threshold = 0;
|
||||
let mut state = ClientShellState::new(config);
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
let frame = state.compose(106, 20).expect("status and overflow tabs");
|
||||
let top = frame.cells[..frame.width as usize]
|
||||
.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>();
|
||||
assert!(top.contains("ZOOM · host"));
|
||||
assert!(top.contains('…'));
|
||||
|
||||
state.tab_scroll = usize::MAX;
|
||||
state.reveal_focused_tab = false;
|
||||
state.compose(106, 20).expect("clamped tab scroll");
|
||||
assert!(state.tab_scroll < 7);
|
||||
let manual_scroll = state.tab_scroll;
|
||||
let mut replacement = (**state.snapshot.as_ref().expect("snapshot")).clone();
|
||||
replacement.revision = 2;
|
||||
replacement.tab_bar_right[1].text = "tick".into();
|
||||
let mut replacement_surface = surface();
|
||||
replacement_surface.projection_revision = 2;
|
||||
state.set_snapshot(Box::new(replacement));
|
||||
state.set_pane_surface(replacement_surface);
|
||||
assert!(!state.reveal_focused_tab);
|
||||
state.compose(106, 20).expect("same-width status update");
|
||||
assert_eq!(state.tab_scroll, manual_scroll);
|
||||
|
||||
state.compose(45, 20).expect("narrow tabs win over status");
|
||||
let narrow = state.compose(45, 20).expect("narrow tab frame");
|
||||
let top = narrow.cells[..narrow.width as usize]
|
||||
.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>();
|
||||
assert!(!top.contains("ZOOM · host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_prefix_is_client_owned_and_renders_its_bar() {
|
||||
let config = toml::from_str::<Config>(
|
||||
r#"
|
||||
[keys]
|
||||
prefix = "ctrl+a"
|
||||
detach = "prefix+x"
|
||||
"#,
|
||||
)
|
||||
.expect("configured keybinds");
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
|
||||
let old_default = state.handle_input_bytes(&[0x02]);
|
||||
assert_eq!(
|
||||
old_default.requests.len(),
|
||||
1,
|
||||
"ctrl-b should reach the pane"
|
||||
);
|
||||
|
||||
let prefix = state.handle_input_bytes(&[0x01]);
|
||||
assert!(prefix.requests.is_empty());
|
||||
assert!(prefix.repaint);
|
||||
let frame = state.compose(106, 20).expect("prefix frame");
|
||||
let text = frame
|
||||
.cells
|
||||
.chunks(frame.width as usize)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("PREFIX"), "frame: {text:?}");
|
||||
assert!(text.contains("ctrl+a"), "frame: {text:?}");
|
||||
|
||||
let detach = state.handle_input_bytes(b"x");
|
||||
assert!(detach.detach);
|
||||
assert!(detach.requests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_endpoint_action_uses_public_api_with_stable_ids() {
|
||||
let mut config = Config::default();
|
||||
config.ui.prompt_new_tab_name = false;
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
|
||||
assert!(state.handle_input_bytes(&[0x02]).actions.is_empty());
|
||||
let create = state.handle_input_bytes(b"c");
|
||||
let [ClientShellAction::Endpoint { boot_id, request }] = &create.actions[..] else {
|
||||
panic!("expected one endpoint action: {:?}", create.actions);
|
||||
};
|
||||
assert_eq!(boot_id, "boot-1");
|
||||
match &request.method {
|
||||
crate::api::schema::Method::TabCreate(params) => {
|
||||
assert_eq!(params.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(params.focus);
|
||||
}
|
||||
other => panic!("expected tab.create, got {other:?}"),
|
||||
}
|
||||
assert!(state.pending_requests.contains_key(&request.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_keybinding_sources_keep_local_commands_off_endpoints_and_apply_server_profiles() {
|
||||
let local: Config = toml::from_str(
|
||||
r#"
|
||||
[keys]
|
||||
prefix = "ctrl+a"
|
||||
new_tab = "prefix+c"
|
||||
|
||||
[[keys.command]]
|
||||
key = "prefix+c"
|
||||
command = "local-only"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let remote_local = ClientShellConfig::from_config(&local)
|
||||
.with_keybinding_source(ClientShellKeybindingSource::RemoteLocal);
|
||||
assert_eq!(remote_local.keybinds.prefix.0, KeyCode::Char('a'));
|
||||
assert!(remote_local.keybinds.keybinds.custom_commands.is_empty());
|
||||
assert_eq!(
|
||||
remote_local.keybinds.keybinds.new_tab.label().as_deref(),
|
||||
Some("prefix+c")
|
||||
);
|
||||
|
||||
let mut local_state = ClientShellState::new(
|
||||
ClientShellConfig::from_config(&local)
|
||||
.with_keybinding_source(ClientShellKeybindingSource::Local),
|
||||
);
|
||||
let mut local_projection = snapshot();
|
||||
local_projection
|
||||
.commands
|
||||
.push(crate::protocol::ClientShellCommand {
|
||||
command_id: "cmd_loaded_endpoint".into(),
|
||||
binding_label: "prefix+c / prefix+y".into(),
|
||||
binding_labels: vec!["prefix+c".into(), "prefix+y".into()],
|
||||
action: crate::protocol::ClientShellCommandAction::Shell,
|
||||
description: Some("loaded endpoint command".into()),
|
||||
});
|
||||
local_state.set_snapshot(Box::new(local_projection));
|
||||
assert_eq!(
|
||||
local_state.config.keybinds.keybinds.custom_commands[0].label,
|
||||
"prefix+y"
|
||||
);
|
||||
assert_eq!(
|
||||
local_state
|
||||
.config
|
||||
.keybinds
|
||||
.keybinds
|
||||
.new_tab
|
||||
.label()
|
||||
.as_deref(),
|
||||
Some("prefix+c")
|
||||
);
|
||||
let mut command_outcome = ClientShellInput::default();
|
||||
local_state.record_binding(
|
||||
crate::input::KeybindMatch::Command(
|
||||
local_state.config.keybinds.keybinds.custom_commands[0].clone(),
|
||||
),
|
||||
&mut command_outcome,
|
||||
);
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &command_outcome.actions[..] else {
|
||||
panic!("expected surviving endpoint command binding");
|
||||
};
|
||||
let crate::api::schema::Method::CommandInvoke(params) = &request.method else {
|
||||
panic!("expected command invocation");
|
||||
};
|
||||
assert_eq!(params.command_id, "cmd_loaded_endpoint");
|
||||
|
||||
let mut id_only_projection = snapshot();
|
||||
id_only_projection.revision = 2;
|
||||
id_only_projection
|
||||
.commands
|
||||
.push(crate::protocol::ClientShellCommand {
|
||||
command_id: "cmd_reloaded_endpoint".into(),
|
||||
binding_label: "prefix+c / prefix+y".into(),
|
||||
binding_labels: vec!["prefix+c".into(), "prefix+y".into()],
|
||||
action: crate::protocol::ClientShellCommandAction::Shell,
|
||||
description: Some("loaded endpoint command".into()),
|
||||
});
|
||||
local_state.mode = ClientShellMode::Prefix;
|
||||
local_state.set_snapshot(Box::new(id_only_projection));
|
||||
assert_eq!(local_state.mode, ClientShellMode::Prefix);
|
||||
assert_eq!(
|
||||
local_state.config.keybinds.keybinds.custom_commands[0].command,
|
||||
"cmd_reloaded_endpoint"
|
||||
);
|
||||
|
||||
let endpoint: Config = toml::from_str(
|
||||
r#"
|
||||
[keys]
|
||||
prefix = "ctrl+x"
|
||||
new_tab = "prefix+n"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut state = ClientShellState::new(
|
||||
ClientShellConfig::from_config(&local)
|
||||
.with_keybinding_source(ClientShellKeybindingSource::Endpoint),
|
||||
);
|
||||
let mut projection = snapshot();
|
||||
projection.server_keybindings_toml = endpoint.local_keybindings_profile_toml().ok();
|
||||
projection
|
||||
.commands
|
||||
.push(crate::protocol::ClientShellCommand {
|
||||
command_id: "cmd_remote".into(),
|
||||
binding_label: "prefix+z".into(),
|
||||
binding_labels: vec!["prefix+z".into()],
|
||||
action: crate::protocol::ClientShellCommandAction::Shell,
|
||||
description: Some("remote command".into()),
|
||||
});
|
||||
state.set_snapshot(Box::new(projection));
|
||||
|
||||
assert_eq!(state.config.keybinds.prefix.0, KeyCode::Char('x'));
|
||||
assert_eq!(
|
||||
state.config.keybinds.keybinds.new_tab.label().as_deref(),
|
||||
Some("prefix+n")
|
||||
);
|
||||
assert_eq!(
|
||||
state.config.keybinds.keybinds.custom_commands[0].label,
|
||||
"prefix+z"
|
||||
);
|
||||
assert_eq!(
|
||||
state.config.keybinds.keybinds.custom_commands[0]
|
||||
.description
|
||||
.as_deref(),
|
||||
Some("remote command")
|
||||
);
|
||||
assert_eq!(
|
||||
state.config.keybinds.keybinds.custom_commands[0].command,
|
||||
"cmd_remote"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_binding_invokes_only_the_endpoint_manifest_id() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
let binding = crate::config::CustomCommandKeybind {
|
||||
bindings: crate::config::ActionKeybinds::prefix("z"),
|
||||
label: "prefix+z".into(),
|
||||
command: "secret-command --token hidden".into(),
|
||||
action: crate::config::CustomCommandAction::Shell,
|
||||
description: None,
|
||||
width: None,
|
||||
height: None,
|
||||
};
|
||||
let mut projection = snapshot();
|
||||
projection
|
||||
.commands
|
||||
.push(crate::protocol::ClientShellCommand {
|
||||
command_id: "cmd_0123456789abcdef0123456789abcdef".into(),
|
||||
binding_label: binding.label.clone(),
|
||||
binding_labels: binding.bindings.labels(),
|
||||
action: crate::protocol::ClientShellCommandAction::Shell,
|
||||
description: None,
|
||||
});
|
||||
state.set_snapshot(Box::new(projection));
|
||||
|
||||
let mut outcome = ClientShellInput::default();
|
||||
state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome);
|
||||
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else {
|
||||
panic!("expected endpoint command invocation");
|
||||
};
|
||||
let crate::api::schema::Method::CommandInvoke(params) = &request.method else {
|
||||
panic!("expected command.invoke");
|
||||
};
|
||||
assert_eq!(params.command_id, "cmd_0123456789abcdef0123456789abcdef");
|
||||
assert_eq!(params.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert_eq!(params.tab_id.as_deref(), Some("tab_1"));
|
||||
assert_eq!(params.pane_id.as_deref(), Some("pane_1"));
|
||||
assert_eq!(params.selection, None);
|
||||
assert!(!serde_json::to_string(request)
|
||||
.unwrap()
|
||||
.contains("secret-command"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_command_carries_client_owned_selection_coordinates() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
let binding = crate::config::CustomCommandKeybind {
|
||||
bindings: crate::config::ActionKeybinds::prefix("p"),
|
||||
label: "prefix+p".into(),
|
||||
command: "plugin.action".into(),
|
||||
action: crate::config::CustomCommandAction::PluginAction,
|
||||
description: None,
|
||||
width: None,
|
||||
height: None,
|
||||
};
|
||||
let mut projection = snapshot();
|
||||
projection
|
||||
.commands
|
||||
.push(crate::protocol::ClientShellCommand {
|
||||
command_id: "cmd_plugin".into(),
|
||||
binding_label: binding.label.clone(),
|
||||
binding_labels: binding.bindings.labels(),
|
||||
action: crate::protocol::ClientShellCommandAction::PluginAction,
|
||||
description: None,
|
||||
});
|
||||
state.set_snapshot(Box::new(projection));
|
||||
let mut pane_surface = surface();
|
||||
pane_surface.panes[0].content_revision = 42;
|
||||
state.set_pane_surface(pane_surface);
|
||||
let mut selection =
|
||||
crate::selection::Selection::absolute_range("pane_1".to_owned(), (2, 3), (4, 5));
|
||||
assert!(selection.finish());
|
||||
state.selection = Some(selection);
|
||||
|
||||
let mut outcome = ClientShellInput::default();
|
||||
state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome);
|
||||
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else {
|
||||
panic!("expected endpoint command invocation");
|
||||
};
|
||||
let crate::api::schema::Method::CommandInvoke(params) = &request.method else {
|
||||
panic!("expected command.invoke");
|
||||
};
|
||||
assert_eq!(
|
||||
params.selection,
|
||||
Some(crate::api::schema::PaneSelectionReadParams {
|
||||
pane_id: "pane_1".into(),
|
||||
anchor: crate::api::schema::PaneTextPoint { row: 2, col: 3 },
|
||||
cursor: crate::api::schema::PaneTextPoint { row: 4, col: 5 },
|
||||
content_revision: Some(42),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_endpoint_failures_and_control_errors_are_visible() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
let mut outcome = ClientShellInput::default();
|
||||
state.push_endpoint_method(
|
||||
crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget {
|
||||
workspace_id: "missing".into(),
|
||||
}),
|
||||
&mut outcome,
|
||||
);
|
||||
let request_id = match &outcome.actions[..] {
|
||||
[ClientShellAction::Endpoint { request, .. }] => request.id.clone(),
|
||||
other => panic!("expected generic endpoint request, got {other:?}"),
|
||||
};
|
||||
let (repaint, actions) = state.handle_endpoint_result(
|
||||
"boot-1",
|
||||
&request_id,
|
||||
Err(ClientShellEndpointError {
|
||||
code: Some("not_found".into()),
|
||||
message: "workspace no longer exists".into(),
|
||||
}),
|
||||
);
|
||||
assert!(repaint);
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(
|
||||
state.endpoint_error.as_deref(),
|
||||
Some("workspace no longer exists")
|
||||
);
|
||||
|
||||
assert!(state.receive_endpoint_error("Paste rejected: too large".into()));
|
||||
assert_eq!(
|
||||
state.endpoint_error.as_deref(),
|
||||
Some("Paste rejected: too large")
|
||||
);
|
||||
assert!(!state.receive_endpoint_error("Paste rejected: too large".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_binding_missing_from_endpoint_manifest_is_not_forwarded() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
let binding = crate::config::CustomCommandKeybind {
|
||||
bindings: crate::config::ActionKeybinds::prefix("z"),
|
||||
label: "prefix+z".into(),
|
||||
command: "secret-command".into(),
|
||||
action: crate::config::CustomCommandAction::Shell,
|
||||
description: None,
|
||||
width: None,
|
||||
height: None,
|
||||
};
|
||||
|
||||
let mut outcome = ClientShellInput::default();
|
||||
state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome);
|
||||
|
||||
assert!(outcome.actions.is_empty());
|
||||
assert!(outcome.repaint);
|
||||
assert!(state
|
||||
.endpoint_error
|
||||
.as_deref()
|
||||
.is_some_and(|error| error.contains("not available")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_overlay_restores_released_search_scroll_and_custom_binding_behavior() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
let mut projection = snapshot();
|
||||
projection
|
||||
.commands
|
||||
.push(crate::protocol::ClientShellCommand {
|
||||
command_id: "plugin-action".into(),
|
||||
binding_label: "prefix+z".into(),
|
||||
binding_labels: vec!["prefix+z".into()],
|
||||
action: crate::protocol::ClientShellCommandAction::PluginAction,
|
||||
description: Some("run plugin action".into()),
|
||||
});
|
||||
state.set_snapshot(Box::new(projection));
|
||||
state.set_pane_surface(surface());
|
||||
let mut open = ClientShellInput::default();
|
||||
state.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help),
|
||||
&mut open,
|
||||
);
|
||||
let initial = state.compose(106, 30).expect("help overlay");
|
||||
let text = initial
|
||||
.cells
|
||||
.chunks(initial.width as usize)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("global"));
|
||||
assert!(state.hits.help_max_scroll > 0);
|
||||
assert_ne!(state.hits.help_scrollbar, Rect::default());
|
||||
|
||||
state.handle_input_bytes(b"/");
|
||||
state.handle_input_bytes(b"plugin");
|
||||
let custom = state.compose(106, 30).expect("custom help search");
|
||||
let text = custom
|
||||
.cells
|
||||
.chunks(custom.width as usize)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("custom"));
|
||||
assert!(text.contains("run plugin action"));
|
||||
state.handle_input_bytes(b"\x1b");
|
||||
|
||||
state.handle_input_bytes(b"/");
|
||||
state.handle_input_bytes(b"does-not-exist");
|
||||
let empty = state.compose(106, 30).expect("empty help search");
|
||||
let text = empty
|
||||
.cells
|
||||
.chunks(empty.width as usize)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("no matching keybinds"));
|
||||
|
||||
state.handle_input_bytes(b"\x1b");
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::Help(ClientHelpOverlay {
|
||||
search_focused: false,
|
||||
ref query,
|
||||
scroll: 0,
|
||||
})) if query.is_empty()
|
||||
));
|
||||
state.compose(106, 30).expect("restored help");
|
||||
state.handle_input_bytes(b"\x1b[F");
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::Help(ClientHelpOverlay { scroll, .. }))
|
||||
if scroll == state.hits.help_max_scroll
|
||||
));
|
||||
state.handle_input_bytes(b"\x1b[H");
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::Help(ClientHelpOverlay {
|
||||
scroll: 0,
|
||||
..
|
||||
}))
|
||||
));
|
||||
state.handle_input_bytes(b"?");
|
||||
assert!(state.overlay.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_mode_reuses_endpoint_resize_and_stays_active_until_done() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
|
||||
assert!(state.handle_input_bytes(&[0x02]).actions.is_empty());
|
||||
assert!(state.handle_input_bytes(b"r").actions.is_empty());
|
||||
assert_eq!(state.mode, ClientShellMode::Resize);
|
||||
|
||||
let modified = state.handle_input_bytes(b"\x1b[1;2D");
|
||||
assert!(matches!(
|
||||
&modified.actions[..],
|
||||
[ClientShellAction::Endpoint { request, .. }]
|
||||
if matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::PaneResize(params)
|
||||
if params.direction == crate::api::schema::PaneDirection::Left
|
||||
)
|
||||
));
|
||||
assert_eq!(state.mode, ClientShellMode::Resize);
|
||||
|
||||
let resize = state.handle_input_bytes(b"h");
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &resize.actions[..] else {
|
||||
panic!("resize should use endpoint API");
|
||||
};
|
||||
assert!(matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::PaneResize(params)
|
||||
if params.pane_id.as_deref() == Some("pane_1")
|
||||
&& params.direction == crate::api::schema::PaneDirection::Left
|
||||
));
|
||||
assert_eq!(state.mode, ClientShellMode::Resize);
|
||||
|
||||
assert!(state.handle_input_bytes(b"\r").actions.is_empty());
|
||||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn navigate_update_status_uses_released_desktop_and_mobile_placement() {
|
||||
let mut config = ClientShellConfig::from_config(&Config::default());
|
||||
config.tab_bar_position = crate::config::TabBarPositionConfig::Bottom;
|
||||
config.hide_tab_bar_when_single_tab = false;
|
||||
let mut state = ClientShellState::new(config);
|
||||
let mut endpoint_snapshot = snapshot();
|
||||
endpoint_snapshot.update_available = Some("0.8.3".into());
|
||||
state.set_snapshot(Box::new(endpoint_snapshot));
|
||||
state.set_pane_surface(surface());
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
|
||||
let bottom = state.compose(106, 30).expect("bottom-tab update shell");
|
||||
let row_text = |frame: &FrameData, row: u16| {
|
||||
let width = usize::from(frame.width);
|
||||
let start = usize::from(row) * width;
|
||||
frame.cells[start..start + width]
|
||||
.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
};
|
||||
assert!(row_text(&bottom, 29).contains("update ready"));
|
||||
assert!(!row_text(&bottom, 28).contains("update ready"));
|
||||
assert!(state.hits.tabs.is_empty());
|
||||
assert!(state.hits.new_tab.is_empty());
|
||||
assert!(state.hits.tab_scroll_left.is_empty());
|
||||
assert!(state.hits.tab_scroll_right.is_empty());
|
||||
|
||||
state.config.tab_bar_position = crate::config::TabBarPositionConfig::Top;
|
||||
state.visible_notification = Some(ClientVisibleNotification {
|
||||
event: SemanticNotification {
|
||||
kind: SemanticNotificationKind::Custom,
|
||||
title: "bottom notification".into(),
|
||||
body: None,
|
||||
sound: None,
|
||||
agent: None,
|
||||
workspace_id: None,
|
||||
tab_id: None,
|
||||
pane_id: None,
|
||||
position: Some(crate::config::ToastHerdrPosition::BottomRight),
|
||||
},
|
||||
deadline: std::time::Instant::now(),
|
||||
});
|
||||
let top = state.compose(106, 30).expect("top-tab update shell");
|
||||
assert!(row_text(&top, 29).contains("update ready"));
|
||||
|
||||
let mobile = state.compose(44, 30).expect("mobile update shell");
|
||||
let mobile_text = mobile
|
||||
.cells
|
||||
.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>();
|
||||
assert!(mobile_text.contains("update ready"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_layout_reserves_only_client_header() {
|
||||
let config = ClientShellConfig::from_config(&Config::default());
|
||||
let state = ClientShellState::new(config);
|
||||
let layout = state.layout(44, 20);
|
||||
assert_eq!(layout.mobile_header, Rect::new(0, 0, 44, 2));
|
||||
assert_eq!(layout.pane_surface, Rect::new(0, 2, 44, 18));
|
||||
assert_eq!(
|
||||
state.surface_size(44, 20),
|
||||
ClientSurfaceSize { cols: 44, rows: 18 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_shell_controls_remain_clickable_when_pane_mouse_capture_is_disabled() {
|
||||
let mut config = ClientShellConfig::from_config(&Config::default());
|
||||
config.mouse_capture = false;
|
||||
let mut state = ClientShellState::new(config);
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(44, 20).expect("mobile header");
|
||||
assert!(!state.hits.mobile_switch.is_empty());
|
||||
let switch = state.hits.mobile_switch;
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: switch.x,
|
||||
row: switch.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||||
state.compose(44, 20).expect("mobile switcher");
|
||||
assert!(!state.hits.mobile_close.is_empty());
|
||||
assert!(!state.hits.mobile_targets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_header_and_switcher_render_released_sections_and_stable_targets() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
let mut projected = snapshot();
|
||||
projected.agents.push(ClientShellAgent {
|
||||
pane_id: "pane_1".into(),
|
||||
workspace_id: "ws_1".into(),
|
||||
tab_id: "tab_1".into(),
|
||||
name: Some("pi".into()),
|
||||
display_agent: Some("pi".into()),
|
||||
agent: Some("pi".into()),
|
||||
title: None,
|
||||
terminal_title: None,
|
||||
terminal_title_stripped: None,
|
||||
agent_status: AgentStatus::Blocked,
|
||||
state_change_seq: 1,
|
||||
state_labels: vec![("blocked".into(), "waiting".into())],
|
||||
tokens: Vec::new(),
|
||||
focused: true,
|
||||
});
|
||||
projected.workspaces[0].agent_status = AgentStatus::Blocked;
|
||||
state.set_snapshot(Box::new(projected));
|
||||
let mut projected_surface = surface();
|
||||
for cell in &mut projected_surface.frame.cells {
|
||||
cell.symbol = "X".to_owned();
|
||||
}
|
||||
state.set_pane_surface(projected_surface);
|
||||
|
||||
let header = state.compose(44, 20).expect("mobile header");
|
||||
let header_text = header
|
||||
.cells
|
||||
.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>();
|
||||
assert!(header_text.contains("client-shell"));
|
||||
assert!(header_text.contains("tab 1"));
|
||||
assert!(header_text.contains("blocked"));
|
||||
assert!(header_text.contains("switch"));
|
||||
assert_eq!(state.hits.mobile_switch, Rect::new(34, 0, 10, 2));
|
||||
|
||||
let click = |rect: Rect| {
|
||||
RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: rect.x,
|
||||
row: rect.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})
|
||||
};
|
||||
let opened = state.handle_raw_events(vec![click(state.hits.mobile_switch)]);
|
||||
assert!(opened.repaint);
|
||||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||||
let switcher = state.compose(44, 20).expect("mobile switcher");
|
||||
let switcher_text = switcher
|
||||
.cells
|
||||
.chunks(switcher.width as usize)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
!switcher_text.contains('X'),
|
||||
"switcher must clear the pane surface"
|
||||
);
|
||||
for expected in [
|
||||
"switch",
|
||||
"close",
|
||||
"agents",
|
||||
"spaces",
|
||||
"+ new workspace",
|
||||
"tabs",
|
||||
"+ new tab",
|
||||
"menu",
|
||||
"settings",
|
||||
"detach",
|
||||
] {
|
||||
assert!(switcher_text.contains(expected), "missing {expected}");
|
||||
}
|
||||
let workspace_hit = state
|
||||
.hits
|
||||
.mobile_targets
|
||||
.iter()
|
||||
.find_map(|(rect, target)| {
|
||||
matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_1").then_some(*rect)
|
||||
})
|
||||
.expect("workspace hit");
|
||||
let focused = state.handle_raw_events(vec![click(workspace_hit)]);
|
||||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||||
assert!(state.navigate_workspace_id.is_none());
|
||||
assert!(focused.actions.iter().any(|action| matches!(
|
||||
action,
|
||||
ClientShellAction::Endpoint { request, .. }
|
||||
if matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::WorkspaceFocus(params)
|
||||
if params.workspace_id == "ws_1"
|
||||
)
|
||||
)));
|
||||
|
||||
state.compose(44, 20).expect("restored mobile header");
|
||||
state.handle_raw_events(vec![click(state.hits.mobile_switch)]);
|
||||
state.compose(44, 20).expect("agent switcher");
|
||||
let agent_hit = state
|
||||
.hits
|
||||
.mobile_targets
|
||||
.iter()
|
||||
.find_map(|(rect, target)| {
|
||||
matches!(target, ClientMobileTarget::Agent(id) if id == "pane_1").then_some(*rect)
|
||||
})
|
||||
.expect("agent hit");
|
||||
let focused = state.handle_raw_events(vec![click(agent_hit)]);
|
||||
assert!(focused.actions.iter().any(|action| matches!(
|
||||
action,
|
||||
ClientShellAction::Endpoint { request, .. }
|
||||
if matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::PaneFocus(params)
|
||||
if params.pane_id == "pane_1"
|
||||
)
|
||||
)));
|
||||
|
||||
state.compose(44, 20).expect("restored mobile header");
|
||||
state.handle_raw_events(vec![click(state.hits.mobile_switch)]);
|
||||
state.compose(44, 20).expect("tab switcher");
|
||||
let tab_hit = state
|
||||
.hits
|
||||
.mobile_targets
|
||||
.iter()
|
||||
.find_map(|(rect, target)| {
|
||||
matches!(target, ClientMobileTarget::Tab(id) if id == "tab_1").then_some(*rect)
|
||||
})
|
||||
.expect("tab hit");
|
||||
let focused = state.handle_raw_events(vec![click(tab_hit)]);
|
||||
assert!(focused.actions.iter().any(|action| matches!(
|
||||
action,
|
||||
ClientShellAction::Endpoint { request, .. }
|
||||
if matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::TabFocus(params)
|
||||
if params.tab_id == "tab_1"
|
||||
)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_background_workspace_uses_its_own_active_tab_status() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
let mut projected = snapshot();
|
||||
projected.tabs.push(ClientShellTab {
|
||||
tab_id: "tab_7".into(),
|
||||
workspace_id: "ws_1".into(),
|
||||
number: 7,
|
||||
label: "logs".into(),
|
||||
custom_label: true,
|
||||
zoomed: false,
|
||||
focused: false,
|
||||
agent_status: AgentStatus::Idle,
|
||||
});
|
||||
projected.workspaces.push(ClientShellWorkspace {
|
||||
workspace_id: "ws_2".into(),
|
||||
active_tab_id: "tab_3".into(),
|
||||
new_workspace_cwd: "/feature".into(),
|
||||
number: 2,
|
||||
label: "background".into(),
|
||||
custom_label: true,
|
||||
branch: Some("feature".into()),
|
||||
git_ahead_behind: None,
|
||||
tokens: Vec::new(),
|
||||
worktree: None,
|
||||
focused: false,
|
||||
agent_status: AgentStatus::Idle,
|
||||
});
|
||||
for (number, tab_id, label) in [(1, "tab_2", "one"), (7, "tab_3", "two")] {
|
||||
projected.tabs.push(ClientShellTab {
|
||||
tab_id: tab_id.into(),
|
||||
workspace_id: "ws_2".into(),
|
||||
number,
|
||||
label: label.into(),
|
||||
custom_label: true,
|
||||
zoomed: false,
|
||||
focused: false,
|
||||
agent_status: AgentStatus::Idle,
|
||||
});
|
||||
}
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
state.navigate_workspace_id = Some("ws_2".into());
|
||||
let frame = state.compose(44, 20).expect("mobile switcher");
|
||||
let text = frame
|
||||
.cells
|
||||
.chunks(frame.width as usize)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("feature · tab two · 2/2"), "{text}");
|
||||
assert!(text.contains("2 · logs"), "{text}");
|
||||
assert!(!text.contains("7 · logs"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_switcher_create_and_menu_rows_reuse_client_actions() {
|
||||
let mut config = ClientShellConfig::from_config(&Config::default());
|
||||
config.prompt_new_workspace_name = true;
|
||||
config.prompt_new_tab_name = true;
|
||||
let mut state = ClientShellState::new(config);
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
let click = |rect: Rect| {
|
||||
RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: rect.x,
|
||||
row: rect.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})
|
||||
};
|
||||
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
state.compose(44, 20).expect("mobile create switcher");
|
||||
let new_tab = state
|
||||
.hits
|
||||
.mobile_targets
|
||||
.iter()
|
||||
.find_map(|(rect, target)| matches!(target, ClientMobileTarget::NewTab).then_some(*rect))
|
||||
.expect("new tab hit");
|
||||
state.handle_raw_events(vec![click(new_tab)]);
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::Rename(ClientRenameOverlay {
|
||||
target: ClientRenameTarget::NewTab { .. },
|
||||
..
|
||||
}))
|
||||
));
|
||||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||||
KeyCode::Esc,
|
||||
KeyModifiers::empty(),
|
||||
))]);
|
||||
assert!(state.overlay.is_none());
|
||||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||||
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
state.compose(44, 20).expect("mobile workspace switcher");
|
||||
let new_workspace = state
|
||||
.hits
|
||||
.mobile_targets
|
||||
.iter()
|
||||
.find_map(|(rect, target)| {
|
||||
matches!(target, ClientMobileTarget::NewWorkspace).then_some(*rect)
|
||||
})
|
||||
.expect("new workspace hit");
|
||||
state.handle_raw_events(vec![click(new_workspace)]);
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::Rename(ClientRenameOverlay {
|
||||
target: ClientRenameTarget::NewWorkspace { .. },
|
||||
..
|
||||
}))
|
||||
));
|
||||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||||
KeyCode::Esc,
|
||||
KeyModifiers::empty(),
|
||||
))]);
|
||||
assert!(state.overlay.is_none());
|
||||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||||
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
state.compose(44, 20).expect("mobile menu switcher");
|
||||
let settings = state
|
||||
.hits
|
||||
.mobile_targets
|
||||
.iter()
|
||||
.find_map(|(rect, target)| matches!(target, ClientMobileTarget::Menu(0)).then_some(*rect))
|
||||
.expect("settings hit");
|
||||
state.handle_raw_events(vec![click(settings)]);
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::Settings(_))
|
||||
));
|
||||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||||
KeyCode::Esc,
|
||||
KeyModifiers::empty(),
|
||||
))]);
|
||||
assert!(state.overlay.is_none());
|
||||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_menu_keeps_inert_notes_open_and_cancel_without_workspace_in_navigate() {
|
||||
let mut source_config = Config::default();
|
||||
source_config.ui.prompt_new_workspace_name = true;
|
||||
let config = ClientShellConfig::from_config(&source_config);
|
||||
let mut projected = snapshot();
|
||||
projected.latest_release_notes_available = true;
|
||||
projected.release_notes = None;
|
||||
let mut state = ClientShellState::new(config);
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
state.compose(44, 20).expect("mobile switcher");
|
||||
let inert_notes = state
|
||||
.hits
|
||||
.mobile_targets
|
||||
.iter()
|
||||
.find_map(|(rect, target)| matches!(target, ClientMobileTarget::Menu(3)).then_some(*rect))
|
||||
.expect("what's new row");
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: inert_notes.x,
|
||||
row: inert_notes.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||||
assert!(state.overlay.is_none());
|
||||
assert!(!state.mobile_switcher_suspended);
|
||||
|
||||
let mut empty = snapshot();
|
||||
empty.focused_workspace_id = None;
|
||||
empty.focused_tab_id = None;
|
||||
empty.focused_pane_id = None;
|
||||
empty.workspaces.clear();
|
||||
empty.tabs.clear();
|
||||
empty.panes.clear();
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&source_config));
|
||||
state.set_snapshot(Box::new(empty));
|
||||
state.set_pane_surface(surface());
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
state.compose(44, 20).expect("empty mobile switcher");
|
||||
let new_workspace = state
|
||||
.hits
|
||||
.mobile_targets
|
||||
.iter()
|
||||
.find_map(|(rect, target)| {
|
||||
matches!(target, ClientMobileTarget::NewWorkspace).then_some(*rect)
|
||||
})
|
||||
.expect("new workspace row");
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: new_workspace.x,
|
||||
row: new_workspace.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(matches!(state.overlay, Some(ClientShellOverlay::Rename(_))));
|
||||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||||
KeyCode::Esc,
|
||||
KeyModifiers::empty(),
|
||||
))]);
|
||||
assert_eq!(state.mode, ClientShellMode::Navigate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_previous_workspace_action_wraps_across_expanded_entries() {
|
||||
let mut projected = snapshot();
|
||||
for index in 2..=3 {
|
||||
projected.workspaces.push(ClientShellWorkspace {
|
||||
workspace_id: format!("ws_{index}"),
|
||||
active_tab_id: format!("tab_{index}"),
|
||||
new_workspace_cwd: "/tmp".into(),
|
||||
number: index,
|
||||
label: format!("workspace-{index}"),
|
||||
custom_label: true,
|
||||
branch: None,
|
||||
git_ahead_behind: None,
|
||||
tokens: Vec::new(),
|
||||
worktree: None,
|
||||
focused: false,
|
||||
agent_status: AgentStatus::Idle,
|
||||
});
|
||||
}
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(44, 20).expect("mobile layout");
|
||||
let mut outcome = ClientShellInput::default();
|
||||
state.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::PreviousWorkspace),
|
||||
&mut outcome,
|
||||
);
|
||||
assert!(outcome.actions.iter().any(|action| matches!(
|
||||
action,
|
||||
ClientShellAction::Endpoint { request, .. }
|
||||
if matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::WorkspaceFocus(target)
|
||||
if target.workspace_id == "ws_3"
|
||||
)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
let mut projected = snapshot();
|
||||
for index in 2..=8 {
|
||||
projected.workspaces.push(ClientShellWorkspace {
|
||||
workspace_id: format!("ws_{index}"),
|
||||
active_tab_id: format!("tab_{index}"),
|
||||
new_workspace_cwd: "/tmp".into(),
|
||||
number: index,
|
||||
label: format!("workspace-{index}"),
|
||||
custom_label: true,
|
||||
branch: None,
|
||||
git_ahead_behind: None,
|
||||
tokens: Vec::new(),
|
||||
worktree: None,
|
||||
focused: false,
|
||||
agent_status: AgentStatus::Idle,
|
||||
});
|
||||
}
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(44, 10).expect("mobile header");
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: state.hits.mobile_switch.x,
|
||||
row: state.hits.mobile_switch.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
state.compose(44, 10).expect("mobile switcher");
|
||||
let wheel = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::ScrollDown,
|
||||
column: 20,
|
||||
row: 8,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(wheel.repaint);
|
||||
assert_eq!(state.mobile_switcher_scroll, 2);
|
||||
state.compose(44, 10).expect("wheel position stays stable");
|
||||
assert_eq!(state.mobile_switcher_scroll, 2);
|
||||
for _ in 0..7 {
|
||||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||||
KeyCode::Down,
|
||||
KeyModifiers::empty(),
|
||||
))]);
|
||||
}
|
||||
state.compose(44, 10).expect("revealed mobile selection");
|
||||
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8"));
|
||||
assert!(state.mobile_switcher_scroll > 2);
|
||||
assert!(state.hits.mobile_targets.iter().any(|(_, target)| {
|
||||
matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_8")
|
||||
}));
|
||||
let close = state.hits.mobile_close;
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: close.x,
|
||||
row: close.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert_eq!(state.mode, ClientShellMode::Terminal);
|
||||
assert!(state.navigate_workspace_id.is_none());
|
||||
|
||||
state.compose(80, 20).expect("desktop transition");
|
||||
assert!(state.hits.mobile_switch.is_empty());
|
||||
assert!(state.hits.mobile_close.is_empty());
|
||||
assert!(state.hits.mobile_targets.is_empty());
|
||||
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
let short = state.compose(44, 2).expect("short mobile switcher");
|
||||
assert_eq!(short.cells[0].symbol, "─");
|
||||
assert!(state.hits.mobile_close.is_empty());
|
||||
assert!(state.hits.mobile_targets.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
use super::*;
|
||||
use crate::api::schema::AgentStatus;
|
||||
use crate::protocol::{
|
||||
ClientShellAgent, ClientShellPane, ClientShellTab, ClientShellWorktree, PaneSurfacePane,
|
||||
PaneSurfaceSplit, PaneSurfaceSplitDirection, SurfaceRect,
|
||||
};
|
||||
use crossterm::event::MouseEvent;
|
||||
|
||||
fn snapshot() -> ClientShellSnapshot {
|
||||
ClientShellSnapshot {
|
||||
boot_id: "boot-1".into(),
|
||||
revision: 1,
|
||||
config_diagnostic: None,
|
||||
product_announcement: None,
|
||||
update_available: None,
|
||||
update_install_command: "herdr update".into(),
|
||||
server_keybindings_toml: None,
|
||||
latest_release_notes_available: false,
|
||||
integration_updates_available: false,
|
||||
worktree_directory: "/tmp/herdr-worktrees".into(),
|
||||
release_notes: None,
|
||||
focused_workspace_id: Some("ws_1".into()),
|
||||
focused_tab_id: Some("tab_1".into()),
|
||||
focused_pane_id: Some("pane_1".into()),
|
||||
tab_bar_right: Vec::new(),
|
||||
tab_bar_right_separator: " ".into(),
|
||||
agent_view_label: None,
|
||||
agent_order: Vec::new(),
|
||||
workspaces: vec![ClientShellWorkspace {
|
||||
workspace_id: "ws_1".into(),
|
||||
active_tab_id: "tab_1".into(),
|
||||
new_workspace_cwd: "/repo".into(),
|
||||
number: 1,
|
||||
label: "client-shell".into(),
|
||||
custom_label: false,
|
||||
branch: Some("main".into()),
|
||||
git_ahead_behind: None,
|
||||
tokens: Vec::new(),
|
||||
worktree: None,
|
||||
focused: true,
|
||||
agent_status: AgentStatus::Idle,
|
||||
}],
|
||||
tabs: vec![ClientShellTab {
|
||||
tab_id: "tab_1".into(),
|
||||
workspace_id: "ws_1".into(),
|
||||
number: 1,
|
||||
label: "1".into(),
|
||||
custom_label: false,
|
||||
zoomed: false,
|
||||
focused: true,
|
||||
agent_status: AgentStatus::Idle,
|
||||
}],
|
||||
panes: vec![ClientShellPane {
|
||||
pane_id: "pane_1".into(),
|
||||
workspace_id: "ws_1".into(),
|
||||
tab_id: "tab_1".into(),
|
||||
label: None,
|
||||
cwd: Some("/repo".into()),
|
||||
foreground_cwd: Some("/repo".into()),
|
||||
focused: true,
|
||||
right_click_passthrough: false,
|
||||
}],
|
||||
agents: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn worktree_list_result(open_workspace_id: Option<&str>) -> crate::api::schema::ResponseResult {
|
||||
crate::api::schema::ResponseResult::WorktreeList {
|
||||
source: crate::api::schema::WorktreeSourceInfo {
|
||||
repo_key: "repo-key".into(),
|
||||
repo_name: "repo".into(),
|
||||
repo_root: "/repo".into(),
|
||||
source_checkout_path: "/repo".into(),
|
||||
source_workspace_id: Some("ws_1".into()),
|
||||
},
|
||||
worktrees: vec![crate::api::schema::WorktreeInfo {
|
||||
path: "/repo-feature".into(),
|
||||
branch: Some("feature".into()),
|
||||
is_bare: false,
|
||||
is_detached: false,
|
||||
is_prunable: false,
|
||||
is_linked_worktree: true,
|
||||
open_workspace_id: open_workspace_id.map(str::to_owned),
|
||||
label: "repo".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn surface() -> PaneSurfaceFrame {
|
||||
let surface_buffer = Buffer::with_lines(["LIVE", "PANE"]);
|
||||
PaneSurfaceFrame {
|
||||
boot_id: "boot-1".into(),
|
||||
projection_revision: 1,
|
||||
frame: FrameData::from_ratatui_buffer_with_hyperlinks(
|
||||
&surface_buffer,
|
||||
Some(crate::protocol::CursorState {
|
||||
x: 1,
|
||||
y: 1,
|
||||
visible: true,
|
||||
shape: 2,
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
panes: vec![PaneSurfacePane {
|
||||
pane_id: "pane_1".into(),
|
||||
content_revision: 0,
|
||||
rect: SurfaceRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 4,
|
||||
height: 2,
|
||||
},
|
||||
inner_rect: SurfaceRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 4,
|
||||
height: 2,
|
||||
},
|
||||
scrollbar_rect: None,
|
||||
scroll: None,
|
||||
focused: true,
|
||||
mouse_reporting: false,
|
||||
sgr_pixel_mouse: false,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
}],
|
||||
splits: Vec::new(),
|
||||
popup: None,
|
||||
graphics: crate::protocol::SurfaceGraphicsScene::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pane_scroll_result(
|
||||
offset_from_bottom: u64,
|
||||
max_offset_from_bottom: u64,
|
||||
viewport_rows: u64,
|
||||
) -> crate::api::schema::ResponseResult {
|
||||
crate::api::schema::ResponseResult::PaneInfo {
|
||||
pane: crate::api::schema::PaneInfo {
|
||||
pane_id: "pane_1".into(),
|
||||
terminal_id: "terminal_1".into(),
|
||||
workspace_id: "ws_1".into(),
|
||||
tab_id: "tab_1".into(),
|
||||
focused: true,
|
||||
cwd: None,
|
||||
foreground_cwd: None,
|
||||
label: None,
|
||||
agent: None,
|
||||
title: None,
|
||||
terminal_title: None,
|
||||
terminal_title_stripped: None,
|
||||
display_agent: None,
|
||||
agent_status: crate::api::schema::AgentStatus::Unknown,
|
||||
state_labels: HashMap::new(),
|
||||
tokens: HashMap::new(),
|
||||
agent_session: None,
|
||||
scroll: Some(crate::api::schema::PaneScrollInfo {
|
||||
offset_from_bottom,
|
||||
max_offset_from_bottom,
|
||||
viewport_rows,
|
||||
}),
|
||||
revision: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_search_result(
|
||||
matches: Vec<crate::api::schema::PaneTextRange>,
|
||||
current: Option<u32>,
|
||||
) -> crate::api::schema::ResponseResult {
|
||||
let total = matches.len() as u64;
|
||||
crate::api::schema::ResponseResult::PaneCopySearch {
|
||||
pane_id: "pane_1".into(),
|
||||
content_revision: 0,
|
||||
matches,
|
||||
total,
|
||||
current,
|
||||
current_global: current.map(u64::from),
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_with_popup() -> PaneSurfaceFrame {
|
||||
let mut surface = surface();
|
||||
let popup_buffer = Buffer::with_lines(["popup-live", "", ""]);
|
||||
surface.popup = Some(Box::new(crate::protocol::ClientShellPopupSurface {
|
||||
terminal_id: "terminal-popup".into(),
|
||||
title: "popup title".into(),
|
||||
width: Some(crate::protocol::ClientShellPopupSize::Cells(12)),
|
||||
height: Some(crate::protocol::ClientShellPopupSize::Cells(5)),
|
||||
frame: FrameData::from_ratatui_buffer_with_hyperlinks(
|
||||
&popup_buffer,
|
||||
Some(crate::protocol::CursorState {
|
||||
x: 2,
|
||||
y: 1,
|
||||
visible: true,
|
||||
shape: 1,
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
mouse_reporting: true,
|
||||
sgr_pixel_mouse: false,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
}));
|
||||
surface
|
||||
}
|
||||
|
||||
mod agents_worktrees_notifications;
|
||||
mod chrome_context;
|
||||
mod copy;
|
||||
#[path = "input.rs"]
|
||||
mod input_domain;
|
||||
mod keybindings_settings;
|
||||
mod mobile;
|
||||
mod mouse_selection;
|
||||
mod popup_focus_projection;
|
||||
mod startup_overlays;
|
||||
@@ -0,0 +1,686 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ctrl_click_routes_link_activation_through_endpoint_then_client_host() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 20).expect("pane frame");
|
||||
let pane = state.hits.panes[0].clone();
|
||||
let down = MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: pane.inner_rect.x + 2,
|
||||
row: pane.inner_rect.y + 1,
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
};
|
||||
let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]);
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &activate.actions[..] else {
|
||||
panic!("expected link activation request");
|
||||
};
|
||||
let request_id = request.id.clone();
|
||||
assert!(matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::PaneLinkActivate(params)
|
||||
if params.pane_id == "pane_1" && params.viewport_row == 1 && params.col == 2
|
||||
));
|
||||
|
||||
let up = MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
..down
|
||||
};
|
||||
let held = state.handle_raw_events(vec![RawInputEvent::Mouse(up)]);
|
||||
assert!(held.requests.is_empty() && held.actions.is_empty());
|
||||
let (_, actions) = state.handle_endpoint_result(
|
||||
"boot-1",
|
||||
&request_id,
|
||||
Ok(crate::api::schema::ResponseResult::PaneLinkActivated {
|
||||
url: Some("https://example.test".to_owned()),
|
||||
handled: false,
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
&actions[..],
|
||||
[ClientShellAction::OpenSafeWebUrl(url)] if url == "https://example.test"
|
||||
));
|
||||
assert!(!state.url_click_consumes_until_up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_click_without_a_link_replays_the_original_gesture() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 20).expect("pane frame");
|
||||
let pane = state.hits.panes[0].clone();
|
||||
let down = MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: pane.inner_rect.x + 2,
|
||||
row: pane.inner_rect.y + 1,
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
};
|
||||
let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]);
|
||||
let request_id = match &activate.actions[..] {
|
||||
[ClientShellAction::Endpoint { request, .. }] => request.id.clone(),
|
||||
_ => panic!("expected link activation request"),
|
||||
};
|
||||
let (_, actions) = state.handle_endpoint_result(
|
||||
"boot-1",
|
||||
&request_id,
|
||||
Ok(crate::api::schema::ResponseResult::PaneLinkActivated {
|
||||
url: None,
|
||||
handled: false,
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
&actions[..],
|
||||
[ClientShellAction::ReplayMouse(events)] if events == &vec![down]
|
||||
));
|
||||
let replay = match actions.into_iter().next().expect("replay action") {
|
||||
ClientShellAction::ReplayMouse(events) => state.replay_mouse_events(events),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(matches!(
|
||||
&replay.actions[..],
|
||||
[ClientShellAction::Endpoint { request, .. }]
|
||||
if matches!(request.method, crate::api::schema::Method::PaneFocus(_))
|
||||
));
|
||||
assert!(state.selection.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_split_drag_uses_projected_handle_and_stable_tab_path() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
let mut pane_surface = surface();
|
||||
pane_surface.splits.push(PaneSurfaceSplit {
|
||||
direction: PaneSurfaceSplitDirection::Horizontal,
|
||||
pos: 40,
|
||||
area: SurfaceRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 19,
|
||||
},
|
||||
hit_rect: SurfaceRect {
|
||||
x: 40,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 19,
|
||||
},
|
||||
path: vec![false, true],
|
||||
});
|
||||
state.set_pane_surface(pane_surface);
|
||||
state.compose(106, 20).expect("split pane surface");
|
||||
let split = state.hits.pane_splits[0].clone();
|
||||
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: split.hit_rect.x,
|
||||
row: split.hit_rect.y + 2,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(matches!(
|
||||
state.chrome_drag,
|
||||
Some(ClientChromeDrag::PaneSplit { .. })
|
||||
));
|
||||
let mut replacement = snapshot();
|
||||
replacement.revision = 2;
|
||||
replacement
|
||||
.tab_bar_right
|
||||
.push(crate::protocol::ClientShellTabStatusSegment {
|
||||
text: "updated".into(),
|
||||
accent: false,
|
||||
});
|
||||
let mut replacement_surface = surface();
|
||||
replacement_surface.projection_revision = 2;
|
||||
replacement_surface.splits.push(PaneSurfaceSplit {
|
||||
direction: PaneSurfaceSplitDirection::Horizontal,
|
||||
pos: 40,
|
||||
area: SurfaceRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 19,
|
||||
},
|
||||
hit_rect: SurfaceRect {
|
||||
x: 40,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 19,
|
||||
},
|
||||
path: vec![false, true],
|
||||
});
|
||||
state.set_snapshot(Box::new(replacement));
|
||||
state.set_pane_surface(replacement_surface);
|
||||
let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: split.area.x + 48,
|
||||
row: split.hit_rect.y + 2,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &drag.actions[..] else {
|
||||
panic!("pane split drag should use endpoint API");
|
||||
};
|
||||
assert!(matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::LayoutSetSplitRatio(params)
|
||||
if params.tab_id.as_deref() == Some("tab_1")
|
||||
&& params.path == vec![false, true]
|
||||
&& (params.ratio - 0.6).abs() < f32::EPSILON
|
||||
));
|
||||
let release =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
column: split.area.x + 48,
|
||||
row: split.hit_rect.y + 2,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(release.actions.is_empty());
|
||||
assert!(state.chrome_drag.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_mouse_chrome_keeps_tab_wheel_but_removes_split_drag_hits() {
|
||||
let mut config = Config::default();
|
||||
config.ui.mouse_capture = false;
|
||||
let mut projected = snapshot();
|
||||
let mut second_tab = projected.tabs[0].clone();
|
||||
second_tab.tab_id = "tab_2".into();
|
||||
second_tab.number = 2;
|
||||
second_tab.label = "2".into();
|
||||
second_tab.focused = false;
|
||||
projected.tabs.push(second_tab);
|
||||
let mut pane_surface = surface();
|
||||
pane_surface.splits.push(PaneSurfaceSplit {
|
||||
direction: PaneSurfaceSplitDirection::Horizontal,
|
||||
pos: 40,
|
||||
area: SurfaceRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 19,
|
||||
},
|
||||
hit_rect: SurfaceRect {
|
||||
x: 40,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 19,
|
||||
},
|
||||
path: Vec::new(),
|
||||
});
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(pane_surface);
|
||||
state.compose(106, 20).expect("mouse-disabled shell");
|
||||
assert!(state.hits.pane_splits.is_empty());
|
||||
let first_tab = state.hits.tabs[0].0;
|
||||
let wheel = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::ScrollDown,
|
||||
column: first_tab.x,
|
||||
row: first_tab.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(matches!(
|
||||
&wheel.actions[..],
|
||||
[ClientShellAction::Endpoint { request, .. }]
|
||||
if matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_2"
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_double_click_selects_and_copies_endpoint_row_word() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 20).expect("composed frame");
|
||||
let pane = state.hits.panes[0].clone();
|
||||
let click = || {
|
||||
RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: pane.inner_rect.x + 1,
|
||||
row: pane.inner_rect.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})
|
||||
};
|
||||
let release = || {
|
||||
RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
column: pane.inner_rect.x + 1,
|
||||
row: pane.inner_rect.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})
|
||||
};
|
||||
|
||||
state.handle_raw_events(vec![click()]);
|
||||
state.handle_raw_events(vec![release()]);
|
||||
let second = state.handle_raw_events(vec![click()]);
|
||||
let ClientShellAction::Endpoint { request, .. } = second
|
||||
.actions
|
||||
.iter()
|
||||
.find(|action| {
|
||||
matches!(
|
||||
action,
|
||||
ClientShellAction::Endpoint { request, .. }
|
||||
if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_))
|
||||
)
|
||||
})
|
||||
.expect("word-row read")
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let word_request_id = request.id.clone();
|
||||
assert!(matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::PaneSelectionRead(params)
|
||||
if params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 0 }
|
||||
&& params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 3 }
|
||||
));
|
||||
|
||||
let (repaint, actions) = state.handle_endpoint_result(
|
||||
"boot-1",
|
||||
&word_request_id,
|
||||
Ok(crate::api::schema::ResponseResult::PaneSelection {
|
||||
pane_id: "pane_1".into(),
|
||||
text: "LIVE".into(),
|
||||
}),
|
||||
);
|
||||
assert!(repaint);
|
||||
assert!(state
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_some_and(crate::selection::Selection::is_finalized));
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &actions[..] else {
|
||||
panic!("auto-copy should read the selected word");
|
||||
};
|
||||
let copy_request_id = request.id.clone();
|
||||
assert!(matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::PaneSelectionRead(params)
|
||||
if params.anchor.col == 0 && params.cursor.col == 3
|
||||
));
|
||||
let (_, actions) = state.handle_endpoint_result(
|
||||
"boot-1",
|
||||
©_request_id,
|
||||
Ok(crate::api::schema::ResponseResult::PaneSelection {
|
||||
pane_id: "pane_1".into(),
|
||||
text: "LIVE".into(),
|
||||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
&actions[..],
|
||||
[ClientShellAction::ClipboardWrite(bytes)] if bytes == b"LIVE"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_mouse_input_keeps_stable_target_and_endpoint_encoding() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
let mut pane_surface = surface();
|
||||
pane_surface.panes[0].mouse_reporting = true;
|
||||
state.set_pane_surface(pane_surface);
|
||||
state.compose(106, 20).expect("composed frame");
|
||||
let pane = state.hits.panes[0].clone();
|
||||
|
||||
let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: pane.inner_rect.x + 2,
|
||||
row: pane.inner_rect.y + 1,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
})]);
|
||||
let [ClientMessage::ClientShellPaneInput { pane_id, events }] = &click.requests[..] else {
|
||||
panic!("pane application click should use targeted canonical input");
|
||||
};
|
||||
assert_eq!(pane_id, "pane_1");
|
||||
assert!(matches!(
|
||||
&events[..],
|
||||
[ClientPaneInputEvent::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::Down(
|
||||
crate::protocol::ClientMouseButton::Left
|
||||
),
|
||||
position: ClientMousePosition::Cell { column: 2, row: 1 },
|
||||
modifiers,
|
||||
..
|
||||
}] if *modifiers == KeyModifiers::ALT.bits()
|
||||
));
|
||||
let moved = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Moved,
|
||||
column: 0,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
})]);
|
||||
assert!(moved.requests.is_empty());
|
||||
assert!(state.pane_mouse_gesture.is_some());
|
||||
state.hits.panes.clear();
|
||||
let release =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
column: 0,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
})]);
|
||||
assert!(matches!(
|
||||
&release.requests[..],
|
||||
[ClientMessage::ClientShellPaneInput { pane_id, events }]
|
||||
if pane_id == "pane_1"
|
||||
&& matches!(
|
||||
&events[..],
|
||||
[ClientPaneInputEvent::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::Up(
|
||||
crate::protocol::ClientMouseButton::Left
|
||||
),
|
||||
..
|
||||
}]
|
||||
)
|
||||
));
|
||||
assert!(state.pane_mouse_gesture.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_pixel_mouse_preserves_pane_relative_pixel_coordinates() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
let mut pane_surface = surface();
|
||||
pane_surface.panes[0].mouse_reporting = true;
|
||||
pane_surface.panes[0].sgr_pixel_mouse = true;
|
||||
pane_surface.panes[0].pixel_width = 39;
|
||||
pane_surface.panes[0].pixel_height = 38;
|
||||
state.set_pane_surface(pane_surface);
|
||||
state.compose(106, 20).expect("composed frame");
|
||||
let pane = state.hits.panes[0].clone();
|
||||
let geometry =
|
||||
crate::input::mouse::HostGeometry::new(106, 20, 1060, 400).expect("host geometry");
|
||||
let x = u32::from(pane.inner_rect.x) * 10 + 21;
|
||||
let y = u32::from(pane.inner_rect.y) * 20 + 21;
|
||||
let report = format!("\x1b[<0;{x};{y}M");
|
||||
|
||||
let outcome = state.handle_pixel_mouse(report.as_bytes(), geometry);
|
||||
assert!(matches!(
|
||||
&outcome.requests[..],
|
||||
[ClientMessage::ClientShellPaneInput { pane_id, events }]
|
||||
if pane_id == "pane_1"
|
||||
&& matches!(
|
||||
&events[..],
|
||||
[ClientPaneInputEvent::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::Down(
|
||||
crate::protocol::ClientMouseButton::Left
|
||||
),
|
||||
position: ClientMousePosition::Pixels { x: 20, y: 20, .. },
|
||||
..
|
||||
}]
|
||||
)
|
||||
));
|
||||
|
||||
let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]);
|
||||
assert!(matches!(
|
||||
&lost.requests[..],
|
||||
[
|
||||
ClientMessage::ClientShellPaneInput { pane_id, events },
|
||||
ClientMessage::ClientShellFocus { focused: false }
|
||||
] if pane_id == "pane_1" && matches!(
|
||||
&events[..],
|
||||
[ClientPaneInputEvent::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::Up(
|
||||
crate::protocol::ClientMouseButton::Left
|
||||
),
|
||||
position: ClientMousePosition::Pixels { x: 20, y: 20, .. },
|
||||
..
|
||||
}]
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_owned_right_click_forwards_the_complete_gesture() {
|
||||
let mut snapshot = snapshot();
|
||||
snapshot.panes[0].right_click_passthrough = true;
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot));
|
||||
let mut pane_surface = surface();
|
||||
pane_surface.panes[0].mouse_reporting = true;
|
||||
state.set_pane_surface(pane_surface);
|
||||
state.compose(106, 20).expect("composed frame");
|
||||
let pane = state.hits.panes[0].clone();
|
||||
|
||||
let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Right),
|
||||
column: pane.inner_rect.x + 1,
|
||||
row: pane.inner_rect.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(matches!(
|
||||
&down.requests[..],
|
||||
[ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1"
|
||||
));
|
||||
assert!(state.overlay.is_none());
|
||||
assert!(state.pane_mouse_gesture.is_some());
|
||||
|
||||
let up = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Right),
|
||||
column: 0,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(matches!(
|
||||
&up.requests[..],
|
||||
[ClientMessage::ClientShellPaneInput { pane_id, events }]
|
||||
if pane_id == "pane_1"
|
||||
&& matches!(
|
||||
&events[..],
|
||||
[ClientPaneInputEvent::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::Up(
|
||||
crate::protocol::ClientMouseButton::Right
|
||||
),
|
||||
..
|
||||
}]
|
||||
)
|
||||
));
|
||||
assert!(state.pane_mouse_gesture.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_click_waits_for_release_and_drag_reorders_by_stable_id() {
|
||||
let mut projected = snapshot();
|
||||
for index in 2..=3 {
|
||||
let mut tab = projected.tabs[0].clone();
|
||||
tab.tab_id = format!("tab_{index}");
|
||||
tab.number = index;
|
||||
tab.label = index.to_string();
|
||||
tab.focused = false;
|
||||
projected.tabs.push(tab);
|
||||
}
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 20).expect("three tabs");
|
||||
let first = state.hits.tabs[0].0;
|
||||
let third = state.hits.tabs[2].0;
|
||||
|
||||
let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: first.x + 1,
|
||||
row: first.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(down.actions.is_empty());
|
||||
let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: third.right().saturating_sub(1),
|
||||
row: third.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(drag.repaint);
|
||||
assert!(matches!(
|
||||
state.chrome_drag,
|
||||
Some(ClientChromeDrag::Tab {
|
||||
ref tab_id,
|
||||
insert_index: Some(3),
|
||||
..
|
||||
}) if tab_id == "tab_1"
|
||||
));
|
||||
let frame = state.compose(106, 20).expect("tab drop indicator");
|
||||
assert!(frame
|
||||
.cells
|
||||
.iter()
|
||||
.take(frame.width as usize)
|
||||
.any(|cell| cell.symbol == "│"));
|
||||
|
||||
let release =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
column: third.right().saturating_sub(1),
|
||||
row: third.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &release.actions[..] else {
|
||||
panic!("tab drag should use endpoint API");
|
||||
};
|
||||
assert!(matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::TabMove(params)
|
||||
if params.tab_id == "tab_1" && params.insert_index == 3
|
||||
));
|
||||
|
||||
state.compose(106, 20).expect("tabs after drag");
|
||||
let second = state.hits.tabs[1].0;
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: second.x + 1,
|
||||
row: second.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
column: second.x + 1,
|
||||
row: second.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(matches!(
|
||||
&click.actions[0],
|
||||
ClientShellAction::Endpoint { request, .. }
|
||||
if matches!(&request.method, crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_2")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_drag_clears_its_drop_target_after_leaving_the_tab_row() {
|
||||
let mut projected = snapshot();
|
||||
for index in 2..=3 {
|
||||
let mut tab = projected.tabs[0].clone();
|
||||
tab.tab_id = format!("tab_{index}");
|
||||
tab.number = index;
|
||||
tab.label = index.to_string();
|
||||
tab.focused = false;
|
||||
projected.tabs.push(tab);
|
||||
}
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 20).expect("three tabs");
|
||||
let first = state.hits.tabs[0].0;
|
||||
let third = state.hits.tabs[2].0;
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: first.x + 1,
|
||||
row: first.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: third.x,
|
||||
row: third.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: third.x,
|
||||
row: third.y + 1,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(matches!(
|
||||
state.chrome_drag,
|
||||
Some(ClientChromeDrag::Tab {
|
||||
insert_index: None,
|
||||
..
|
||||
})
|
||||
));
|
||||
let release =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
column: third.x,
|
||||
row: third.y + 1,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(release.actions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_wheel_switches_tabs_without_changing_overflow_scroll() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 20).expect("tab bar");
|
||||
let tab = state.hits.tabs[0].0;
|
||||
|
||||
let outcome =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::ScrollDown,
|
||||
column: tab.x,
|
||||
row: tab.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(matches!(
|
||||
&outcome.actions[..],
|
||||
[ClientShellAction::Endpoint { request, .. }]
|
||||
if matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_1"
|
||||
)
|
||||
));
|
||||
assert_eq!(state.tab_scroll, 0);
|
||||
state.compose(106, 20).expect("tab bar after wheel");
|
||||
assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_menu_keyboard_and_outside_click_are_client_owned() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.compose(106, 20).expect("composed frame");
|
||||
let tab = state.hits.tabs[0].0;
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Right),
|
||||
column: tab.x + 1,
|
||||
row: tab.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
state.compose(106, 20).expect("tab context menu");
|
||||
let moved = state.handle_input_bytes(b"\x1b[B");
|
||||
assert!(moved.repaint);
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay {
|
||||
highlighted: 1,
|
||||
..
|
||||
}))
|
||||
));
|
||||
let text = state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new(
|
||||
"not pane input",
|
||||
))]);
|
||||
assert!(text.requests.is_empty());
|
||||
let paste = state.handle_raw_events(vec![RawInputEvent::Paste("not pane input".into())]);
|
||||
assert!(paste.requests.is_empty());
|
||||
let outside =
|
||||
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 105,
|
||||
row: 19,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(outside.repaint);
|
||||
assert!(state.overlay.is_none());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
||||
use super::*;
|
||||
|
||||
impl ClientShellState {
|
||||
fn endpoint_worktree_directory(&self) -> Option<std::path::PathBuf> {
|
||||
self.snapshot
|
||||
.as_deref()
|
||||
.map(|snapshot| std::path::PathBuf::from(&snapshot.worktree_directory))
|
||||
}
|
||||
|
||||
pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool {
|
||||
match self.overlay.as_mut() {
|
||||
Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating => {
|
||||
@@ -229,11 +235,14 @@ impl ClientShellState {
|
||||
}
|
||||
|
||||
pub(super) fn sync_worktree_create_path(&mut self) {
|
||||
let Some(worktree_directory) = self.endpoint_worktree_directory() else {
|
||||
return;
|
||||
};
|
||||
let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else {
|
||||
return;
|
||||
};
|
||||
create.checkout_path = crate::worktree::default_checkout_path(
|
||||
&self.config.worktree_directory,
|
||||
&worktree_directory,
|
||||
&create.repo_name,
|
||||
&create.branch,
|
||||
)
|
||||
@@ -243,6 +252,9 @@ impl ClientShellState {
|
||||
}
|
||||
|
||||
pub(super) fn submit_worktree_create(&mut self, outcome: &mut ClientShellInput) {
|
||||
let Some(worktree_directory) = self.endpoint_worktree_directory() else {
|
||||
return;
|
||||
};
|
||||
let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -257,24 +269,20 @@ impl ClientShellState {
|
||||
}
|
||||
create.branch = branch.clone();
|
||||
create.replace_on_type = false;
|
||||
create.checkout_path = crate::worktree::default_checkout_path(
|
||||
&self.config.worktree_directory,
|
||||
&create.repo_name,
|
||||
&branch,
|
||||
)
|
||||
.display()
|
||||
.to_string();
|
||||
create.checkout_path =
|
||||
crate::worktree::default_checkout_path(&worktree_directory, &create.repo_name, &branch)
|
||||
.display()
|
||||
.to_string();
|
||||
create.creating = true;
|
||||
create.error = None;
|
||||
let workspace_id = create.source_workspace_id.clone();
|
||||
let path = create.checkout_path.clone();
|
||||
self.push_endpoint_method_with_kind(
|
||||
crate::api::schema::Method::WorktreeCreate(crate::api::schema::WorktreeCreateParams {
|
||||
workspace_id: Some(workspace_id),
|
||||
cwd: None,
|
||||
branch: Some(branch),
|
||||
base: Some("HEAD".to_owned()),
|
||||
path: Some(path),
|
||||
path: None,
|
||||
label: None,
|
||||
focus: true,
|
||||
trust_repository: false,
|
||||
@@ -376,8 +384,11 @@ impl ClientShellState {
|
||||
.map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64)
|
||||
.unwrap_or(0);
|
||||
let branch = crate::worktree::generated_branch_slug(seed);
|
||||
let Some(worktree_directory) = self.endpoint_worktree_directory() else {
|
||||
return false;
|
||||
};
|
||||
let checkout_path = crate::worktree::default_checkout_path(
|
||||
&self.config.worktree_directory,
|
||||
&worktree_directory,
|
||||
&source.repo_name,
|
||||
&branch,
|
||||
)
|
||||
@@ -518,9 +529,10 @@ impl ClientShellState {
|
||||
| PendingEndpointKind::ReloadConfig
|
||||
| PendingEndpointKind::IntegrationList
|
||||
| PendingEndpointKind::IntegrationInstall
|
||||
| PendingEndpointKind::SelectionCopy
|
||||
| PendingEndpointKind::SelectionCopy { .. }
|
||||
| PendingEndpointKind::PaneScroll { .. }
|
||||
| PendingEndpointKind::WordSelection { .. }
|
||||
| PendingEndpointKind::PaneLinkActivate { .. }
|
||||
| PendingEndpointKind::CopyMotion { .. }
|
||||
| PendingEndpointKind::CopySearch { .. },
|
||||
Err(error),
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
use std::io;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
use tracing::debug;
|
||||
|
||||
use super::ClientLoopEvent;
|
||||
|
||||
const DEFAULT_CELL_WIDTH_PX: u32 = 8;
|
||||
const DEFAULT_CELL_HEIGHT_PX: u32 = 16;
|
||||
|
||||
/// Average cell size derived from a terminal ioctl pixel extent.
|
||||
///
|
||||
/// The extent need not divide evenly by the grid: terminals may include padding,
|
||||
/// and pixel mouse coordinates retain the raw extent for proportional mapping.
|
||||
pub(super) fn ioctl_cell_size(
|
||||
columns: u16,
|
||||
rows: u16,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
) -> Option<(u32, u32)> {
|
||||
if columns == 0 || rows == 0 || width_px == 0 || height_px == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
(width_px / u32::from(columns)).max(1),
|
||||
(height_px / u32::from(rows)).max(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn ioctl_terminal_geometry() -> Option<(u16, u16, u32, u32)> {
|
||||
let size = crossterm::terminal::window_size().ok()?;
|
||||
let (cell_width_px, cell_height_px) = ioctl_cell_size(
|
||||
size.columns,
|
||||
size.rows,
|
||||
u32::from(size.width),
|
||||
u32::from(size.height),
|
||||
)?;
|
||||
Some((size.columns, size.rows, cell_width_px, cell_height_px))
|
||||
}
|
||||
|
||||
pub(super) fn cell_size_fallback(reported: u64, last: Option<(u32, u32)>) -> (u32, u32) {
|
||||
unpack_cell_size(reported)
|
||||
.or(last.filter(|(width, height)| *width > 0 && *height > 0))
|
||||
.unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX))
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
pub(super) fn pack_cell_size(width_px: u32, height_px: u32) -> u64 {
|
||||
(u64::from(width_px) << 32) | u64::from(height_px)
|
||||
}
|
||||
|
||||
fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> {
|
||||
let width_px = (packed >> 32) as u32;
|
||||
let height_px = (packed & u64::from(u32::MAX)) as u32;
|
||||
(width_px > 0 && height_px > 0).then_some((width_px, height_px))
|
||||
}
|
||||
|
||||
fn current_terminal_geometry(
|
||||
pixel_geometry_enabled: bool,
|
||||
pixel_geometry_fallback: bool,
|
||||
reported_cell_size: &AtomicU64,
|
||||
last_cell_size: Option<(u32, u32)>,
|
||||
) -> (u16, u16, u32, u32, bool) {
|
||||
if !pixel_geometry_enabled {
|
||||
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
|
||||
return (cols, rows, 0, 0, false);
|
||||
}
|
||||
if let Some((cols, rows, cell_width_px, cell_height_px)) = ioctl_terminal_geometry() {
|
||||
return (cols, rows, cell_width_px, cell_height_px, true);
|
||||
}
|
||||
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
|
||||
if !pixel_geometry_fallback {
|
||||
return (cols, rows, 0, 0, false);
|
||||
}
|
||||
let (cell_width_px, cell_height_px) =
|
||||
cell_size_fallback(reported_cell_size.load(Ordering::Acquire), last_cell_size);
|
||||
(cols, rows, cell_width_px, cell_height_px, false)
|
||||
}
|
||||
|
||||
/// Reads terminal geometry before the handshake. Pixel input and direct graphics
|
||||
/// are eligible only when one ioctl supplied a coherent exact geometry snapshot.
|
||||
pub(super) fn initial_terminal_geometry(
|
||||
pixel_geometry_enabled: bool,
|
||||
pixel_geometry_fallback: bool,
|
||||
) -> (u16, u16, u32, u32, bool) {
|
||||
if !pixel_geometry_enabled {
|
||||
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
|
||||
return (cols, rows, 0, 0, false);
|
||||
}
|
||||
match ioctl_terminal_geometry() {
|
||||
Some((cols, rows, width, height)) => (cols, rows, width, height, true),
|
||||
None => {
|
||||
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
|
||||
if pixel_geometry_fallback {
|
||||
(
|
||||
cols,
|
||||
rows,
|
||||
DEFAULT_CELL_WIDTH_PX,
|
||||
DEFAULT_CELL_HEIGHT_PX,
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
(cols, rows, 0, 0, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resize_report_required(
|
||||
signalled: bool,
|
||||
new_size: (u16, u16, u32, u32, bool),
|
||||
last_size: (u16, u16, u32, u32, bool),
|
||||
) -> bool {
|
||||
signalled || new_size != last_size
|
||||
}
|
||||
|
||||
/// Watches the terminal size and sends resize events when it changes.
|
||||
///
|
||||
/// The baseline cell size must match what the handshake sent to the server:
|
||||
/// reading a fresh one here would race the host cell size reply and could
|
||||
/// swallow the first change.
|
||||
#[allow(clippy::too_many_arguments)] // The arguments are one immutable launch snapshot, not shared state.
|
||||
pub(super) fn resize_poll_loop(
|
||||
resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
|
||||
initial_cols: u16,
|
||||
initial_rows: u16,
|
||||
initial_cell_width: u32,
|
||||
initial_cell_height: u32,
|
||||
initial_pixel_geometry_exact: bool,
|
||||
pixel_geometry_enabled: bool,
|
||||
pixel_geometry_fallback: bool,
|
||||
reported_cell_size: &AtomicU64,
|
||||
should_quit: &Arc<AtomicBool>,
|
||||
) {
|
||||
crate::platform::watch_terminal_resize_signal();
|
||||
let mut last_size = (
|
||||
initial_cols,
|
||||
initial_rows,
|
||||
initial_cell_width,
|
||||
initial_cell_height,
|
||||
initial_pixel_geometry_exact,
|
||||
);
|
||||
while !should_quit.load(Ordering::Acquire) {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let signalled = crate::platform::take_terminal_resize_signal();
|
||||
let new_size = current_terminal_geometry(
|
||||
pixel_geometry_enabled,
|
||||
pixel_geometry_fallback,
|
||||
reported_cell_size,
|
||||
Some((last_size.2, last_size.3)),
|
||||
);
|
||||
if resize_report_required(signalled, new_size, last_size) {
|
||||
last_size = new_size;
|
||||
if resize_tx
|
||||
.blocking_send(ClientLoopEvent::Resize(
|
||||
new_size.0, new_size.1, new_size.2, new_size.3, new_size.4,
|
||||
))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(not(windows), test))]
|
||||
pub(super) fn query_host_terminal_appearance() {
|
||||
let _ = write_host_terminal_appearance_query(io::stdout());
|
||||
}
|
||||
|
||||
#[cfg(any(not(windows), test))]
|
||||
pub(super) fn write_host_terminal_appearance_query(mut writer: impl io::Write) -> io::Result<()> {
|
||||
writer.write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
pub(super) fn query_host_terminal_theme() {
|
||||
let _ = write_host_terminal_theme_query(io::stdout());
|
||||
}
|
||||
|
||||
pub(super) fn should_query_host_terminal_theme() -> bool {
|
||||
!cfg!(windows)
|
||||
}
|
||||
|
||||
pub(super) fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> {
|
||||
let query = crate::terminal_theme::host_terminal_theme_query_sequence(
|
||||
crate::platform::should_query_host_terminal_palette(),
|
||||
);
|
||||
writer.write_all(query.as_bytes())?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t";
|
||||
|
||||
pub(super) fn query_host_cell_size() {
|
||||
let _ = write_host_cell_size_query(io::stdout());
|
||||
}
|
||||
|
||||
pub(super) fn should_query_host_cell_size() -> bool {
|
||||
!cfg!(windows)
|
||||
}
|
||||
|
||||
pub(super) fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool {
|
||||
kitty_graphics_enabled && should_query_host_cell_size() && ioctl_terminal_geometry().is_none()
|
||||
}
|
||||
|
||||
pub(super) fn write_host_cell_size_query(mut writer: impl io::Write) -> io::Result<()> {
|
||||
writer.write_all(HOST_CELL_SIZE_QUERY)?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
pub(super) fn store_reported_cell_size(
|
||||
reported_cell_size: &AtomicU64,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
) {
|
||||
let packed = pack_cell_size(width_px, height_px);
|
||||
if reported_cell_size.swap(packed, Ordering::AcqRel) != packed {
|
||||
debug!(width_px, height_px, "host terminal reported cell size");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
pub(super) fn reported_cell_size_from_events(
|
||||
events: &[crate::raw_input::RawInputEvent],
|
||||
) -> Option<(u32, u32)> {
|
||||
events.iter().rev().find_map(|event| match event {
|
||||
crate::raw_input::RawInputEvent::HostCellSizeReport {
|
||||
width_px,
|
||||
height_px,
|
||||
} => Some((*width_px, *height_px)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use std::io::{self, BufRead, Write as _};
|
||||
|
||||
use base64::Engine;
|
||||
use interprocess::local_socket::traits::Stream as _;
|
||||
use interprocess::TryClone as _;
|
||||
use tracing::info;
|
||||
|
||||
use crate::ipc::LocalStream;
|
||||
use crate::protocol::{
|
||||
self, AttachScrollDirection, AttachScrollSource, ClientMessage, RenderEncoding, ServerMessage,
|
||||
MAX_GRAPHICS_FRAME_SIZE,
|
||||
};
|
||||
use crate::server::socket_paths::client_socket_path;
|
||||
|
||||
use super::{do_handshake, init_logging, write_to_server, ClientError};
|
||||
|
||||
/// Runs a read-only terminal session observer and prints one JSON envelope per frame.
|
||||
pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> {
|
||||
let mut stream =
|
||||
connect_terminal_session_stream(target.clone(), cols, rows, "observing terminal session")?;
|
||||
write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?;
|
||||
write_terminal_session_output(stream)
|
||||
}
|
||||
|
||||
/// Runs a writable terminal session controller.
|
||||
pub fn run_terminal_session_control(
|
||||
target: String,
|
||||
takeover: bool,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) -> io::Result<()> {
|
||||
let mut stream = connect_terminal_session_stream(
|
||||
target.clone(),
|
||||
cols,
|
||||
rows,
|
||||
"controlling terminal session",
|
||||
)?;
|
||||
write_to_server(
|
||||
&mut stream,
|
||||
&ClientMessage::ControlTerminal { target, takeover },
|
||||
)?;
|
||||
|
||||
let mut write_stream = stream.try_clone()?;
|
||||
let _input_thread = std::thread::spawn(move || {
|
||||
let stdin = io::stdin();
|
||||
for line in stdin.lock().lines() {
|
||||
let Ok(line) = line else {
|
||||
break;
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
match terminal_control_command_from_json(&line) {
|
||||
Ok(message) => {
|
||||
let release = matches!(message, ClientMessage::Detach);
|
||||
if write_to_server(&mut write_stream, &message).is_err() {
|
||||
return;
|
||||
}
|
||||
if release {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(err) => eprintln!("herdr: terminal session control input ignored: {err}"),
|
||||
}
|
||||
}
|
||||
let _ = write_to_server(&mut write_stream, &ClientMessage::Detach);
|
||||
});
|
||||
|
||||
write_terminal_session_output(stream)
|
||||
}
|
||||
|
||||
fn connect_terminal_session_stream(
|
||||
target: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
log_message: &'static str,
|
||||
) -> io::Result<LocalStream> {
|
||||
init_logging();
|
||||
|
||||
let socket_path = client_socket_path();
|
||||
crate::logging::startup("client");
|
||||
info!(path = %socket_path.display(), target = %target, cols, rows, "{log_message}");
|
||||
|
||||
let mut stream = match crate::ipc::connect_local_stream(&socket_path) {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => {
|
||||
eprintln!("herdr: {}", ClientError::ConnectionFailed(err));
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match do_handshake(&mut stream, cols, rows, 0, 0, false, None, false, false) {
|
||||
Ok(RenderEncoding::TerminalAnsi) => {}
|
||||
Ok(encoding) => {
|
||||
eprintln!(
|
||||
"herdr: terminal session observe negotiated unsupported encoding {encoding:?}"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("herdr: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
stream.set_nonblocking(false)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn write_terminal_session_output(mut stream: LocalStream) -> io::Result<()> {
|
||||
let mut stdout = io::stdout().lock();
|
||||
loop {
|
||||
match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) {
|
||||
Ok(ServerMessage::Terminal(frame)) => {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&frame.bytes);
|
||||
let line = serde_json::json!({
|
||||
"type": "terminal.frame",
|
||||
"seq": frame.seq,
|
||||
"encoding": "ansi",
|
||||
"width": frame.width,
|
||||
"height": frame.height,
|
||||
"full": frame.full,
|
||||
"bytes": encoded,
|
||||
});
|
||||
serde_json::to_writer(&mut stdout, &line)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
stdout.flush()?;
|
||||
}
|
||||
Ok(ServerMessage::ServerShutdown { reason }) => {
|
||||
let line = serde_json::json!({
|
||||
"type": "terminal.closed",
|
||||
"reason": reason,
|
||||
});
|
||||
serde_json::to_writer(&mut stdout, &line)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
stdout.flush()?;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(ServerMessage::Graphics { .. }) => {}
|
||||
Ok(_) => {}
|
||||
Err(protocol::FramingError::UnexpectedEof) => return Ok(()),
|
||||
Err(err) => return Err(io::Error::other(err.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum TerminalControlCommand {
|
||||
#[serde(rename = "terminal.input")]
|
||||
Input {
|
||||
text: Option<String>,
|
||||
bytes: Option<String>,
|
||||
},
|
||||
#[serde(rename = "terminal.resize")]
|
||||
Resize {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
#[serde(default)]
|
||||
cell_width_px: u32,
|
||||
#[serde(default)]
|
||||
cell_height_px: u32,
|
||||
},
|
||||
#[serde(rename = "terminal.scroll")]
|
||||
Scroll {
|
||||
direction: TerminalControlScrollDirection,
|
||||
lines: u16,
|
||||
#[serde(default)]
|
||||
source: TerminalControlScrollSource,
|
||||
#[serde(default)]
|
||||
column: Option<u16>,
|
||||
#[serde(default)]
|
||||
row: Option<u16>,
|
||||
#[serde(default)]
|
||||
modifiers: u8,
|
||||
},
|
||||
#[serde(rename = "terminal.release")]
|
||||
Release {},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TerminalControlScrollDirection {
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TerminalControlScrollSource {
|
||||
#[default]
|
||||
Wheel,
|
||||
PageKey,
|
||||
}
|
||||
|
||||
pub(super) fn terminal_control_command_from_json(raw: &str) -> Result<ClientMessage, String> {
|
||||
let command = serde_json::from_str::<TerminalControlCommand>(raw)
|
||||
.map_err(|err| format!("invalid json command: {err}"))?;
|
||||
match command {
|
||||
TerminalControlCommand::Input { text, bytes } => {
|
||||
let data = match (text, bytes) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err("terminal.input accepts text or bytes, not both".into())
|
||||
}
|
||||
(Some(text), None) => text.into_bytes(),
|
||||
(None, Some(bytes)) => base64::engine::general_purpose::STANDARD
|
||||
.decode(bytes)
|
||||
.map_err(|err| format!("invalid terminal.input bytes: {err}"))?,
|
||||
(None, None) => Vec::new(),
|
||||
};
|
||||
Ok(ClientMessage::Input { data })
|
||||
}
|
||||
TerminalControlCommand::Resize {
|
||||
cols,
|
||||
rows,
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
} => {
|
||||
if cols == 0 || rows == 0 {
|
||||
return Err("terminal.resize cols and rows must be greater than 0".into());
|
||||
}
|
||||
Ok(ClientMessage::Resize {
|
||||
cols,
|
||||
rows,
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
pixel_mouse: false,
|
||||
})
|
||||
}
|
||||
TerminalControlCommand::Scroll {
|
||||
direction,
|
||||
lines,
|
||||
source,
|
||||
column,
|
||||
row,
|
||||
modifiers,
|
||||
} => {
|
||||
if lines == 0 {
|
||||
return Err("terminal.scroll lines must be greater than 0".into());
|
||||
}
|
||||
let direction = match direction {
|
||||
TerminalControlScrollDirection::Up => AttachScrollDirection::Up,
|
||||
TerminalControlScrollDirection::Down => AttachScrollDirection::Down,
|
||||
};
|
||||
let source = match source {
|
||||
TerminalControlScrollSource::Wheel => AttachScrollSource::Wheel,
|
||||
TerminalControlScrollSource::PageKey => AttachScrollSource::PageKey {
|
||||
input: match direction {
|
||||
AttachScrollDirection::Up => b"\x1b[5~".to_vec(),
|
||||
AttachScrollDirection::Down => b"\x1b[6~".to_vec(),
|
||||
},
|
||||
},
|
||||
};
|
||||
Ok(ClientMessage::AttachScroll {
|
||||
source,
|
||||
direction,
|
||||
lines,
|
||||
column,
|
||||
row,
|
||||
modifiers,
|
||||
})
|
||||
}
|
||||
TerminalControlCommand::Release {} => Ok(ClientMessage::Detach),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Terminal setup and restoration for the rendered client.
|
||||
|
||||
use std::io::{self, Write as _};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::event::{
|
||||
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
|
||||
EnableFocusChange, EnableMouseCapture,
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{DisableLineWrap, EnableLineWrap};
|
||||
|
||||
use super::frame_output::clear_received_kitty_graphics;
|
||||
use super::terminal_geometry::should_query_host_terminal_theme;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal setup / restore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements).
|
||||
///
|
||||
/// Returns a guard that restores the terminal when dropped.
|
||||
pub(super) fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> {
|
||||
setup_terminal_with_capabilities(true, mouse_capture)
|
||||
}
|
||||
|
||||
/// Sets up a direct attach terminal.
|
||||
///
|
||||
/// Direct attach forwards stdin to the attached PTY. When configured, mouse
|
||||
/// capture lets wheel events drive the attached viewport or reach child
|
||||
/// programs that requested mouse input.
|
||||
pub(super) fn setup_direct_attach_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> {
|
||||
setup_terminal_with_capabilities(false, mouse_capture)
|
||||
}
|
||||
|
||||
pub(super) fn setup_terminal_with_capabilities(
|
||||
enable_client_protocols: bool,
|
||||
mouse_capture: bool,
|
||||
) -> io::Result<TerminalGuard> {
|
||||
ratatui::init();
|
||||
crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
|
||||
let host_color_scheme_reports =
|
||||
should_enable_host_color_scheme_reports(enable_client_protocols);
|
||||
|
||||
#[cfg(windows)]
|
||||
let windows_ssh_session = is_ssh_session();
|
||||
#[cfg(windows)]
|
||||
let mut windows_virtual_terminal_input =
|
||||
if windows_vti_input_backend_enabled() && windows_ssh_session {
|
||||
enable_windows_virtual_terminal_input()
|
||||
} else {
|
||||
WindowsVirtualTerminalInputSetup::default()
|
||||
};
|
||||
|
||||
if enable_client_protocols {
|
||||
set_mouse_capture(mouse_capture, false)?;
|
||||
execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?;
|
||||
if host_color_scheme_reports {
|
||||
write_host_color_scheme_report_mode(&mut io::stdout(), true)?;
|
||||
}
|
||||
push_keyboard_enhancement_flags()?;
|
||||
} else {
|
||||
if should_query_host_terminal_theme() {
|
||||
write_host_color_scheme_report_mode(&mut io::stdout(), false)?;
|
||||
}
|
||||
set_mouse_capture(mouse_capture, false)?;
|
||||
execute!(io::stdout(), EnableBracketedPaste)?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session {
|
||||
windows_virtual_terminal_input = enable_windows_virtual_terminal_input();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
if enable_client_protocols
|
||||
&& windows_vti_input_backend_enabled()
|
||||
&& windows_virtual_terminal_input.active
|
||||
&& windows_win32_input_mode_enabled()
|
||||
{
|
||||
if let Err(err) = enable_windows_win32_input_mode(&mut io::stdout()) {
|
||||
if let Some(mode) = windows_virtual_terminal_input.restore_mode {
|
||||
restore_windows_input_mode_value(mode);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
let modify_other_keys_mode = enable_client_protocols
|
||||
.then(crate::input::host_modify_other_keys_mode)
|
||||
.flatten();
|
||||
if let Some(mode) = modify_other_keys_mode {
|
||||
io::stdout().write_all(mode.set_sequence())?;
|
||||
io::stdout().flush()?;
|
||||
}
|
||||
|
||||
execute!(io::stdout(), DisableLineWrap)?;
|
||||
|
||||
Ok(TerminalGuard {
|
||||
reset_keyboard_enhancements: enable_client_protocols,
|
||||
reset_modify_other_keys: modify_other_keys_mode.is_some(),
|
||||
reset_host_color_scheme_reports: host_color_scheme_reports,
|
||||
restore_claimed: Arc::new(AtomicBool::new(false)),
|
||||
restored: false,
|
||||
#[cfg(windows)]
|
||||
restore_windows_input_mode: windows_virtual_terminal_input.restore_mode,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool {
|
||||
enable_client_protocols && should_query_host_terminal_theme()
|
||||
}
|
||||
|
||||
/// Guard that restores the terminal when dropped.
|
||||
pub(super) struct TerminalGuard {
|
||||
reset_keyboard_enhancements: bool,
|
||||
reset_modify_other_keys: bool,
|
||||
reset_host_color_scheme_reports: bool,
|
||||
restore_claimed: Arc<AtomicBool>,
|
||||
restored: bool,
|
||||
#[cfg(windows)]
|
||||
restore_windows_input_mode: Option<u32>,
|
||||
}
|
||||
|
||||
pub(super) fn write_host_color_scheme_report_mode(
|
||||
writer: &mut impl io::Write,
|
||||
enabled: bool,
|
||||
) -> io::Result<()> {
|
||||
let sequence = if enabled {
|
||||
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE
|
||||
} else {
|
||||
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE
|
||||
};
|
||||
writer.write_all(sequence.as_bytes())?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
pub(super) fn write_terminal_restore_postlude(
|
||||
writer: &mut impl io::Write,
|
||||
reset_host_color_scheme_reports: bool,
|
||||
) -> io::Result<()> {
|
||||
if reset_host_color_scheme_reports {
|
||||
writer.write_all(
|
||||
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(),
|
||||
)?;
|
||||
}
|
||||
// Restore a visible cursor and reset DECSCUSR back to the terminal default.
|
||||
writer.write_all(b"\x1b[?25h\x1b[0 q")?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
pub(super) fn should_draw_host_cursor(mode: crate::config::HostCursorModeConfig) -> bool {
|
||||
match mode {
|
||||
crate::config::HostCursorModeConfig::Auto => {
|
||||
crate::platform::should_draw_host_cursor_by_default()
|
||||
}
|
||||
crate::config::HostCursorModeConfig::Native => false,
|
||||
crate::config::HostCursorModeConfig::Drawn => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[derive(Default)]
|
||||
pub(super) struct WindowsVirtualTerminalInputSetup {
|
||||
active: bool,
|
||||
restore_mode: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalInputSetup {
|
||||
use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Console::{
|
||||
GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT,
|
||||
STD_INPUT_HANDLE,
|
||||
};
|
||||
|
||||
let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
|
||||
if handle.is_null() || handle == INVALID_HANDLE_VALUE {
|
||||
tracing::warn!("failed to get Windows console input handle for VT input");
|
||||
return WindowsVirtualTerminalInputSetup::default();
|
||||
}
|
||||
|
||||
let mut mode = 0;
|
||||
if unsafe { GetConsoleMode(handle, &mut mode) } == 0 {
|
||||
tracing::warn!("failed to read Windows console input mode for VT input");
|
||||
return WindowsVirtualTerminalInputSetup::default();
|
||||
}
|
||||
|
||||
let desired = windows_virtual_terminal_input_mode(mode);
|
||||
if desired == mode {
|
||||
return WindowsVirtualTerminalInputSetup {
|
||||
active: true,
|
||||
restore_mode: None,
|
||||
};
|
||||
}
|
||||
|
||||
if unsafe { SetConsoleMode(handle, desired) } == 0 {
|
||||
tracing::warn!("failed to enable Windows virtual terminal input");
|
||||
return WindowsVirtualTerminalInputSetup::default();
|
||||
}
|
||||
|
||||
let mut applied = 0;
|
||||
if unsafe { GetConsoleMode(handle, &mut applied) } == 0 {
|
||||
tracing::warn!("failed to verify Windows virtual terminal input mode");
|
||||
let _ = unsafe { SetConsoleMode(handle, mode) };
|
||||
return WindowsVirtualTerminalInputSetup::default();
|
||||
}
|
||||
if applied & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 {
|
||||
tracing::warn!("Windows virtual terminal input bit did not stick");
|
||||
let _ = unsafe { SetConsoleMode(handle, mode) };
|
||||
return WindowsVirtualTerminalInputSetup::default();
|
||||
}
|
||||
|
||||
WindowsVirtualTerminalInputSetup {
|
||||
active: true,
|
||||
restore_mode: Some(mode),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_ssh_session() -> bool {
|
||||
std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn windows_vti_input_backend_enabled() -> bool {
|
||||
std::env::var("HERDR_WINDOWS_INPUT_BACKEND")
|
||||
.map(|backend| !backend.eq_ignore_ascii_case("crossterm"))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
pub(super) fn windows_virtual_terminal_input_mode(mode: u32) -> u32 {
|
||||
mode | 0x0200
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn restore_windows_input_mode_value(mode: u32) {
|
||||
use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Console::{GetStdHandle, SetConsoleMode, STD_INPUT_HANDLE};
|
||||
|
||||
let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
|
||||
if handle.is_null() || handle == INVALID_HANDLE_VALUE {
|
||||
return;
|
||||
}
|
||||
if unsafe { SetConsoleMode(handle, mode) } == 0 {
|
||||
tracing::warn!("failed to restore Windows console input mode");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn effective_mouse_capture(
|
||||
server_enabled: bool,
|
||||
direct_attach_preference: bool,
|
||||
) -> bool {
|
||||
server_enabled || direct_attach_preference
|
||||
}
|
||||
|
||||
pub(super) fn effective_sgr_pixel_mouse(
|
||||
enabled: bool,
|
||||
requested: bool,
|
||||
exact_geometry: bool,
|
||||
) -> bool {
|
||||
enabled && requested && exact_geometry
|
||||
}
|
||||
|
||||
pub(super) fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> {
|
||||
crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
|
||||
#[cfg(windows)]
|
||||
if is_ssh_session() && windows_vti_input_backend_enabled() {
|
||||
return crate::terminal_modes::set_windows_ssh_mouse_reporting(
|
||||
&mut io::stdout(),
|
||||
enabled,
|
||||
sgr_pixels,
|
||||
);
|
||||
}
|
||||
if enabled {
|
||||
execute!(io::stdout(), EnableMouseCapture)?;
|
||||
if sgr_pixels {
|
||||
io::stdout().write_all(b"\x1b[?1016h")?;
|
||||
io::stdout().flush()?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
match execute!(io::stdout(), DisableMouseCapture) {
|
||||
Ok(()) => Ok(()),
|
||||
#[cfg(windows)]
|
||||
Err(err) if err.to_string() == "Initial console modes not set" => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_terminal_state_once(
|
||||
restore_claimed: &AtomicBool,
|
||||
reset_keyboard_enhancements: bool,
|
||||
reset_modify_other_keys: bool,
|
||||
reset_host_color_scheme_reports: bool,
|
||||
#[cfg(windows)] restore_windows_input_mode: Option<u32>,
|
||||
) -> io::Result<()> {
|
||||
if restore_claimed.swap(true, Ordering::AcqRel) {
|
||||
return Ok(());
|
||||
}
|
||||
restore_terminal_state(
|
||||
reset_keyboard_enhancements,
|
||||
reset_modify_other_keys,
|
||||
reset_host_color_scheme_reports,
|
||||
#[cfg(windows)]
|
||||
restore_windows_input_mode,
|
||||
)
|
||||
}
|
||||
|
||||
fn restore_terminal_state(
|
||||
reset_keyboard_enhancements: bool,
|
||||
reset_modify_other_keys: bool,
|
||||
reset_host_color_scheme_reports: bool,
|
||||
#[cfg(windows)] restore_windows_input_mode: Option<u32>,
|
||||
) -> io::Result<()> {
|
||||
let _ = clear_received_kitty_graphics(&mut io::stdout());
|
||||
|
||||
// Reset modifyOtherKeys if we enabled it.
|
||||
if reset_modify_other_keys {
|
||||
let _ = io::stdout().write_all(b"\x1b[>4;0m");
|
||||
let _ = io::stdout().flush();
|
||||
}
|
||||
|
||||
if reset_keyboard_enhancements {
|
||||
let _ = pop_keyboard_enhancement_flags();
|
||||
}
|
||||
|
||||
let _ = execute!(
|
||||
io::stdout(),
|
||||
EnableLineWrap,
|
||||
DisableFocusChange,
|
||||
DisableBracketedPaste
|
||||
);
|
||||
let _ = set_mouse_capture(false, false);
|
||||
#[cfg(windows)]
|
||||
if let Some(mode) = restore_windows_input_mode {
|
||||
restore_windows_input_mode_value(mode);
|
||||
}
|
||||
|
||||
let restore_result = ratatui::try_restore();
|
||||
let postlude_result =
|
||||
write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports);
|
||||
|
||||
#[cfg(windows)]
|
||||
if windows_vti_input_backend_enabled() && windows_win32_input_mode_enabled() {
|
||||
let _ = disable_windows_win32_input_mode(&mut io::stdout());
|
||||
}
|
||||
|
||||
restore_result.and(postlude_result)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn push_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
execute!(
|
||||
io::stdout(),
|
||||
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn push_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn pop_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
execute!(io::stdout(), PopKeyboardEnhancementFlags)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn pop_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_win32_input_mode_enabled() -> bool {
|
||||
std::env::var("HERDR_WINDOWS_INPUT_PROBE")
|
||||
.map(|probe| probe.eq_ignore_ascii_case("win32"))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn enable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> {
|
||||
writer.write_all(b"\x1b[?9001h")?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> {
|
||||
writer.write_all(b"\x1b[?9001l")?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
impl TerminalGuard {
|
||||
/// Captures the restoration state for use by the process panic hook.
|
||||
pub(super) fn panic_restore(&self) -> impl Fn() + Send + Sync + 'static {
|
||||
let restore_claimed = self.restore_claimed.clone();
|
||||
let reset_keyboard_enhancements = self.reset_keyboard_enhancements;
|
||||
let reset_modify_other_keys = self.reset_modify_other_keys;
|
||||
let reset_host_color_scheme_reports = self.reset_host_color_scheme_reports;
|
||||
#[cfg(windows)]
|
||||
let restore_windows_input_mode = self.restore_windows_input_mode;
|
||||
move || {
|
||||
let _ = restore_terminal_state_once(
|
||||
&restore_claimed,
|
||||
reset_keyboard_enhancements,
|
||||
reset_modify_other_keys,
|
||||
reset_host_color_scheme_reports,
|
||||
#[cfg(windows)]
|
||||
restore_windows_input_mode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn restore(mut self) -> io::Result<()> {
|
||||
self.restored = true;
|
||||
restore_terminal_state_once(
|
||||
&self.restore_claimed,
|
||||
self.reset_keyboard_enhancements,
|
||||
self.reset_modify_other_keys,
|
||||
self.reset_host_color_scheme_reports,
|
||||
#[cfg(windows)]
|
||||
self.restore_windows_input_mode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.restored {
|
||||
let _ = restore_terminal_state_once(
|
||||
&self.restore_claimed,
|
||||
self.reset_keyboard_enhancements,
|
||||
self.reset_modify_other_keys,
|
||||
self.reset_host_color_scheme_reports,
|
||||
#[cfg(windows)]
|
||||
self.restore_windows_input_mode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,817 @@
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_signal_reports_even_when_polled_size_is_unchanged() {
|
||||
let size = (120, 40, 8, 16, true);
|
||||
assert!(resize_report_required(true, size, size));
|
||||
assert!(!resize_report_required(false, size, size));
|
||||
assert!(resize_report_required(false, (120, 41, 8, 16, true), size));
|
||||
assert!(resize_report_required(false, (120, 40, 9, 18, true), size));
|
||||
assert!(resize_report_required(false, (120, 40, 8, 16, false), size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_graphics_profile_is_narrow_and_transport_safe() {
|
||||
for (program, term, kitty, expected) in [
|
||||
("ghostty", "", false, true),
|
||||
("WezTerm", "", false, true),
|
||||
("", "xterm-kitty", false, true),
|
||||
("", "xterm-256color", true, true),
|
||||
("", "xterm-256color", false, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
direct_graphics_profile_values(program, term, kitty, false, true),
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert!(!direct_graphics_profile_values(
|
||||
"ghostty", "", false, true, true
|
||||
));
|
||||
assert!(!direct_graphics_profile_values(
|
||||
"ghostty", "", false, false, false
|
||||
));
|
||||
}
|
||||
|
||||
fn restore_env_var(key: &str, value: Option<OsString>) {
|
||||
if let Some(value) = value {
|
||||
std::env::set_var(key, value);
|
||||
} else {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &str) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
std::env::set_var(key, value);
|
||||
Self { key, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
restore_env_var(self.key, self.previous.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_virtual_terminal_input_mode_sets_only_vti_bit() {
|
||||
assert_eq!(windows_virtual_terminal_input_mode(0x01f0), 0x03f0);
|
||||
assert_eq!(windows_virtual_terminal_input_mode(0x03f0), 0x03f0);
|
||||
}
|
||||
|
||||
struct EnvVarsRemovedGuard {
|
||||
previous: Vec<(&'static str, Option<OsString>)>,
|
||||
}
|
||||
|
||||
impl EnvVarsRemovedGuard {
|
||||
fn new(keys: &[&'static str]) -> Self {
|
||||
let previous: Vec<_> = keys
|
||||
.iter()
|
||||
.map(|key| (*key, std::env::var_os(key)))
|
||||
.collect();
|
||||
for key in keys {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarsRemovedGuard {
|
||||
fn drop(&mut self) {
|
||||
for (key, value) in self.previous.clone() {
|
||||
restore_env_var(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_client_uses_extended_handshake_timeout() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local");
|
||||
|
||||
assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cursor_policy_auto_uses_platform_default() {
|
||||
assert_eq!(
|
||||
should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto),
|
||||
crate::platform::should_draw_host_cursor_by_default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cursor_policy_native_and_drawn_override_auto_detection() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let _env = EnvVarGuard::set("TERM_PROGRAM", "WezTerm");
|
||||
|
||||
assert!(!should_draw_host_cursor(
|
||||
crate::config::HostCursorModeConfig::Native
|
||||
));
|
||||
assert!(should_draw_host_cursor(
|
||||
crate::config::HostCursorModeConfig::Drawn
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() {
|
||||
let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap();
|
||||
assert!(should_bridge_clipboard_image_paste(
|
||||
&[0x16],
|
||||
true,
|
||||
Some(ctrl_v)
|
||||
));
|
||||
assert!(should_bridge_clipboard_image_paste(
|
||||
b"\x1b[118;5u",
|
||||
true,
|
||||
Some(ctrl_v)
|
||||
));
|
||||
assert!(should_bridge_clipboard_image_paste(
|
||||
b"\x1b[200~\x1b[201~",
|
||||
true,
|
||||
None
|
||||
));
|
||||
assert!(!should_bridge_clipboard_image_paste(
|
||||
b"\x1b[200~\x1b[201~",
|
||||
false,
|
||||
Some(ctrl_v)
|
||||
));
|
||||
assert!(!should_bridge_clipboard_image_paste(
|
||||
b"\x1b[200~text\x1b[201~",
|
||||
true,
|
||||
Some(ctrl_v)
|
||||
));
|
||||
assert!(!should_bridge_clipboard_image_paste(&[0x16], true, None));
|
||||
assert!(!should_bridge_clipboard_image_paste(
|
||||
b"v",
|
||||
true,
|
||||
Some(ctrl_v)
|
||||
));
|
||||
}
|
||||
|
||||
struct TempImageFile {
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl TempImageFile {
|
||||
fn new(extension: &str, bytes: &[u8]) -> Self {
|
||||
Self::with_name_fragment("test", extension, bytes)
|
||||
}
|
||||
|
||||
fn with_name_fragment(name_fragment: &str, extension: &str, bytes: &[u8]) -> Self {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"herdr-client-drop-{name_fragment}-{}-{nanos}.{extension}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempImageFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() {
|
||||
let file = TempImageFile::new("PNG", b"image-bytes");
|
||||
let input = format!("\x1b[200~{}\x1b[201~", file.path.display());
|
||||
|
||||
let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap();
|
||||
|
||||
assert_eq!(image.extension, "png");
|
||||
assert_eq!(image.bytes, b"image-bytes");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_image_file_drop_bridge_reads_plain_quoted_path_with_newline() {
|
||||
let file = TempImageFile::new("jpeg", b"jpeg-bytes");
|
||||
let input = format!("'{}'\n", file.path.display());
|
||||
|
||||
let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap();
|
||||
|
||||
assert_eq!(image.extension, "jpg");
|
||||
assert_eq!(image.bytes, b"jpeg-bytes");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_image_file_drop_bridge_unescapes_spaces_in_paths() {
|
||||
let file = TempImageFile::with_name_fragment("space test", "png", b"image-bytes");
|
||||
let escaped_path = file.path.display().to_string().replace(' ', "\\ ");
|
||||
|
||||
let image = read_image_file_from_terminal_drop(escaped_path.as_bytes(), true).unwrap();
|
||||
|
||||
assert_eq!(image.extension, "png");
|
||||
assert_eq!(image.bytes, b"image-bytes");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_image_file_drop_bridge_ignores_non_remote_and_non_image_input() {
|
||||
let file = TempImageFile::new("png", b"image-bytes");
|
||||
let path = file.path.display().to_string();
|
||||
|
||||
assert!(read_image_file_from_terminal_drop(path.as_bytes(), false).is_none());
|
||||
assert!(read_image_file_from_terminal_drop(b"relative.png\n", true).is_none());
|
||||
assert!(read_image_file_from_terminal_drop(b"/tmp/file.txt\n", true).is_none());
|
||||
assert!(read_image_file_from_terminal_drop(
|
||||
format!("{}\nextra", file.path.display()).as_bytes(),
|
||||
true
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_bytes_are_written_inside_synchronized_blit_with_saved_cursor() {
|
||||
let mut output = Vec::new();
|
||||
write_encoded_frame_with_graphics(
|
||||
&mut output,
|
||||
b"\x1b[?2026htext\x1b[?2026lcursor",
|
||||
b"graphics",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
output,
|
||||
b"\x1b[?2026htext\x1b7graphics\x1b8\x1b[?2026lcursor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_graphics_writes_only_blit_frame() {
|
||||
let mut output = Vec::new();
|
||||
write_encoded_frame_with_graphics(&mut output, b"text", b"").unwrap();
|
||||
|
||||
assert_eq!(output, b"text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_frame_kitty_detection_matches_apc_prefix() {
|
||||
assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\"));
|
||||
assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() {
|
||||
let ids = kitty_graphics_image_ids(
|
||||
b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\",
|
||||
);
|
||||
assert_eq!(ids, vec![10023, 10023]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() {
|
||||
record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\");
|
||||
let mut output = Vec::new();
|
||||
clear_received_kitty_graphics(&mut output).unwrap();
|
||||
let text = String::from_utf8(output).unwrap();
|
||||
assert!(text.contains("a=d,d=I,i=123"));
|
||||
assert!(!text.contains("d=A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_host_terminal_appearance_query_emits_mode_2031_query() {
|
||||
let mut output = Vec::new();
|
||||
write_host_terminal_appearance_query(&mut output).unwrap();
|
||||
assert_eq!(output, b"\x1b[?996n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_host_terminal_theme_query_emits_osc_queries() {
|
||||
let mut output = Vec::new();
|
||||
write_host_terminal_theme_query(&mut output).unwrap();
|
||||
assert_eq!(
|
||||
output,
|
||||
crate::terminal_theme::host_terminal_theme_query_sequence(
|
||||
crate::platform::should_query_host_terminal_palette(),
|
||||
)
|
||||
.as_bytes()
|
||||
);
|
||||
assert!(
|
||||
!output
|
||||
.windows(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.len())
|
||||
.any(|window| window
|
||||
== crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_host_color_scheme_report_mode_emits_mode_sequences() {
|
||||
let mut output = Vec::new();
|
||||
write_host_color_scheme_report_mode(&mut output, true).unwrap();
|
||||
write_host_color_scheme_report_mode(&mut output, false).unwrap();
|
||||
|
||||
let mut expected = Vec::new();
|
||||
expected.extend_from_slice(
|
||||
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(),
|
||||
);
|
||||
expected.extend_from_slice(
|
||||
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(),
|
||||
);
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_scheme_change_event_requests_host_theme_query() {
|
||||
let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n");
|
||||
|
||||
assert!(crate::raw_input::events_require_host_terminal_theme_query(
|
||||
&events
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_terminal_theme_query_is_disabled_on_windows() {
|
||||
assert_eq!(should_query_host_terminal_theme(), !cfg!(windows));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_host_cell_size_query_emits_xtwinops_request() {
|
||||
let mut output = Vec::new();
|
||||
write_host_cell_size_query(&mut output).unwrap();
|
||||
|
||||
assert_eq!(output, b"\x1b[16t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cell_size_query_is_disabled_on_windows() {
|
||||
assert_eq!(should_query_host_cell_size(), !cfg!(windows));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_size_fallback_prefers_reported_then_previous_size() {
|
||||
assert_eq!(cell_size_fallback(0, None), (8, 16));
|
||||
assert_eq!(cell_size_fallback(0, Some((11, 22))), (11, 22));
|
||||
assert_eq!(
|
||||
cell_size_fallback(pack_cell_size(10, 21), Some((11, 22))),
|
||||
(10, 21)
|
||||
);
|
||||
assert_eq!(cell_size_fallback(pack_cell_size(10, 0), None), (8, 16));
|
||||
assert_eq!(cell_size_fallback(pack_cell_size(0, 21), None), (8, 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_cell_size_is_taken_from_host_cell_size_events() {
|
||||
let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n");
|
||||
assert_eq!(reported_cell_size_from_events(&events), None);
|
||||
|
||||
let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t");
|
||||
assert_eq!(reported_cell_size_from_events(&events), Some((9, 18)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_scheme_reports_are_enabled_only_for_full_clients() {
|
||||
assert_eq!(
|
||||
should_enable_host_color_scheme_reports(true),
|
||||
!cfg!(windows)
|
||||
);
|
||||
assert!(!should_enable_host_color_scheme_reports(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_restore_postlude_restores_visible_default_cursor() {
|
||||
let mut output = Vec::new();
|
||||
write_terminal_restore_postlude(&mut output, false).unwrap();
|
||||
assert_eq!(output, b"\x1b[?25h\x1b[0 q");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_attach_mouse_capture_combines_local_preference_with_child_demand() {
|
||||
assert!(effective_mouse_capture(false, true));
|
||||
assert!(effective_mouse_capture(true, false));
|
||||
assert!(!effective_mouse_capture(false, false));
|
||||
assert!(effective_sgr_pixel_mouse(true, true, true));
|
||||
assert!(!effective_sgr_pixel_mouse(true, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() {
|
||||
let mut output = Vec::new();
|
||||
write_terminal_restore_postlude(&mut output, true).unwrap();
|
||||
|
||||
let mut expected = Vec::new();
|
||||
expected.extend_from_slice(
|
||||
crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(),
|
||||
);
|
||||
expected.extend_from_slice(b"\x1b[?25h\x1b[0 q");
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_error_display_connection_failed() {
|
||||
let err = ClientError::ConnectionFailed(io::Error::new(
|
||||
io::ErrorKind::ConnectionRefused,
|
||||
"connection refused",
|
||||
));
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("failed to connect to server"),
|
||||
"should mention connection failure: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("herdr server"),
|
||||
"should suggest starting server: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_error_display_handshake_rejected() {
|
||||
let err = ClientError::HandshakeRejected {
|
||||
version: 1,
|
||||
error: "incompatible".into(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("rejected handshake"),
|
||||
"should mention rejection: {msg}"
|
||||
);
|
||||
assert!(msg.contains("incompatible"), "should include error: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_error_display_server_shutdown() {
|
||||
let err = ClientError::ServerShutdown {
|
||||
reason: Some("maintenance".into()),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("server shut down"),
|
||||
"should mention shutdown: {msg}"
|
||||
);
|
||||
assert!(msg.contains("maintenance"), "should include reason: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_error_display_server_shutdown_no_reason() {
|
||||
let err = ClientError::ServerShutdown { reason: None };
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("server shut down"),
|
||||
"should mention shutdown: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_error_display_detached_default_session_reattach_hint() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let _env = EnvVarsRemovedGuard::new(&[
|
||||
crate::remote::REATTACH_COMMAND_ENV_VAR,
|
||||
crate::session::SESSION_ENV_VAR,
|
||||
]);
|
||||
let err = ClientError::ServerShutdown {
|
||||
reason: Some("detached".into()),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("Run `herdr` to reattach"),
|
||||
"should suggest default reattach command: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_error_display_detached_named_session_reattach_hint() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let _remote_env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]);
|
||||
let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work");
|
||||
let err = ClientError::ServerShutdown {
|
||||
reason: Some("detached".into()),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("Run `herdr session attach work` to reattach"),
|
||||
"should suggest named session reattach command: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_error_display_detached_remote_reattach_hint_takes_precedence() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let _remote_env = EnvVarGuard::set(
|
||||
crate::remote::REATTACH_COMMAND_ENV_VAR,
|
||||
"herdr --remote host --session work",
|
||||
);
|
||||
let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work");
|
||||
let err = ClientError::ServerShutdown {
|
||||
reason: Some("detached".into()),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("Run `herdr --remote host --session work` to reattach"),
|
||||
"should prefer remote reattach command: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_error_display_connection_lost() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let _env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]);
|
||||
let err = ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"));
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("lost connection to server"),
|
||||
"should mention lost connection: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_error_display_remote_connection_lost_has_reattach_hint() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let _remote_env = EnvVarGuard::set(
|
||||
crate::remote::REATTACH_COMMAND_ENV_VAR,
|
||||
"herdr --remote host --session work",
|
||||
);
|
||||
let err = ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"));
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("lost connection to remote Herdr"),
|
||||
"should mention remote connection loss: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("panes may still be running"),
|
||||
"should explain possible persistence: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("Run `herdr --remote host --session work` to reattach"),
|
||||
"should show remote reattach command: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_from_notify_message_maps_done() {
|
||||
assert_eq!(
|
||||
sound_from_notify_message("agent done"),
|
||||
Some(crate::sound::Sound::Done)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_from_notify_message_maps_attention() {
|
||||
assert_eq!(
|
||||
sound_from_notify_message("agent attention"),
|
||||
Some(crate::sound::Sound::Request)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_from_notify_message_rejects_unknown_payloads() {
|
||||
assert_eq!(sound_from_notify_message("toast"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_local_client_config_refreshes_local_client_presentation_state() {
|
||||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"herdr-client-config-reload-{}-{}.toml",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::write(
|
||||
&path,
|
||||
"[ui]\nredraw_on_focus_gained = false\nhost_cursor = \"drawn\"\nmouse_capture = false\n",
|
||||
)
|
||||
.unwrap();
|
||||
let path_string = path.to_string_lossy().to_string();
|
||||
let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string);
|
||||
let mut sound_config = crate::config::SoundConfig::default();
|
||||
let mut redraw_on_focus_gained = true;
|
||||
let mut draw_host_cursor = false;
|
||||
let mut remote_image_paste_key = None;
|
||||
let mut mouse_capture = true;
|
||||
|
||||
reload_local_client_config(
|
||||
&mut sound_config,
|
||||
&mut redraw_on_focus_gained,
|
||||
&mut draw_host_cursor,
|
||||
&mut remote_image_paste_key,
|
||||
&mut mouse_capture,
|
||||
);
|
||||
|
||||
assert!(!redraw_on_focus_gained);
|
||||
assert!(draw_host_cursor);
|
||||
assert!(!mouse_capture);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_local_client_config_keeps_ui_preferences_when_ui_is_invalid() {
|
||||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"herdr-client-invalid-ui-reload-{}-{}.toml",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::write(&path, "[ui]\nmouse_capture = \"invalid\"\n").unwrap();
|
||||
let path_string = path.to_string_lossy().to_string();
|
||||
let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string);
|
||||
let mut sound_config = crate::config::SoundConfig::default();
|
||||
let mut redraw_on_focus_gained = false;
|
||||
let mut draw_host_cursor = true;
|
||||
let mut remote_image_paste_key = None;
|
||||
let mut mouse_capture = false;
|
||||
|
||||
reload_local_client_config(
|
||||
&mut sound_config,
|
||||
&mut redraw_on_focus_gained,
|
||||
&mut draw_host_cursor,
|
||||
&mut remote_image_paste_key,
|
||||
&mut mouse_capture,
|
||||
);
|
||||
|
||||
assert!(!mouse_capture);
|
||||
assert!(!redraw_on_focus_gained);
|
||||
assert!(draw_host_cursor);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() {
|
||||
let sound_config = crate::config::SoundConfig::default();
|
||||
let mut emitted = None;
|
||||
|
||||
handle_notify_with_notifiers(
|
||||
NotifyKind::Toast,
|
||||
"pi finished",
|
||||
Some("workspace 1"),
|
||||
&sound_config,
|
||||
|title, body| {
|
||||
emitted = Some((title.to_string(), body.map(str::to_string)));
|
||||
Ok(true)
|
||||
},
|
||||
|_, _| Ok(false),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
emitted,
|
||||
Some(("pi finished".to_string(), Some("workspace 1".to_string())))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_toast_notify_from_server_uses_system_notifier() {
|
||||
let sound_config = crate::config::SoundConfig::default();
|
||||
let mut emitted = None;
|
||||
|
||||
handle_notify_with_notifiers(
|
||||
NotifyKind::SystemToast,
|
||||
"pi finished",
|
||||
Some("workspace 1"),
|
||||
&sound_config,
|
||||
|_, _| Ok(false),
|
||||
|title, body| {
|
||||
emitted = Some((title.to_string(), body.map(str::to_string)));
|
||||
Ok(true)
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
emitted,
|
||||
Some(("pi finished".to_string(), Some("workspace 1".to_string())))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_toast_notify_preserves_colon_in_title() {
|
||||
let sound_config = crate::config::SoundConfig::default();
|
||||
let mut emitted = None;
|
||||
|
||||
handle_notify_with_notifiers(
|
||||
NotifyKind::SystemToast,
|
||||
"build: failed",
|
||||
Some("api workspace"),
|
||||
&sound_config,
|
||||
|_, _| Ok(false),
|
||||
|title, body| {
|
||||
emitted = Some((title.to_string(), body.map(str::to_string)));
|
||||
Ok(true)
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
emitted,
|
||||
Some((
|
||||
"build: failed".to_string(),
|
||||
Some("api workspace".to_string())
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_clipboard_payload_decodes_base64() {
|
||||
assert_eq!(decode_clipboard_payload("dGVzdA=="), Some(b"test".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ioctl_cell_size_accepts_fractional_terminal_geometry() {
|
||||
assert_eq!(ioctl_cell_size(80, 24, 800, 480), Some((10, 20)));
|
||||
assert_eq!(ioctl_cell_size(80, 24, 805, 480), Some((10, 20)));
|
||||
assert_eq!(ioctl_cell_size(80, 24, 800, 485), Some((10, 20)));
|
||||
assert_eq!(ioctl_cell_size(80, 24, 0, 485), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_clipboard_payload_rejects_invalid_base64() {
|
||||
assert_eq!(decode_clipboard_payload("not-base64!!!"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_input_command_accepts_text() {
|
||||
let action =
|
||||
terminal_control_command_from_json(r#"{"type":"terminal.input","text":"hello"}"#).unwrap();
|
||||
let ClientMessage::Input { data } = action else {
|
||||
panic!("expected input command");
|
||||
};
|
||||
assert_eq!(data, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_input_command_accepts_base64_bytes() {
|
||||
let action =
|
||||
terminal_control_command_from_json(r#"{"type":"terminal.input","bytes":"G1tB"}"#).unwrap();
|
||||
let ClientMessage::Input { data } = action else {
|
||||
panic!("expected input command");
|
||||
};
|
||||
assert_eq!(data, b"\x1b[A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_resize_command_maps_to_client_resize() {
|
||||
let action = terminal_control_command_from_json(
|
||||
r#"{"type":"terminal.resize","cols":100,"rows":30,"cell_width_px":8,"cell_height_px":16}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let ClientMessage::Resize {
|
||||
cols,
|
||||
rows,
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
pixel_mouse,
|
||||
} = action
|
||||
else {
|
||||
panic!("expected resize command");
|
||||
};
|
||||
assert_eq!(
|
||||
(cols, rows, cell_width_px, cell_height_px),
|
||||
(100, 30, 8, 16)
|
||||
);
|
||||
assert!(!pixel_mouse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_scroll_command_maps_to_attach_scroll() {
|
||||
let action = terminal_control_command_from_json(
|
||||
r#"{"type":"terminal.scroll","direction":"up","lines":3}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let ClientMessage::AttachScroll {
|
||||
source,
|
||||
direction,
|
||||
lines,
|
||||
..
|
||||
} = action
|
||||
else {
|
||||
panic!("expected scroll command");
|
||||
};
|
||||
assert_eq!(source, AttachScrollSource::Wheel);
|
||||
assert_eq!(direction, AttachScrollDirection::Up);
|
||||
assert_eq!(lines, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_clipboard_uses_local_clipboard_path() {
|
||||
unsafe {
|
||||
std::env::set_var("SSH_CONNECTION", "1 2 3 4");
|
||||
}
|
||||
assert!(forward_clipboard("dGVzdA=="));
|
||||
assert!(!forward_clipboard("not base64"));
|
||||
unsafe {
|
||||
std::env::remove_var("SSH_CONNECTION");
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -39,7 +39,7 @@ pub use self::{
|
||||
};
|
||||
|
||||
pub(crate) use self::keybinds::parse_key_combo;
|
||||
pub(crate) use self::write::{update_file, write_edit, ConfigEdit};
|
||||
pub(crate) use self::write::{update_file_at, write_edit, ConfigEdit};
|
||||
pub(crate) use self::{
|
||||
io::upsert_top_level_bool,
|
||||
tab_bar::{
|
||||
@@ -157,12 +157,6 @@ impl Config {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn live_keybinds(&self) -> Result<LiveKeybindConfig, Vec<String>> {
|
||||
self.live_keybinds_with_diagnostics()
|
||||
.map(|(live, _diagnostics)| live)
|
||||
}
|
||||
|
||||
pub(crate) fn live_keybinds_with_diagnostics(
|
||||
&self,
|
||||
) -> Result<(LiveKeybindConfig, Vec<String>), Vec<String>> {
|
||||
|
||||
@@ -101,15 +101,6 @@ pub enum AgentPanelSortConfig {
|
||||
Priority,
|
||||
}
|
||||
|
||||
impl AgentPanelSortConfig {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Spaces => "spaces",
|
||||
Self::Priority => "priority",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum LegacyAgentPanelScopeConfig {
|
||||
|
||||
+7
-35
@@ -4,8 +4,6 @@ pub(crate) enum ConfigEdit<'a> {
|
||||
StatusIndicators(super::StatusIndicatorStyle),
|
||||
Sound(bool),
|
||||
ToastDelivery(super::ToastDelivery),
|
||||
AgentBorderLabels(bool),
|
||||
AgentPanelSort(super::AgentPanelSortConfig),
|
||||
}
|
||||
|
||||
impl ConfigEdit<'_> {
|
||||
@@ -15,8 +13,6 @@ impl ConfigEdit<'_> {
|
||||
Self::StatusIndicators(_) => "status indicators",
|
||||
Self::Sound(_) => "sound setting",
|
||||
Self::ToastDelivery(_) => "toast setting",
|
||||
Self::AgentBorderLabels(_) => "agent border labels",
|
||||
Self::AgentPanelSort(_) => "agent panel sort",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,32 +42,20 @@ impl ConfigEdit<'_> {
|
||||
let content = super::upsert_section_value(content, "ui.toast", "delivery", value);
|
||||
super::remove_section_key(&content, "ui.toast", "enabled")
|
||||
}
|
||||
Self::AgentBorderLabels(enabled) => super::upsert_section_bool(
|
||||
content,
|
||||
"ui",
|
||||
"show_agent_labels_on_pane_borders",
|
||||
enabled,
|
||||
),
|
||||
Self::AgentPanelSort(sort) => super::upsert_section_value(
|
||||
content,
|
||||
"ui",
|
||||
"agent_panel_sort",
|
||||
&format!("\"{}\"", sort.as_str()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_file(
|
||||
pub(crate) fn update_file_at(
|
||||
path: &std::path::Path,
|
||||
description: &str,
|
||||
update: impl FnOnce(&str) -> String,
|
||||
) -> Result<(), String> {
|
||||
let path = super::config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("failed to create config directory: {error}"))?;
|
||||
}
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(content) => content,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(error) => {
|
||||
@@ -80,24 +64,12 @@ pub(crate) fn update_file(
|
||||
));
|
||||
}
|
||||
};
|
||||
std::fs::write(&path, update(&content))
|
||||
std::fs::write(path, update(&content))
|
||||
.map_err(|error| format!("failed to save {description}: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn write_edit(edit: ConfigEdit<'_>) -> Result<(), String> {
|
||||
update_file(edit.description(), |content| edit.apply(content))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn edits_preserve_unrelated_config() {
|
||||
let content = "[terminal]\ndefault_shell = \"fish\"\n";
|
||||
let edited =
|
||||
ConfigEdit::AgentPanelSort(super::super::AgentPanelSortConfig::Priority).apply(content);
|
||||
assert!(edited.contains("default_shell = \"fish\""));
|
||||
assert!(edited.contains("agent_panel_sort = \"priority\""));
|
||||
}
|
||||
update_file_at(&super::config_path(), edit.description(), |content| {
|
||||
edit.apply(content)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
use crate::input::TerminalKey;
|
||||
|
||||
pub(crate) fn first_non_blank_col(text: &str) -> Option<u16> {
|
||||
let mut col = 0u16;
|
||||
for ch in text.chars() {
|
||||
if !ch.is_whitespace() {
|
||||
return Some(col);
|
||||
}
|
||||
col = col.saturating_add(char_cell_width(ch));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn last_character_col(text: &str) -> Option<u16> {
|
||||
let mut col = 0u16;
|
||||
let mut last_col = None;
|
||||
for ch in text.chars() {
|
||||
let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32));
|
||||
if width > 0 {
|
||||
last_col = Some(col);
|
||||
col = col.saturating_add(width);
|
||||
}
|
||||
}
|
||||
last_col
|
||||
}
|
||||
|
||||
fn char_cell_width(ch: char) -> u16 {
|
||||
u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)).max(1)
|
||||
}
|
||||
|
||||
pub(crate) fn copy_mode_page_lines(height: u16, half_page: bool) -> usize {
|
||||
if height <= 2 {
|
||||
1
|
||||
} else if half_page {
|
||||
usize::from(height / 2)
|
||||
} else {
|
||||
usize::from(height - 2)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn copy_mode_command_char(key: TerminalKey) -> Option<char> {
|
||||
if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(ch) = key.shifted_codepoint.and_then(char::from_u32) {
|
||||
return Some(ch);
|
||||
}
|
||||
let KeyCode::Char(ch) = key.code else {
|
||||
return None;
|
||||
};
|
||||
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
Some(shifted_ascii_char(ch).unwrap_or(ch))
|
||||
} else {
|
||||
Some(ch)
|
||||
}
|
||||
}
|
||||
|
||||
fn shifted_ascii_char(ch: char) -> Option<char> {
|
||||
match ch {
|
||||
'a'..='z' => Some(ch.to_ascii_uppercase()),
|
||||
'1' => Some('!'),
|
||||
'2' => Some('@'),
|
||||
'3' => Some('#'),
|
||||
'4' => Some('$'),
|
||||
'5' => Some('%'),
|
||||
'6' => Some('^'),
|
||||
'7' => Some('&'),
|
||||
'8' => Some('*'),
|
||||
'9' => Some('('),
|
||||
'0' => Some(')'),
|
||||
'-' => Some('_'),
|
||||
'=' => Some('+'),
|
||||
'[' => Some('{'),
|
||||
']' => Some('}'),
|
||||
'\\' => Some('|'),
|
||||
';' => Some(':'),
|
||||
'\'' => Some('"'),
|
||||
',' => Some('<'),
|
||||
'.' => Some('>'),
|
||||
'/' => Some('?'),
|
||||
'`' => Some('~'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -137,10 +137,6 @@ pub enum AppEvent {
|
||||
/// A pane child emitted a valid OSC 52 clipboard write. The main loop
|
||||
/// re-emits it through herdr's own clipboard writer.
|
||||
ClipboardWrite { content: Vec<u8> },
|
||||
/// Prefix-mode ASCII input-source request, emitted on entering/leaving the ASCII input
|
||||
/// realm. The foreground process applies the host-local TIS switch (`active = true`) /
|
||||
/// restore (`active = false`): the foreground client applies the forwarded request.
|
||||
PrefixInputSource { active: bool },
|
||||
/// A pane child reported its shell current directory through terminal
|
||||
/// metadata such as OSC 7.
|
||||
TerminalCwdReported {
|
||||
|
||||
+2
-2
@@ -396,7 +396,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_generated_text_remains_untracked() {
|
||||
fn forwarded_semantic_generated_text_has_no_release_lease() {
|
||||
let key = TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
|
||||
.with_generated_text(Some("/".to_owned()))
|
||||
.with_repeat_count(3);
|
||||
@@ -408,7 +408,7 @@ mod tests {
|
||||
leases.complete_press(lease_key, &key, Some(&context), Some(&context), Some(10)),
|
||||
RepeatPlan::Ignore
|
||||
));
|
||||
assert!(leases.is_empty());
|
||||
assert_eq!(leases.remove_forwarded(&lease_key), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+4
-6
@@ -10,8 +10,6 @@ mod parse;
|
||||
pub use encode::{
|
||||
encode_cursor_key, encode_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use keybind_help::KeybindHelpGroup;
|
||||
pub(crate) use keybind_help::{
|
||||
filter_keybind_help_groups, keybind_help_groups, keybind_help_text_char,
|
||||
};
|
||||
@@ -20,15 +18,15 @@ pub(crate) use keybindings::{
|
||||
resolve_non_indexed_action, resolve_prefix_binding, KeybindAction, KeybindDispatch,
|
||||
KeybindMatch,
|
||||
};
|
||||
pub(crate) use lease::{
|
||||
ConsumedInputLease, ForwardedInputLease, InputLeaseKey, InputLeaseTable, RepeatPlan,
|
||||
};
|
||||
pub(crate) use lease::{InputLeaseKey, InputLeaseTable, RepeatPlan};
|
||||
#[cfg(not(windows))]
|
||||
pub use model::ime_compatible_keyboard_enhancement_flags;
|
||||
#[cfg(any(unix, test))]
|
||||
pub use model::MouseProtocolMode;
|
||||
#[cfg(any(windows, test))]
|
||||
pub use model::WindowsKeyRecord;
|
||||
pub use model::{
|
||||
host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding, TerminalKey,
|
||||
TextCommit, WindowsKeyRecord,
|
||||
TextCommit,
|
||||
};
|
||||
pub use parse::parse_terminal_key_sequence;
|
||||
|
||||
+34
-10
@@ -3,6 +3,7 @@ use crossterm::event::KeyboardEnhancementFlags;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WindowsKeyRecord {
|
||||
pub key_down: bool,
|
||||
@@ -32,11 +33,13 @@ impl TextCommit {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct PhysicalKeyId(u32);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum KeyIdentity {
|
||||
#[cfg(any(windows, test))]
|
||||
Physical(PhysicalKeyId),
|
||||
Semantic(KeyCode),
|
||||
}
|
||||
@@ -47,12 +50,14 @@ pub(crate) enum KeySource {
|
||||
Vt {
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
#[cfg(any(windows, test))]
|
||||
WindowsConsole {
|
||||
record: WindowsKeyRecord,
|
||||
physical_key: Option<PhysicalKeyId>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
impl WindowsKeyRecord {
|
||||
fn physical_key_id(self) -> Option<PhysicalKeyId> {
|
||||
const ENHANCED_KEY: u32 = 0x0100;
|
||||
@@ -73,6 +78,7 @@ pub struct TerminalKey {
|
||||
pub repeat_count: u16,
|
||||
pub shifted_codepoint: Option<u32>,
|
||||
pub generated_text: Option<String>,
|
||||
physical_identity_hint: bool,
|
||||
source: KeySource,
|
||||
}
|
||||
|
||||
@@ -85,6 +91,7 @@ impl TerminalKey {
|
||||
repeat_count: 1,
|
||||
shifted_codepoint: None,
|
||||
generated_text: None,
|
||||
physical_identity_hint: false,
|
||||
source: KeySource::Synthesized,
|
||||
}
|
||||
}
|
||||
@@ -112,7 +119,6 @@ impl TerminalKey {
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Reserved for the upcoming raw input parser to preserve shifted/base key pairs.
|
||||
pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self {
|
||||
self.shifted_codepoint = Some(shifted_codepoint);
|
||||
self
|
||||
@@ -132,19 +138,27 @@ impl TerminalKey {
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
pub fn with_windows_record(mut self, record: WindowsKeyRecord) -> Self {
|
||||
self.repeat_count = if self.kind == crossterm::event::KeyEventKind::Release {
|
||||
1
|
||||
} else {
|
||||
record.repeat_count.max(1)
|
||||
};
|
||||
let physical_key = record.physical_key_id();
|
||||
self.physical_identity_hint = physical_key.is_some();
|
||||
self.source = KeySource::WindowsConsole {
|
||||
physical_key: record.physical_key_id(),
|
||||
physical_key,
|
||||
record,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_physical_identity_hint(mut self, physical: bool) -> Self {
|
||||
self.physical_identity_hint = physical;
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn vt_bytes(&self) -> Option<&[u8]> {
|
||||
match &self.source {
|
||||
@@ -163,26 +177,36 @@ impl TerminalKey {
|
||||
|
||||
pub(crate) fn identity(&self) -> KeyIdentity {
|
||||
match self.source {
|
||||
#[cfg(any(windows, test))]
|
||||
KeySource::WindowsConsole {
|
||||
physical_key: Some(physical_key),
|
||||
..
|
||||
} => KeyIdentity::Physical(physical_key),
|
||||
#[cfg(any(windows, test))]
|
||||
KeySource::WindowsConsole {
|
||||
physical_key: None, ..
|
||||
}
|
||||
| KeySource::Synthesized
|
||||
| KeySource::Vt { .. } => KeyIdentity::Semantic(self.code),
|
||||
} => KeyIdentity::Semantic(self.code),
|
||||
KeySource::Synthesized | KeySource::Vt { .. } => KeyIdentity::Semantic(self.code),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_physical_identity(&self) -> bool {
|
||||
matches!(
|
||||
self.source,
|
||||
self.physical_identity_hint || self.physical_key_id().is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn physical_key_id(&self) -> Option<u32> {
|
||||
match &self.source {
|
||||
#[cfg(any(windows, test))]
|
||||
KeySource::WindowsConsole {
|
||||
physical_key: Some(_),
|
||||
physical_key: Some(PhysicalKeyId(id)),
|
||||
..
|
||||
}
|
||||
)
|
||||
} => Some(*id),
|
||||
#[cfg(any(windows, test))]
|
||||
KeySource::WindowsConsole {
|
||||
physical_key: None, ..
|
||||
} => None,
|
||||
KeySource::Synthesized | KeySource::Vt { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_text_commit(mut self) -> Self {
|
||||
|
||||
+74
-15
@@ -47,10 +47,12 @@ impl HostGeometry {
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn column_boundary(self, column: u16) -> Option<u32> {
|
||||
boundary(column, self.cols, self.width_px)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn row_boundary(self, row: u16) -> Option<u32> {
|
||||
boundary(row, self.rows, self.height_px)
|
||||
}
|
||||
@@ -63,28 +65,68 @@ impl HostPixels {
|
||||
child_width_px: u32,
|
||||
child_height_px: u32,
|
||||
) -> Option<Position> {
|
||||
let start_x = self.geometry.column_boundary(inner.x)?;
|
||||
let start_y = self.geometry.row_boundary(inner.y)?;
|
||||
let end_x = self
|
||||
.geometry
|
||||
.column_boundary(inner.x.checked_add(inner.width)?)?;
|
||||
let end_y = self
|
||||
.geometry
|
||||
.row_boundary(inner.y.checked_add(inner.height)?)?;
|
||||
let x = self.x.checked_sub(1)?.checked_sub(start_x)?;
|
||||
let y = self.y.checked_sub(1)?.checked_sub(start_y)?;
|
||||
let source_width = end_x.checked_sub(start_x)?;
|
||||
let source_height = end_y.checked_sub(start_y)?;
|
||||
if x >= source_width || y >= source_height || child_width_px == 0 || child_height_px == 0 {
|
||||
let (host_column, host_row) = self.geometry.cell(self.x, self.y)?;
|
||||
let end_column = inner.x.checked_add(inner.width)?;
|
||||
let end_row = inner.y.checked_add(inner.height)?;
|
||||
if host_column < inner.x
|
||||
|| host_column >= end_column
|
||||
|| host_row < inner.y
|
||||
|| host_row >= end_row
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Position::Pixels {
|
||||
x: scale(x, source_width, child_width_px).checked_add(1)?,
|
||||
y: scale(y, source_height, child_height_px).checked_add(1)?,
|
||||
x: map_axis_within_cell(
|
||||
self.x,
|
||||
host_column,
|
||||
inner.x,
|
||||
inner.width,
|
||||
self.geometry.cols,
|
||||
self.geometry.width_px,
|
||||
child_width_px,
|
||||
)?,
|
||||
y: map_axis_within_cell(
|
||||
self.y,
|
||||
host_row,
|
||||
inner.y,
|
||||
inner.height,
|
||||
self.geometry.rows,
|
||||
self.geometry.height_px,
|
||||
child_height_px,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn map_axis_within_cell(
|
||||
pixel: u32,
|
||||
host_cell: u16,
|
||||
pane_start: u16,
|
||||
pane_cells: u16,
|
||||
host_cells: u16,
|
||||
host_extent: u32,
|
||||
child_extent: u32,
|
||||
) -> Option<u32> {
|
||||
let local_cell = host_cell.checked_sub(pane_start)?;
|
||||
if local_cell >= pane_cells {
|
||||
return None;
|
||||
}
|
||||
let source_start = boundary(host_cell, host_cells, host_extent)?;
|
||||
let source_end = boundary(host_cell.checked_add(1)?, host_cells, host_extent)?;
|
||||
let target_start = boundary(local_cell, pane_cells, child_extent)?;
|
||||
let target_end = boundary(local_cell.checked_add(1)?, pane_cells, child_extent)?;
|
||||
let source_width = source_end.checked_sub(source_start)?;
|
||||
let target_width = target_end.checked_sub(target_start)?;
|
||||
let offset = pixel.checked_sub(1)?.checked_sub(source_start)?;
|
||||
if source_width == 0 || target_width == 0 || offset >= source_width {
|
||||
return None;
|
||||
}
|
||||
target_start
|
||||
.checked_add(scale(offset, source_width, target_width))?
|
||||
.checked_add(1)
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
pub(crate) fn parse_report(data: &[u8]) -> Option<(u32, u32)> {
|
||||
let body = data.strip_prefix(b"\x1b[<")?;
|
||||
let body = body
|
||||
@@ -97,6 +139,7 @@ pub(crate) fn parse_report(data: &[u8]) -> Option<(u32, u32)> {
|
||||
fields.next().is_none().then_some((x, y))
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
pub(crate) fn report_at_cell(data: &[u8], column: u16, row: u16) -> Option<Vec<u8>> {
|
||||
let body = data.strip_prefix(b"\x1b[<")?;
|
||||
let suffix = if body.ends_with(b"M") { 'M' } else { 'm' };
|
||||
@@ -114,6 +157,7 @@ pub(crate) fn report_at_cell(data: &[u8], column: u16, row: u16) -> Option<Vec<u
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn parse_number(value: &[u8]) -> Option<u32> {
|
||||
(!value.is_empty() && value.iter().all(u8::is_ascii_digit))
|
||||
.then(|| std::str::from_utf8(value).ok()?.parse().ok())
|
||||
@@ -183,6 +227,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_scaling_preserves_the_canonical_child_cell() {
|
||||
let geometry = HostGeometry::new(80, 1, 805, 20).unwrap();
|
||||
assert_eq!(geometry.cell(11, 1), Some((1, 0)));
|
||||
assert_eq!(
|
||||
HostPixels {
|
||||
x: 11,
|
||||
y: 1,
|
||||
geometry,
|
||||
}
|
||||
.pane_position(ratatui::layout::Rect::new(0, 0, 80, 1), 800, 20),
|
||||
Some(Position::Pixels { x: 11, y: 1 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_rejects_outside_pixels_and_maps_cells() {
|
||||
let geometry = HostGeometry::new(80, 24, 800, 480).unwrap();
|
||||
|
||||
@@ -164,15 +164,6 @@ impl IntegrationRecommendation {
|
||||
self.state == IntegrationStatusKind::Outdated
|
||||
|| (self.available && self.state == IntegrationStatusKind::NotInstalled)
|
||||
}
|
||||
|
||||
pub fn status_label(&self) -> &'static str {
|
||||
match (self.available, self.state) {
|
||||
(_, IntegrationStatusKind::Current) => "installed",
|
||||
(_, IntegrationStatusKind::Outdated) => "update available",
|
||||
(true, IntegrationStatusKind::NotInstalled) => "available",
|
||||
(false, IntegrationStatusKind::NotInstalled) => "not found",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
+133
-1793
File diff suppressed because it is too large
Load Diff
@@ -254,19 +254,6 @@ pub(crate) fn workspace_renamed(workspace_id: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn tab_created(workspace_id: &str, tab_id: &str, root_pane_id: u32) {
|
||||
tracing::info!(
|
||||
event = "tab.create",
|
||||
subsystem = "tab",
|
||||
outcome = "ok",
|
||||
workspace_id,
|
||||
tab_id,
|
||||
pane_id = root_pane_id,
|
||||
"tab created"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn tab_focused(workspace_id: &str, tab_id: &str) {
|
||||
tracing::info!(
|
||||
event = "tab.focus",
|
||||
@@ -383,18 +370,6 @@ pub(crate) fn update_available(version: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn config_write_failed(path: &Path, context: &str, err: &str) {
|
||||
tracing::warn!(
|
||||
event = "config.write",
|
||||
subsystem = "config",
|
||||
outcome = "error",
|
||||
path = %path.display(),
|
||||
context,
|
||||
err,
|
||||
"failed to write config"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn integration_action(
|
||||
action: &'static str,
|
||||
target: &'static str,
|
||||
|
||||
@@ -19,6 +19,7 @@ mod checksum;
|
||||
mod cli;
|
||||
mod client;
|
||||
mod config;
|
||||
mod copy_mode;
|
||||
mod detect;
|
||||
mod events;
|
||||
mod ghostty;
|
||||
|
||||
+6
-55
@@ -44,8 +44,8 @@ use self::agent_detection::{
|
||||
pub use self::terminal::InputState;
|
||||
use self::terminal::{GhosttyPaneTerminal, PaneTerminal};
|
||||
pub(crate) use self::terminal::{
|
||||
TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalReadSnapshot, TerminalSearchDirection,
|
||||
TerminalSearchWindow, TerminalTextMatch, TerminalTextPoint, TerminalWordMotion,
|
||||
TerminalReadSnapshot, TerminalSearchDirection, TerminalSearchWindow, TerminalTextPoint,
|
||||
TerminalWordMotion,
|
||||
};
|
||||
pub use self::{
|
||||
state::PaneState,
|
||||
@@ -56,21 +56,6 @@ const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::
|
||||
const PANE_TERM: &str = "xterm-256color";
|
||||
const PANE_COLORTERM: &str = "truecolor";
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static AGGREGATE_INPUT_STATE_READS: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_aggregate_input_state_reads() {
|
||||
AGGREGATE_INPUT_STATE_READS.set(0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn aggregate_input_state_reads() -> usize {
|
||||
AGGREGATE_INPUT_STATE_READS.get()
|
||||
}
|
||||
|
||||
fn apply_pane_terminal_env(cmd: &mut CommandBuilder) {
|
||||
// Each pane is rendered by herdr's own terminal layer, not the outer terminal
|
||||
// that launched the app. Advertising the inherited TERM leaks the host terminal
|
||||
@@ -2607,11 +2592,6 @@ impl PaneRuntime {
|
||||
self.detect_reset_notify.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn agent_detection_enabled_for_test(&self) -> bool {
|
||||
self.detect_handle.is_some()
|
||||
}
|
||||
|
||||
pub fn set_full_lifecycle_authority_active(&self, active: bool) {
|
||||
let previous = self
|
||||
.full_lifecycle_authority_active
|
||||
@@ -2690,14 +2670,6 @@ impl PaneRuntime {
|
||||
self.terminal.scroll_metrics()
|
||||
}
|
||||
|
||||
pub(crate) fn search_text_matches(
|
||||
&self,
|
||||
query: &str,
|
||||
case_sensitive: bool,
|
||||
) -> Vec<crate::pane::TerminalTextMatch> {
|
||||
self.terminal.search_text_matches(query, case_sensitive)
|
||||
}
|
||||
|
||||
pub(crate) fn search_text_window(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -2714,17 +2686,6 @@ impl PaneRuntime {
|
||||
.search_text_window(query, case_sensitive, direction, cursor, previous, limit)
|
||||
}
|
||||
|
||||
pub(crate) fn text_match_is_current(&self, text_match: crate::pane::TerminalTextMatch) -> bool {
|
||||
self.terminal.text_match_is_current(text_match)
|
||||
}
|
||||
|
||||
pub(crate) fn text_matches_are_current(
|
||||
&self,
|
||||
text_matches: &[crate::pane::TerminalTextMatch],
|
||||
) -> Vec<bool> {
|
||||
self.terminal.text_matches_are_current(text_matches)
|
||||
}
|
||||
|
||||
pub(crate) fn word_motion_target(
|
||||
&self,
|
||||
row: u32,
|
||||
@@ -2748,15 +2709,9 @@ impl PaneRuntime {
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
pub fn input_state(&self) -> Option<InputState> {
|
||||
#[cfg(test)]
|
||||
AGGREGATE_INPUT_STATE_READS.set(AGGREGATE_INPUT_STATE_READS.get() + 1);
|
||||
self.terminal.input_state()
|
||||
}
|
||||
|
||||
pub fn keyboard_report_all_requested(&self) -> bool {
|
||||
self.terminal.keyboard_report_all_requested()
|
||||
}
|
||||
|
||||
pub fn bracketed_paste_enabled(&self) -> bool {
|
||||
self.terminal.bracketed_paste_enabled()
|
||||
}
|
||||
@@ -2858,14 +2813,6 @@ impl PaneRuntime {
|
||||
self.terminal.render(frame, area, show_cursor);
|
||||
}
|
||||
|
||||
pub(crate) fn collect_dirty_patch(
|
||||
&self,
|
||||
area_width: u16,
|
||||
area_height: u16,
|
||||
) -> TerminalDirtyPatchOutcome {
|
||||
self.terminal.collect_dirty_patch(area_width, area_height)
|
||||
}
|
||||
|
||||
pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> {
|
||||
self.terminal.visible_hyperlinks(area)
|
||||
}
|
||||
@@ -2888,6 +2835,10 @@ impl PaneRuntime {
|
||||
self.terminal.keyboard_protocol(fallback)
|
||||
}
|
||||
|
||||
pub fn modify_other_keys_level(&self) -> u8 {
|
||||
self.terminal.modify_other_keys_level()
|
||||
}
|
||||
|
||||
pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec<u8> {
|
||||
self.terminal
|
||||
.encode_terminal_key(key, self.keyboard_protocol())
|
||||
|
||||
+56
-21
@@ -3,8 +3,7 @@ pub(crate) struct KittyKeyboardTracker {
|
||||
pending: Vec<u8>,
|
||||
stack: Vec<u16>,
|
||||
flags: u16,
|
||||
#[cfg(windows)]
|
||||
modify_other_keys: bool,
|
||||
modify_other_keys_level: u8,
|
||||
}
|
||||
|
||||
impl KittyKeyboardTracker {
|
||||
@@ -32,9 +31,10 @@ impl KittyKeyboardTracker {
|
||||
self.store_pending(&bytes[index..]);
|
||||
break;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if bytes[index + 1] == b'c' {
|
||||
self.modify_other_keys = false;
|
||||
self.stack.clear();
|
||||
self.flags = 0;
|
||||
self.modify_other_keys_level = 0;
|
||||
}
|
||||
if bytes[index + 1] != b'[' {
|
||||
index += 1;
|
||||
@@ -52,7 +52,6 @@ impl KittyKeyboardTracker {
|
||||
|
||||
match bytes[end] {
|
||||
b'u' => self.observe_csi_u(&bytes[index + 2..end]),
|
||||
#[cfg(windows)]
|
||||
b'm' => self.observe_modify_other_keys(&bytes[index + 2..end]),
|
||||
#[cfg(windows)]
|
||||
b'n' if bytes[index + 2..end]
|
||||
@@ -61,7 +60,7 @@ impl KittyKeyboardTracker {
|
||||
!params.contains(&b';') && parse_kitty_keyboard_flags(params) == 4
|
||||
}) =>
|
||||
{
|
||||
self.modify_other_keys = false;
|
||||
self.modify_other_keys_level = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -69,18 +68,21 @@ impl KittyKeyboardTracker {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn modify_other_keys_enabled(&self) -> bool {
|
||||
self.modify_other_keys
|
||||
pub(crate) fn modify_other_keys_level(&self) -> u8 {
|
||||
self.modify_other_keys_level
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn modify_other_keys_enabled(&self) -> bool {
|
||||
self.modify_other_keys_level > 0
|
||||
}
|
||||
|
||||
fn observe_modify_other_keys(&mut self, params: &[u8]) {
|
||||
let Some(params) = params.strip_prefix(b">") else {
|
||||
return;
|
||||
};
|
||||
if params.is_empty() {
|
||||
self.modify_other_keys = false;
|
||||
self.modify_other_keys_level = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,8 +93,8 @@ impl KittyKeyboardTracker {
|
||||
return;
|
||||
}
|
||||
if parse_kitty_keyboard_flags(resource) == 4 {
|
||||
self.modify_other_keys =
|
||||
value.is_some_and(|value| parse_kitty_keyboard_flags(value) != 0);
|
||||
self.modify_other_keys_level =
|
||||
value.map(parse_kitty_keyboard_flags).unwrap_or(0).min(2) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,17 +130,22 @@ impl KittyKeyboardTracker {
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn replay_ansi(&self) -> Option<String> {
|
||||
if self.stack.is_empty() {
|
||||
return (self.flags != 0).then(|| format!("\x1b[={}u", self.flags));
|
||||
}
|
||||
|
||||
let mut ansi = String::new();
|
||||
let baseline = self.stack[0];
|
||||
if baseline != 0 {
|
||||
ansi.push_str(&format!("\x1b[={baseline}u"));
|
||||
if self.stack.is_empty() {
|
||||
if self.flags != 0 {
|
||||
ansi.push_str(&format!("\x1b[={}u", self.flags));
|
||||
}
|
||||
} else {
|
||||
let baseline = self.stack[0];
|
||||
if baseline != 0 {
|
||||
ansi.push_str(&format!("\x1b[={baseline}u"));
|
||||
}
|
||||
for flags in self.stack.iter().skip(1).copied().chain([self.flags]) {
|
||||
ansi.push_str(&format!("\x1b[>{flags}u"));
|
||||
}
|
||||
}
|
||||
for flags in self.stack.iter().skip(1).copied().chain([self.flags]) {
|
||||
ansi.push_str(&format!("\x1b[>{flags}u"));
|
||||
if self.modify_other_keys_level > 0 {
|
||||
ansi.push_str(&format!("\x1b[>4;{}m", self.modify_other_keys_level));
|
||||
}
|
||||
(!ansi.is_empty()).then_some(ansi)
|
||||
}
|
||||
@@ -166,6 +173,7 @@ mod tests {
|
||||
|
||||
assert_eq!(tracker.flags, 1);
|
||||
assert_eq!(tracker.stack, vec![0]);
|
||||
assert_eq!(tracker.modify_other_keys_level(), 1);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(tracker.modify_other_keys_enabled());
|
||||
@@ -175,4 +183,31 @@ mod tests {
|
||||
assert!(!tracker.modify_other_keys_enabled());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ris_clears_kitty_flags_stack_and_modify_other_keys() {
|
||||
let mut tracker = KittyKeyboardTracker::default();
|
||||
tracker.observe(b"\x1b[>1u\x1b[>5u\x1b[>4;2m\x1bc");
|
||||
|
||||
assert_eq!(tracker.flags, 0);
|
||||
assert!(tracker.stack.is_empty());
|
||||
assert_eq!(tracker.modify_other_keys_level(), 0);
|
||||
#[cfg(unix)]
|
||||
assert_eq!(tracker.replay_ansi(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_and_replays_exact_modify_other_keys_level() {
|
||||
let mut tracker = KittyKeyboardTracker::default();
|
||||
|
||||
tracker.observe(b"\x1b[>4;1m");
|
||||
assert_eq!(tracker.modify_other_keys_level(), 1);
|
||||
#[cfg(unix)]
|
||||
assert_eq!(tracker.replay_ansi().as_deref(), Some("\x1b[>4;1m"));
|
||||
|
||||
tracker.observe(b"\x1b[>4;2m");
|
||||
assert_eq!(tracker.modify_other_keys_level(), 2);
|
||||
tracker.observe(b"\x1b[>4;0m");
|
||||
assert_eq!(tracker.modify_other_keys_level(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
+44
-472
@@ -14,7 +14,6 @@ use tracing::{debug, error};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::layout::PaneId;
|
||||
use crate::protocol::CellData;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_recent_fallback;
|
||||
@@ -103,18 +102,6 @@ pub struct TerminalCursorState {
|
||||
pub shape: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TerminalDirtyPatch {
|
||||
pub rows: Vec<(u16, Vec<CellData>)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum TerminalDirtyPatchOutcome {
|
||||
Clean,
|
||||
Patch(TerminalDirtyPatch),
|
||||
Fallback,
|
||||
}
|
||||
|
||||
fn decscusr_cursor_shape(style: crate::ghostty::CursorVisualStyle, blinking: bool) -> u8 {
|
||||
match (style, blinking) {
|
||||
(crate::ghostty::CursorVisualStyle::Block, true)
|
||||
@@ -255,17 +242,6 @@ impl PaneTerminal {
|
||||
self.ghostty.scroll_metrics()
|
||||
}
|
||||
|
||||
pub(crate) fn search_text_matches(
|
||||
&self,
|
||||
query: &str,
|
||||
case_sensitive: bool,
|
||||
) -> Vec<TerminalTextMatch> {
|
||||
let Some((buffer, active_screen)) = self.retained_text_buffer() else {
|
||||
return Vec::new();
|
||||
};
|
||||
buffer.search(query, case_sensitive, active_screen)
|
||||
}
|
||||
|
||||
pub(crate) fn search_text_window(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -294,60 +270,6 @@ impl PaneTerminal {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn text_match_is_current(&self, text_match: TerminalTextMatch) -> bool {
|
||||
self.text_matches_are_current(&[text_match])
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn text_matches_are_current(&self, text_matches: &[TerminalTextMatch]) -> Vec<bool> {
|
||||
if text_matches.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(core) = self.ghostty.core.lock() else {
|
||||
return vec![false; text_matches.len()];
|
||||
};
|
||||
let Some(cols) = core.terminal.cols().ok() else {
|
||||
return vec![false; text_matches.len()];
|
||||
};
|
||||
let Some(active_screen) = core.terminal.active_screen().ok() else {
|
||||
return vec![false; text_matches.len()];
|
||||
};
|
||||
let row_range = text_matches
|
||||
.iter()
|
||||
.filter(|text_match| {
|
||||
text_match.scan_cols == cols && text_match.scan_screen == active_screen
|
||||
})
|
||||
.fold(None::<(u32, u32)>, |range, text_match| {
|
||||
Some(match range {
|
||||
Some((start_row, end_row)) => (
|
||||
start_row.min(text_match.start.row),
|
||||
end_row.max(text_match.end.row),
|
||||
),
|
||||
None => (text_match.start.row, text_match.end.row),
|
||||
})
|
||||
});
|
||||
let Some((start_row, end_row)) = row_range else {
|
||||
return vec![false; text_matches.len()];
|
||||
};
|
||||
let Ok(rows) = core
|
||||
.terminal
|
||||
.screen_text_rows_range(start_row as usize, end_row.saturating_add(1) as usize)
|
||||
else {
|
||||
return vec![false; text_matches.len()];
|
||||
};
|
||||
let buffer = RetainedTextBuffer::new_search(cols, rows, start_row);
|
||||
text_matches
|
||||
.iter()
|
||||
.map(|text_match| {
|
||||
text_match.scan_cols == cols
|
||||
&& text_match.scan_screen == active_screen
|
||||
&& buffer.contains_match(*text_match)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn word_motion_target(
|
||||
&self,
|
||||
row: u32,
|
||||
@@ -482,10 +404,6 @@ impl PaneTerminal {
|
||||
self.ghostty.input_state()
|
||||
}
|
||||
|
||||
pub fn keyboard_report_all_requested(&self) -> bool {
|
||||
self.ghostty.keyboard_report_all_requested()
|
||||
}
|
||||
|
||||
pub fn bracketed_paste_enabled(&self) -> bool {
|
||||
self.ghostty.bracketed_paste_enabled()
|
||||
}
|
||||
@@ -498,6 +416,10 @@ impl PaneTerminal {
|
||||
self.ghostty.mouse_reporting_enabled()
|
||||
}
|
||||
|
||||
pub fn modify_other_keys_level(&self) -> u8 {
|
||||
self.ghostty.modify_other_keys_level()
|
||||
}
|
||||
|
||||
pub fn sgr_pixel_mouse_enabled(&self) -> bool {
|
||||
self.ghostty.sgr_pixel_mouse_enabled()
|
||||
}
|
||||
@@ -572,14 +494,6 @@ impl PaneTerminal {
|
||||
self.ghostty.render(frame, area, show_cursor);
|
||||
}
|
||||
|
||||
pub fn collect_dirty_patch(
|
||||
&self,
|
||||
area_width: u16,
|
||||
area_height: u16,
|
||||
) -> TerminalDirtyPatchOutcome {
|
||||
self.ghostty.collect_dirty_patch(area_width, area_height)
|
||||
}
|
||||
|
||||
pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> {
|
||||
self.ghostty.visible_hyperlinks(area)
|
||||
}
|
||||
@@ -619,12 +533,10 @@ impl PaneTerminal {
|
||||
self.ghostty.terminal_title()
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // exposed for Stage C (detection loop wiring)
|
||||
pub fn agent_osc_title(&self) -> String {
|
||||
self.ghostty.agent_osc_title()
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // exposed for Stage C (detection loop wiring)
|
||||
pub fn agent_osc_progress(&self) -> String {
|
||||
self.ghostty.agent_osc_progress()
|
||||
}
|
||||
@@ -831,50 +743,6 @@ impl RetainedTextBuffer {
|
||||
Self { cols, lines, atoms }
|
||||
}
|
||||
|
||||
fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
case_sensitive: bool,
|
||||
active_screen: crate::ghostty::ActiveScreen,
|
||||
) -> Vec<TerminalTextMatch> {
|
||||
if query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(regex) = regex::RegexBuilder::new(®ex::escape(query))
|
||||
.case_insensitive(!case_sensitive)
|
||||
.build()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut matches = Vec::new();
|
||||
for line in &self.lines {
|
||||
for found in regex.find_iter(&line.text) {
|
||||
let Ok(start_index) = line
|
||||
.spans
|
||||
.binary_search_by_key(&found.start(), |span| span.byte_start)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(end_index) = line
|
||||
.spans
|
||||
.binary_search_by_key(&found.end(), |span| span.byte_end)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let start_span = &line.spans[start_index];
|
||||
let end_span = &line.spans[end_index];
|
||||
matches.push(TerminalTextMatch {
|
||||
start: start_span.start,
|
||||
end: end_span.end,
|
||||
source_fingerprint: text_fingerprint(found.as_str()),
|
||||
scan_cols: self.cols,
|
||||
scan_screen: active_screen,
|
||||
});
|
||||
}
|
||||
}
|
||||
matches
|
||||
}
|
||||
|
||||
fn search_window(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -993,28 +861,6 @@ impl RetainedTextBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_match(&self, text_match: TerminalTextMatch) -> bool {
|
||||
self.lines.iter().any(|line| {
|
||||
let Ok(start_index) = line
|
||||
.spans
|
||||
.binary_search_by_key(&text_match.start, |span| span.start)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(end_index) = line
|
||||
.spans
|
||||
.binary_search_by_key(&text_match.end, |span| span.end)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let start_span = &line.spans[start_index];
|
||||
let end_span = &line.spans[end_index];
|
||||
start_span.byte_start <= end_span.byte_end
|
||||
&& text_fingerprint(&line.text[start_span.byte_start..end_span.byte_end])
|
||||
== text_match.source_fingerprint
|
||||
})
|
||||
}
|
||||
|
||||
fn word_motion(
|
||||
&self,
|
||||
row: u32,
|
||||
@@ -1394,7 +1240,6 @@ impl GhosttyPaneTerminal {
|
||||
|
||||
/// Returns the latest OSC 0/2 title retained for agent detection, or `""`
|
||||
/// if no title has been seen or the last update was an empty clear.
|
||||
#[allow(dead_code)] // exposed for Stage C (detection loop wiring)
|
||||
pub fn agent_osc_title(&self) -> String {
|
||||
self.core
|
||||
.lock()
|
||||
@@ -1404,7 +1249,6 @@ impl GhosttyPaneTerminal {
|
||||
|
||||
/// Returns the latest OSC 9 progress payload retained for agent detection,
|
||||
/// or `""` if none has been seen.
|
||||
#[allow(dead_code)] // exposed for Stage C (detection loop wiring)
|
||||
pub fn agent_osc_progress(&self) -> String {
|
||||
self.core
|
||||
.lock()
|
||||
@@ -1656,7 +1500,6 @@ impl GhosttyPaneTerminal {
|
||||
let Ok(mut core) = self.core.lock() else {
|
||||
return;
|
||||
};
|
||||
#[cfg(windows)]
|
||||
core.kitty_keyboard.observe(ansi.as_bytes());
|
||||
core.terminal.write(ansi.as_bytes());
|
||||
#[cfg(windows)]
|
||||
@@ -1721,6 +1564,7 @@ impl GhosttyPaneTerminal {
|
||||
}
|
||||
|
||||
if input_state.modify_other_keys {
|
||||
core.kitty_keyboard.observe(b"\x1b[>4;2m");
|
||||
core.terminal.write(b"\x1b[>4;2m");
|
||||
}
|
||||
|
||||
@@ -1882,17 +1726,6 @@ impl GhosttyPaneTerminal {
|
||||
core.kitty_keyboard.replay_ansi()
|
||||
}
|
||||
|
||||
pub fn keyboard_report_all_requested(&self) -> bool {
|
||||
self.core.lock().is_ok_and(|core| {
|
||||
let protocol = crate::input::KeyboardProtocol::from_kitty_flags(
|
||||
core.terminal.kitty_keyboard_flags().unwrap_or(0) as u16,
|
||||
);
|
||||
protocol.reports_all_keys()
|
||||
|| (protocol.reports_event_types()
|
||||
&& core.terminal.modify_other_keys_enabled().unwrap_or(false))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bracketed_paste_enabled(&self) -> bool {
|
||||
self.mode_enabled(crate::ghostty::MODE_BRACKETED_PASTE)
|
||||
}
|
||||
@@ -1907,6 +1740,12 @@ impl GhosttyPaneTerminal {
|
||||
.is_ok_and(|core| core.terminal.mouse_tracking_enabled().unwrap_or(false))
|
||||
}
|
||||
|
||||
pub fn modify_other_keys_level(&self) -> u8 {
|
||||
self.core
|
||||
.lock()
|
||||
.map_or(0, |core| core.kitty_keyboard.modify_other_keys_level())
|
||||
}
|
||||
|
||||
pub fn sgr_pixel_mouse_enabled(&self) -> bool {
|
||||
self.mode_enabled(crate::ghostty::MODE_MOUSE_SGR_PIXELS)
|
||||
}
|
||||
@@ -2409,18 +2248,6 @@ impl GhosttyPaneTerminal {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn collect_dirty_patch(
|
||||
&self,
|
||||
area_width: u16,
|
||||
area_height: u16,
|
||||
) -> TerminalDirtyPatchOutcome {
|
||||
self.core
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|mut core| ghostty_collect_dirty_patch(&mut core, area_width, area_height))
|
||||
.unwrap_or(TerminalDirtyPatchOutcome::Fallback)
|
||||
}
|
||||
}
|
||||
|
||||
fn encoded_key_preserves_event_kind(
|
||||
@@ -2523,166 +2350,6 @@ fn ghostty_clear_render_dirty(render_state: &mut crate::ghostty::RenderState, ar
|
||||
let _ = render_state.set_dirty(crate::ghostty::Dirty::Clean);
|
||||
}
|
||||
|
||||
fn ghostty_collect_dirty_patch(
|
||||
core: &mut GhosttyPaneCore,
|
||||
area_width: u16,
|
||||
area_height: u16,
|
||||
) -> TerminalDirtyPatchOutcome {
|
||||
let prof_started = crate::render_prof::timer();
|
||||
macro_rules! finish {
|
||||
($outcome:expr) => {{
|
||||
let outcome = $outcome;
|
||||
if let Some(started) = prof_started {
|
||||
crate::render_prof::duration("dirty_collect.total", started.elapsed());
|
||||
match &outcome {
|
||||
TerminalDirtyPatchOutcome::Clean => {
|
||||
crate::render_prof::event("dirty_collect.clean");
|
||||
}
|
||||
TerminalDirtyPatchOutcome::Fallback => {
|
||||
crate::render_prof::event("dirty_collect.fallback");
|
||||
}
|
||||
TerminalDirtyPatchOutcome::Patch(patch) => {
|
||||
crate::render_prof::event("dirty_collect.patch");
|
||||
crate::render_prof::counter("dirty_collect.rows", patch.rows.len() as u64);
|
||||
let cells = patch.rows.iter().map(|(_, cells)| cells.len() as u64).sum();
|
||||
crate::render_prof::counter("dirty_collect.cells", cells);
|
||||
}
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}};
|
||||
}
|
||||
macro_rules! fallback {
|
||||
($reason:literal) => {{
|
||||
crate::render_prof::event(concat!("dirty_fallback.", $reason));
|
||||
finish!(TerminalDirtyPatchOutcome::Fallback);
|
||||
}};
|
||||
}
|
||||
|
||||
let host_theme = core.host_terminal_theme;
|
||||
let initial_default_foreground = core.initial_default_foreground;
|
||||
let initial_default_background = core.initial_default_background;
|
||||
let GhosttyPaneCore {
|
||||
terminal,
|
||||
render_state,
|
||||
..
|
||||
} = core;
|
||||
if render_state.update(terminal).is_err() {
|
||||
fallback!("render_state_update_error");
|
||||
}
|
||||
match render_state.dirty() {
|
||||
Ok(crate::ghostty::Dirty::Clean) => finish!(TerminalDirtyPatchOutcome::Clean),
|
||||
Ok(crate::ghostty::Dirty::Partial) => {}
|
||||
Ok(crate::ghostty::Dirty::Full) => fallback!("dirty_full"),
|
||||
Err(_) => fallback!("dirty_read_error"),
|
||||
}
|
||||
|
||||
let colors = render_state.colors().ok();
|
||||
let default_bg = colors
|
||||
.and_then(|c| ghostty_default_bg(c.background, host_theme, initial_default_background));
|
||||
let default_fg = colors
|
||||
.and_then(|c| ghostty_default_fg(c.foreground, host_theme, initial_default_foreground));
|
||||
let resolved_fg = colors.map(|c| ghostty_color(c.foreground));
|
||||
let resolved_bg = colors.map(|c| ghostty_color(c.background));
|
||||
let palette_overrides = colors
|
||||
.zip(terminal.default_palette().ok())
|
||||
.and_then(|(colors, default)| PaletteOverrides::new(&colors.palette, &default));
|
||||
let hide_kitty_placeholders = crate::kitty_graphics::is_enabled();
|
||||
|
||||
let Ok(mut row_iterator) = crate::ghostty::RowIterator::new() else {
|
||||
fallback!("row_iterator_new_error");
|
||||
};
|
||||
let Ok(mut row_cells) = crate::ghostty::RowCells::new() else {
|
||||
fallback!("row_cells_new_error");
|
||||
};
|
||||
let Ok(mut rows) = render_state.populate_row_iterator(&mut row_iterator) else {
|
||||
fallback!("populate_rows_error");
|
||||
};
|
||||
let mut grapheme_bytes = Vec::new();
|
||||
let mut symbol_scratch = String::new();
|
||||
let mut patch_rows = Vec::new();
|
||||
let mut y = 0u16;
|
||||
while y < area_height && rows.next() {
|
||||
let Ok(dirty) = rows.dirty() else {
|
||||
fallback!("row_dirty_read_error");
|
||||
};
|
||||
if dirty {
|
||||
match rows.selection() {
|
||||
Ok(None) => {}
|
||||
Ok(Some(_)) => fallback!("row_selection_present"),
|
||||
Err(_) => fallback!("row_selection_error"),
|
||||
}
|
||||
let Ok(mut cells) = rows.populate_cells(&mut row_cells) else {
|
||||
fallback!("populate_cells_error");
|
||||
};
|
||||
let mut patch_cells = Vec::with_capacity(usize::from(area_width));
|
||||
let mut x = 0u16;
|
||||
while x < area_width && cells.next() {
|
||||
let Ok(basic) = cells.basic_data() else {
|
||||
fallback!("basic_data_error");
|
||||
};
|
||||
if basic.has_hyperlink {
|
||||
fallback!("hyperlink_present");
|
||||
}
|
||||
let style = ghostty_cell_style(
|
||||
&cells,
|
||||
&basic,
|
||||
default_fg,
|
||||
default_bg,
|
||||
resolved_fg,
|
||||
resolved_bg,
|
||||
palette_overrides.as_ref(),
|
||||
);
|
||||
let symbol = match ghostty_buffer_symbol_into(
|
||||
&cells,
|
||||
basic.wide,
|
||||
hide_kitty_placeholders,
|
||||
&mut grapheme_bytes,
|
||||
&mut symbol_scratch,
|
||||
) {
|
||||
Ok(symbol) => symbol.to_owned(),
|
||||
Err(_) => ghostty_blank_symbol_for_width(basic.wide).to_owned(),
|
||||
};
|
||||
patch_cells.push(cell_data_from_style(symbol, style));
|
||||
x += 1;
|
||||
}
|
||||
while x < area_width {
|
||||
patch_cells.push(blank_cell_data(default_fg, default_bg));
|
||||
x += 1;
|
||||
}
|
||||
patch_rows.push((y, patch_cells));
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
|
||||
let dirty_ys: std::collections::HashSet<u16> = patch_rows.iter().map(|(row, _)| *row).collect();
|
||||
if !dirty_ys.is_empty() {
|
||||
let Ok(mut clear_row_iterator) = crate::ghostty::RowIterator::new() else {
|
||||
fallback!("clear_row_iterator_new_error");
|
||||
};
|
||||
let Ok(mut clear_rows) = render_state.populate_row_iterator(&mut clear_row_iterator) else {
|
||||
fallback!("clear_populate_rows_error");
|
||||
};
|
||||
let mut clear_y = 0u16;
|
||||
while clear_y < area_height && clear_rows.next() {
|
||||
if dirty_ys.contains(&clear_y) && clear_rows.clear_dirty().is_err() {
|
||||
fallback!("clear_dirty_error");
|
||||
}
|
||||
clear_y += 1;
|
||||
}
|
||||
}
|
||||
if render_state
|
||||
.set_dirty(crate::ghostty::Dirty::Clean)
|
||||
.is_err()
|
||||
{
|
||||
fallback!("set_clean_error");
|
||||
}
|
||||
|
||||
finish!(TerminalDirtyPatchOutcome::Patch(TerminalDirtyPatch {
|
||||
rows: patch_rows
|
||||
}));
|
||||
}
|
||||
|
||||
fn ghostty_visible_hyperlinks(
|
||||
core: &mut GhosttyPaneCore,
|
||||
area: Rect,
|
||||
@@ -3125,24 +2792,6 @@ fn ghostty_reset_cell(
|
||||
}
|
||||
}
|
||||
|
||||
fn blank_cell_data(default_fg: Option<Color>, default_bg: Option<Color>) -> CellData {
|
||||
cell_data_from_style(
|
||||
" ".to_string(),
|
||||
ghostty_default_style(default_fg, default_bg),
|
||||
)
|
||||
}
|
||||
|
||||
fn cell_data_from_style(symbol: String, style: Style) -> CellData {
|
||||
CellData {
|
||||
symbol,
|
||||
fg: crate::protocol::color_to_u32(style.fg.unwrap_or(Color::Reset)),
|
||||
bg: crate::protocol::color_to_u32(style.bg.unwrap_or(Color::Reset)),
|
||||
modifier: crate::protocol::modifier_to_u16(style.add_modifier),
|
||||
skip: false,
|
||||
hyperlink: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ghostty_default_style(default_fg: Option<Color>, default_bg: Option<Color>) -> Style {
|
||||
let mut style = Style::default();
|
||||
if let Some(fg) = default_fg {
|
||||
@@ -3626,7 +3275,17 @@ mod tests {
|
||||
query: &str,
|
||||
case_sensitive: bool,
|
||||
) -> Vec<TerminalTextMatch> {
|
||||
buffer.search(query, case_sensitive, crate::ghostty::ActiveScreen::Primary)
|
||||
buffer
|
||||
.search_window(
|
||||
query,
|
||||
case_sensitive,
|
||||
crate::ghostty::ActiveScreen::Primary,
|
||||
TerminalSearchDirection::Forward,
|
||||
TerminalTextPoint { row: 0, col: 0 },
|
||||
None,
|
||||
usize::MAX,
|
||||
)
|
||||
.matches
|
||||
}
|
||||
|
||||
fn write_numbered_lines(terminal: &mut crate::ghostty::Terminal, count: usize) {
|
||||
@@ -3858,56 +3517,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_terminal_match_validation_rejects_overwritten_text() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap();
|
||||
terminal.write(b"alpha needle");
|
||||
let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap());
|
||||
|
||||
let text_match = pane.search_text_matches("needle", true)[0];
|
||||
assert!(pane.text_match_is_current(text_match));
|
||||
pane.ghostty
|
||||
.core
|
||||
.lock()
|
||||
.unwrap()
|
||||
.terminal
|
||||
.write(b"\r\x1b[2Kalpha changed");
|
||||
assert!(!pane.text_match_is_current(text_match));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_terminal_match_validation_handles_soft_wrapped_matches() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let mut terminal = crate::ghostty::Terminal::new(5, 3, 100).unwrap();
|
||||
terminal.write(b"abcdef");
|
||||
let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap());
|
||||
|
||||
let text_match = pane.search_text_matches("def", true)[0];
|
||||
|
||||
assert_eq!(text_match.start, TerminalTextPoint { row: 0, col: 3 });
|
||||
assert_eq!(text_match.end, TerminalTextPoint { row: 1, col: 0 });
|
||||
assert!(pane.text_match_is_current(text_match));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_terminal_match_validation_rejects_an_active_screen_change() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap();
|
||||
terminal.write(b"alpha needle");
|
||||
let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap());
|
||||
|
||||
let text_match = pane.search_text_matches("needle", true)[0];
|
||||
pane.ghostty
|
||||
.core
|
||||
.lock()
|
||||
.unwrap()
|
||||
.terminal
|
||||
.write(b"\x1b[?1049hneedle");
|
||||
|
||||
assert!(!pane.text_match_is_current(text_match));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_terminal_word_motion_expands_across_long_blank_history() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
@@ -3932,7 +3541,16 @@ mod tests {
|
||||
let word = "a".repeat(132);
|
||||
terminal.write(word.as_bytes());
|
||||
let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap());
|
||||
let text_match = pane.search_text_matches(&word, true)[0];
|
||||
let text_match = pane
|
||||
.search_text_window(
|
||||
&word,
|
||||
true,
|
||||
TerminalSearchDirection::Forward,
|
||||
TerminalTextPoint { row: 0, col: 0 },
|
||||
None,
|
||||
1,
|
||||
)
|
||||
.matches[0];
|
||||
|
||||
assert_eq!(
|
||||
pane.word_motion_target(
|
||||
@@ -3951,7 +3569,16 @@ mod tests {
|
||||
let word = "界".repeat(66);
|
||||
terminal.write(word.as_bytes());
|
||||
let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap());
|
||||
let text_match = pane.search_text_matches(&word, true)[0];
|
||||
let text_match = pane
|
||||
.search_text_window(
|
||||
&word,
|
||||
true,
|
||||
TerminalSearchDirection::Forward,
|
||||
TerminalTextPoint { row: 0, col: 0 },
|
||||
None,
|
||||
1,
|
||||
)
|
||||
.matches[0];
|
||||
|
||||
// The word end sits on the head cell of the final wide glyph, past the
|
||||
// initial read window, so the window has to expand to reach it.
|
||||
@@ -4683,6 +4310,7 @@ mod tests {
|
||||
color_scheme_reporting: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(pane.modify_other_keys_level(), 2);
|
||||
|
||||
let encoded = pane.encode_terminal_key(
|
||||
crate::input::TerminalKey::new(
|
||||
@@ -4900,6 +4528,7 @@ mod tests {
|
||||
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
|
||||
|
||||
pane.seed_history_ansi("\x1b[>4;1m");
|
||||
assert_eq!(pane.modify_other_keys_level(), 1);
|
||||
let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
|
||||
|
||||
assert_eq!(encoded, b"\x1b[27;2;13~");
|
||||
@@ -5259,35 +4888,6 @@ mod tests {
|
||||
assert_eq!(buffer[(2, 0)].symbol(), "Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_patch_keeps_halfwidth_katakana_voiced_tail_empty() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let terminal = crate::ghostty::Terminal::new(20, 1, 0).unwrap();
|
||||
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
|
||||
let backend = ratatui::backend::TestBackend::new(20, 1);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 1), false))
|
||||
.unwrap();
|
||||
{
|
||||
let mut core = pane.core.lock().unwrap();
|
||||
core.terminal.write("ガZ".as_bytes());
|
||||
}
|
||||
|
||||
let patch = match pane.collect_dirty_patch(20, 1) {
|
||||
TerminalDirtyPatchOutcome::Patch(patch) => patch,
|
||||
other => panic!("expected dirty patch, got {other:?}"),
|
||||
};
|
||||
let row = &patch.rows[0].1;
|
||||
|
||||
assert_eq!(row[0].symbol, "カ\u{ff9e}");
|
||||
assert_eq!(
|
||||
row[1].symbol, "",
|
||||
"wide spacer tail must stay empty in retained terminal patches"
|
||||
);
|
||||
assert_eq!(row[2].symbol, "Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_scrollback_controls_round_trip_and_clamp_without_ui_interference() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
@@ -6180,34 +5780,6 @@ mod tests {
|
||||
assert_eq!(style.underline_color, Some(Color::Rgb(17, 34, 51)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_patch_preserves_curly_underline_style() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
|
||||
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
|
||||
let backend = ratatui::backend::TestBackend::new(20, 5);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false))
|
||||
.unwrap();
|
||||
{
|
||||
let mut core = pane.core.lock().unwrap();
|
||||
core.terminal.write(b"\x1b[4:3mU");
|
||||
}
|
||||
|
||||
let patch = match pane.collect_dirty_patch(20, 5) {
|
||||
TerminalDirtyPatchOutcome::Patch(patch) => patch,
|
||||
other => panic!("expected dirty patch, got {other:?}"),
|
||||
};
|
||||
|
||||
let cell = &patch.rows[0].1[0];
|
||||
assert_eq!(cell.symbol, "U");
|
||||
assert_eq!(
|
||||
crate::protocol::underline_style_from_modifier(cell.modifier),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_frame_preserves_curly_underline_style() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user