fix(layout): auto-réparer l'onglet actif
stale
Le backend renvoie l'id actif autoritaire et le
frontend l'adopte pour éviter de rejouer un layout
disparu après overwrite externe.
Co-Authored-By: Claude Opus 4.8
<noreply@anthropic.com>
This commit is contained in:
@ -297,6 +297,15 @@ pub struct SetActiveLayoutInput {
|
||||
pub layout_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Output of [`SetActiveLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetActiveLayoutOutput {
|
||||
/// The id of the layout that was **actually** made active. Equals the
|
||||
/// requested id when it exists; otherwise the unchanged current active id
|
||||
/// (self-healing fallback). Authoritative for the frontend (invariant I4).
|
||||
pub active_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Switches the active layout of a project.
|
||||
pub struct SetActiveLayout {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
@ -317,20 +326,30 @@ impl SetActiveLayout {
|
||||
|
||||
/// Sets the active layout.
|
||||
///
|
||||
/// A stale requested id (e.g. an `activeId` left over after git overwrote
|
||||
/// `layouts.json`) must **not** freeze the workspace: instead of hard-
|
||||
/// erroring, it degrades silently to the current active layout (invariant
|
||||
/// I3). The returned [`SetActiveLayoutOutput::active_id`] is the id that was
|
||||
/// actually activated and is authoritative for the frontend (invariant I4).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] if the project or layout is unknown.
|
||||
pub async fn execute(&self, input: SetActiveLayoutInput) -> Result<(), AppError> {
|
||||
/// - [`AppError::FileSystem`] on persistence failure,
|
||||
/// - [`AppError::Store`] on registry I/O failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: SetActiveLayoutInput,
|
||||
) -> Result<SetActiveLayoutOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
if doc.find(input.layout_id).is_none() {
|
||||
return Err(AppError::NotFound(format!("layout {}", input.layout_id)));
|
||||
}
|
||||
doc.active_id = input.layout_id;
|
||||
// Self-heal: keep the requested id when valid, else fall back to the
|
||||
// (always-valid, I2) current active id rather than erroring.
|
||||
let active_id = doc.resolve_existing_id(Some(input.layout_id));
|
||||
doc.active_id = active_id;
|
||||
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(())
|
||||
Ok(SetActiveLayoutOutput { active_id })
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ mod usecases;
|
||||
pub use management::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput,
|
||||
};
|
||||
pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput};
|
||||
pub use snapshot::{
|
||||
|
||||
@ -77,6 +77,22 @@ impl LayoutsDoc {
|
||||
id.unwrap_or(self.active_id)
|
||||
}
|
||||
|
||||
/// Resolves a requested view id to a **valid** layout id, never failing.
|
||||
///
|
||||
/// Returns `id` when it is present in `layouts`; otherwise falls back to
|
||||
/// `active_id` (guaranteed valid by invariant I2 / [`Self::is_valid`]).
|
||||
/// A `None` request (the current view) also resolves to `active_id`. This
|
||||
/// is the self-healing path: a stale `active_id` written by an external tool
|
||||
/// (e.g. git overwriting `layouts.json`) degrades silently instead of
|
||||
/// hard-erroring and freezing the workspace (invariant I3).
|
||||
#[must_use]
|
||||
pub fn resolve_existing_id(&self, id: Option<LayoutId>) -> LayoutId {
|
||||
match id {
|
||||
Some(id) if self.find(id).is_some() => id,
|
||||
_ => self.active_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds a layout by id.
|
||||
#[must_use]
|
||||
pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> {
|
||||
@ -177,3 +193,55 @@ pub async fn resolve_doc(fs: &dyn FileSystem, project: &Project) -> Result<Layou
|
||||
persist_doc(fs, project, &doc).await?;
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Builds a doc with two layouts; the first one is active.
|
||||
fn two_layout_doc() -> (LayoutsDoc, LayoutId, LayoutId) {
|
||||
let active = LayoutId::new_random();
|
||||
let other = LayoutId::new_random();
|
||||
let doc = LayoutsDoc {
|
||||
version: LAYOUTS_VERSION,
|
||||
active_id: active,
|
||||
layouts: vec![
|
||||
NamedLayout {
|
||||
id: active,
|
||||
name: "Default".to_owned(),
|
||||
kind: LayoutKind::Terminal,
|
||||
tree: default_tree(),
|
||||
},
|
||||
NamedLayout {
|
||||
id: other,
|
||||
name: "Second".to_owned(),
|
||||
kind: LayoutKind::Terminal,
|
||||
tree: default_tree(),
|
||||
},
|
||||
],
|
||||
};
|
||||
(doc, active, other)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_existing_id_returns_present_id() {
|
||||
let (doc, _active, other) = two_layout_doc();
|
||||
// A present, non-active id is returned verbatim.
|
||||
assert_eq!(doc.resolve_existing_id(Some(other)), other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_existing_id_falls_back_to_active_when_absent() {
|
||||
let (doc, active, _other) = two_layout_doc();
|
||||
// A stale id absent from `layouts` degrades to the valid active id.
|
||||
let stale = LayoutId::new_random();
|
||||
assert_eq!(doc.resolve_existing_id(Some(stale)), active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_existing_id_none_resolves_to_active() {
|
||||
let (doc, active, _other) = two_layout_doc();
|
||||
// The "current view" request resolves to the active id.
|
||||
assert_eq!(doc.resolve_existing_id(None), active);
|
||||
}
|
||||
}
|
||||
|
||||
@ -169,7 +169,10 @@ impl LoadLayout {
|
||||
pub async fn execute(&self, input: LoadLayoutInput) -> Result<LoadLayoutOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
let id = doc.resolve_id(input.layout_id);
|
||||
// Self-healing (I3): a stale requested id degrades to the valid active
|
||||
// id instead of hard-erroring. `find` is now infallible in practice;
|
||||
// the `ok_or` is kept as a purely defensive guard.
|
||||
let id = doc.resolve_existing_id(input.layout_id);
|
||||
let named = doc
|
||||
.find(id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
|
||||
@ -229,7 +232,10 @@ impl MutateLayout {
|
||||
pub async fn execute(&self, input: MutateLayoutInput) -> Result<MutateLayoutOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
let id = doc.resolve_id(input.layout_id);
|
||||
// Self-healing (I3): a stale requested id degrades to the valid active
|
||||
// id instead of hard-erroring. `find_mut` is now infallible in
|
||||
// practice; the `ok_or` is kept as a purely defensive guard.
|
||||
let id = doc.resolve_existing_id(input.layout_id);
|
||||
|
||||
let named = doc
|
||||
.find_mut(id)
|
||||
|
||||
Reference in New Issue
Block a user