diff --git a/crates/embers-cli/src/lib.rs b/crates/embers-cli/src/lib.rs index 80fee3f2..5a32648f 100644 --- a/crates/embers-cli/src/lib.rs +++ b/crates/embers-cli/src/lib.rs @@ -90,6 +90,12 @@ pub enum Command { #[arg(long)] force: bool, }, + #[command(name = "rename-session")] + RenameSession { + #[arg(short = 't', long = "target")] + target: Option, + name: String, + }, #[command(name = "list-buffers")] ListBuffers { #[arg(short = 't', long = "target")] @@ -258,6 +264,17 @@ async fn execute(socket: &Path, command: Command) -> Result { .await?; Ok(String::new()) } + Command::RenameSession { target, name } => { + let session = connection.resolve_session_record(target.as_deref()).await?; + connection + .request(ClientMessage::Session(SessionRequest::Rename { + request_id: new_request_id(), + session_id: session.id, + name, + })) + .await?; + Ok(String::new()) + } Command::ListBuffers { target, attached, diff --git a/crates/embers-cli/tests/sessions.rs b/crates/embers-cli/tests/sessions.rs index e2a10131..e4fa1aed 100644 --- a/crates/embers-cli/tests/sessions.rs +++ b/crates/embers-cli/tests/sessions.rs @@ -70,18 +70,38 @@ async fn session_commands_round_trip_through_cli() { assert_eq!(stdout(&listed).trim(), "1\talpha"); cli_command(&server) - .arg("has-session") + .arg("rename-session") .arg("-t") .arg("alpha") + .arg("ops") .assert() .success() .stdout(predicate::str::is_empty()); + let listed = run_cli(&server, ["list-sessions"]); + assert_eq!(stdout(&listed).trim(), "1\tops"); + cli_command(&server) - .arg("kill-session") + .arg("has-session") + .arg("-t") + .arg("ops") + .assert() + .success() + .stdout(predicate::str::is_empty()); + + cli_command(&server) + .arg("has-session") .arg("-t") .arg("alpha") .assert() + .failure() + .stderr(predicate::str::contains("session 'alpha' was not found")); + + cli_command(&server) + .arg("kill-session") + .arg("-t") + .arg("ops") + .assert() .success() .stdout(predicate::str::is_empty()); diff --git a/crates/embers-client/src/client.rs b/crates/embers-client/src/client.rs index 98945a9e..0f442520 100644 --- a/crates/embers-client/src/client.rs +++ b/crates/embers-client/src/client.rs @@ -216,6 +216,11 @@ where self.resync_detached_buffers().await } ServerEvent::SessionClosed(_) => self.resync_detached_buffers().await, + ServerEvent::SessionRenamed(event) => { + self.state + .apply_event(&ServerEvent::SessionRenamed(event.clone())); + self.resync_session(event.session_id).await + } ServerEvent::BufferCreated(_) | ServerEvent::BufferDetached(_) | ServerEvent::FocusChanged(_) diff --git a/crates/embers-client/src/configured_client.rs b/crates/embers-client/src/configured_client.rs index 31fa448f..2eab6c7b 100644 --- a/crates/embers-client/src/configured_client.rs +++ b/crates/embers-client/src/configured_client.rs @@ -1138,6 +1138,7 @@ where ServerEvent::RenderInvalidated(event) => self.session_id_for_buffer(event.buffer_id), ServerEvent::SessionCreated(_) | ServerEvent::SessionClosed(_) + | ServerEvent::SessionRenamed(_) | ServerEvent::NodeChanged(_) | ServerEvent::FloatingChanged(_) | ServerEvent::FocusChanged(_) => None, @@ -2279,6 +2280,7 @@ fn event_name(event: &ServerEvent) -> &'static str { match event { ServerEvent::SessionCreated(_) => "session_created", ServerEvent::SessionClosed(_) => "session_closed", + ServerEvent::SessionRenamed(_) => "session_renamed", ServerEvent::BufferCreated(_) => "buffer_created", ServerEvent::BufferDetached(_) => "buffer_detached", ServerEvent::NodeChanged(_) => "node_changed", @@ -2304,6 +2306,13 @@ fn event_info(name: &str, event: &ServerEvent) -> EventInfo { node_id: None, floating_id: None, }, + ServerEvent::SessionRenamed(event) => EventInfo { + name: name.to_owned(), + session_id: Some(event.session_id), + buffer_id: None, + node_id: None, + floating_id: None, + }, ServerEvent::BufferCreated(event) => EventInfo { name: name.to_owned(), session_id: None, diff --git a/crates/embers-client/src/state.rs b/crates/embers-client/src/state.rs index 0f419e06..9a998911 100644 --- a/crates/embers-client/src/state.rs +++ b/crates/embers-client/src/state.rs @@ -190,6 +190,13 @@ impl ClientState { self.dirty_sessions.insert(event.session.id); } ServerEvent::SessionClosed(event) => self.remove_session(event.session_id), + ServerEvent::SessionRenamed(event) => { + if let Some(session) = self.sessions.get_mut(&event.session_id) { + session.name = event.name.clone(); + } else { + self.dirty_sessions.insert(event.session_id); + } + } ServerEvent::BufferCreated(event) => { self.buffers.insert(event.buffer.id, event.buffer.clone()); } diff --git a/crates/embers-protocol/schema/embers.fbs b/crates/embers-protocol/schema/embers.fbs index 58dfcc0d..04404ec5 100644 --- a/crates/embers-protocol/schema/embers.fbs +++ b/crates/embers-protocol/schema/embers.fbs @@ -33,6 +33,7 @@ enum MessageKind : ubyte { FloatingChangedEvent = 45, FocusChangedEvent = 46, RenderInvalidatedEvent = 47, + SessionRenamedEvent = 48, } enum ErrorCodeWire : ubyte { @@ -56,6 +57,7 @@ enum SessionOp : ubyte { SelectRootTab = 5, RenameRootTab = 6, CloseRootTab = 7, + Rename = 8, } enum BufferOp : ubyte { @@ -380,6 +382,11 @@ table SessionClosedEvent { session_id:ulong; } +table SessionRenamedEvent { + session_id:ulong; + name:string; +} + table BufferCreatedEvent { buffer:BufferRecord; } @@ -441,6 +448,7 @@ table Envelope { floating_changed_event:FloatingChangedEvent; focus_changed_event:FocusChangedEvent; render_invalidated_event:RenderInvalidatedEvent; + session_renamed_event:SessionRenamedEvent; } root_type Envelope; diff --git a/crates/embers-protocol/src/codec.rs b/crates/embers-protocol/src/codec.rs index 263f4046..7f2cfe07 100644 --- a/crates/embers-protocol/src/codec.rs +++ b/crates/embers-protocol/src/codec.rs @@ -192,6 +192,18 @@ fn encode_session_request<'a>( *force, 0, ), + SessionRequest::Rename { + session_id, name, .. + } => ( + fb::SessionOp::Rename, + (*session_id).into(), + 0, + 0, + Some(name.as_str()), + None, + false, + 0, + ), SessionRequest::AddRootTab { session_id, title, @@ -1402,6 +1414,25 @@ fn encode_server_event<'a>( }, ) } + ServerEvent::SessionRenamed(e) => { + let name = builder.create_string(&e.name); + let event = fb::SessionRenamedEvent::create( + builder, + &fb::SessionRenamedEventArgs { + session_id: e.session_id.into(), + name: Some(name), + }, + ); + fb::Envelope::create( + builder, + &fb::EnvelopeArgs { + request_id: 0, + kind: fb::MessageKind::SessionRenamedEvent, + session_renamed_event: Some(event), + ..Default::default() + }, + ) + } ServerEvent::BufferCreated(e) => { let buffer = encode_buffer_record(builder, &e.buffer); let event = fb::BufferCreatedEvent::create( @@ -1764,6 +1795,11 @@ pub fn decode_client_message(bytes: &[u8]) -> Result SessionRequest::Rename { + request_id, + session_id: SessionId(req.session_id()), + name: required(req.name(), "session_request.name")?.to_owned(), + }, fb::SessionOp::AddRootTab => SessionRequest::AddRootTab { request_id, session_id: SessionId(req.session_id()), @@ -2265,6 +2301,16 @@ pub fn decode_server_envelope(bytes: &[u8]) -> Result { + let event = required(envelope.session_renamed_event(), "session_renamed_event")?; + let name = required(event.name(), "session_renamed_event.name")?; + Ok(ServerEnvelope::Event(ServerEvent::SessionRenamed( + SessionRenamedEvent { + session_id: SessionId(event.session_id()), + name: name.to_owned(), + }, + ))) + } fb::MessageKind::BufferCreatedEvent => { let event = required(envelope.buffer_created_event(), "buffer_created_event")?; let buffer = required(event.buffer(), "buffer_created_event.buffer")?; @@ -2663,4 +2709,111 @@ mod tests { ProtocolError::InvalidMessage("node_record.tabs.tabs") )); } + + #[test] + fn encode_decode_session_renamed_event_roundtrip() { + let original = ServerEnvelope::Event(ServerEvent::SessionRenamed(SessionRenamedEvent { + session_id: SessionId(123), + name: "my-session".to_string(), + })); + + let encoded = encode_server_envelope(&original).expect("encode should succeed"); + + let decoded = decode_server_envelope(&encoded).expect("decode should succeed"); + + let ServerEnvelope::Event(ServerEvent::SessionRenamed(decoded_event)) = decoded else { + panic!( + "expected ServerEnvelope::Event(ServerEvent::SessionRenamed), got {:?}", + decoded + ); + }; + + assert_eq!(decoded_event.session_id, SessionId(123)); + assert_eq!(decoded_event.name, "my-session"); + } + + #[test] + fn encode_decode_session_rename_roundtrip() { + let _builder = FlatBufferBuilder::new(); + + // Create a SessionRequest::Rename + let original = SessionRequest::Rename { + request_id: RequestId(42), + session_id: SessionId(123), + name: "my-session".to_string(), + }; + + // Encode it + let encoded = encode_client_message(&ClientMessage::Session(original.clone())) + .expect("encode should succeed"); + + // Decode it back + let decoded = decode_client_message(&encoded).expect("decode should succeed"); + + // Verify round-trip + let ClientMessage::Session(SessionRequest::Rename { + request_id: decoded_req_id, + session_id: decoded_session_id, + name: decoded_name, + }) = &decoded + else { + panic!("expected SessionRequest::Rename, got {:?}", decoded); + }; + + // Also extract fields from original for comparison + let SessionRequest::Rename { + request_id: orig_req_id, + session_id: orig_session_id, + name: orig_name, + .. + } = &original + else { + panic!("expected SessionRequest::Rename, got {:?}", original); + }; + + assert_eq!(decoded_req_id, orig_req_id); + assert_eq!(decoded_session_id, orig_session_id); + assert_eq!(decoded_name, orig_name); + } + + #[test] + fn decode_session_rename_rejects_missing_name() { + let mut builder = FlatBufferBuilder::new(); + + // Construct the wire Rename payload WITHOUT a name field + let session_req = fb::SessionRequest::create( + &mut builder, + &fb::SessionRequestArgs { + op: fb::SessionOp::Rename, + session_id: 123, + buffer_id: 0, + child_node_id: 0, + name: None, // Missing name! + title: None, + force: false, + index: 0, + }, + ); + + let envelope = fb::Envelope::create( + &mut builder, + &fb::EnvelopeArgs { + request_id: 42, + kind: fb::MessageKind::SessionRequest, + session_request: Some(session_req), + ..Default::default() + }, + ); + + builder.finish(envelope, Some("EMBR")); + + // Decode should fail with the expected error + let error = decode_client_message(builder.finished_data()) + .expect_err("missing name should be rejected"); + + assert!(matches!( + error, + ProtocolError::InvalidMessage("session_request.name") + )); + } } diff --git a/crates/embers-protocol/src/lib.rs b/crates/embers-protocol/src/lib.rs index 6e72e01f..346e2459 100644 --- a/crates/embers-protocol/src/lib.rs +++ b/crates/embers-protocol/src/lib.rs @@ -30,7 +30,7 @@ pub use types::{ FocusChangedEvent, InputRequest, NodeChangedEvent, NodeRecord, NodeRecordKind, NodeRequest, OkResponse, PingRequest, PingResponse, RenderInvalidatedEvent, ScrollbackSliceResponse, ServerEnvelope, ServerEvent, ServerResponse, SessionClosedEvent, SessionCreatedEvent, - SessionRecord, SessionRequest, SessionSnapshot, SessionSnapshotResponse, SessionsResponse, - SnapshotResponse, SplitRecord, SubscribeRequest, SubscriptionAckResponse, TabRecord, - TabsRecord, UnsubscribeRequest, VisibleSnapshotResponse, + SessionRecord, SessionRenamedEvent, SessionRequest, SessionSnapshot, SessionSnapshotResponse, + SessionsResponse, SnapshotResponse, SplitRecord, SubscribeRequest, SubscriptionAckResponse, + TabRecord, TabsRecord, UnsubscribeRequest, VisibleSnapshotResponse, }; diff --git a/crates/embers-protocol/src/types.rs b/crates/embers-protocol/src/types.rs index 548efa22..8d3149cb 100644 --- a/crates/embers-protocol/src/types.rs +++ b/crates/embers-protocol/src/types.rs @@ -35,6 +35,11 @@ pub enum SessionRequest { session_id: SessionId, force: bool, }, + Rename { + request_id: RequestId, + session_id: SessionId, + name: String, + }, AddRootTab { request_id: RequestId, session_id: SessionId, @@ -67,6 +72,7 @@ impl SessionRequest { | Self::List { request_id } | Self::Get { request_id, .. } | Self::Close { request_id, .. } + | Self::Rename { request_id, .. } | Self::AddRootTab { request_id, .. } | Self::SelectRootTab { request_id, .. } | Self::RenameRootTab { request_id, .. } @@ -602,10 +608,17 @@ pub struct RenderInvalidatedEvent { pub buffer_id: BufferId, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionRenamedEvent { + pub session_id: SessionId, + pub name: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum ServerEvent { SessionCreated(SessionCreatedEvent), SessionClosed(SessionClosedEvent), + SessionRenamed(SessionRenamedEvent), BufferCreated(BufferCreatedEvent), BufferDetached(BufferDetachedEvent), NodeChanged(NodeChangedEvent), @@ -619,6 +632,7 @@ impl ServerEvent { match self { Self::SessionCreated(event) => Some(event.session.id), Self::SessionClosed(event) => Some(event.session_id), + Self::SessionRenamed(event) => Some(event.session_id), Self::BufferCreated(_) => None, Self::BufferDetached(_) => None, Self::NodeChanged(event) => Some(event.session_id), diff --git a/crates/embers-server/src/server.rs b/crates/embers-server/src/server.rs index 314d898b..816885d5 100644 --- a/crates/embers-server/src/server.rs +++ b/crates/embers-server/src/server.rs @@ -15,10 +15,10 @@ use embers_protocol::{ ClientMessage, ErrorResponse, FloatingChangedEvent, FloatingRequest, FloatingResponse, FocusChangedEvent, FrameType, InputRequest, NodeChangedEvent, OkResponse, PingResponse, ProtocolError, RawFrame, RenderInvalidatedEvent, ScrollbackSliceResponse, ServerEnvelope, - ServerEvent, ServerResponse, SessionClosedEvent, SessionCreatedEvent, SessionRequest, - SessionSnapshotResponse, SessionsResponse, SnapshotResponse, SubscriptionAckResponse, - VisibleSnapshotResponse, decode_client_message, encode_server_envelope, read_frame, - write_frame_no_flush, + ServerEvent, ServerResponse, SessionClosedEvent, SessionCreatedEvent, SessionRenamedEvent, + SessionRequest, SessionSnapshotResponse, SessionsResponse, SnapshotResponse, + SubscriptionAckResponse, VisibleSnapshotResponse, decode_client_message, + encode_server_envelope, read_frame, write_frame_no_flush, }; use tokio::net::UnixListener; use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; @@ -536,6 +536,29 @@ impl Runtime { ), Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), }, + SessionRequest::Rename { + request_id, + session_id, + name, + } => match state.rename_session(session_id, name) { + Ok(()) => match session_snapshot(&state, session_id) { + Ok(snapshot) => { + let name = snapshot.session.name.clone(); + ( + ServerResponse::SessionSnapshot(SessionSnapshotResponse { + request_id, + snapshot, + }), + vec![ServerEvent::SessionRenamed(SessionRenamedEvent { + session_id, + name, + })], + ) + } + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, + Err(error) => (mux_error_response(Some(request_id), error), Vec::new()), + }, SessionRequest::AddRootTab { request_id, session_id, diff --git a/crates/embers-server/src/state.rs b/crates/embers-server/src/state.rs index 15c05464..35cfbdbf 100644 --- a/crates/embers-server/src/state.rs +++ b/crates/embers-server/src/state.rs @@ -289,6 +289,17 @@ impl ServerState { Ok(()) } + pub fn rename_session(&mut self, session_id: SessionId, name: impl Into) -> Result<()> { + let name = name.into().trim().to_string(); + if name.is_empty() { + return Err(MuxError::invalid_input( + "session name cannot be empty or whitespace", + )); + } + self.session_mut(session_id)?.name = name; + Ok(()) + } + pub fn create_session(&mut self, name: impl Into) -> SessionId { let session_id = self.session_ids.next(); let root_node = self.node_ids.next();