aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/schema/events.rs29
-rw-r--r--src/api/schema/response.rs3
-rw-r--r--src/api/schema/tests.rs75
-rw-r--r--src/api/subscriptions.rs8
-rw-r--r--src/app/actions.rs20
-rw-r--r--src/app/api/layouts.rs6
-rw-r--r--src/app/api/plugins/context.rs6
-rw-r--r--src/app/api/tabs.rs76
-rw-r--r--src/app/api/workspaces.rs90
-rw-r--r--src/protocol/wire.rs4
10 files changed, 239 insertions, 78 deletions
diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs
index 6e489180..014288b1 100644
--- a/src/api/schema/events.rs
+++ b/src/api/schema/events.rs
@@ -22,6 +22,8 @@ pub enum Subscription {
WorkspaceUpdated {},
#[serde(rename = "workspace.renamed")]
WorkspaceRenamed {},
+ #[serde(rename = "workspace.moved")]
+ WorkspaceMoved {},
#[serde(rename = "workspace.closed")]
WorkspaceClosed {},
#[serde(rename = "workspace.focused")]
@@ -40,6 +42,8 @@ pub enum Subscription {
TabFocused {},
#[serde(rename = "tab.renamed")]
TabRenamed {},
+ #[serde(rename = "tab.moved")]
+ TabMoved {},
#[serde(rename = "pane.created")]
PaneCreated {},
#[serde(rename = "pane.closed")]
@@ -115,6 +119,9 @@ pub enum EventMatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
},
+ WorkspaceMoved {
+ workspace_id: String,
+ },
WorkspaceFocused {
workspace_id: String,
},
@@ -132,6 +139,9 @@ pub enum EventMatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
},
+ TabMoved {
+ tab_id: String,
+ },
TabFocused {
tab_id: String,
},
@@ -176,6 +186,7 @@ pub enum EventKind {
WorkspaceUpdated,
WorkspaceClosed,
WorkspaceRenamed,
+ WorkspaceMoved,
WorkspaceFocused,
WorktreeCreated,
WorktreeOpened,
@@ -183,6 +194,7 @@ pub enum EventKind {
TabCreated,
TabClosed,
TabRenamed,
+ TabMoved,
TabFocused,
PaneCreated,
PaneClosed,
@@ -201,6 +213,7 @@ impl EventKind {
EventKind::WorkspaceUpdated => "workspace.updated",
EventKind::WorkspaceClosed => "workspace.closed",
EventKind::WorkspaceRenamed => "workspace.renamed",
+ EventKind::WorkspaceMoved => "workspace.moved",
EventKind::WorkspaceFocused => "workspace.focused",
EventKind::WorktreeCreated => "worktree.created",
EventKind::WorktreeOpened => "worktree.opened",
@@ -208,6 +221,7 @@ impl EventKind {
EventKind::TabCreated => "tab.created",
EventKind::TabClosed => "tab.closed",
EventKind::TabRenamed => "tab.renamed",
+ EventKind::TabMoved => "tab.moved",
EventKind::TabFocused => "tab.focused",
EventKind::PaneCreated => "pane.created",
EventKind::PaneClosed => "pane.closed",
@@ -227,6 +241,7 @@ pub const KNOWN_EVENT_KINDS: &[EventKind] = &[
EventKind::WorkspaceUpdated,
EventKind::WorkspaceClosed,
EventKind::WorkspaceRenamed,
+ EventKind::WorkspaceMoved,
EventKind::WorkspaceFocused,
EventKind::WorktreeCreated,
EventKind::WorktreeOpened,
@@ -234,6 +249,7 @@ pub const KNOWN_EVENT_KINDS: &[EventKind] = &[
EventKind::TabCreated,
EventKind::TabClosed,
EventKind::TabRenamed,
+ EventKind::TabMoved,
EventKind::TabFocused,
EventKind::PaneCreated,
EventKind::PaneClosed,
@@ -250,6 +266,7 @@ pub const PLUGIN_HOOK_EVENT_KINDS: &[EventKind] = &[
EventKind::WorkspaceUpdated,
EventKind::WorkspaceClosed,
EventKind::WorkspaceRenamed,
+ EventKind::WorkspaceMoved,
EventKind::WorkspaceFocused,
EventKind::WorktreeCreated,
EventKind::WorktreeOpened,
@@ -257,6 +274,7 @@ pub const PLUGIN_HOOK_EVENT_KINDS: &[EventKind] = &[
EventKind::TabCreated,
EventKind::TabClosed,
EventKind::TabRenamed,
+ EventKind::TabMoved,
EventKind::TabFocused,
EventKind::PaneCreated,
EventKind::PaneClosed,
@@ -383,6 +401,11 @@ pub enum EventData {
workspace_id: String,
label: String,
},
+ WorkspaceMoved {
+ workspace_id: String,
+ insert_index: usize,
+ workspaces: Vec<WorkspaceInfo>,
+ },
WorkspaceFocused {
workspace_id: String,
},
@@ -414,6 +437,12 @@ pub enum EventData {
workspace_id: String,
label: String,
},
+ TabMoved {
+ tab_id: String,
+ workspace_id: String,
+ insert_index: usize,
+ tabs: Vec<TabInfo>,
+ },
TabFocused {
tab_id: String,
workspace_id: String,
diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs
index 51ea0d36..57ab215b 100644
--- a/src/api/schema/response.rs
+++ b/src/api/schema/response.rs
@@ -130,6 +130,9 @@ pub enum ResponseResult {
LayoutApply {
layout: LayoutDescription,
},
+ LayoutSplitRatioSet {
+ layout: LayoutDescription,
+ },
PaneNeighbor {
neighbor: PaneNeighborResult,
},
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
index d9ebe30b..dd2004cf 100644
--- a/src/api/schema/tests.rs
+++ b/src/api/schema/tests.rs
@@ -250,18 +250,39 @@ fn pane_process_info_request_round_trips() {
#[test]
fn event_envelope_round_trips() {
- let event = EventEnvelope {
- event: EventKind::PaneOutputChanged,
- data: EventData::PaneOutputChanged {
- pane_id: "p_1".into(),
- workspace_id: "w_1".into(),
- revision: 42,
+ let events = [
+ EventEnvelope {
+ event: EventKind::PaneOutputChanged,
+ data: EventData::PaneOutputChanged {
+ pane_id: "p_1".into(),
+ workspace_id: "w_1".into(),
+ revision: 42,
+ },
},
- };
+ EventEnvelope {
+ event: EventKind::WorkspaceMoved,
+ data: EventData::WorkspaceMoved {
+ workspace_id: "w_1".into(),
+ insert_index: 2,
+ workspaces: vec![],
+ },
+ },
+ EventEnvelope {
+ event: EventKind::TabMoved,
+ data: EventData::TabMoved {
+ tab_id: "w_1:1".into(),
+ workspace_id: "w_1".into(),
+ insert_index: 1,
+ tabs: vec![],
+ },
+ },
+ ];
- let json = serde_json::to_string(&event).unwrap();
- let restored: EventEnvelope = serde_json::from_str(&json).unwrap();
- assert_eq!(restored, event);
+ for event in events {
+ let json = serde_json::to_string(&event).unwrap();
+ let restored: EventEnvelope = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, event);
+ }
}
#[test]
@@ -700,6 +721,28 @@ fn layout_export_apply_round_trip() {
let json = serde_json::to_string(&response).unwrap();
let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
assert_eq!(restored, response);
+
+ let response = SuccessResponse {
+ id: "layout_ratio".into(),
+ result: ResponseResult::LayoutSplitRatioSet {
+ layout: LayoutDescription {
+ workspace_id: "w1".into(),
+ tab_id: "w1:1".into(),
+ zoomed: false,
+ focused_pane_id: "w1-1".into(),
+ root: LayoutNode::Pane {
+ pane: LayoutPane {
+ pane_id: Some("w1-1".into()),
+ ..Default::default()
+ },
+ },
+ },
+ },
+ };
+ let json = serde_json::to_string(&response).unwrap();
+ assert!(json.contains("\"type\":\"layout_split_ratio_set\""));
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
}
#[test]
@@ -752,6 +795,18 @@ fn authority_mutation_requests_round_trip() {
assert_eq!(json["method"], "layout.set_split_ratio");
let restored: Request = serde_json::from_value(json).unwrap();
assert_eq!(restored, split_ratio);
+
+ let subscription = Request {
+ id: "sub_moves".into(),
+ method: Method::EventsSubscribe(EventsSubscribeParams {
+ subscriptions: vec![Subscription::WorkspaceMoved {}, Subscription::TabMoved {}],
+ }),
+ };
+ let json = serde_json::to_string(&subscription).unwrap();
+ assert!(json.contains("\"type\":\"workspace.moved\""));
+ assert!(json.contains("\"type\":\"tab.moved\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, subscription);
}
#[test]
diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs
index d71ea24d..f3b82090 100644
--- a/src/api/subscriptions.rs
+++ b/src/api/subscriptions.rs
@@ -120,6 +120,10 @@ impl ActiveSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceRenamed,
last_sequence: 0,
})),
+ Subscription::WorkspaceMoved {} => Ok(Self::Event(ActiveEventSubscription {
+ event_kind: crate::api::schema::EventKind::WorkspaceMoved,
+ last_sequence: 0,
+ })),
Subscription::WorkspaceClosed {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceClosed,
last_sequence: 0,
@@ -156,6 +160,10 @@ impl ActiveSubscription {
event_kind: crate::api::schema::EventKind::TabRenamed,
last_sequence: 0,
})),
+ Subscription::TabMoved {} => Ok(Self::Event(ActiveEventSubscription {
+ event_kind: crate::api::schema::EventKind::TabMoved,
+ last_sequence: 0,
+ })),
Subscription::PaneCreated {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneCreated,
last_sequence: 0,
diff --git a/src/app/actions.rs b/src/app/actions.rs
index d791da03..eccc8d4d 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1193,9 +1193,18 @@ impl AppState {
self.switch_workspace(prev);
}
- pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) {
+ pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) -> bool {
if source_idx >= self.workspaces.len() || insert_idx > self.workspaces.len() {
- return;
+ return false;
+ }
+
+ let target_idx = if source_idx < insert_idx {
+ insert_idx - 1
+ } else {
+ insert_idx
+ };
+ if source_idx == target_idx {
+ return false;
}
self.mark_session_dirty();
@@ -1207,12 +1216,6 @@ impl AppState {
.map(|workspace| workspace.id.clone());
let workspace = self.workspaces.remove(source_idx);
- let target_idx = if source_idx < insert_idx {
- insert_idx.saturating_sub(1)
- } else {
- insert_idx
- }
- .min(self.workspaces.len());
self.workspaces.insert(target_idx, workspace);
self.active = active_id.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id));
@@ -1220,6 +1223,7 @@ impl AppState {
.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id))
.unwrap_or(0);
self.ensure_workspace_visible(self.selected);
+ true
}
pub fn scroll_tabs_left(&mut self) {
diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs
index 709d9f39..c7c02f5f 100644
--- a/src/app/api/layouts.rs
+++ b/src/app/api/layouts.rs
@@ -243,7 +243,7 @@ impl App {
let Some(layout) = self.layout_description(ws_idx, tab_idx) else {
return encode_error(id, "layout_not_found", "layout unavailable");
};
- encode_success(id, ResponseResult::LayoutApply { layout })
+ encode_success(id, ResponseResult::LayoutSplitRatioSet { layout })
}
fn resolve_layout_export_target(&self, params: &LayoutExportParams) -> Option<(usize, usize)> {
@@ -684,8 +684,8 @@ mod tests {
);
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
- let ResponseResult::LayoutApply { layout } = success.result else {
- panic!("expected layout apply response");
+ let ResponseResult::LayoutSplitRatioSet { layout } = success.result else {
+ panic!("expected layout split ratio set response");
};
let LayoutNode::Split { ratio, .. } = layout.root else {
panic!("expected split layout root");
diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs
index 92d0d4c6..e81a74d3 100644
--- a/src/app/api/plugins/context.rs
+++ b/src/app/api/plugins/context.rs
@@ -63,6 +63,7 @@ impl App {
})
}),
EventData::WorkspaceRenamed { workspace_id, .. }
+ | EventData::WorkspaceMoved { workspace_id, .. }
| EventData::WorkspaceFocused { workspace_id } => self
.plugin_context_for_workspace_id(workspace_id, correlation_id)
.unwrap_or_else(|| {
@@ -103,6 +104,11 @@ impl App {
workspace_id,
..
}
+ | EventData::TabMoved {
+ tab_id,
+ workspace_id,
+ ..
+ }
| EventData::TabFocused {
tab_id,
workspace_id,
diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs
index 1ffcafdd..abd05b29 100644
--- a/src/app/api/tabs.rs
+++ b/src/app/api/tabs.rs
@@ -14,12 +14,10 @@ impl App {
let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else {
return workspace_not_found(id, &workspace_id);
};
- let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ let Some(_) = self.state.workspaces.get(ws_idx) else {
return workspace_not_found(id, &workspace_id);
};
- (0..ws.tabs.len())
- .filter_map(|tab_idx| self.tab_info(ws_idx, tab_idx))
- .collect()
+ self.tab_list_info(ws_idx)
} else {
let mut tabs = Vec::new();
for (ws_idx, ws) in self.state.workspaces.iter().enumerate() {
@@ -190,34 +188,35 @@ impl App {
);
}
+ let tab_id = self
+ .public_tab_id(ws_idx, tab_idx)
+ .unwrap_or_else(|| crate::workspace::public_tab_id_for_number(&ws.id, tab_idx + 1));
+ let workspace_id = self.public_workspace_id(ws_idx);
+ let insert_index = params.insert_index;
let moved = self
.state
.workspaces
.get_mut(ws_idx)
- .is_some_and(|ws| ws.move_tab(tab_idx, params.insert_index));
+ .is_some_and(|ws| ws.move_tab(tab_idx, insert_index));
+ 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 {
+ tab_id,
+ workspace_id,
+ insert_index,
+ tabs: tabs.clone(),
+ },
+ });
}
- encode_success(
- id,
- ResponseResult::TabList {
- tabs: self
- .state
- .workspaces
- .get(ws_idx)
- .map(|ws| {
- (0..ws.tabs.len())
- .filter_map(|idx| self.tab_info(ws_idx, idx))
- .collect()
- })
- .unwrap_or_default(),
- },
- )
+ encode_success(id, ResponseResult::TabList { tabs })
}
pub(super) fn handle_tab_close(&mut self, id: String, target: TabTarget) -> String {
@@ -269,6 +268,18 @@ impl App {
encode_success(id, ResponseResult::Ok {})
}
+
+ fn tab_list_info(&self, ws_idx: usize) -> Vec<crate::api::schema::TabInfo> {
+ self.state
+ .workspaces
+ .get(ws_idx)
+ .map(|ws| {
+ (0..ws.tabs.len())
+ .filter_map(|idx| self.tab_info(ws_idx, idx))
+ .collect()
+ })
+ .unwrap_or_default()
+ }
}
fn workspace_not_found(id: String, workspace_id: &str) -> String {
@@ -290,14 +301,9 @@ mod tests {
#[test]
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,
- crate::api::EventHub::default(),
- );
+ let mut app = App::new(&Config::default(), true, 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"));
@@ -310,7 +316,7 @@ mod tests {
let response = app.handle_tab_move(
"req".into(),
TabMoveParams {
- tab_id: moved_id,
+ tab_id: moved_id.clone(),
insert_index: 3,
},
);
@@ -321,5 +327,19 @@ mod tests {
};
assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root);
assert_eq!(tabs[2].tab_id, app.public_tab_id(0, 2).unwrap());
+ let events = event_hub.events_after(0);
+ assert!(events.iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::TabMoved {
+ tab_id,
+ workspace_id,
+ insert_index: 3,
+ tabs,
+ } if tab_id == &moved_id
+ && workspace_id == &app.public_workspace_id(0)
+ && tabs[2].tab_id == moved_id
+ )
+ }));
}
}
diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs
index 56427463..f4189a04 100644
--- a/src/app/api/workspaces.rs
+++ b/src/app/api/workspaces.rs
@@ -13,13 +13,7 @@ impl App {
encode_success(
id,
ResponseResult::WorkspaceList {
- workspaces: self
- .state
- .workspaces
- .iter()
- .enumerate()
- .map(|(idx, _)| self.workspace_info(idx))
- .collect(),
+ workspaces: self.workspace_list_info(),
},
)
}
@@ -140,20 +134,22 @@ impl App {
);
}
- self.state.move_workspace(index, params.insert_index);
+ let workspace_id = self.public_workspace_id(index);
+ let insert_index = params.insert_index;
+ let moved = self.state.move_workspace(index, insert_index);
+ let workspaces = self.workspace_list_info();
+ if moved {
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceMoved,
+ data: EventData::WorkspaceMoved {
+ workspace_id,
+ insert_index,
+ workspaces: workspaces.clone(),
+ },
+ });
+ }
- encode_success(
- id,
- ResponseResult::WorkspaceList {
- workspaces: self
- .state
- .workspaces
- .iter()
- .enumerate()
- .map(|(idx, _)| self.workspace_info(idx))
- .collect(),
- },
- )
+ encode_success(id, ResponseResult::WorkspaceList { workspaces })
}
pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String {
@@ -192,6 +188,15 @@ impl App {
encode_success(id, ResponseResult::Ok {})
}
+
+ fn workspace_list_info(&self) -> Vec<crate::api::schema::WorkspaceInfo> {
+ self.state
+ .workspaces
+ .iter()
+ .enumerate()
+ .map(|(idx, _)| self.workspace_info(idx))
+ .collect()
+ }
}
fn workspace_not_found(id: String, workspace_id: &str) -> String {
@@ -279,14 +284,9 @@ mod tests {
#[test]
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,
- crate::api::EventHub::default(),
- );
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
app.state.workspaces = vec![
Workspace::test_new("one"),
Workspace::test_new("two"),
@@ -310,5 +310,41 @@ mod tests {
};
assert_eq!(workspaces[2].workspace_id, moved_id);
assert_eq!(app.state.workspaces[2].display_name(), "one");
+ let events = event_hub.events_after(0);
+ assert!(events.iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorkspaceMoved {
+ workspace_id,
+ insert_index: 3,
+ workspaces,
+ } if workspace_id == &moved_id
+ && workspaces[2].workspace_id == moved_id
+ )
+ }));
+ }
+
+ #[test]
+ 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());
+ app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
+ let moved_id = app.public_workspace_id(0);
+
+ let response = app.handle_workspace_move(
+ "req".into(),
+ WorkspaceMoveParams {
+ workspace_id: moved_id.clone(),
+ insert_index: 1,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorkspaceList { workspaces } = success.result else {
+ panic!("expected workspace list");
+ };
+ assert_eq!(workspaces[0].workspace_id, moved_id);
+ assert!(event_hub.events_after(0).is_empty());
}
}
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index 1bbfd9a8..ac21df19 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 14;
+pub const PROTOCOL_VERSION: u32 = 15;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
@@ -935,7 +935,7 @@ mod tests {
}
#[test]
- fn client_message_wire_tags_preserve_protocol_14_order() {
+ fn client_message_wire_tags_preserve_protocol_15_order() {
fn tag(msg: &ClientMessage) -> u8 {
*bincode::serde::encode_to_vec(msg, bincode::config::standard())
.unwrap()