mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
feat: render mobile switcher in client shell
This commit is contained in:
@@ -4405,6 +4405,13 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source_workspace_id": {
|
||||
"description": "Workspace whose focused pane supplies the `follow` cwd policy.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -50,6 +50,7 @@ fn request_uses_dot_method_names() {
|
||||
let request = Request {
|
||||
id: "req_1".into(),
|
||||
method: Method::WorkspaceCreate(WorkspaceCreateParams {
|
||||
source_workspace_id: None,
|
||||
cwd: Some("/tmp".into()),
|
||||
focus: true,
|
||||
label: Some("api".into()),
|
||||
|
||||
@@ -6,6 +6,9 @@ use super::common::AgentStatus;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct WorkspaceCreateParams {
|
||||
/// Workspace whose focused pane supplies the `follow` cwd policy.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_workspace_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(default)]
|
||||
|
||||
+112
-6
@@ -41,12 +41,25 @@ impl App {
|
||||
id: String,
|
||||
params: WorkspaceCreateParams,
|
||||
) -> String {
|
||||
let source_workspace_index = if params.cwd.is_some() {
|
||||
None
|
||||
} else {
|
||||
match params.source_workspace_id.as_deref() {
|
||||
Some(workspace_id) => match self
|
||||
.parse_workspace_id(workspace_id)
|
||||
.filter(|index| self.state.workspaces.get(*index).is_some())
|
||||
{
|
||||
Some(index) => Some(index),
|
||||
None => return workspace_not_found(id, workspace_id),
|
||||
},
|
||||
None => self.workspace_creation_source(),
|
||||
}
|
||||
};
|
||||
let cwd = params.cwd.map(PathBuf::from).unwrap_or_else(|| {
|
||||
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))
|
||||
});
|
||||
self.resolve_new_terminal_cwd(follow_cwd)
|
||||
source_workspace_index.map_or_else(
|
||||
|| self.resolve_new_terminal_cwd(None),
|
||||
|index| self.resolved_new_workspace_cwd_from(index),
|
||||
)
|
||||
});
|
||||
let extra_env = match super::env::normalize_launch_env(params.env) {
|
||||
Ok(env) => env,
|
||||
@@ -360,7 +373,11 @@ fn workspace_not_found(id: String, workspace_id: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace};
|
||||
use crate::{
|
||||
api::schema::{ErrorResponse, SuccessResponse},
|
||||
config::Config,
|
||||
workspace::Workspace,
|
||||
};
|
||||
|
||||
// `new_cwd = follow` must anchor on the focused pane for every creation
|
||||
// surface. Splits and tabs already do; a new workspace must follow the
|
||||
@@ -419,6 +436,7 @@ mod tests {
|
||||
let response = app.handle_workspace_create(
|
||||
"req".into(),
|
||||
WorkspaceCreateParams {
|
||||
source_workspace_id: None,
|
||||
cwd: None,
|
||||
focus: false,
|
||||
label: None,
|
||||
@@ -444,6 +462,94 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&focused_cwd);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_create_uses_explicit_source_workspace() {
|
||||
use super::super::test_support::{exiting_test_command, shutdown_test_runtimes};
|
||||
use crate::config::ShellModeConfig;
|
||||
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&Config::default(),
|
||||
true,
|
||||
None,
|
||||
api_rx,
|
||||
crate::api::EventHub::default(),
|
||||
);
|
||||
app.state.default_shell = exiting_test_command().into();
|
||||
app.state.shell_mode = ShellModeConfig::NonLogin;
|
||||
app.state.workspaces = vec![Workspace::test_new("first"), Workspace::test_new("source")];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.ensure_test_terminals();
|
||||
shutdown_test_runtimes(&mut app);
|
||||
|
||||
let source_cwd =
|
||||
std::env::temp_dir().join(format!("herdr-ws-explicit-source-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&source_cwd).unwrap();
|
||||
let pane_id = app.state.workspaces[1].focused_pane_id().unwrap();
|
||||
let terminal_id = app.state.workspaces[1]
|
||||
.terminal_id(pane_id)
|
||||
.cloned()
|
||||
.unwrap();
|
||||
app.state.terminals.get_mut(&terminal_id).unwrap().cwd = source_cwd.clone();
|
||||
let source_workspace_id = app.public_workspace_id(1);
|
||||
|
||||
let response = app.handle_workspace_create(
|
||||
"req".into(),
|
||||
WorkspaceCreateParams {
|
||||
source_workspace_id: Some(source_workspace_id),
|
||||
cwd: None,
|
||||
focus: false,
|
||||
label: None,
|
||||
env: Default::default(),
|
||||
},
|
||||
);
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
assert!(matches!(
|
||||
success.result,
|
||||
ResponseResult::WorkspaceCreated { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
crate::worktree::canonical_or_original(&app.state.workspaces[2].identity_cwd),
|
||||
crate::worktree::canonical_or_original(&source_cwd)
|
||||
);
|
||||
|
||||
let invalid = app.handle_workspace_create(
|
||||
"invalid".into(),
|
||||
WorkspaceCreateParams {
|
||||
source_workspace_id: Some("w_999".into()),
|
||||
cwd: None,
|
||||
focus: false,
|
||||
label: None,
|
||||
env: Default::default(),
|
||||
},
|
||||
);
|
||||
let error: ErrorResponse = serde_json::from_str(&invalid).unwrap();
|
||||
assert_eq!(error.error.code, "workspace_not_found");
|
||||
|
||||
let captured = app.handle_workspace_create(
|
||||
"captured".into(),
|
||||
WorkspaceCreateParams {
|
||||
source_workspace_id: Some("w_999".into()),
|
||||
cwd: Some(source_cwd.display().to_string()),
|
||||
focus: false,
|
||||
label: None,
|
||||
env: Default::default(),
|
||||
},
|
||||
);
|
||||
let success: SuccessResponse = serde_json::from_str(&captured).unwrap();
|
||||
assert!(matches!(
|
||||
success.result,
|
||||
ResponseResult::WorkspaceCreated { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
crate::worktree::canonical_or_original(&app.state.workspaces[3].identity_cwd),
|
||||
crate::worktree::canonical_or_original(&source_cwd)
|
||||
);
|
||||
shutdown_test_runtimes(&mut app);
|
||||
let _ = std::fs::remove_dir_all(&source_cwd);
|
||||
}
|
||||
|
||||
fn app_with_linked_worktree() -> App {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
|
||||
@@ -81,6 +81,13 @@ impl App {
|
||||
resolve_new_terminal_cwd(&self.state.new_terminal_cwd, follow_cwd)
|
||||
}
|
||||
|
||||
pub(crate) fn resolved_new_workspace_cwd_from(&self, ws_idx: usize) -> PathBuf {
|
||||
let follow_cwd = self
|
||||
.focused_pane_cwd_in_workspace(ws_idx)
|
||||
.or_else(|| self.seed_cwd_from_workspace(ws_idx));
|
||||
self.resolve_new_terminal_cwd(follow_cwd)
|
||||
}
|
||||
|
||||
pub(super) fn workspace_creation_source(&self) -> Option<usize> {
|
||||
if self.state.mode == Mode::Navigate
|
||||
&& self.state.workspaces.get(self.state.selected).is_some()
|
||||
@@ -110,6 +117,7 @@ impl App {
|
||||
self.runtime_workspace_create(
|
||||
request_id,
|
||||
crate::api::schema::WorkspaceCreateParams {
|
||||
source_workspace_id: None,
|
||||
cwd: None,
|
||||
focus: true,
|
||||
label: None,
|
||||
|
||||
@@ -1010,6 +1010,7 @@ impl App {
|
||||
self.runtime_workspace_create(
|
||||
"tui.workspace.create_named",
|
||||
crate::api::schema::WorkspaceCreateParams {
|
||||
source_workspace_id: None,
|
||||
cwd: Some(cwd.display().to_string()),
|
||||
focus: true,
|
||||
label,
|
||||
|
||||
@@ -1025,6 +1025,7 @@ impl App {
|
||||
self.runtime_workspace_create(
|
||||
"tui.workspace.create",
|
||||
crate::api::schema::WorkspaceCreateParams {
|
||||
source_workspace_id: None,
|
||||
cwd: None,
|
||||
focus: true,
|
||||
label: None,
|
||||
@@ -1064,6 +1065,7 @@ impl App {
|
||||
self.runtime_workspace_create(
|
||||
"tui.workspace.create_cwd",
|
||||
crate::api::schema::WorkspaceCreateParams {
|
||||
source_workspace_id: None,
|
||||
cwd: Some(cwd.display().to_string()),
|
||||
focus: true,
|
||||
label: None,
|
||||
|
||||
@@ -94,6 +94,7 @@ fn workspace_create(args: &[String]) -> std::io::Result<i32> {
|
||||
}
|
||||
|
||||
super::runtime::workspace_create(WorkspaceCreateParams {
|
||||
source_workspace_id: None,
|
||||
cwd,
|
||||
focus,
|
||||
label,
|
||||
|
||||
+517
-10
@@ -8,6 +8,7 @@ mod context_menu;
|
||||
mod copy_mode;
|
||||
mod global_menu;
|
||||
mod input;
|
||||
mod mobile;
|
||||
mod mouse;
|
||||
mod notifications;
|
||||
mod overlay_input;
|
||||
@@ -293,6 +294,8 @@ mod tests {
|
||||
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,
|
||||
@@ -3474,6 +3477,8 @@ mod tests {
|
||||
});
|
||||
snapshot.workspaces.push(ClientShellWorkspace {
|
||||
workspace_id: "ws_2".into(),
|
||||
active_tab_id: "tab_ws2".into(),
|
||||
new_workspace_cwd: "/repo/feature".into(),
|
||||
number: 2,
|
||||
label: "repo-feature".into(),
|
||||
custom_label: false,
|
||||
@@ -4245,7 +4250,7 @@ detach = "prefix+x"
|
||||
.iter()
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>();
|
||||
assert!(!mobile_text.contains("update ready"));
|
||||
assert!(mobile_text.contains("update ready"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5200,13 +5205,13 @@ detach = "prefix+x"
|
||||
snapshot.workspaces.push(second);
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot));
|
||||
state.mode = ClientShellMode::Navigate;
|
||||
state.navigate_workspace_id = Some("ws_2".into());
|
||||
|
||||
let mut rename = ClientShellInput::default();
|
||||
state.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::RenameWorkspace),
|
||||
&mut rename,
|
||||
);
|
||||
let rename = state.handle_raw_events(vec![RawInputEvent::Key(
|
||||
crate::input::TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT),
|
||||
)]);
|
||||
assert!(rename.actions.is_empty());
|
||||
assert!(matches!(
|
||||
state.overlay.as_ref(),
|
||||
Some(ClientShellOverlay::Rename(ClientRenameOverlay {
|
||||
@@ -5252,7 +5257,7 @@ detach = "prefix+x"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_workspace_overlay_uses_projected_pane_cwd() {
|
||||
fn named_workspace_overlay_targets_projected_source_workspace() {
|
||||
let mut config = Config::default();
|
||||
config.ui.prompt_new_workspace_name = true;
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
|
||||
@@ -5266,9 +5271,12 @@ detach = "prefix+x"
|
||||
state.overlay.as_ref(),
|
||||
Some(ClientShellOverlay::Rename(ClientRenameOverlay {
|
||||
input: value,
|
||||
target: ClientRenameTarget::NewWorkspace { cwd, .. },
|
||||
target: ClientRenameTarget::NewWorkspace {
|
||||
source_workspace_id,
|
||||
..
|
||||
},
|
||||
..
|
||||
})) if value == "repo" && cwd.as_deref() == Some("/repo")
|
||||
})) if value == "repo" && source_workspace_id.as_deref() == Some("ws_1")
|
||||
));
|
||||
let create = state.handle_input_bytes(b"\r");
|
||||
let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else {
|
||||
@@ -5277,7 +5285,9 @@ detach = "prefix+x"
|
||||
assert!(matches!(
|
||||
&request.method,
|
||||
crate::api::schema::Method::WorkspaceCreate(params)
|
||||
if params.cwd.as_deref() == Some("/repo") && params.label.is_none()
|
||||
if params.source_workspace_id.as_deref() == Some("ws_1")
|
||||
&& params.cwd.as_deref() == Some("/repo")
|
||||
&& params.label.is_none()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -6360,4 +6370,501 @@ detach = "prefix+x"
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ impl ClientShellState {
|
||||
self.push_endpoint_method(
|
||||
crate::api::schema::Method::WorkspaceCreate(
|
||||
crate::api::schema::WorkspaceCreateParams {
|
||||
source_workspace_id: self.workspace_action_id(),
|
||||
cwd: None,
|
||||
focus: true,
|
||||
label: None,
|
||||
@@ -125,6 +126,8 @@ impl ClientShellState {
|
||||
return;
|
||||
}
|
||||
if action == crate::input::KeybindAction::WorkspacePicker {
|
||||
self.mobile_switcher_scroll = 0;
|
||||
self.reveal_mobile_workspace = false;
|
||||
self.mode = ClientShellMode::Navigate;
|
||||
self.navigate_workspace_id = self
|
||||
.snapshot
|
||||
@@ -704,7 +707,7 @@ impl ClientShellState {
|
||||
}))
|
||||
}
|
||||
KeybindAction::SwitchWorkspace(index) => {
|
||||
let entries = render::workspace_entries(snapshot, &self.collapsed_groups);
|
||||
let entries = self.navigation_workspace_entries(snapshot);
|
||||
Some(Method::WorkspaceFocus(WorkspaceTarget {
|
||||
workspace_id: snapshot
|
||||
.workspaces
|
||||
@@ -714,7 +717,7 @@ impl ClientShellState {
|
||||
}))
|
||||
}
|
||||
KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace => {
|
||||
let entries = render::workspace_entries(snapshot, &self.collapsed_groups);
|
||||
let entries = self.navigation_workspace_entries(snapshot);
|
||||
let current = entries.iter().position(|entry| {
|
||||
snapshot.workspaces[entry.index].workspace_id == focused_workspace
|
||||
})?;
|
||||
|
||||
@@ -19,15 +19,6 @@ fn restore_mode_bar(
|
||||
}
|
||||
}
|
||||
|
||||
fn clipboard_feedback_starts_at_top(position: crate::config::ToastClipboardPosition) -> bool {
|
||||
matches!(
|
||||
position,
|
||||
crate::config::ToastClipboardPosition::TopLeft
|
||||
| crate::config::ToastClipboardPosition::TopCenter
|
||||
| crate::config::ToastClipboardPosition::TopRight
|
||||
)
|
||||
}
|
||||
|
||||
impl ClientShellState {
|
||||
pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> {
|
||||
self.last_composed_size = Some((cols, rows));
|
||||
@@ -295,33 +286,38 @@ impl ClientShellState {
|
||||
);
|
||||
}
|
||||
if let Some(notification) = self.visible_notification.as_ref() {
|
||||
let notification_area = if layout.mobile_header.is_empty() {
|
||||
Rect::new(0, 0, cols, rows)
|
||||
self.hits.notification_toast = if layout.mobile_header.is_empty() {
|
||||
notifications::render_visible_notification(
|
||||
&mut composed,
|
||||
Rect::new(0, 0, cols, rows),
|
||||
notification,
|
||||
self.config.toast_position,
|
||||
u16::from(has_config_diagnostic),
|
||||
&self.config.palette,
|
||||
)
|
||||
} else {
|
||||
layout.pane_surface
|
||||
notifications::render_mobile_notification_banner(
|
||||
&mut composed,
|
||||
Rect::new(0, 0, cols, rows),
|
||||
notification,
|
||||
has_config_diagnostic,
|
||||
&self.config.palette,
|
||||
)
|
||||
};
|
||||
self.hits.notification_toast = notifications::render_visible_notification(
|
||||
&mut composed,
|
||||
notification_area,
|
||||
notification,
|
||||
self.config.toast_position,
|
||||
u16::from(has_config_diagnostic),
|
||||
&self.config.palette,
|
||||
);
|
||||
}
|
||||
frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor);
|
||||
}
|
||||
if let Some(feedback) = self.copy_feedback.as_ref() {
|
||||
let cursor = frame.cursor.clone();
|
||||
let mut composed = frame.to_ratatui_buffer()?;
|
||||
let base_offset =
|
||||
if clipboard_feedback_starts_at_top(self.config.clipboard_toast_position) {
|
||||
u16::from(has_config_diagnostic)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let base_offset = u16::from(has_config_diagnostic);
|
||||
let feedback_area = if layout.mobile_header.is_empty() {
|
||||
layout.pane_surface
|
||||
} else {
|
||||
Rect::new(0, 0, cols, rows)
|
||||
};
|
||||
let offset = crate::ui::copy_feedback_offset_for_toast(
|
||||
layout.pane_surface,
|
||||
feedback_area,
|
||||
feedback,
|
||||
base_offset,
|
||||
self.config.clipboard_toast_position,
|
||||
@@ -329,7 +325,7 @@ impl ClientShellState {
|
||||
);
|
||||
crate::ui::render_copy_feedback_buffer(
|
||||
&mut composed,
|
||||
layout.pane_surface,
|
||||
feedback_area,
|
||||
feedback,
|
||||
offset,
|
||||
self.config.clipboard_toast_position,
|
||||
@@ -372,6 +368,26 @@ impl ClientShellState {
|
||||
});
|
||||
}
|
||||
}
|
||||
if !layout.mobile_header.is_empty()
|
||||
&& self.mode == ClientShellMode::Navigate
|
||||
&& self.overlay.is_none()
|
||||
{
|
||||
let mut composed = frame.to_ratatui_buffer()?;
|
||||
super::mobile::render_mobile_switcher(
|
||||
&mut composed,
|
||||
Rect::new(0, 0, cols, rows),
|
||||
snapshot,
|
||||
&self.config,
|
||||
self.navigate_workspace_id.as_deref(),
|
||||
&mut self.mobile_switcher_scroll,
|
||||
&mut self.reveal_mobile_workspace,
|
||||
&mut self.hits,
|
||||
);
|
||||
frame.replace_from_ratatui_buffer_preserving_effects(&composed, None);
|
||||
self.hits.panes.clear();
|
||||
self.hits.pane_splits.clear();
|
||||
self.hits.popup = None;
|
||||
}
|
||||
restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref());
|
||||
if let Some(overlay) = self.overlay.as_ref() {
|
||||
let mut composed = frame.to_ratatui_buffer()?;
|
||||
|
||||
@@ -159,6 +159,7 @@ impl ClientShellState {
|
||||
| RawInputEvent::Unsupported => {}
|
||||
}
|
||||
}
|
||||
outcome.repaint |= self.resume_mobile_switcher_if_ready();
|
||||
outcome
|
||||
}
|
||||
|
||||
@@ -373,7 +374,7 @@ impl ClientShellState {
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_or_terminal_mode(&self) -> ClientShellMode {
|
||||
pub(super) fn copy_or_terminal_mode(&self) -> ClientShellMode {
|
||||
if self.copy_mode.as_ref().is_some_and(|copy_mode| {
|
||||
self.focused_pane_id().as_deref() == Some(copy_mode.pane_id.as_str())
|
||||
}) {
|
||||
@@ -431,7 +432,7 @@ impl ClientShellState {
|
||||
)
|
||||
}) {
|
||||
let valid = self.snapshot.as_deref().is_some_and(|snapshot| {
|
||||
render::workspace_entries(snapshot, &self.collapsed_groups)
|
||||
self.navigation_workspace_entries(snapshot)
|
||||
.get(index)
|
||||
.is_some()
|
||||
});
|
||||
@@ -573,7 +574,7 @@ impl ClientShellState {
|
||||
let indexed_target_exists = match &binding {
|
||||
KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)) => {
|
||||
self.snapshot.as_deref().is_some_and(|snapshot| {
|
||||
render::workspace_entries(snapshot, &self.collapsed_groups)
|
||||
self.navigation_workspace_entries(snapshot)
|
||||
.get(*index)
|
||||
.is_some()
|
||||
})
|
||||
@@ -613,7 +614,6 @@ impl ClientShellState {
|
||||
} else {
|
||||
if !preserve_navigate {
|
||||
self.mode = ClientShellMode::Terminal;
|
||||
self.navigate_workspace_id = None;
|
||||
}
|
||||
self.record_binding(binding, outcome);
|
||||
}
|
||||
@@ -630,7 +630,8 @@ impl ClientShellState {
|
||||
let Some(snapshot) = self.snapshot.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let entries = render::workspace_entries(snapshot, &self.collapsed_groups);
|
||||
let mobile = self.mobile_layout_active();
|
||||
let entries = self.navigation_workspace_entries(snapshot);
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -643,12 +644,17 @@ impl ClientShellState {
|
||||
.position(|entry| snapshot.workspaces[entry.index].workspace_id == selected)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let next = (current as isize + delta).rem_euclid(entries.len() as isize) as usize;
|
||||
let next = if mobile {
|
||||
(current as isize + delta).clamp(0, entries.len().saturating_sub(1) as isize) as usize
|
||||
} 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(),
|
||||
);
|
||||
self.reveal_mobile_workspace = mobile;
|
||||
}
|
||||
|
||||
fn cycle_pane(&mut self, reverse: bool, outcome: &mut ClientShellInput) {
|
||||
|
||||
@@ -0,0 +1,999 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Clear, Paragraph, Widget},
|
||||
};
|
||||
|
||||
use super::render::{display_width, put_segment, put_text};
|
||||
use super::*;
|
||||
|
||||
const MOBILE_BUTTON_WIDTH: u16 = 10;
|
||||
|
||||
struct MobileItem {
|
||||
lines: Vec<Line<'static>>,
|
||||
background: Color,
|
||||
target: Option<ClientMobileTarget>,
|
||||
}
|
||||
|
||||
impl MobileItem {
|
||||
fn section(label: impl Into<String>, palette: &Palette) -> Self {
|
||||
Self {
|
||||
lines: vec![Line::from(Span::styled(
|
||||
format!(" {} ", label.into()),
|
||||
Style::default()
|
||||
.fg(palette.overlay1)
|
||||
.bg(palette.panel_bg)
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
|
||||
))],
|
||||
background: palette.panel_bg,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn action(label: &'static str, target: ClientMobileTarget, palette: &Palette) -> Self {
|
||||
Self {
|
||||
lines: vec![Line::from(Span::styled(
|
||||
label,
|
||||
Style::default()
|
||||
.fg(palette.accent)
|
||||
.bg(palette.panel_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))],
|
||||
background: palette.panel_bg,
|
||||
target: Some(target),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_mobile_header(
|
||||
buffer: &mut Buffer,
|
||||
area: Rect,
|
||||
snapshot: &ClientShellSnapshot,
|
||||
config: &ClientShellConfig,
|
||||
hits: &mut ShellHitMap,
|
||||
) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
let palette = &config.palette;
|
||||
buffer.set_style(area, Style::default().bg(palette.panel_bg));
|
||||
let button_width = MOBILE_BUTTON_WIDTH.min(area.width);
|
||||
let button = Rect::new(
|
||||
area.right().saturating_sub(button_width),
|
||||
area.y,
|
||||
button_width,
|
||||
area.height,
|
||||
);
|
||||
hits.mobile_switch = button;
|
||||
let status_width = button.x.saturating_sub(area.x).saturating_sub(1);
|
||||
let status = Rect::new(area.x, area.y, status_width, area.height);
|
||||
render_header_status(buffer, status, snapshot, config);
|
||||
render_header_button(buffer, button, snapshot, config);
|
||||
}
|
||||
|
||||
fn render_header_status(
|
||||
buffer: &mut Buffer,
|
||||
area: Rect,
|
||||
snapshot: &ClientShellSnapshot,
|
||||
config: &ClientShellConfig,
|
||||
) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
let palette = &config.palette;
|
||||
let Some(workspace) = snapshot.focused_workspace_id.as_deref().and_then(|id| {
|
||||
snapshot
|
||||
.workspaces
|
||||
.iter()
|
||||
.find(|workspace| workspace.workspace_id == id)
|
||||
}) else {
|
||||
put_text(
|
||||
buffer,
|
||||
area.x,
|
||||
area.y,
|
||||
area.width,
|
||||
" no workspace",
|
||||
Style::default().fg(palette.text).bg(palette.panel_bg),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let tab_status = compact_tab_status(snapshot, workspace);
|
||||
let tab_width = display_width(&tab_status).saturating_add(1).min(area.width);
|
||||
let name_width = area.width.saturating_sub(tab_width);
|
||||
put_text(
|
||||
buffer,
|
||||
area.x,
|
||||
area.y,
|
||||
name_width.min(3),
|
||||
&format!(
|
||||
" {} ",
|
||||
status_icon(workspace.agent_status, config.status_indicators)
|
||||
),
|
||||
Style::default()
|
||||
.fg(status_color(workspace.agent_status, palette))
|
||||
.bg(palette.panel_bg),
|
||||
);
|
||||
put_text(
|
||||
buffer,
|
||||
area.x.saturating_add(3),
|
||||
area.y,
|
||||
name_width.saturating_sub(3),
|
||||
&crate::ui::truncate_end(&workspace.label, usize::from(name_width.saturating_sub(4))),
|
||||
Style::default()
|
||||
.fg(palette.text)
|
||||
.bg(palette.panel_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
put_text(
|
||||
buffer,
|
||||
area.right().saturating_sub(tab_width).saturating_add(1),
|
||||
area.y,
|
||||
tab_width.saturating_sub(1),
|
||||
&tab_status,
|
||||
Style::default().fg(palette.overlay1).bg(palette.panel_bg),
|
||||
);
|
||||
if area.height > 1 {
|
||||
render_agent_summary(
|
||||
buffer,
|
||||
Rect::new(area.x, area.y + 1, area.width, 1),
|
||||
snapshot,
|
||||
config,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header_button(
|
||||
buffer: &mut Buffer,
|
||||
area: Rect,
|
||||
snapshot: &ClientShellSnapshot,
|
||||
config: &ClientShellConfig,
|
||||
) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
let palette = &config.palette;
|
||||
buffer.set_style(area, Style::default().bg(palette.surface0));
|
||||
for y in area.y..area.bottom() {
|
||||
put_text(
|
||||
buffer,
|
||||
area.x,
|
||||
y,
|
||||
1,
|
||||
"│",
|
||||
Style::default()
|
||||
.fg(palette.surface_dim)
|
||||
.bg(palette.surface0),
|
||||
);
|
||||
}
|
||||
let label_y = if area.height > 1 { area.y + 1 } else { area.y };
|
||||
let label = "switch";
|
||||
let label_width = display_width(label);
|
||||
put_text(
|
||||
buffer,
|
||||
area.x
|
||||
.saturating_add(1)
|
||||
.saturating_add(area.width.saturating_sub(1 + label_width) / 2),
|
||||
label_y,
|
||||
area.width.saturating_sub(1),
|
||||
label,
|
||||
Style::default()
|
||||
.fg(palette.text)
|
||||
.bg(palette.surface0)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
if snapshot
|
||||
.agents
|
||||
.iter()
|
||||
.any(|agent| agent.agent_status == crate::api::schema::AgentStatus::Blocked)
|
||||
{
|
||||
put_text(
|
||||
buffer,
|
||||
area.right().saturating_sub(1),
|
||||
area.y,
|
||||
1,
|
||||
status_icon(
|
||||
crate::api::schema::AgentStatus::Blocked,
|
||||
config.status_indicators,
|
||||
),
|
||||
Style::default().fg(palette.red).bg(palette.surface0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String {
|
||||
let tabs = snapshot
|
||||
.tabs
|
||||
.iter()
|
||||
.filter(|tab| tab.workspace_id == workspace.workspace_id)
|
||||
.collect::<Vec<_>>();
|
||||
let active = tabs
|
||||
.iter()
|
||||
.position(|tab| tab.tab_id == workspace.active_tab_id)
|
||||
.unwrap_or(0);
|
||||
let label = tabs
|
||||
.get(active)
|
||||
.map(|tab| tab.label.as_str())
|
||||
.unwrap_or("1");
|
||||
if tabs.len() <= 1 {
|
||||
format!("tab {label}")
|
||||
} else {
|
||||
format!("tab {label} · {}/{}", active + 1, tabs.len())
|
||||
}
|
||||
}
|
||||
|
||||
fn render_agent_summary(
|
||||
buffer: &mut Buffer,
|
||||
area: Rect,
|
||||
snapshot: &ClientShellSnapshot,
|
||||
config: &ClientShellConfig,
|
||||
) {
|
||||
use crate::api::schema::AgentStatus;
|
||||
let counts = [
|
||||
(AgentStatus::Blocked, "blocked"),
|
||||
(AgentStatus::Done, "done"),
|
||||
(AgentStatus::Working, "working"),
|
||||
(AgentStatus::Idle, "idle"),
|
||||
]
|
||||
.map(|(status, label)| {
|
||||
(
|
||||
status,
|
||||
label,
|
||||
snapshot
|
||||
.agents
|
||||
.iter()
|
||||
.filter(|agent| agent.agent_status == status)
|
||||
.count(),
|
||||
)
|
||||
});
|
||||
let total = counts.iter().map(|(_, _, count)| count).sum::<usize>();
|
||||
let pending = counts[..3].iter().map(|(_, _, count)| count).sum::<usize>();
|
||||
if total == 0 {
|
||||
put_text(
|
||||
buffer,
|
||||
area.x,
|
||||
area.y,
|
||||
area.width,
|
||||
" no agents",
|
||||
Style::default()
|
||||
.fg(config.palette.overlay1)
|
||||
.bg(config.palette.panel_bg),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if pending == 0 {
|
||||
put_text(
|
||||
buffer,
|
||||
area.x,
|
||||
area.y,
|
||||
area.width,
|
||||
" all idle",
|
||||
Style::default()
|
||||
.fg(config.palette.overlay1)
|
||||
.bg(config.palette.panel_bg),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut x = area.x.saturating_add(1);
|
||||
let mut shown = 0usize;
|
||||
let mut omitted = false;
|
||||
for (status, label, count) in counts {
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let symbol = match (config.status_indicators, status) {
|
||||
(crate::config::StatusIndicatorStyle::Dots, AgentStatus::Blocked) => Some("◉"),
|
||||
(crate::config::StatusIndicatorStyle::Dots, AgentStatus::Done) => Some("●"),
|
||||
(crate::config::StatusIndicatorStyle::Dots, _) => None,
|
||||
_ => Some(status_icon(status, config.status_indicators)),
|
||||
};
|
||||
let text = symbol.map_or_else(
|
||||
|| format!("{count} {label}"),
|
||||
|symbol| format!("{symbol} {count} {label}"),
|
||||
);
|
||||
let separator = if shown == 0 { "" } else { " · " };
|
||||
let needed = display_width(separator).saturating_add(display_width(&text));
|
||||
if area.right().saturating_sub(x) < needed {
|
||||
omitted = true;
|
||||
break;
|
||||
}
|
||||
if !separator.is_empty() {
|
||||
x = put_segment(
|
||||
buffer,
|
||||
x,
|
||||
area.y,
|
||||
area.right(),
|
||||
separator,
|
||||
Style::default()
|
||||
.fg(config.palette.overlay0)
|
||||
.bg(config.palette.panel_bg),
|
||||
);
|
||||
}
|
||||
let color = if shown == 0 {
|
||||
match status {
|
||||
AgentStatus::Done => config.palette.blue,
|
||||
_ => status_color(status, &config.palette),
|
||||
}
|
||||
} else {
|
||||
config.palette.overlay1
|
||||
};
|
||||
x = put_segment(
|
||||
buffer,
|
||||
x,
|
||||
area.y,
|
||||
area.right(),
|
||||
&text,
|
||||
Style::default()
|
||||
.fg(color)
|
||||
.bg(config.palette.panel_bg)
|
||||
.add_modifier(if shown == 0 {
|
||||
Modifier::BOLD
|
||||
} else {
|
||||
Modifier::empty()
|
||||
}),
|
||||
);
|
||||
shown += 1;
|
||||
}
|
||||
if omitted && area.right().saturating_sub(x) >= 2 {
|
||||
put_text(
|
||||
buffer,
|
||||
x,
|
||||
area.y,
|
||||
2,
|
||||
" …",
|
||||
Style::default()
|
||||
.fg(config.palette.overlay0)
|
||||
.bg(config.palette.panel_bg),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_mobile_switcher(
|
||||
buffer: &mut Buffer,
|
||||
area: Rect,
|
||||
snapshot: &ClientShellSnapshot,
|
||||
config: &ClientShellConfig,
|
||||
selected_workspace_id: Option<&str>,
|
||||
scroll: &mut usize,
|
||||
reveal_workspace: &mut bool,
|
||||
hits: &mut ShellHitMap,
|
||||
) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
let palette = &config.palette;
|
||||
Clear.render(area, buffer);
|
||||
buffer.set_style(area, Style::default().bg(palette.panel_bg));
|
||||
hits.mobile_switch = Rect::default();
|
||||
if area.height <= 2 {
|
||||
*scroll = 0;
|
||||
put_text(
|
||||
buffer,
|
||||
area.x,
|
||||
area.y,
|
||||
area.width,
|
||||
&"─".repeat(usize::from(area.width)),
|
||||
Style::default()
|
||||
.fg(palette.surface_dim)
|
||||
.bg(palette.panel_bg),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let header_height = area.height.min(2);
|
||||
let close_width = MOBILE_BUTTON_WIDTH.min(area.width);
|
||||
let close = Rect::new(
|
||||
area.right().saturating_sub(close_width),
|
||||
area.y,
|
||||
close_width,
|
||||
header_height,
|
||||
);
|
||||
hits.mobile_close = close;
|
||||
put_text(
|
||||
buffer,
|
||||
area.x,
|
||||
area.y,
|
||||
close.x.saturating_sub(area.x),
|
||||
" switch",
|
||||
Style::default()
|
||||
.fg(palette.text)
|
||||
.bg(palette.panel_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
render_close_button(buffer, close, palette);
|
||||
let rule_y = area.y + header_height;
|
||||
put_text(
|
||||
buffer,
|
||||
area.x,
|
||||
rule_y,
|
||||
area.width,
|
||||
&"─".repeat(usize::from(area.width)),
|
||||
Style::default()
|
||||
.fg(palette.surface_dim)
|
||||
.bg(palette.panel_bg),
|
||||
);
|
||||
let viewport = Rect::new(
|
||||
area.x,
|
||||
rule_y.saturating_add(1),
|
||||
area.width,
|
||||
area.height.saturating_sub(header_height + 1),
|
||||
);
|
||||
if viewport.is_empty() {
|
||||
*scroll = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let items = mobile_items(
|
||||
snapshot,
|
||||
config,
|
||||
selected_workspace_id,
|
||||
viewport.width.saturating_sub(1),
|
||||
);
|
||||
let total_rows = items.iter().map(|item| item.lines.len()).sum::<usize>();
|
||||
let max_scroll = total_rows.saturating_sub(usize::from(viewport.height));
|
||||
*scroll = (*scroll).min(max_scroll);
|
||||
if *reveal_workspace {
|
||||
if let Some(selected_workspace_id) = selected_workspace_id {
|
||||
let mut start = 0usize;
|
||||
for item in &items {
|
||||
let end = start.saturating_add(item.lines.len());
|
||||
if matches!(
|
||||
item.target.as_ref(),
|
||||
Some(ClientMobileTarget::Workspace(workspace_id))
|
||||
if workspace_id == selected_workspace_id
|
||||
) {
|
||||
if start < *scroll {
|
||||
*scroll = start;
|
||||
} else if end > (*scroll).saturating_add(usize::from(viewport.height)) {
|
||||
*scroll = end
|
||||
.saturating_sub(usize::from(viewport.height))
|
||||
.min(max_scroll);
|
||||
}
|
||||
break;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
*reveal_workspace = false;
|
||||
}
|
||||
hits.mobile_max_scroll = max_scroll;
|
||||
let content = if viewport.width > 1 {
|
||||
Rect::new(
|
||||
viewport.x + 1,
|
||||
viewport.y,
|
||||
viewport.width - 1,
|
||||
viewport.height,
|
||||
)
|
||||
} else {
|
||||
Rect::default()
|
||||
};
|
||||
if max_scroll > 0 {
|
||||
render_left_scrollbar(buffer, viewport, total_rows, *scroll, palette);
|
||||
}
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let viewport_start = *scroll;
|
||||
let viewport_end = viewport_start.saturating_add(usize::from(viewport.height));
|
||||
let mut document_row = 0usize;
|
||||
for item in items {
|
||||
let item_start = document_row;
|
||||
let item_end = item_start.saturating_add(item.lines.len());
|
||||
let visible_start = item_start.max(viewport_start);
|
||||
let visible_end = item_end.min(viewport_end);
|
||||
if visible_start < visible_end {
|
||||
let y = viewport.y + u16::try_from(visible_start - viewport_start).unwrap_or(u16::MAX);
|
||||
let height = u16::try_from(visible_end - visible_start).unwrap_or(u16::MAX);
|
||||
let rect = Rect::new(content.x, y, content.width, height);
|
||||
buffer.set_style(rect, Style::default().bg(item.background));
|
||||
for row in visible_start..visible_end {
|
||||
let line = item.lines[row - item_start].clone();
|
||||
Paragraph::new(line).render(
|
||||
Rect::new(
|
||||
content.x,
|
||||
viewport.y + u16::try_from(row - viewport_start).unwrap_or(u16::MAX),
|
||||
content.width,
|
||||
1,
|
||||
),
|
||||
buffer,
|
||||
);
|
||||
}
|
||||
if let Some(target) = item.target {
|
||||
hits.mobile_targets.push((rect, target));
|
||||
}
|
||||
}
|
||||
document_row = item_end;
|
||||
}
|
||||
}
|
||||
|
||||
fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
buffer.set_style(area, Style::default().bg(palette.surface0));
|
||||
for y in area.y..area.bottom() {
|
||||
put_text(
|
||||
buffer,
|
||||
area.x,
|
||||
y,
|
||||
1,
|
||||
"│",
|
||||
Style::default()
|
||||
.fg(palette.surface_dim)
|
||||
.bg(palette.surface0),
|
||||
);
|
||||
}
|
||||
let label_width = 5;
|
||||
let label_x = area
|
||||
.x
|
||||
.saturating_add(1)
|
||||
.saturating_add(area.width.saturating_sub(1 + label_width) / 2);
|
||||
put_text(
|
||||
buffer,
|
||||
label_x,
|
||||
area.y,
|
||||
area.width.saturating_sub(1),
|
||||
"close",
|
||||
Style::default()
|
||||
.fg(palette.overlay1)
|
||||
.bg(palette.surface0)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
if area.height > 1 {
|
||||
put_text(
|
||||
buffer,
|
||||
area.x.saturating_add(area.width / 2),
|
||||
area.y + 1,
|
||||
1,
|
||||
"×",
|
||||
Style::default()
|
||||
.fg(palette.text)
|
||||
.bg(palette.surface0)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_items(
|
||||
snapshot: &ClientShellSnapshot,
|
||||
config: &ClientShellConfig,
|
||||
selected_workspace_id: Option<&str>,
|
||||
content_width: u16,
|
||||
) -> Vec<MobileItem> {
|
||||
let palette = &config.palette;
|
||||
let mut items = Vec::new();
|
||||
let ordered_agents =
|
||||
super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort);
|
||||
if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() {
|
||||
let title = snapshot
|
||||
.agent_view_label
|
||||
.as_deref()
|
||||
.map(|label| format!("agents · {label}"))
|
||||
.unwrap_or_else(|| "agents".to_owned());
|
||||
items.push(MobileItem::section(title, palette));
|
||||
if ordered_agents.is_empty() {
|
||||
items.push(MobileItem {
|
||||
lines: vec![Line::from(Span::styled(
|
||||
" no matching agents",
|
||||
Style::default()
|
||||
.fg(palette.overlay0)
|
||||
.bg(palette.panel_bg)
|
||||
.add_modifier(Modifier::DIM),
|
||||
))],
|
||||
background: palette.panel_bg,
|
||||
target: None,
|
||||
});
|
||||
}
|
||||
for pane_id in ordered_agents {
|
||||
let Some(agent) = snapshot
|
||||
.agents
|
||||
.iter()
|
||||
.find(|agent| agent.pane_id == pane_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let workspace = snapshot
|
||||
.workspaces
|
||||
.iter()
|
||||
.find(|workspace| workspace.workspace_id == agent.workspace_id);
|
||||
let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id);
|
||||
let agent_label = agent
|
||||
.display_agent
|
||||
.as_deref()
|
||||
.or(agent.name.as_deref())
|
||||
.or(agent.agent.as_deref())
|
||||
.unwrap_or("agent");
|
||||
let primary = workspace
|
||||
.map(|workspace| workspace.label.as_str())
|
||||
.unwrap_or(agent_label);
|
||||
let mut detail = Vec::new();
|
||||
let workspace_tab_count = snapshot
|
||||
.tabs
|
||||
.iter()
|
||||
.filter(|candidate| candidate.workspace_id == agent.workspace_id)
|
||||
.count();
|
||||
if let Some(tab) = tab.filter(|tab| tab.custom_label || workspace_tab_count > 1) {
|
||||
detail.push(tab.label.clone());
|
||||
}
|
||||
let status_key = status_text(agent.agent_status);
|
||||
detail.push(
|
||||
agent
|
||||
.state_labels
|
||||
.iter()
|
||||
.find(|(key, _)| key == status_key)
|
||||
.map(|(_, label)| label.clone())
|
||||
.unwrap_or_else(|| {
|
||||
if agent.agent_status == crate::api::schema::AgentStatus::Unknown {
|
||||
"idle".to_owned()
|
||||
} else {
|
||||
status_key.to_owned()
|
||||
}
|
||||
}),
|
||||
);
|
||||
detail.push(agent_label.to_owned());
|
||||
let background = if agent.focused {
|
||||
palette.surface_dim
|
||||
} else {
|
||||
palette.panel_bg
|
||||
};
|
||||
items.push(MobileItem {
|
||||
lines: vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" ", Style::default().bg(background)),
|
||||
Span::styled(
|
||||
status_icon(agent.agent_status, config.status_indicators),
|
||||
Style::default()
|
||||
.fg(status_color(agent.agent_status, palette))
|
||||
.bg(background),
|
||||
),
|
||||
Span::styled(" ", Style::default().bg(background)),
|
||||
Span::styled(
|
||||
crate::ui::truncate_end(
|
||||
primary,
|
||||
usize::from(content_width.saturating_sub(5)),
|
||||
),
|
||||
Style::default()
|
||||
.fg(palette.text)
|
||||
.bg(background)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(Span::styled(
|
||||
crate::ui::truncate_end(
|
||||
&format!(" {}", detail.join(" · ")),
|
||||
usize::from(content_width),
|
||||
),
|
||||
Style::default().fg(palette.overlay0).bg(background),
|
||||
)),
|
||||
],
|
||||
background,
|
||||
target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.push(MobileItem::section("spaces", palette));
|
||||
items.push(MobileItem::action(
|
||||
" + new workspace",
|
||||
ClientMobileTarget::NewWorkspace,
|
||||
palette,
|
||||
));
|
||||
for entry in super::render::workspace_entries(snapshot, &HashSet::new()) {
|
||||
let Some(workspace) = snapshot.workspaces.get(entry.index) else {
|
||||
continue;
|
||||
};
|
||||
let selected = selected_workspace_id == Some(workspace.workspace_id.as_str());
|
||||
let background = if selected {
|
||||
palette.surface0
|
||||
} else if workspace.focused {
|
||||
palette.surface_dim
|
||||
} else {
|
||||
palette.panel_bg
|
||||
};
|
||||
let connector = if entry.indented {
|
||||
if entry.last_child {
|
||||
"└─ "
|
||||
} else {
|
||||
"├─ "
|
||||
}
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let name = if entry.indented && !workspace.custom_label {
|
||||
workspace
|
||||
.branch
|
||||
.as_deref()
|
||||
.and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch)))
|
||||
.unwrap_or(&workspace.label)
|
||||
} else {
|
||||
&workspace.label
|
||||
};
|
||||
let branch = workspace.branch.as_deref().unwrap_or("shell");
|
||||
let detail_prefix = if entry.indented {
|
||||
if entry.last_child {
|
||||
" "
|
||||
} else {
|
||||
" │ "
|
||||
}
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
items.push(MobileItem {
|
||||
lines: vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {connector}"),
|
||||
Style::default().fg(palette.overlay0).bg(background),
|
||||
),
|
||||
Span::styled(
|
||||
status_icon(workspace.agent_status, config.status_indicators),
|
||||
Style::default()
|
||||
.fg(status_color(workspace.agent_status, palette))
|
||||
.bg(background),
|
||||
),
|
||||
Span::styled(" ", Style::default().bg(background)),
|
||||
Span::styled(
|
||||
crate::ui::truncate_end(
|
||||
name,
|
||||
usize::from(content_width.saturating_sub(if entry.indented {
|
||||
8
|
||||
} else {
|
||||
5
|
||||
})),
|
||||
),
|
||||
Style::default()
|
||||
.fg(palette.text)
|
||||
.bg(background)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(Span::styled(
|
||||
crate::ui::truncate_end(
|
||||
&format!(
|
||||
"{detail_prefix}{branch} · {}",
|
||||
compact_tab_status(snapshot, workspace)
|
||||
),
|
||||
usize::from(content_width),
|
||||
),
|
||||
Style::default().fg(palette.overlay0).bg(background),
|
||||
)),
|
||||
],
|
||||
background,
|
||||
target: Some(ClientMobileTarget::Workspace(
|
||||
workspace.workspace_id.clone(),
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() {
|
||||
items.push(MobileItem::section("tabs", palette));
|
||||
items.push(MobileItem::action(
|
||||
" + new tab",
|
||||
ClientMobileTarget::NewTab,
|
||||
palette,
|
||||
));
|
||||
for (index, tab) in snapshot
|
||||
.tabs
|
||||
.iter()
|
||||
.filter(|tab| tab.workspace_id == workspace_id)
|
||||
.enumerate()
|
||||
{
|
||||
let background = if tab.focused {
|
||||
palette.surface_dim
|
||||
} else {
|
||||
palette.panel_bg
|
||||
};
|
||||
let label = if tab.custom_label {
|
||||
format!("{} · {}", index + 1, tab.label)
|
||||
} else {
|
||||
format!("tab {}", tab.label)
|
||||
};
|
||||
let label = format!(
|
||||
" {}",
|
||||
crate::ui::truncate_end(&label, usize::from(content_width.saturating_sub(3)),)
|
||||
);
|
||||
items.push(MobileItem {
|
||||
lines: vec![Line::from(Span::styled(
|
||||
label,
|
||||
Style::default()
|
||||
.fg(palette.text)
|
||||
.bg(background)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))],
|
||||
background,
|
||||
target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.push(MobileItem::section("menu", palette));
|
||||
for (index, (label, _)) in super::global_menu::global_menu_items(snapshot)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
items.push(MobileItem {
|
||||
lines: vec![Line::from(Span::styled(
|
||||
format!(" {label}"),
|
||||
Style::default().fg(palette.overlay1).bg(palette.panel_bg),
|
||||
))],
|
||||
background: palette.panel_bg,
|
||||
target: Some(ClientMobileTarget::Menu(index)),
|
||||
});
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
impl ClientShellState {
|
||||
pub(super) fn handle_mobile_mouse(
|
||||
&mut self,
|
||||
mouse: crossterm::event::MouseEvent,
|
||||
outcome: &mut ClientShellInput,
|
||||
) -> bool {
|
||||
let mobile = self
|
||||
.last_composed_size
|
||||
.is_some_and(|(cols, rows)| !self.layout(cols, rows).mobile_header.is_empty());
|
||||
if !mobile || self.overlay.is_some() {
|
||||
return false;
|
||||
}
|
||||
use crossterm::event::{MouseButton, MouseEventKind};
|
||||
let point = (mouse.column, mouse.row);
|
||||
if self.mode != ClientShellMode::Navigate {
|
||||
if matches!(
|
||||
self.mode,
|
||||
ClientShellMode::Terminal | ClientShellMode::Resize
|
||||
) && mouse.kind == MouseEventKind::Down(MouseButton::Left)
|
||||
&& super::contains(self.hits.mobile_switch, point)
|
||||
{
|
||||
self.mobile_switcher_scroll = 0;
|
||||
self.reveal_mobile_workspace = false;
|
||||
self.mode = ClientShellMode::Navigate;
|
||||
self.navigate_workspace_id = self
|
||||
.snapshot
|
||||
.as_deref()
|
||||
.and_then(|snapshot| snapshot.focused_workspace_id.clone());
|
||||
outcome.repaint = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollUp => {
|
||||
self.mobile_switcher_scroll = self.mobile_switcher_scroll.saturating_sub(2);
|
||||
outcome.repaint = true;
|
||||
return true;
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
self.mobile_switcher_scroll = self
|
||||
.mobile_switcher_scroll
|
||||
.saturating_add(2)
|
||||
.min(self.hits.mobile_max_scroll);
|
||||
outcome.repaint = true;
|
||||
return true;
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {}
|
||||
_ => return true,
|
||||
}
|
||||
|
||||
if super::contains(self.hits.mobile_close, point) {
|
||||
self.mode = ClientShellMode::Terminal;
|
||||
self.navigate_workspace_id = None;
|
||||
outcome.repaint = true;
|
||||
return true;
|
||||
}
|
||||
let target = self
|
||||
.hits
|
||||
.mobile_targets
|
||||
.iter()
|
||||
.find(|(rect, _)| super::contains(*rect, point))
|
||||
.map(|(_, target)| target.clone());
|
||||
match target {
|
||||
Some(ClientMobileTarget::NewWorkspace) => {
|
||||
self.mobile_switcher_suspended = true;
|
||||
self.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace),
|
||||
outcome,
|
||||
);
|
||||
}
|
||||
Some(
|
||||
target @ (ClientMobileTarget::Workspace(_)
|
||||
| ClientMobileTarget::Tab(_)
|
||||
| ClientMobileTarget::Agent(_)),
|
||||
) => {
|
||||
let method = match target {
|
||||
ClientMobileTarget::Workspace(workspace_id) => {
|
||||
crate::api::schema::Method::WorkspaceFocus(
|
||||
crate::api::schema::WorkspaceTarget { workspace_id },
|
||||
)
|
||||
}
|
||||
ClientMobileTarget::Tab(tab_id) => {
|
||||
crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget {
|
||||
tab_id,
|
||||
})
|
||||
}
|
||||
ClientMobileTarget::Agent(pane_id) => {
|
||||
crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget {
|
||||
pane_id,
|
||||
})
|
||||
}
|
||||
ClientMobileTarget::NewWorkspace
|
||||
| ClientMobileTarget::NewTab
|
||||
| ClientMobileTarget::Menu(_) => return true,
|
||||
};
|
||||
self.mode = ClientShellMode::Terminal;
|
||||
self.navigate_workspace_id = None;
|
||||
self.push_endpoint_method(method, outcome);
|
||||
}
|
||||
Some(ClientMobileTarget::NewTab) => {
|
||||
self.mobile_switcher_suspended = true;
|
||||
self.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab),
|
||||
outcome,
|
||||
);
|
||||
}
|
||||
Some(ClientMobileTarget::Menu(index)) => {
|
||||
let actionable = self.snapshot.as_deref().is_some_and(|snapshot| {
|
||||
super::global_menu::global_menu_items(snapshot)
|
||||
.get(index)
|
||||
.is_some_and(|(_, action)| {
|
||||
*action != super::global_menu::ClientGlobalMenuAction::WhatsNew
|
||||
|| snapshot.release_notes.is_some()
|
||||
})
|
||||
});
|
||||
if actionable {
|
||||
self.mobile_switcher_suspended = true;
|
||||
self.activate_global_menu_item(index, outcome);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
outcome.repaint = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn render_left_scrollbar(
|
||||
buffer: &mut Buffer,
|
||||
viewport: Rect,
|
||||
total_rows: usize,
|
||||
scroll: usize,
|
||||
palette: &Palette,
|
||||
) {
|
||||
if viewport.is_empty() {
|
||||
return;
|
||||
}
|
||||
let metrics = crate::pane::ScrollMetrics {
|
||||
offset_from_bottom: total_rows
|
||||
.saturating_sub(usize::from(viewport.height))
|
||||
.saturating_sub(scroll),
|
||||
max_offset_from_bottom: total_rows.saturating_sub(usize::from(viewport.height)),
|
||||
viewport_rows: usize::from(viewport.height),
|
||||
};
|
||||
let track = Rect::new(viewport.x, viewport.y, 1, viewport.height);
|
||||
for y in track.y..track.bottom() {
|
||||
put_text(
|
||||
buffer,
|
||||
track.x,
|
||||
y,
|
||||
1,
|
||||
"│",
|
||||
Style::default()
|
||||
.fg(palette.surface_dim)
|
||||
.bg(palette.panel_bg),
|
||||
);
|
||||
}
|
||||
if let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) {
|
||||
for y in thumb.top..thumb.top.saturating_add(thumb.len) {
|
||||
put_text(
|
||||
buffer,
|
||||
track.x,
|
||||
y,
|
||||
1,
|
||||
"▌",
|
||||
Style::default().fg(palette.accent).bg(palette.panel_bg),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -807,6 +807,9 @@ impl ClientShellState {
|
||||
self.focus_visible_notification(outcome);
|
||||
return;
|
||||
}
|
||||
if self.handle_mobile_mouse(mouse, outcome) {
|
||||
return;
|
||||
}
|
||||
if mouse.kind == MouseEventKind::Drag(MouseButton::Left) {
|
||||
match self.chrome_drag.as_ref() {
|
||||
Some(ClientChromeDrag::SidebarWidth) => {
|
||||
|
||||
@@ -4,6 +4,104 @@ use ratatui::{
|
||||
widgets::{Block, Borders, Clear, Paragraph, Widget},
|
||||
};
|
||||
|
||||
pub(super) fn render_mobile_notification_banner(
|
||||
buffer: &mut Buffer,
|
||||
area: Rect,
|
||||
notification: &ClientVisibleNotification,
|
||||
offset_for_warning: bool,
|
||||
palette: &Palette,
|
||||
) -> Rect {
|
||||
if area.is_empty() {
|
||||
return Rect::default();
|
||||
}
|
||||
let warning_offset = u16::from(offset_for_warning);
|
||||
let y = area.y
|
||||
+ area
|
||||
.height
|
||||
.saturating_sub(1u16.saturating_add(warning_offset));
|
||||
let rect = Rect::new(area.x, y, area.width, 1);
|
||||
let background = palette.surface0;
|
||||
Clear.render(rect, buffer);
|
||||
buffer.set_style(rect, Style::default().bg(background));
|
||||
let event = ¬ification.event;
|
||||
let title = match event.kind {
|
||||
SemanticNotificationKind::NeedsAttention => event
|
||||
.title
|
||||
.strip_suffix(" needs attention")
|
||||
.map(|agent| format!("{agent} waiting"))
|
||||
.unwrap_or_else(|| event.title.clone()),
|
||||
SemanticNotificationKind::Finished => event
|
||||
.title
|
||||
.strip_suffix(" finished")
|
||||
.map(|agent| format!("{agent} done"))
|
||||
.unwrap_or_else(|| event.title.clone()),
|
||||
SemanticNotificationKind::UpdateInstalled => "update ready".to_owned(),
|
||||
SemanticNotificationKind::Custom => event.title.clone(),
|
||||
};
|
||||
let dot_color = match event.kind {
|
||||
SemanticNotificationKind::NeedsAttention => palette.red,
|
||||
SemanticNotificationKind::Finished => palette.blue,
|
||||
SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => {
|
||||
palette.accent
|
||||
}
|
||||
};
|
||||
let mut x = rect.x;
|
||||
x = super::render::put_segment(
|
||||
buffer,
|
||||
x,
|
||||
rect.y,
|
||||
rect.right(),
|
||||
" ",
|
||||
Style::default().bg(background),
|
||||
);
|
||||
x = super::render::put_segment(
|
||||
buffer,
|
||||
x,
|
||||
rect.y,
|
||||
rect.right(),
|
||||
"●",
|
||||
Style::default().fg(dot_color).bg(background),
|
||||
);
|
||||
x = super::render::put_segment(
|
||||
buffer,
|
||||
x,
|
||||
rect.y,
|
||||
rect.right(),
|
||||
" ",
|
||||
Style::default().bg(background),
|
||||
);
|
||||
x = super::render::put_segment(
|
||||
buffer,
|
||||
x,
|
||||
rect.y,
|
||||
rect.right(),
|
||||
&title,
|
||||
Style::default()
|
||||
.fg(palette.text)
|
||||
.bg(background)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
if let Some(body) = event.body.as_deref().filter(|body| !body.is_empty()) {
|
||||
x = super::render::put_segment(
|
||||
buffer,
|
||||
x,
|
||||
rect.y,
|
||||
rect.right(),
|
||||
" · ",
|
||||
Style::default().fg(palette.overlay0).bg(background),
|
||||
);
|
||||
super::render::put_text(
|
||||
buffer,
|
||||
x,
|
||||
rect.y,
|
||||
rect.right().saturating_sub(x),
|
||||
body,
|
||||
Style::default().fg(palette.overlay0).bg(background),
|
||||
);
|
||||
}
|
||||
rect
|
||||
}
|
||||
|
||||
pub(super) fn render_visible_notification(
|
||||
buffer: &mut Buffer,
|
||||
area: Rect,
|
||||
@@ -267,6 +365,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_notification_is_a_bottom_banner_with_released_title() {
|
||||
let palette = crate::app::client_palette_from_config(&Config::default());
|
||||
let mut notification = notification();
|
||||
notification.event.kind = SemanticNotificationKind::NeedsAttention;
|
||||
notification.event.title = "pi needs attention".into();
|
||||
notification.event.body = Some("workspace · tab 1".into());
|
||||
let area = Rect::new(0, 0, 44, 20);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
for cell in &mut buffer.content {
|
||||
cell.set_symbol("X");
|
||||
}
|
||||
let rect =
|
||||
render_mobile_notification_banner(&mut buffer, area, ¬ification, true, &palette);
|
||||
assert_eq!(rect, Rect::new(0, 18, 44, 1));
|
||||
let text = buffer
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<String>();
|
||||
assert!(text.contains("pi waiting"));
|
||||
assert!(text.contains("workspace · tab 1"));
|
||||
assert!(buffer.content[18 * 44..19 * 44]
|
||||
.iter()
|
||||
.all(|cell| cell.symbol() != "X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_rect_stays_inside_short_nonzero_area() {
|
||||
let palette = crate::app::client_palette_from_config(&Config::default());
|
||||
|
||||
@@ -294,10 +294,14 @@ impl ClientShellState {
|
||||
}
|
||||
|
||||
pub(super) fn open_new_workspace_overlay(&mut self) {
|
||||
let source_workspace_id = self.workspace_action_id();
|
||||
let cwd = self.snapshot.as_deref().and_then(|snapshot| {
|
||||
let pane_id = snapshot.focused_pane_id.as_deref()?;
|
||||
let pane = snapshot.panes.iter().find(|pane| pane.pane_id == pane_id)?;
|
||||
pane.foreground_cwd.clone().or_else(|| pane.cwd.clone())
|
||||
let workspace_id = source_workspace_id.as_deref()?;
|
||||
snapshot
|
||||
.workspaces
|
||||
.iter()
|
||||
.find(|workspace| workspace.workspace_id == workspace_id)
|
||||
.map(|workspace| workspace.new_workspace_cwd.clone())
|
||||
});
|
||||
let suggested_name = cwd
|
||||
.as_deref()
|
||||
@@ -309,6 +313,7 @@ impl ClientShellState {
|
||||
input: suggested_name.clone(),
|
||||
replace_on_type: true,
|
||||
target: ClientRenameTarget::NewWorkspace {
|
||||
source_workspace_id,
|
||||
cwd,
|
||||
suggested_name,
|
||||
},
|
||||
@@ -960,10 +965,12 @@ impl ClientShellState {
|
||||
let trimmed = rename.input.trim();
|
||||
let method = match rename.target {
|
||||
ClientRenameTarget::NewWorkspace {
|
||||
source_workspace_id,
|
||||
cwd,
|
||||
suggested_name,
|
||||
} => Some(crate::api::schema::Method::WorkspaceCreate(
|
||||
crate::api::schema::WorkspaceCreateParams {
|
||||
source_workspace_id,
|
||||
cwd,
|
||||
focus: true,
|
||||
label: (!trimmed.is_empty() && trimmed != suggested_name)
|
||||
|
||||
+17
-46
@@ -218,7 +218,13 @@ pub(super) fn render_shell(
|
||||
) -> ShellHitMap {
|
||||
let mut hits = ShellHitMap::default();
|
||||
if layout.mobile_header.height > 0 {
|
||||
render_mobile_header(buffer, layout.mobile_header, snapshot, &config.palette);
|
||||
super::mobile::render_mobile_header(
|
||||
buffer,
|
||||
layout.mobile_header,
|
||||
snapshot,
|
||||
config,
|
||||
&mut hits,
|
||||
);
|
||||
}
|
||||
if layout.sidebar.width > 0 {
|
||||
if state.sidebar_collapsed {
|
||||
@@ -270,48 +276,6 @@ pub(super) fn render_shell(
|
||||
hits
|
||||
}
|
||||
|
||||
fn render_mobile_header(
|
||||
buffer: &mut Buffer,
|
||||
area: Rect,
|
||||
snapshot: &ClientShellSnapshot,
|
||||
palette: &Palette,
|
||||
) {
|
||||
buffer.set_style(area, Style::default().bg(palette.panel_bg));
|
||||
let workspace = snapshot
|
||||
.focused_workspace_id
|
||||
.as_deref()
|
||||
.and_then(|id| snapshot.workspaces.iter().find(|ws| ws.workspace_id == id))
|
||||
.map(|ws| ws.label.as_str())
|
||||
.unwrap_or("Herdr");
|
||||
put_text(
|
||||
buffer,
|
||||
area.x,
|
||||
area.y,
|
||||
area.width,
|
||||
&format!(" ☰ {workspace}"),
|
||||
Style::default()
|
||||
.fg(palette.text)
|
||||
.bg(palette.panel_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
if area.height > 1 {
|
||||
let tab = snapshot
|
||||
.focused_tab_id
|
||||
.as_deref()
|
||||
.and_then(|id| snapshot.tabs.iter().find(|tab| tab.tab_id == id))
|
||||
.map(|tab| tab.label.as_str())
|
||||
.unwrap_or("terminal");
|
||||
put_text(
|
||||
buffer,
|
||||
area.x + 1,
|
||||
area.y + 1,
|
||||
area.width.saturating_sub(1),
|
||||
tab,
|
||||
Style::default().fg(palette.overlay1).bg(palette.panel_bg),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) {
|
||||
let width = display_width(text).min(area.width);
|
||||
put_text(
|
||||
@@ -324,19 +288,26 @@ fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: St
|
||||
);
|
||||
}
|
||||
|
||||
fn put_segment(buffer: &mut Buffer, x: u16, y: u16, right: u16, text: &str, style: Style) -> u16 {
|
||||
pub(super) fn put_segment(
|
||||
buffer: &mut Buffer,
|
||||
x: u16,
|
||||
y: u16,
|
||||
right: u16,
|
||||
text: &str,
|
||||
style: Style,
|
||||
) -> u16 {
|
||||
let width = display_width(text).min(right.saturating_sub(x));
|
||||
put_text(buffer, x, y, width, text, style);
|
||||
x.saturating_add(width)
|
||||
}
|
||||
|
||||
fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) {
|
||||
pub(super) fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) {
|
||||
if width == 0 || y >= buffer.area.bottom() || x >= buffer.area.right() {
|
||||
return;
|
||||
}
|
||||
buffer.set_stringn(x, y, text, width as usize, style);
|
||||
}
|
||||
|
||||
fn display_width(text: &str) -> u16 {
|
||||
pub(super) fn display_width(text: &str) -> u16 {
|
||||
UnicodeWidthStr::width(text).min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
||||
@@ -49,6 +49,16 @@ pub(super) struct ClientShellLayout {
|
||||
pub pane_surface: Rect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum ClientMobileTarget {
|
||||
NewWorkspace,
|
||||
Workspace(String),
|
||||
NewTab,
|
||||
Tab(String),
|
||||
Agent(String),
|
||||
Menu(usize),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ShellHitMap {
|
||||
pub(super) workspaces: Vec<WorkspaceHit>,
|
||||
@@ -73,6 +83,10 @@ pub(super) struct ShellHitMap {
|
||||
pub(super) new_tab: Rect,
|
||||
pub(super) tab_scroll_left: Rect,
|
||||
pub(super) tab_scroll_right: Rect,
|
||||
pub(super) mobile_switch: Rect,
|
||||
pub(super) mobile_close: Rect,
|
||||
pub(super) mobile_targets: Vec<(Rect, ClientMobileTarget)>,
|
||||
pub(super) mobile_max_scroll: usize,
|
||||
pub(super) global_launcher: Rect,
|
||||
pub(super) notification_toast: Rect,
|
||||
pub(super) global_menu_rows: Vec<(Rect, usize)>,
|
||||
@@ -241,6 +255,7 @@ pub(super) enum ClientShellOverlayKind {
|
||||
#[derive(Debug)]
|
||||
pub(super) enum ClientRenameTarget {
|
||||
NewWorkspace {
|
||||
source_workspace_id: Option<String>,
|
||||
cwd: Option<String>,
|
||||
suggested_name: String,
|
||||
},
|
||||
@@ -758,6 +773,9 @@ pub(crate) struct ClientShellState {
|
||||
pub(super) workspace_scroll: usize,
|
||||
pub(super) agent_scroll: usize,
|
||||
pub(super) tab_scroll: usize,
|
||||
pub(super) mobile_switcher_scroll: usize,
|
||||
pub(super) reveal_mobile_workspace: bool,
|
||||
pub(super) mobile_switcher_suspended: bool,
|
||||
pub(super) reveal_focused_tab: bool,
|
||||
pub(super) last_tab_bar_width: Option<u16>,
|
||||
pub(super) last_composed_size: Option<(u16, u16)>,
|
||||
@@ -879,6 +897,9 @@ impl ClientShellState {
|
||||
workspace_scroll: 0,
|
||||
agent_scroll: 0,
|
||||
tab_scroll: 0,
|
||||
mobile_switcher_scroll: 0,
|
||||
reveal_mobile_workspace: false,
|
||||
mobile_switcher_suspended: false,
|
||||
reveal_focused_tab: true,
|
||||
last_tab_bar_width: None,
|
||||
last_composed_size: None,
|
||||
@@ -936,6 +957,41 @@ impl ClientShellState {
|
||||
.count()
|
||||
}
|
||||
|
||||
pub(super) fn resume_mobile_switcher_if_ready(&mut self) -> bool {
|
||||
if !self.mobile_switcher_suspended || self.overlay.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.mobile_switcher_suspended = false;
|
||||
if self
|
||||
.snapshot
|
||||
.as_deref()
|
||||
.and_then(|snapshot| snapshot.focused_workspace_id.as_ref())
|
||||
.is_some()
|
||||
{
|
||||
self.mode = self.copy_or_terminal_mode();
|
||||
self.navigate_workspace_id = None;
|
||||
} else {
|
||||
self.mode = ClientShellMode::Navigate;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn mobile_layout_active(&self) -> bool {
|
||||
self.last_composed_size
|
||||
.is_some_and(|(cols, rows)| !self.layout(cols, rows).mobile_header.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn navigation_workspace_entries(
|
||||
&self,
|
||||
snapshot: &ClientShellSnapshot,
|
||||
) -> Vec<WorkspaceEntry> {
|
||||
if self.mobile_layout_active() {
|
||||
render::workspace_entries(snapshot, &HashSet::new())
|
||||
} else {
|
||||
render::workspace_entries(snapshot, &self.collapsed_groups)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn layout(&self, cols: u16, rows: u16) -> ClientShellLayout {
|
||||
self.config.layout(
|
||||
cols,
|
||||
@@ -980,6 +1036,9 @@ impl ClientShellState {
|
||||
self.workspace_scroll = 0;
|
||||
self.agent_scroll = 0;
|
||||
self.tab_scroll = 0;
|
||||
self.mobile_switcher_scroll = 0;
|
||||
self.reveal_mobile_workspace = false;
|
||||
self.mobile_switcher_suspended = false;
|
||||
self.reveal_focused_tab = true;
|
||||
self.last_tab_bar_width = None;
|
||||
self.last_composed_size = None;
|
||||
@@ -1113,6 +1172,7 @@ impl ClientShellState {
|
||||
})
|
||||
{
|
||||
self.navigate_workspace_id = snapshot.focused_workspace_id.clone();
|
||||
self.reveal_mobile_workspace = self.mobile_layout_active();
|
||||
}
|
||||
let pane_exists =
|
||||
|pane_id: &String| snapshot.panes.iter().any(|pane| &pane.pane_id == pane_id);
|
||||
@@ -1179,6 +1239,7 @@ impl ClientShellState {
|
||||
}
|
||||
}
|
||||
self.snapshot = Some(snapshot);
|
||||
self.resume_mobile_switcher_if_ready();
|
||||
}
|
||||
|
||||
pub(crate) fn set_pane_surface(&mut self, surface: PaneSurfaceFrame) {
|
||||
@@ -1312,6 +1373,7 @@ impl ClientShellState {
|
||||
}
|
||||
self.popup_terminal_id = next_popup;
|
||||
self.pane_surface = Some(surface);
|
||||
self.resume_mobile_switcher_if_ready();
|
||||
}
|
||||
|
||||
pub(crate) fn tick_popup_pending(&mut self, now: std::time::Instant) {
|
||||
|
||||
@@ -875,6 +875,8 @@ pub struct ClientShellTabStatusSegment {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ClientShellWorkspace {
|
||||
pub workspace_id: String,
|
||||
pub active_tab_id: String,
|
||||
pub new_workspace_cwd: String,
|
||||
pub number: usize,
|
||||
pub label: String,
|
||||
pub custom_label: bool,
|
||||
@@ -1975,6 +1977,8 @@ mod tests {
|
||||
agent_order: Vec::new(),
|
||||
workspaces: vec![ClientShellWorkspace {
|
||||
workspace_id: "w1".into(),
|
||||
active_tab_id: "w1:t1".into(),
|
||||
new_workspace_cwd: "/tmp".into(),
|
||||
number: 1,
|
||||
label: "shell".into(),
|
||||
custom_label: false,
|
||||
|
||||
@@ -14,11 +14,17 @@ pub(super) fn snapshot(
|
||||
.workspaces
|
||||
.into_iter()
|
||||
.zip(&app.state.workspaces)
|
||||
.map(|(workspace, state)| {
|
||||
.enumerate()
|
||||
.map(|(workspace_index, (workspace, state))| {
|
||||
let mut tokens = workspace.tokens.into_iter().collect::<Vec<_>>();
|
||||
tokens.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
protocol::ClientShellWorkspace {
|
||||
workspace_id: workspace.workspace_id,
|
||||
active_tab_id: workspace.active_tab_id,
|
||||
new_workspace_cwd: app
|
||||
.resolved_new_workspace_cwd_from(workspace_index)
|
||||
.display()
|
||||
.to_string(),
|
||||
number: workspace.number,
|
||||
label: workspace.label,
|
||||
custom_label: state.custom_name.is_some(),
|
||||
|
||||
@@ -993,6 +993,7 @@ impl HeadlessServer {
|
||||
self.dispatch_headless_runtime_mutation(
|
||||
id,
|
||||
api::schema::Method::WorkspaceCreate(api::schema::WorkspaceCreateParams {
|
||||
source_workspace_id: None,
|
||||
cwd,
|
||||
focus: true,
|
||||
label,
|
||||
|
||||
Reference in New Issue
Block a user