Étend le port/usecases window et layout.customPluginLayout pour ouvrir et piloter des fenêtres OS hébergeant un layout plugin, en réutilisant contributes.layouts plutôt qu'un système de fenêtres parallèle. Câble la commande Tauri et l'état app-tauri correspondants. Cargo test -p domain --test window : 5/5 Cargo test -p application --test window_usecases : 7/7 Cargo test -p infrastructure --test window_state_store : 1/1 Cargo test -p app-tauri view_window_tests : 8/8 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
277 lines
8.5 KiB
Rust
277 lines
8.5 KiB
Rust
//! Window/tab use cases (ARCHITECTURE §6, §10; L10).
|
|
//!
|
|
//! [`MoveTabToNewWindow`] detaches a tab into a new OS window. The topology
|
|
//! change is the pure [`Workspace::move_tab_to_new_window`] domain operation; the
|
|
//! use case only loads/persists the workspace and mints the new window id.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use domain::ids::{TabId, WindowId};
|
|
use std::collections::HashSet;
|
|
|
|
use domain::layout::{
|
|
PersistedPluginLayoutWindow, PersistedWindowKind, PersistedWindowState, WindowStateSnapshot,
|
|
Workspace,
|
|
};
|
|
use domain::ports::{
|
|
IdGenerator, PluginManifestValidator, PluginPackageStore, PluginRegistryStore, ProjectStore,
|
|
WindowStateStore,
|
|
};
|
|
use domain::{PluginId, PluginLayoutType};
|
|
|
|
use crate::error::AppError;
|
|
use crate::plugin::{ensure_runtime_plugin_layout_contribution, PluginRuntimeLayoutContribution};
|
|
|
|
/// Input for [`MoveTabToNewWindow::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct MoveTabToNewWindowInput {
|
|
/// The tab to detach into its own new window.
|
|
pub tab_id: TabId,
|
|
}
|
|
|
|
/// Output of [`MoveTabToNewWindow::execute`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct MoveTabToNewWindowOutput {
|
|
/// The id minted for the new window.
|
|
pub new_window_id: WindowId,
|
|
/// The resulting workspace (already persisted).
|
|
pub workspace: Workspace,
|
|
}
|
|
|
|
/// Detaches a tab into a freshly-created window and persists the workspace.
|
|
pub struct MoveTabToNewWindow {
|
|
store: Arc<dyn ProjectStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
}
|
|
|
|
impl MoveTabToNewWindow {
|
|
/// Builds the use case from its ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn ProjectStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
|
Self { store, ids }
|
|
}
|
|
|
|
/// Executes the detach.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the tab is not in the workspace,
|
|
/// - [`AppError::Invalid`] if the resulting window is invalid,
|
|
/// - [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: MoveTabToNewWindowInput,
|
|
) -> Result<MoveTabToNewWindowOutput, AppError> {
|
|
let workspace = self.store.load_workspace().await?;
|
|
let new_window_id = WindowId::from_uuid(self.ids.new_uuid());
|
|
|
|
let workspace = workspace
|
|
.move_tab_to_new_window(input.tab_id, new_window_id)
|
|
.map_err(|e| match e {
|
|
domain::layout::LayoutError::TabNotFound(t) => {
|
|
AppError::NotFound(format!("tab {t}"))
|
|
}
|
|
other => AppError::Invalid(other.to_string()),
|
|
})?;
|
|
|
|
self.store.save_workspace(&workspace).await?;
|
|
|
|
Ok(MoveTabToNewWindowOutput {
|
|
new_window_id,
|
|
workspace,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Input for [`SnapshotOpenWindows::execute`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct SnapshotOpenWindowsInput {
|
|
/// Open windows captured by the presentation adapter.
|
|
pub windows: Vec<PersistedWindowState>,
|
|
}
|
|
|
|
/// Persists the latest open OS window snapshot.
|
|
pub struct SnapshotOpenWindows {
|
|
store: Arc<dyn WindowStateStore>,
|
|
}
|
|
|
|
impl SnapshotOpenWindows {
|
|
/// Builds the use case from its store port.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn WindowStateStore>) -> Self {
|
|
Self { store }
|
|
}
|
|
|
|
/// Saves a versioned window snapshot.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on persistence failure.
|
|
pub async fn execute(&self, input: SnapshotOpenWindowsInput) -> Result<(), AppError> {
|
|
self.store
|
|
.save_window_state(&WindowStateSnapshot::new(input.windows))
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Output of [`RestoreOpenWindows::execute`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct RestoreOpenWindowsOutput {
|
|
/// Restorable windows, deduplicated by stable label.
|
|
pub windows: Vec<PersistedWindowState>,
|
|
}
|
|
|
|
/// Loads and filters the latest persisted OS window snapshot.
|
|
pub struct RestoreOpenWindows {
|
|
windows: Arc<dyn WindowStateStore>,
|
|
_projects: Arc<dyn ProjectStore>,
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
}
|
|
|
|
impl RestoreOpenWindows {
|
|
/// Builds the use case from its ports.
|
|
#[must_use]
|
|
pub fn new(
|
|
windows: Arc<dyn WindowStateStore>,
|
|
projects: Arc<dyn ProjectStore>,
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
) -> Self {
|
|
Self {
|
|
windows,
|
|
_projects: projects,
|
|
packages,
|
|
registry,
|
|
validator,
|
|
}
|
|
}
|
|
|
|
/// Loads restorable windows.
|
|
///
|
|
/// Views are panel-only: their persisted project id, including legacy
|
|
/// `view-<panel>-<project>` identities, is ignored. Duplicate labels are ignored
|
|
/// after the first occurrence.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Store`] on snapshot read failure.
|
|
pub async fn execute(&self) -> Result<RestoreOpenWindowsOutput, AppError> {
|
|
let snapshot = self.windows.load_window_state().await?;
|
|
let mut seen = HashSet::new();
|
|
let mut windows = Vec::new();
|
|
|
|
for window in snapshot.windows {
|
|
if window.label.is_empty() || !seen.insert(window.label.clone()) {
|
|
continue;
|
|
}
|
|
|
|
match window.kind {
|
|
PersistedWindowKind::Main => windows.push(window),
|
|
PersistedWindowKind::View => {
|
|
if window.panel.is_none() {
|
|
continue;
|
|
}
|
|
windows.push(window);
|
|
}
|
|
PersistedWindowKind::PluginLayout => {
|
|
let Some(surface) = &window.plugin_layout else {
|
|
continue;
|
|
};
|
|
if self.validate_plugin_layout_surface(surface).await.is_ok() {
|
|
windows.push(window);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(RestoreOpenWindowsOutput { windows })
|
|
}
|
|
|
|
async fn validate_plugin_layout_surface(
|
|
&self,
|
|
surface: &PersistedPluginLayoutWindow,
|
|
) -> Result<(), AppError> {
|
|
ensure_runtime_plugin_layout_contribution(
|
|
self.packages.as_ref(),
|
|
self.registry.as_ref(),
|
|
self.validator.as_ref(),
|
|
&surface.plugin_id,
|
|
&surface.layout_type,
|
|
)
|
|
.await
|
|
.map(|_| ())
|
|
}
|
|
}
|
|
|
|
/// Input for [`OpenPluginLayoutWindow::execute`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct OpenPluginLayoutWindowInput {
|
|
/// Provider plugin id.
|
|
pub plugin_id: PluginId,
|
|
/// Layout type declared by the provider plugin.
|
|
pub layout_type: PluginLayoutType,
|
|
/// Opaque plugin-owned initial/window state.
|
|
pub state: serde_json::Value,
|
|
}
|
|
|
|
/// Output of [`OpenPluginLayoutWindow::execute`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct OpenPluginLayoutWindowOutput {
|
|
/// Runtime contribution that was validated.
|
|
pub contribution: PluginRuntimeLayoutContribution,
|
|
/// Persistable plugin layout surface.
|
|
pub surface: PersistedPluginLayoutWindow,
|
|
}
|
|
|
|
/// Validates a plugin layout contribution before a detached OS window hosts it.
|
|
pub struct OpenPluginLayoutWindow {
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
}
|
|
|
|
impl OpenPluginLayoutWindow {
|
|
/// Builds the use case from plugin runtime ports.
|
|
#[must_use]
|
|
pub fn new(
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
registry,
|
|
validator,
|
|
}
|
|
}
|
|
|
|
/// Executes the validation.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Invalid`] when the plugin is inactive or does not declare the
|
|
/// requested layout contribution; other errors bubble from plugin loading.
|
|
pub async fn execute(
|
|
&self,
|
|
input: OpenPluginLayoutWindowInput,
|
|
) -> Result<OpenPluginLayoutWindowOutput, AppError> {
|
|
let contribution = ensure_runtime_plugin_layout_contribution(
|
|
self.packages.as_ref(),
|
|
self.registry.as_ref(),
|
|
self.validator.as_ref(),
|
|
&input.plugin_id,
|
|
&input.layout_type,
|
|
)
|
|
.await?;
|
|
let surface = PersistedPluginLayoutWindow {
|
|
plugin_id: input.plugin_id,
|
|
layout_type: input.layout_type,
|
|
state: input.state,
|
|
};
|
|
Ok(OpenPluginLayoutWindowOutput {
|
|
contribution,
|
|
surface,
|
|
})
|
|
}
|
|
}
|