Lot en un seul commit: les quatre tickets partagent les mêmes fichiers (événements domaine/DTO, ProjectPluginsPanel, useProjectPlugins) — une séparation par hunks aurait risqué des états intermédiaires non compilables (convention lot des batches #310-#313, #315 et #325-#326). #328 — section dédiée plugins dans Paramètres du projet: - frontend: ProjectPluginsPanel intégré à ProjectSettingsPanel (Panneaux > Paramètres du projet) — activation/désactivation par plugin avec état actif/inactif explicite par projet, distinct de l'assignation agents/skills/tools - domaine: événement ProjectPluginSettingsChanged publié par SetProjectPluginEnabled, MutateAgentPluginSkill et MutateAgentPluginTool après persistance de .ideai/project-plugins.json (application + backend + DTO camelCase, test dto.rs) #329 — les menus plugin qui échouent ne sont plus muets: - usePluginMenus: dispatcher strict (runCommandStrict) pour les commandes réellement enregistrées au lieu du dispatcher laxiste qui journalisait et avalait les rejets — rendait les échecs d'ouverture de fenêtres natifs invisibles pour l'hôte de menus - le no-op SDK historique (commande déclarée sans handler) est préservé - échec remonté à l'hôte via commandError/clearCommandError (journal console + état exposé); test window_usecases adapté à un plugin installé - le correctif côté SDK/GitPlugin vit dans le projet GitPlugin (son commit/merge local y est fait, hors périmètre de ce dépôt) #330 — refresh automatique des pages: - frontend/domain: typage des événements pluginEnabled, pluginDisabled (restartRequired), pluginUninstalled (restartRequired), pluginLoadFailed et projectPluginSettingsChanged - useProjectPlugins et PluginRuntimeProvider: souscription onDomainEvent — refresh sur changement catalogue global ou sur projectPluginSettingsChanged du projet courant; gardes révision/projet contre les réponses obsolètes - useProjectAgents: refresh auto quand un agent est ajouté au projet; ProjectsView et AgentsPanel suivent (erreur de rafraîchissement affichée avec bouton Réessayer) #331 — droplist agents des cellules: - SmallDropdown: liste bg-overlay + bordure border-strong (plus de fond blanc hors thème), hover/focus bg-raised, sélection bg-primary/15, disabled text-faint opacity-60, role=combobox - LayoutGrid: sélecteur d'agent des cellules migré du <select> natif vers SmallDropdown (mêmes libellés Terminal/visible ailleurs/arrière-plan) QA verte réelle sur les quatre lots (tests ciblés + exécution). sdk/IdeaSDK: contenu modifié non committable ici (pointeur inchangé). Aucun push.
538 lines
16 KiB
Rust
538 lines
16 KiB
Rust
//! L10 tests for [`MoveTabToNewWindow`] with a fake [`ProjectStore`]: the tab is
|
|
//! detached and the workspace is persisted (load returns the new state).
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::ids::{ProjectId, TabId, WindowId};
|
|
use domain::layout::{
|
|
LayoutNode, LayoutTree, LeafCell, PersistedPluginLayoutWindow, PersistedWindowKind,
|
|
PersistedWindowState, Tab, Window, WindowStateSnapshot, Workspace,
|
|
};
|
|
use domain::ports::{
|
|
IdGenerator, PluginManifestBytes, PluginPackageStore, PluginRegistryError, PluginRegistryStore,
|
|
PluginStoreError, ProjectStore, StoreError, WindowStateStore,
|
|
};
|
|
use domain::project::{Project, ProjectPath};
|
|
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, OpenPluginLayoutWindow,
|
|
OpenPluginLayoutWindowInput, RestoreOpenWindows, SnapshotOpenWindows, SnapshotOpenWindowsInput,
|
|
};
|
|
|
|
/// A `ProjectStore` fake that only implements the workspace persistence the use
|
|
/// case needs (the project methods are never called here).
|
|
#[derive(Clone)]
|
|
struct FakeStore(Arc<Mutex<Workspace>>);
|
|
#[async_trait]
|
|
impl ProjectStore for FakeStore {
|
|
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
|
unreachable!()
|
|
}
|
|
async fn load_project(&self, _id: ProjectId) -> Result<Project, StoreError> {
|
|
unreachable!()
|
|
}
|
|
async fn save_project(&self, _p: &Project) -> Result<(), StoreError> {
|
|
unreachable!()
|
|
}
|
|
async fn save_workspace(&self, ws: &Workspace) -> Result<(), StoreError> {
|
|
*self.0.lock().unwrap() = ws.clone();
|
|
Ok(())
|
|
}
|
|
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
|
Ok(self.0.lock().unwrap().clone())
|
|
}
|
|
}
|
|
|
|
struct SeqIds(Mutex<u128>);
|
|
impl IdGenerator for SeqIds {
|
|
fn new_uuid(&self) -> Uuid {
|
|
let mut n = self.0.lock().unwrap();
|
|
let id = Uuid::from_u128(*n);
|
|
*n += 1;
|
|
id
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct FakeWindowStateStore(Arc<Mutex<WindowStateSnapshot>>);
|
|
|
|
#[async_trait]
|
|
impl WindowStateStore for FakeWindowStateStore {
|
|
async fn save_window_state(&self, snapshot: &WindowStateSnapshot) -> Result<(), StoreError> {
|
|
*self.0.lock().unwrap() = snapshot.clone();
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_window_state(&self) -> Result<WindowStateSnapshot, StoreError> {
|
|
Ok(self.0.lock().unwrap().clone())
|
|
}
|
|
}
|
|
|
|
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![PluginPackageRef {
|
|
plugin_id: Some(PluginId::new("dev.idea.android-plugin").unwrap()),
|
|
root: "/plugin".to_owned(),
|
|
}])
|
|
}
|
|
|
|
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>>>,
|
|
}
|
|
|
|
impl FakeProjectRegistry {
|
|
fn new(existing: Vec<ProjectId>) -> Self {
|
|
Self {
|
|
existing: Arc::new(Mutex::new(existing)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProjectStore for FakeProjectRegistry {
|
|
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
|
unreachable!()
|
|
}
|
|
|
|
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
|
if !self.existing.lock().unwrap().contains(&id) {
|
|
return Err(StoreError::NotFound);
|
|
}
|
|
|
|
Ok(Project {
|
|
id,
|
|
name: format!("project-{id}"),
|
|
root: ProjectPath::new(format!("/tmp/{id}")).unwrap(),
|
|
remote: RemoteRef::Local,
|
|
created_at: 0,
|
|
})
|
|
}
|
|
|
|
async fn save_project(&self, _p: &Project) -> Result<(), StoreError> {
|
|
unreachable!()
|
|
}
|
|
|
|
async fn save_workspace(&self, _ws: &Workspace) -> Result<(), StoreError> {
|
|
unreachable!()
|
|
}
|
|
|
|
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
|
unreachable!()
|
|
}
|
|
}
|
|
|
|
fn tid(n: u128) -> TabId {
|
|
TabId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn wid(n: u128) -> WindowId {
|
|
WindowId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn tab(n: u128) -> Tab {
|
|
Tab {
|
|
id: tid(n),
|
|
project_id: ProjectId::from_uuid(Uuid::from_u128(1000 + n)),
|
|
layout: LayoutTree::new(LayoutNode::Leaf(LeafCell {
|
|
id: NodeId::from_uuid(Uuid::from_u128(900 + n)),
|
|
session: None,
|
|
agent: None,
|
|
conversation_id: None,
|
|
engine_session_id: None,
|
|
agent_was_running: false,
|
|
preferred_view: domain::PreferredView::Tui,
|
|
})),
|
|
}
|
|
}
|
|
|
|
fn seeded() -> FakeStore {
|
|
let ws = Workspace {
|
|
windows: vec![Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap()],
|
|
};
|
|
FakeStore(Arc::new(Mutex::new(ws)))
|
|
}
|
|
|
|
fn persisted_main() -> PersistedWindowState {
|
|
PersistedWindowState {
|
|
label: "main".to_owned(),
|
|
kind: PersistedWindowKind::Main,
|
|
panel: None,
|
|
project_id: None,
|
|
plugin_layout: None,
|
|
url: None,
|
|
visible: true,
|
|
maximized: false,
|
|
fullscreen: false,
|
|
outer_position: None,
|
|
outer_size: None,
|
|
monitor: None,
|
|
last_focused_at: None,
|
|
}
|
|
}
|
|
|
|
fn persisted_view(label: &str, project_id: ProjectId) -> PersistedWindowState {
|
|
PersistedWindowState {
|
|
label: label.to_owned(),
|
|
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,
|
|
fullscreen: false,
|
|
outer_position: None,
|
|
outer_size: None,
|
|
monitor: None,
|
|
last_focused_at: None,
|
|
}
|
|
}
|
|
|
|
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();
|
|
// The id generator's first uuid (from_u128(7)) becomes the new window id.
|
|
let ids = Arc::new(SeqIds(Mutex::new(7)));
|
|
let uc = MoveTabToNewWindow::new(Arc::new(store.clone()), ids);
|
|
|
|
let out = uc
|
|
.execute(MoveTabToNewWindowInput { tab_id: tid(1) })
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.new_window_id, WindowId::from_uuid(Uuid::from_u128(7)));
|
|
assert_eq!(out.workspace.windows.len(), 2);
|
|
|
|
// Persisted: reloading the store yields the detached layout.
|
|
let reloaded = store.load_workspace().await.unwrap();
|
|
assert_eq!(reloaded, out.workspace);
|
|
let detached = reloaded
|
|
.windows
|
|
.iter()
|
|
.find(|w| w.id == out.new_window_id)
|
|
.unwrap();
|
|
assert_eq!(detached.tabs.len(), 1);
|
|
assert_eq!(detached.tabs[0].id, tid(1));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_tab_is_not_found() {
|
|
let store = seeded();
|
|
let uc = MoveTabToNewWindow::new(Arc::new(store), Arc::new(SeqIds(Mutex::new(7))));
|
|
let err = uc
|
|
.execute(MoveTabToNewWindowInput { tab_id: tid(404) })
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn snapshot_open_windows_persists_the_supplied_snapshot() {
|
|
let store = FakeWindowStateStore::default();
|
|
let uc = SnapshotOpenWindows::new(Arc::new(store.clone()));
|
|
let windows = vec![persisted_main()];
|
|
|
|
uc.execute(SnapshotOpenWindowsInput {
|
|
windows: windows.clone(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let saved = store.load_window_state().await.unwrap();
|
|
assert_eq!(saved.version, domain::WINDOW_STATE_SNAPSHOT_VERSION);
|
|
assert_eq!(saved.windows, windows);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn restore_open_windows_keeps_panel_views_without_reopening_projects() {
|
|
let existing = ProjectId::from_uuid(Uuid::from_u128(42));
|
|
let missing = ProjectId::from_uuid(Uuid::from_u128(43));
|
|
let valid_label = "view-tickets-0000000000000000000000000000002a";
|
|
let mut url_missing = persisted_view("view-tickets-0000000000000000000000000000002c", existing);
|
|
url_missing.url = None;
|
|
let mut no_panel = persisted_view("view-tickets-0000000000000000000000000000002d", existing);
|
|
no_panel.panel = None;
|
|
|
|
let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![
|
|
persisted_main(),
|
|
persisted_main(),
|
|
persisted_view(valid_label, existing),
|
|
persisted_view(valid_label, existing),
|
|
persisted_view("view-tickets-0000000000000000000000000000002b", missing),
|
|
url_missing,
|
|
no_panel,
|
|
]))));
|
|
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![
|
|
"main",
|
|
valid_label,
|
|
"view-tickets-0000000000000000000000000000002b",
|
|
"view-tickets-0000000000000000000000000000002c"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[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"]
|
|
);
|
|
}
|