fix: align tui status and list viewports
This commit is contained in:
@@ -381,7 +381,8 @@ impl App {
|
|||||||
operation: editor.operation.name.clone(),
|
operation: editor.operation.name.clone(),
|
||||||
arguments,
|
arguments,
|
||||||
};
|
};
|
||||||
self.status = "operation dispatched; automatic retry is disabled".into();
|
self.status =
|
||||||
|
"operation confirmed; preparing request (not yet dispatched)".into();
|
||||||
return vec![action];
|
return vec![action];
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -569,6 +570,7 @@ impl App {
|
|||||||
pub fn request_dispatched(&mut self, id: u64) {
|
pub fn request_dispatched(&mut self, id: u64) {
|
||||||
if let Some(InFlightKind::Operation { dispatched, .. }) = self.in_flight.get_mut(&id) {
|
if let Some(InFlightKind::Operation { dispatched, .. }) = self.in_flight.get_mut(&id) {
|
||||||
*dispatched = true;
|
*dispatched = true;
|
||||||
|
self.status = "operation dispatched once; automatic retry is disabled".into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,13 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_tree(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
fn render_tree(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
||||||
let rows = app.visible_paths().into_iter().map(|path| {
|
let mut paths = app.visible_paths();
|
||||||
|
let selected_index = paths
|
||||||
|
.iter()
|
||||||
|
.position(|path| path == &app.selected_path)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let (start, end) = list_window(paths.len(), selected_index, area.height.saturating_sub(2));
|
||||||
|
let rows = paths.drain(start..end).map(|path| {
|
||||||
let node = app.entries.get(&path);
|
let node = app.entries.get(&path);
|
||||||
let selected = path == app.selected_path;
|
let selected = path == app.selected_path;
|
||||||
let depth = if path == "/" {
|
let depth = if path == "/" {
|
||||||
@@ -191,7 +197,10 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
|||||||
|
|
||||||
fn render_operations(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
fn render_operations(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
||||||
let rows = app.operation_rows();
|
let rows = app.operation_rows();
|
||||||
let items = rows.iter().enumerate().map(|(index, row)| {
|
let selected_index = app.operation_cursor.min(rows.len().saturating_sub(1));
|
||||||
|
let (start, end) = list_window(rows.len(), selected_index, area.height.saturating_sub(2));
|
||||||
|
let items = rows[start..end].iter().enumerate().map(|(offset, row)| {
|
||||||
|
let index = start + offset;
|
||||||
let (text, style) = operation_line(app, row);
|
let (text, style) = operation_line(app, row);
|
||||||
let selected = app.focus == Focus::Operations && index == app.operation_cursor;
|
let selected = app.focus == Focus::Operations && index == app.operation_cursor;
|
||||||
ListItem::new(text).style(if selected {
|
ListItem::new(text).style(if selected {
|
||||||
@@ -280,7 +289,17 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_history(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
fn render_history(frame: &mut Frame<'_>, area: Rect, app: &App) {
|
||||||
let items = app.history.iter().enumerate().map(|(index, item)| {
|
let selected_index = app.history_cursor.min(app.history.len().saturating_sub(1));
|
||||||
|
let (start, end) = list_window(
|
||||||
|
app.history.len(),
|
||||||
|
selected_index,
|
||||||
|
area.height.saturating_sub(2),
|
||||||
|
);
|
||||||
|
let items = app.history[start..end]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(offset, item)| {
|
||||||
|
let index = start + offset;
|
||||||
let label = match item.kind {
|
let label = match item.kind {
|
||||||
HistoryKind::Success => "SUCCESS",
|
HistoryKind::Success => "SUCCESS",
|
||||||
HistoryKind::DomainFailure => "HOST FAILURE",
|
HistoryKind::DomainFailure => "HOST FAILURE",
|
||||||
@@ -425,6 +444,19 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_window(total: usize, selected: usize, area_height: u16) -> (usize, usize) {
|
||||||
|
let capacity = usize::from(area_height);
|
||||||
|
if total == 0 || capacity == 0 {
|
||||||
|
return (0, 0);
|
||||||
|
}
|
||||||
|
let selected = selected.min(total - 1);
|
||||||
|
let start = selected
|
||||||
|
.saturating_add(1)
|
||||||
|
.saturating_sub(capacity)
|
||||||
|
.min(total.saturating_sub(capacity));
|
||||||
|
(start, start.saturating_add(capacity).min(total))
|
||||||
|
}
|
||||||
|
|
||||||
fn focused_block(title: &'static str, focused: bool) -> Block<'static> {
|
fn focused_block(title: &'static str, focused: bool) -> Block<'static> {
|
||||||
let style = if focused {
|
let style = if focused {
|
||||||
Style::default().fg(Color::Cyan)
|
Style::default().fg(Color::Cyan)
|
||||||
@@ -684,6 +716,99 @@ mod tests {
|
|||||||
assert!(snapshot.contains("ERROR: expected integer"));
|
assert!(snapshot.contains("ERROR: expected integer"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_panes_keep_the_active_row_inside_the_bounded_viewport() {
|
||||||
|
let mut app = App::new("https://example.test/wip/");
|
||||||
|
app.connection = ConnectionState::Connected;
|
||||||
|
|
||||||
|
let children = (0..40)
|
||||||
|
.map(|index| format!("/tree-row-{index:02}"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
{
|
||||||
|
let root = app.entries.get_mut("/").unwrap();
|
||||||
|
root.children = children.clone();
|
||||||
|
root.tree_state = ViewState::Fresh;
|
||||||
|
root.expanded = true;
|
||||||
|
}
|
||||||
|
for child in &children {
|
||||||
|
app.entries.insert(
|
||||||
|
child.clone(),
|
||||||
|
EntryView {
|
||||||
|
path: child.clone(),
|
||||||
|
object: None,
|
||||||
|
state: ViewState::Unloaded,
|
||||||
|
children: vec![],
|
||||||
|
tree_state: ViewState::Unloaded,
|
||||||
|
expanded: false,
|
||||||
|
truncated: false,
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
app.selected_path = children.last().unwrap().clone();
|
||||||
|
app.focus = Focus::Tree;
|
||||||
|
let snapshot = rendered(&app, 100, 30);
|
||||||
|
let tree_pane = snapshot
|
||||||
|
.lines()
|
||||||
|
.map(|line| line.chars().take(38).collect::<String>())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(tree_pane.contains("tree-row-39"));
|
||||||
|
|
||||||
|
app.selected_path = "/".into();
|
||||||
|
app.apply_object(ObjectObservation {
|
||||||
|
path: "/".into(),
|
||||||
|
object: Some(Object {
|
||||||
|
name: String::new(),
|
||||||
|
description: None,
|
||||||
|
interfaces: vec!["many".into()],
|
||||||
|
r#ref: None,
|
||||||
|
validator: None,
|
||||||
|
}),
|
||||||
|
validator: None,
|
||||||
|
state: ObservationState::Fresh,
|
||||||
|
});
|
||||||
|
let operations = (0..20)
|
||||||
|
.map(|index| OperationDeclaration {
|
||||||
|
name: format!("operation-{index:02}"),
|
||||||
|
documentation: None,
|
||||||
|
parameters: vec![],
|
||||||
|
returns: ReturnDeclaration {
|
||||||
|
documentation: None,
|
||||||
|
r#type: TypeExpr::Unit,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
app.apply_interface(InterfaceObservation {
|
||||||
|
reference: "many".into(),
|
||||||
|
descriptor: Some(InterfaceDescriptor {
|
||||||
|
format: INTERFACE_FORMAT_V1.into(),
|
||||||
|
documentation: None,
|
||||||
|
types: vec![],
|
||||||
|
operations,
|
||||||
|
}),
|
||||||
|
validator: None,
|
||||||
|
state: ObservationState::Fresh,
|
||||||
|
});
|
||||||
|
app.focus = Focus::Operations;
|
||||||
|
app.operation_cursor = app.operation_rows().len() - 1;
|
||||||
|
let snapshot = rendered(&app, 100, 30);
|
||||||
|
assert!(snapshot.contains("(many, operation-19)"));
|
||||||
|
|
||||||
|
for index in 0..20 {
|
||||||
|
app.push_history(HistoryItem {
|
||||||
|
kind: HistoryKind::Information,
|
||||||
|
title: format!("history-{index:02}"),
|
||||||
|
detail: "diagnostic".into(),
|
||||||
|
entry_target: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.focus = Focus::History;
|
||||||
|
app.history_cursor = app.history.len() - 1;
|
||||||
|
let snapshot = rendered(&app, 100, 30);
|
||||||
|
assert!(snapshot.contains("[INFO] history-00"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tiny_terminal_has_a_safe_fallback() {
|
fn tiny_terminal_has_a_safe_fallback() {
|
||||||
let snapshot = rendered(&App::new("https://example.test/"), 30, 8);
|
let snapshot = rendered(&App::new("https://example.test/"), 30, 8);
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ impl<T: Transport> Runtime<T> {
|
|||||||
let prepared = match self.transport.prepare(request.request) {
|
let prepared = match self.transport.prepare(request.request) {
|
||||||
Ok(prepared) => prepared,
|
Ok(prepared) => prepared,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
let failure_detail = sanitize(&error.detail);
|
||||||
let result = self.client.fail_transport(id, error.detail);
|
let result = self.client.fail_transport(id, error.detail);
|
||||||
self.app.request_finished(id.get());
|
self.app.request_finished(id.get());
|
||||||
match result {
|
match result {
|
||||||
@@ -428,6 +429,9 @@ impl<T: Transport> Runtime<T> {
|
|||||||
self.sync_kind(Some(&kind));
|
self.sync_kind(Some(&kind));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if matches!(&kind, InFlightKind::Tree(path) if path == "/") {
|
||||||
|
self.fail_connection(&failure_detail);
|
||||||
|
}
|
||||||
self.reconcile_authoritative();
|
self.reconcile_authoritative();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -567,7 +571,7 @@ impl<T: Transport> Runtime<T> {
|
|||||||
self.push_client_error(kind.as_ref(), &error);
|
self.push_client_error(kind.as_ref(), &error);
|
||||||
self.sync_kind(kind.as_ref());
|
self.sync_kind(kind.as_ref());
|
||||||
if matches!(kind, Some(InFlightKind::Tree(ref path)) if path == "/") {
|
if matches!(kind, Some(InFlightKind::Tree(ref path)) if path == "/") {
|
||||||
self.app.connection = ConnectionState::Failed(sanitize(&error.to_string()));
|
self.fail_connection(&sanitize(&error.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -604,6 +608,9 @@ impl<T: Transport> Runtime<T> {
|
|||||||
entry_target: None,
|
entry_target: None,
|
||||||
});
|
});
|
||||||
self.sync_kind(kind);
|
self.sync_kind(kind);
|
||||||
|
if matches!(kind, Some(InFlightKind::Tree(path)) if path == "/") {
|
||||||
|
self.fail_connection(&sanitize(&error.message));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Completion::StaleResponseRejected(id) => {
|
Completion::StaleResponseRejected(id) => {
|
||||||
self.app.push_history(HistoryItem {
|
self.app.push_history(HistoryItem {
|
||||||
@@ -624,6 +631,16 @@ impl<T: Transport> Runtime<T> {
|
|||||||
record.context.operation().name,
|
record.context.operation().name,
|
||||||
record.context.object().path
|
record.context.object().path
|
||||||
);
|
);
|
||||||
|
self.app.status = match &record.outcome {
|
||||||
|
CallOutcome::Success(_) => "operation completed".into(),
|
||||||
|
CallOutcome::ProtocolFailure(_) => {
|
||||||
|
"operation failed; automatic retry was not attempted".into()
|
||||||
|
}
|
||||||
|
CallOutcome::Unknown { .. } => {
|
||||||
|
"operation outcome unknown; refresh before deciding again".into()
|
||||||
|
}
|
||||||
|
CallOutcome::NotDispatched(_) => "operation not dispatched; not executed".into(),
|
||||||
|
};
|
||||||
match &record.outcome {
|
match &record.outcome {
|
||||||
CallOutcome::Success(response) => {
|
CallOutcome::Success(response) => {
|
||||||
let entries = collect_entries(
|
let entries = collect_entries(
|
||||||
@@ -705,6 +722,11 @@ impl<T: Transport> Runtime<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fail_connection(&mut self, detail: &str) {
|
||||||
|
self.app.connection = ConnectionState::Failed(detail.into());
|
||||||
|
self.app.status = "connection failed; press R to reconnect".into();
|
||||||
|
}
|
||||||
|
|
||||||
fn local_error(&mut self, title: &str, error: ClientError) {
|
fn local_error(&mut self, title: &str, error: ClientError) {
|
||||||
self.app.push_history(HistoryItem {
|
self.app.push_history(HistoryItem {
|
||||||
kind: error_history_kind(&error),
|
kind: error_history_kind(&error),
|
||||||
@@ -714,7 +736,7 @@ impl<T: Transport> Runtime<T> {
|
|||||||
});
|
});
|
||||||
self.app.status = format!("{title} failed; see diagnostics");
|
self.app.status = format!("{title} failed; see diagnostics");
|
||||||
if title == "connect" {
|
if title == "connect" {
|
||||||
self.app.connection = ConnectionState::Failed(sanitize(&error.to_string()));
|
self.fail_connection(&sanitize(&error.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use wip_fs_server::{ServerConfig, WipFsServer};
|
|||||||
use wip_https::encode_protocol_error_response;
|
use wip_https::encode_protocol_error_response;
|
||||||
use wip_https::http::{Request, Response};
|
use wip_https::http::{Request, Response};
|
||||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
|
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
|
||||||
use wip_tui::app::{Focus, HistoryKind, InputKey, Modal, ViewState};
|
use wip_tui::app::{ConnectionState, Focus, HistoryKind, InputKey, Modal, ViewState};
|
||||||
use wip_tui::config::Config;
|
use wip_tui::config::Config;
|
||||||
use wip_tui::runtime::{
|
use wip_tui::runtime::{
|
||||||
Runtime, Transport, TransportError, TransportErrorKind, default_wire_limits,
|
Runtime, Transport, TransportError, TransportErrorKind, default_wire_limits,
|
||||||
@@ -20,7 +20,9 @@ struct ScriptedTransport {
|
|||||||
delayed_after_startup: bool,
|
delayed_after_startup: bool,
|
||||||
unknown_calls: bool,
|
unknown_calls: bool,
|
||||||
fail_call_prepare: bool,
|
fail_call_prepare: bool,
|
||||||
|
fail_tree_prepare: bool,
|
||||||
panic_calls: bool,
|
panic_calls: bool,
|
||||||
|
root_tree_protocol_failures: Arc<AtomicUsize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScriptedTransport {
|
impl ScriptedTransport {
|
||||||
@@ -31,7 +33,9 @@ impl ScriptedTransport {
|
|||||||
delayed_after_startup: false,
|
delayed_after_startup: false,
|
||||||
unknown_calls: false,
|
unknown_calls: false,
|
||||||
fail_call_prepare: false,
|
fail_call_prepare: false,
|
||||||
|
fail_tree_prepare: false,
|
||||||
panic_calls: false,
|
panic_calls: false,
|
||||||
|
root_tree_protocol_failures: Arc::new(AtomicUsize::new(0)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,11 +50,35 @@ impl Transport for ScriptedTransport {
|
|||||||
detail: "scripted local conversion failure".into(),
|
detail: "scripted local conversion failure".into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if self.fail_tree_prepare && request.uri().path().ends_with("/v1/fetch_tree") {
|
||||||
|
return Err(TransportError {
|
||||||
|
kind: TransportErrorKind::Disconnect,
|
||||||
|
detail: "scripted tree conversion failure".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
Ok(request)
|
Ok(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
|
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
|
||||||
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel);
|
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel);
|
||||||
|
if request.uri().path().ends_with("/v1/fetch_tree")
|
||||||
|
&& self
|
||||||
|
.root_tree_protocol_failures
|
||||||
|
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
|
||||||
|
remaining.checked_sub(1)
|
||||||
|
})
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Ok(encode_protocol_error_response(
|
||||||
|
ProtocolInteraction::FetchTree,
|
||||||
|
&ProtocolError {
|
||||||
|
code: ProtocolErrorCode::Internal,
|
||||||
|
message: "scripted startup failure".into(),
|
||||||
|
},
|
||||||
|
default_wire_limits(),
|
||||||
|
)
|
||||||
|
.expect("encode tree failure"));
|
||||||
|
}
|
||||||
if self.delayed_after_startup && sequence > 0 {
|
if self.delayed_after_startup && sequence > 0 {
|
||||||
// Each refresh starts object then tree. Make the first pair lose to
|
// Each refresh starts object then tree. Make the first pair lose to
|
||||||
// the second pair to exercise exact RequestId ownership.
|
// the second pair to exercise exact RequestId ownership.
|
||||||
@@ -123,6 +151,56 @@ fn out_of_order_refresh_and_cancel_keep_request_identity_safe() {
|
|||||||
assert!(runtime.app().operation_rows().is_empty());
|
assert!(runtime.app().operation_rows().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_transport_preparation_failure_enters_failed_state() {
|
||||||
|
let temporary = tempdir().expect("temporary root");
|
||||||
|
let server = Arc::new(
|
||||||
|
WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"),
|
||||||
|
);
|
||||||
|
let mut transport = ScriptedTransport::new(server);
|
||||||
|
transport.fail_tree_prepare = true;
|
||||||
|
let config = config();
|
||||||
|
let mut runtime = Runtime::new(&config, transport).expect("runtime");
|
||||||
|
|
||||||
|
runtime.connect();
|
||||||
|
settle(&mut runtime);
|
||||||
|
assert!(matches!(
|
||||||
|
runtime.app().connection,
|
||||||
|
ConnectionState::Failed(ref detail) if detail.contains("scripted tree conversion failure")
|
||||||
|
));
|
||||||
|
assert!(runtime.app().status.contains("press R to reconnect"));
|
||||||
|
assert!(runtime.app().in_flight.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_protocol_failure_enters_failed_state_and_reconnects() {
|
||||||
|
let temporary = tempdir().expect("temporary root");
|
||||||
|
let server = Arc::new(
|
||||||
|
WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"),
|
||||||
|
);
|
||||||
|
let transport = ScriptedTransport::new(server);
|
||||||
|
transport
|
||||||
|
.root_tree_protocol_failures
|
||||||
|
.store(1, Ordering::Release);
|
||||||
|
let config = config();
|
||||||
|
let mut runtime = Runtime::new(&config, transport).expect("runtime");
|
||||||
|
|
||||||
|
runtime.connect();
|
||||||
|
settle(&mut runtime);
|
||||||
|
assert!(matches!(
|
||||||
|
runtime.app().connection,
|
||||||
|
ConnectionState::Failed(ref detail) if detail.contains("scripted startup failure")
|
||||||
|
));
|
||||||
|
assert!(runtime.app().status.contains("press R to reconnect"));
|
||||||
|
assert!(runtime.app().history.iter().any(|item| {
|
||||||
|
item.kind == HistoryKind::DomainFailure && item.detail.contains("scripted startup failure")
|
||||||
|
}));
|
||||||
|
|
||||||
|
runtime.handle_key(InputKey::Reconnect);
|
||||||
|
settle(&mut runtime);
|
||||||
|
assert_eq!(runtime.app().connection, ConnectionState::Connected);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_transport_preparation_failure_is_definitely_not_dispatched() {
|
fn local_transport_preparation_failure_is_definitely_not_dispatched() {
|
||||||
let temporary = tempdir().expect("temporary root");
|
let temporary = tempdir().expect("temporary root");
|
||||||
@@ -150,6 +228,10 @@ fn local_transport_preparation_failure_is_definitely_not_dispatched() {
|
|||||||
assert!(runtime.app().in_flight.is_empty());
|
assert!(runtime.app().in_flight.is_empty());
|
||||||
assert_eq!(runtime.app().history[0].kind, HistoryKind::TransportFailure);
|
assert_eq!(runtime.app().history[0].kind, HistoryKind::TransportFailure);
|
||||||
assert!(runtime.app().history[0].detail.contains("not dispatched"));
|
assert!(runtime.app().history[0].detail.contains("not dispatched"));
|
||||||
|
assert_eq!(
|
||||||
|
runtime.app().status,
|
||||||
|
"operation not dispatched; not executed"
|
||||||
|
);
|
||||||
assert_eq!(runtime.app().entries["/file"].state, ViewState::Fresh);
|
assert_eq!(runtime.app().entries["/file"].state, ViewState::Fresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user