feat(window): support des fenêtres plugin-hébergées (backend)
É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>
This commit is contained in:
@ -6,17 +6,25 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::ids::{ProjectId, TabId, WindowId};
|
||||
use domain::layout::{
|
||||
LayoutNode, LayoutTree, LeafCell, PersistedWindowKind, PersistedWindowState, Tab, Window,
|
||||
WindowStateSnapshot, Workspace,
|
||||
LayoutNode, LayoutTree, LeafCell, PersistedPluginLayoutWindow, PersistedWindowKind,
|
||||
PersistedWindowState, Tab, Window, WindowStateSnapshot, Workspace,
|
||||
};
|
||||
use domain::ports::{
|
||||
IdGenerator, PluginManifestBytes, PluginPackageStore, PluginRegistryError, PluginRegistryStore,
|
||||
PluginStoreError, ProjectStore, StoreError, WindowStateStore,
|
||||
};
|
||||
use domain::ports::{IdGenerator, ProjectStore, StoreError, WindowStateStore};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::{NodeId, RemoteRef};
|
||||
use domain::{
|
||||
ContentHash, LocalPath, NodeId, PluginBundleUrl, PluginId, PluginInstallSource,
|
||||
PluginLayoutType, PluginLifecycleState, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
|
||||
RelativePath, RemoteRef, RemovalOutcome, StagedPluginPackage,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::plugin::JsonPluginManifestValidator;
|
||||
use application::{
|
||||
MoveTabToNewWindow, MoveTabToNewWindowInput, RestoreOpenWindows, SnapshotOpenWindows,
|
||||
SnapshotOpenWindowsInput,
|
||||
MoveTabToNewWindow, MoveTabToNewWindowInput, OpenPluginLayoutWindow,
|
||||
OpenPluginLayoutWindowInput, RestoreOpenWindows, SnapshotOpenWindows, SnapshotOpenWindowsInput,
|
||||
};
|
||||
|
||||
/// A `ProjectStore` fake that only implements the workspace persistence the use
|
||||
@ -68,6 +76,134 @@ impl WindowStateStore for FakeWindowStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_manifest() -> Vec<u8> {
|
||||
br#"{
|
||||
"ideaPluginManifestVersion": 1,
|
||||
"id": "dev.idea.android-plugin",
|
||||
"displayName": "Android",
|
||||
"version": "1.0.0",
|
||||
"engines": {"idea": ">=0.1.0 <1.0.0"},
|
||||
"main": "dist/index.js",
|
||||
"trustLevel": "full",
|
||||
"capabilities": ["ui"],
|
||||
"contributes": {
|
||||
"layouts": [{"type":"idea-android.health","label":"Android Health","component":"AndroidHealth"}]
|
||||
}
|
||||
}"#.to_vec()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakePluginPackages {
|
||||
manifest: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl FakePluginPackages {
|
||||
fn new(manifest: Vec<u8>) -> Self {
|
||||
Self {
|
||||
manifest: Arc::new(Mutex::new(manifest)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPackageStore for FakePluginPackages {
|
||||
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn read_manifest(
|
||||
&self,
|
||||
_package: &PluginPackageRef,
|
||||
) -> Result<PluginManifestBytes, PluginStoreError> {
|
||||
Ok(PluginManifestBytes {
|
||||
bytes: self.manifest.lock().unwrap().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn install_from_archive(
|
||||
&self,
|
||||
_archive: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("unused".to_owned()))
|
||||
}
|
||||
|
||||
async fn install_from_directory(
|
||||
&self,
|
||||
_dir: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("unused".to_owned()))
|
||||
}
|
||||
|
||||
async fn commit_install(
|
||||
&self,
|
||||
staged: StagedPluginPackage,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<PluginPackageRef, PluginStoreError> {
|
||||
Ok(PluginPackageRef {
|
||||
plugin_id: Some(plugin_id.clone()),
|
||||
root: staged.root,
|
||||
})
|
||||
}
|
||||
|
||||
async fn remove_package(
|
||||
&self,
|
||||
_plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStoreError> {
|
||||
Ok(RemovalOutcome::NotFound)
|
||||
}
|
||||
|
||||
fn bundle_url(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
entry: &RelativePath,
|
||||
hash: &ContentHash,
|
||||
) -> Result<PluginBundleUrl, PluginStoreError> {
|
||||
Ok(PluginBundleUrl::new(format!(
|
||||
"idea-plugin://{}/current/{}/{}",
|
||||
plugin_id.as_str(),
|
||||
hash.as_str(),
|
||||
entry.as_str()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakePluginRegistry {
|
||||
registry: Arc<Mutex<PluginRegistry>>,
|
||||
}
|
||||
|
||||
impl FakePluginRegistry {
|
||||
fn with_state(lifecycle_state: PluginLifecycleState) -> Self {
|
||||
Self {
|
||||
registry: Arc::new(Mutex::new(PluginRegistry {
|
||||
version: 1,
|
||||
plugins: vec![PluginRegistryEntry {
|
||||
id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
||||
lifecycle_state,
|
||||
source: PluginInstallSource::Directory {
|
||||
path_label: "/plugin".to_owned(),
|
||||
},
|
||||
content_hash: ContentHash::new("abc123").unwrap(),
|
||||
restart_required: false,
|
||||
error: None,
|
||||
}],
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginRegistryStore for FakePluginRegistry {
|
||||
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
||||
Ok(self.registry.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> {
|
||||
*self.registry.lock().unwrap() = registry.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeProjectRegistry {
|
||||
existing: Arc<Mutex<Vec<ProjectId>>>,
|
||||
@ -148,6 +284,7 @@ fn persisted_main() -> PersistedWindowState {
|
||||
kind: PersistedWindowKind::Main,
|
||||
panel: None,
|
||||
project_id: None,
|
||||
plugin_layout: None,
|
||||
url: None,
|
||||
visible: true,
|
||||
maximized: false,
|
||||
@ -165,6 +302,7 @@ fn persisted_view(label: &str, project_id: ProjectId) -> PersistedWindowState {
|
||||
kind: PersistedWindowKind::View,
|
||||
panel: Some("tickets".to_owned()),
|
||||
project_id: Some(project_id),
|
||||
plugin_layout: None,
|
||||
url: Some(format!("index.html?panel=tickets&project={project_id}")),
|
||||
visible: true,
|
||||
maximized: false,
|
||||
@ -176,6 +314,52 @@ fn persisted_view(label: &str, project_id: ProjectId) -> PersistedWindowState {
|
||||
}
|
||||
}
|
||||
|
||||
fn persisted_plugin_layout(
|
||||
label: &str,
|
||||
plugin_id: &str,
|
||||
layout_type: &str,
|
||||
) -> PersistedWindowState {
|
||||
PersistedWindowState {
|
||||
label: label.to_owned(),
|
||||
kind: PersistedWindowKind::PluginLayout,
|
||||
panel: None,
|
||||
project_id: None,
|
||||
plugin_layout: Some(PersistedPluginLayoutWindow {
|
||||
plugin_id: PluginId::new(plugin_id).unwrap(),
|
||||
layout_type: PluginLayoutType::new(layout_type).unwrap(),
|
||||
state: serde_json::json!({ "from": "test" }),
|
||||
}),
|
||||
url: Some(format!(
|
||||
"index.html?pluginLayout=1&pluginId={plugin_id}&layoutType={layout_type}"
|
||||
)),
|
||||
visible: true,
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
outer_position: None,
|
||||
outer_size: None,
|
||||
monitor: None,
|
||||
last_focused_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_uc(store: FakeWindowStateStore, registry: FakePluginRegistry) -> RestoreOpenWindows {
|
||||
RestoreOpenWindows::new(
|
||||
Arc::new(store),
|
||||
Arc::new(FakeProjectRegistry::new(vec![])),
|
||||
Arc::new(FakePluginPackages::new(plugin_manifest())),
|
||||
Arc::new(registry),
|
||||
Arc::new(JsonPluginManifestValidator::new("0.3.0")),
|
||||
)
|
||||
}
|
||||
|
||||
fn open_plugin_layout_uc(registry: FakePluginRegistry) -> OpenPluginLayoutWindow {
|
||||
OpenPluginLayoutWindow::new(
|
||||
Arc::new(FakePluginPackages::new(plugin_manifest())),
|
||||
Arc::new(registry),
|
||||
Arc::new(JsonPluginManifestValidator::new("0.3.0")),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detaches_tab_and_persists_workspace() {
|
||||
let store = seeded();
|
||||
@ -250,8 +434,10 @@ async fn restore_open_windows_keeps_panel_views_without_reopening_projects() {
|
||||
url_missing,
|
||||
no_panel,
|
||||
]))));
|
||||
let projects = FakeProjectRegistry::new(vec![]);
|
||||
let uc = RestoreOpenWindows::new(Arc::new(store), Arc::new(projects));
|
||||
let uc = restore_uc(
|
||||
store,
|
||||
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||
);
|
||||
|
||||
let out = uc.execute().await.unwrap();
|
||||
|
||||
@ -268,3 +454,80 @@ async fn restore_open_windows_keeps_panel_views_without_reopening_projects() {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_plugin_layout_window_validates_active_runtime_contribution() {
|
||||
let uc = open_plugin_layout_uc(FakePluginRegistry::with_state(
|
||||
PluginLifecycleState::Enabled,
|
||||
));
|
||||
|
||||
let out = uc
|
||||
.execute(OpenPluginLayoutWindowInput {
|
||||
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
||||
layout_type: PluginLayoutType::new("idea-android.health").unwrap(),
|
||||
state: serde_json::json!({ "deviceId": "pixel-8" }),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.contribution.provider_plugin_display_name, "Android");
|
||||
assert_eq!(out.contribution.label, "Android Health");
|
||||
assert_eq!(out.surface.plugin_id.as_str(), "dev.idea.android-plugin");
|
||||
assert_eq!(out.surface.layout_type.as_str(), "idea-android.health");
|
||||
assert_eq!(out.surface.state["deviceId"], "pixel-8");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_plugin_layout_window_rejects_inactive_plugin() {
|
||||
let uc = open_plugin_layout_uc(FakePluginRegistry::with_state(
|
||||
PluginLifecycleState::Disabled,
|
||||
));
|
||||
|
||||
let err = uc
|
||||
.execute(OpenPluginLayoutWindowInput {
|
||||
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
||||
layout_type: PluginLayoutType::new("idea-android.health").unwrap(),
|
||||
state: serde_json::Value::Null,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_open_windows_keeps_valid_plugin_layout_windows_only() {
|
||||
let mut missing_surface = persisted_plugin_layout(
|
||||
"view-plugin-layout-missing",
|
||||
"dev.idea.android-plugin",
|
||||
"idea-android.health",
|
||||
);
|
||||
missing_surface.plugin_layout = None;
|
||||
let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![
|
||||
persisted_plugin_layout(
|
||||
"view-plugin-layout-valid",
|
||||
"dev.idea.android-plugin",
|
||||
"idea-android.health",
|
||||
),
|
||||
persisted_plugin_layout(
|
||||
"view-plugin-layout-unknown-layout",
|
||||
"dev.idea.android-plugin",
|
||||
"idea-android.missing",
|
||||
),
|
||||
missing_surface,
|
||||
]))));
|
||||
let uc = restore_uc(
|
||||
store,
|
||||
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||
);
|
||||
|
||||
let out = uc.execute().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
out.windows
|
||||
.iter()
|
||||
.map(|w| w.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["view-plugin-layout-valid"]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user