fix(web): route la famille layout dans /api/invoke + fallback UX restauration — #212
Le web-server ne routait pas load_layout (UNKNOWN_COMMAND a l'ouverture d'un projet web PC). Alignement de l'adapter web sur les contrats Tauri: load_layout, mutate_layout, list_layouts, create_layout, rename_layout, delete_layout, set_active_layout. useLayout ne propage plus l'erreur brute: message utilisateur + fallback singleLeafTree.
This commit is contained in:
@ -40,24 +40,25 @@ use zip::write::SimpleFileOptions;
|
|||||||
use application::{
|
use application::{
|
||||||
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput,
|
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput,
|
||||||
AssignTicketToSprintInput, AttachLiveAgentInput, AuthenticateSessionInput,
|
AssignTicketToSprintInput, AttachLiveAgentInput, AuthenticateSessionInput,
|
||||||
ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateMemoryInput,
|
ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateLayoutInput,
|
||||||
CreateSkillInput, CreateSprintInput, DeleteAgentInput, DeleteEmbedderProfileInput,
|
CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput,
|
||||||
DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput,
|
DeleteEmbedderProfileInput, DeleteIssueInput, DeleteLayoutInput, DeleteMemoryInput,
|
||||||
DetectAgentDriftInput, GetMemoryInput, GetProjectSystemPermissionsInput,
|
DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput,
|
||||||
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
|
GetMemoryInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput,
|
||||||
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput,
|
||||||
LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput,
|
GitStatusInput, InspectConversationInput, LaunchAgentInput, LinkIssuesInput, ListAgentsInput,
|
||||||
ListMemoriesInput, ListProjectFilesInput, ListResumableAgentsInput, ListSkillsInput,
|
ListDevicesInput, ListIssuesInput, ListLayoutsInput, ListMemoriesInput, ListProjectFilesInput,
|
||||||
ListSprintsInput, LiveSessions, MarkIssueAttachmentSummarizedInput, McpRuntime,
|
ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions, LoadLayoutInput,
|
||||||
OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
|
MarkIssueAttachmentSummarizedInput, McpRuntime, MutateLayoutInput, OpenProjectInput,
|
||||||
|
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
|
||||||
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
|
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
|
||||||
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||||
RenameDeviceInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput,
|
RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput,
|
||||||
ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput,
|
ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
|
||||||
RevokeDeviceInput, RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
|
||||||
TouchDeviceInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput,
|
StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
|
||||||
UnlinkIssuesInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContextInput,
|
||||||
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||||
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
|
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
|
||||||
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
|
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
|
||||||
@ -71,31 +72,33 @@ use domain::{
|
|||||||
|
|
||||||
use backend::dto::{
|
use backend::dto::{
|
||||||
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_dto,
|
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_dto,
|
||||||
parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id, parse_ref_dto,
|
parse_layout_id, parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id,
|
||||||
parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id,
|
parse_ref_dto, parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto,
|
||||||
parse_template_id, sort_ticket_rows, sprint_version_dto, update_input, version_dto,
|
parse_task_id, parse_template_id, sort_ticket_rows, sprint_version_dto, update_input,
|
||||||
AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
|
version_dto, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto,
|
||||||
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
|
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto,
|
||||||
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||||
CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||||
|
CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||||
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
|
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||||
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
|
||||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
||||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||||
|
ReadConversationPageRequestDto, RecallMemoryRequestDto, RenameLayoutRequestDto,
|
||||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
||||||
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
|
SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto,
|
||||||
SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
|
SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto,
|
||||||
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
SprintListRequestDto, SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
|
||||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||||
TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
|
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
|
||||||
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
|
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
|
||||||
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
|
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
|
||||||
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
|
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
|
||||||
@ -2872,6 +2875,23 @@ struct InvokeRequest {
|
|||||||
args: Value,
|
args: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LoadLayoutInvokeArgs {
|
||||||
|
project_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
layout_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct MutateLayoutInvokeArgs {
|
||||||
|
project_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
layout_id: Option<String>,
|
||||||
|
operation: LayoutOperationDto,
|
||||||
|
}
|
||||||
|
|
||||||
async fn invoke(
|
async fn invoke(
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
state: Arc<ServerState>,
|
state: Arc<ServerState>,
|
||||||
@ -2893,6 +2913,13 @@ async fn invoke(
|
|||||||
"health" => invoke_health(&request.args, &state.app),
|
"health" => invoke_health(&request.args, &state.app),
|
||||||
"list_projects" => invoke_list_projects(&state.app).await,
|
"list_projects" => invoke_list_projects(&state.app).await,
|
||||||
"open_project" => invoke_open_project(&request.args, &state.app).await,
|
"open_project" => invoke_open_project(&request.args, &state.app).await,
|
||||||
|
"load_layout" => invoke_load_layout(&request.args, &state.app).await,
|
||||||
|
"mutate_layout" => invoke_mutate_layout(&request.args, &state.app).await,
|
||||||
|
"list_layouts" => invoke_list_layouts(&request.args, &state.app).await,
|
||||||
|
"create_layout" => invoke_create_layout(&request.args, &state.app).await,
|
||||||
|
"rename_layout" => invoke_rename_layout(&request.args, &state.app).await,
|
||||||
|
"delete_layout" => invoke_delete_layout(&request.args, &state.app).await,
|
||||||
|
"set_active_layout" => invoke_set_active_layout(&request.args, &state.app).await,
|
||||||
"read_project_context" => invoke_read_project_context(&request.args, &state.app).await,
|
"read_project_context" => invoke_read_project_context(&request.args, &state.app).await,
|
||||||
"update_project_context" => invoke_update_project_context(&request.args, &state.app).await,
|
"update_project_context" => invoke_update_project_context(&request.args, &state.app).await,
|
||||||
"first_run_state" => invoke_first_run_state(&state.app).await,
|
"first_run_state" => invoke_first_run_state(&state.app).await,
|
||||||
@ -3072,6 +3099,124 @@ async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result<Value,
|
|||||||
serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error)
|
serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn invoke_load_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<LoadLayoutInvokeArgs>("load_layout", args)?;
|
||||||
|
let project_id = parse_project_id(&request.project_id)?;
|
||||||
|
let layout_id = request
|
||||||
|
.layout_id
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_layout_id)
|
||||||
|
.transpose()?;
|
||||||
|
let output = state
|
||||||
|
.load_layout
|
||||||
|
.execute(LoadLayoutInput {
|
||||||
|
project_id,
|
||||||
|
layout_id,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(LayoutDto::from)
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
serde_json::to_value(output).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_mutate_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<MutateLayoutInvokeArgs>("mutate_layout", args)?;
|
||||||
|
let project_id = parse_project_id(&request.project_id)?;
|
||||||
|
let layout_id = request
|
||||||
|
.layout_id
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_layout_id)
|
||||||
|
.transpose()?;
|
||||||
|
let operation = request.operation.into_operation()?;
|
||||||
|
let output = state
|
||||||
|
.mutate_layout
|
||||||
|
.execute(MutateLayoutInput {
|
||||||
|
project_id,
|
||||||
|
layout_id,
|
||||||
|
operation,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(LayoutDto::from)
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
serde_json::to_value(output).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_list_layouts(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let project_id = parse_project_id(string_arg(args, "projectId", "list_layouts")?)?;
|
||||||
|
let output = state
|
||||||
|
.list_layouts
|
||||||
|
.execute(ListLayoutsInput { project_id })
|
||||||
|
.await
|
||||||
|
.map(ListLayoutsDto::from)
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
serde_json::to_value(output).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_create_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<CreateLayoutRequestDto>("create_layout", args)?;
|
||||||
|
let project_id = parse_project_id(&request.project_id)?;
|
||||||
|
let kind = request.parse_kind()?;
|
||||||
|
let output = state
|
||||||
|
.create_layout
|
||||||
|
.execute(CreateLayoutInput {
|
||||||
|
project_id,
|
||||||
|
name: request.name,
|
||||||
|
kind,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(CreateLayoutResultDto::from)
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
serde_json::to_value(output).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_rename_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<RenameLayoutRequestDto>("rename_layout", args)?;
|
||||||
|
let project_id = parse_project_id(&request.project_id)?;
|
||||||
|
let layout_id = parse_layout_id(&request.layout_id)?;
|
||||||
|
state
|
||||||
|
.rename_layout
|
||||||
|
.execute(RenameLayoutInput {
|
||||||
|
project_id,
|
||||||
|
layout_id,
|
||||||
|
name: request.name,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_delete_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<DeleteLayoutRequestDto>("delete_layout", args)?;
|
||||||
|
let project_id = parse_project_id(&request.project_id)?;
|
||||||
|
let layout_id = parse_layout_id(&request.layout_id)?;
|
||||||
|
let output = state
|
||||||
|
.delete_layout
|
||||||
|
.execute(DeleteLayoutInput {
|
||||||
|
project_id,
|
||||||
|
layout_id,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(DeleteLayoutResultDto::from)
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
serde_json::to_value(output).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_set_active_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let request = required_request::<SetActiveLayoutRequestDto>("set_active_layout", args)?;
|
||||||
|
let project_id = parse_project_id(&request.project_id)?;
|
||||||
|
let layout_id = parse_layout_id(&request.layout_id)?;
|
||||||
|
let output = state
|
||||||
|
.set_active_layout
|
||||||
|
.execute(SetActiveLayoutInput {
|
||||||
|
project_id,
|
||||||
|
layout_id,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(SetActiveLayoutResultDto::from)
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
serde_json::to_value(output).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
async fn invoke_read_project_context(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
async fn invoke_read_project_context(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
let project = resolve_project_readonly(
|
let project = resolve_project_readonly(
|
||||||
string_arg(args, "projectId", "read_project_context")?,
|
string_arg(args, "projectId", "read_project_context")?,
|
||||||
@ -8252,6 +8397,104 @@ mod tests {
|
|||||||
assert!(body["root"].is_string());
|
assert!(body["root"].is_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_layout_invokes_match_tauri_contracts() {
|
||||||
|
let state = state();
|
||||||
|
let project_id = create_project_for_test(&state, "Web Layouts").await;
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
let (load_status, layout) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"load_layout",
|
||||||
|
json!({ "projectId": project_id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(load_status, StatusCode::OK);
|
||||||
|
assert_eq!(layout["root"]["type"], "leaf");
|
||||||
|
|
||||||
|
let (list_status, listed) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"list_layouts",
|
||||||
|
json!({ "projectId": project_id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(list_status, StatusCode::OK);
|
||||||
|
assert!(listed["activeId"].is_string());
|
||||||
|
assert_eq!(listed["layouts"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let (create_status, created) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"create_layout",
|
||||||
|
json!({ "projectId": project_id, "name": "Second", "kind": "terminal" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(create_status, StatusCode::OK);
|
||||||
|
let created_layout_id = created["layoutId"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
let (rename_status, renamed) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"rename_layout",
|
||||||
|
json!({ "projectId": project_id, "layoutId": created_layout_id, "name": "Renamed" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(rename_status, StatusCode::OK);
|
||||||
|
assert_eq!(renamed, Value::Null);
|
||||||
|
|
||||||
|
let (activate_status, activated) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"set_active_layout",
|
||||||
|
json!({ "projectId": project_id, "layoutId": created_layout_id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(activate_status, StatusCode::OK);
|
||||||
|
assert_eq!(activated["activeId"], created_layout_id);
|
||||||
|
|
||||||
|
let (load_created_status, created_layout) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"load_layout",
|
||||||
|
json!({ "projectId": project_id, "layoutId": created_layout_id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(load_created_status, StatusCode::OK);
|
||||||
|
|
||||||
|
let (mutate_status, mutated) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"mutate_layout",
|
||||||
|
json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"layoutId": created_layout_id,
|
||||||
|
"operation": {
|
||||||
|
"type": "setCellConversation",
|
||||||
|
"target": created_layout["root"]["node"]["id"],
|
||||||
|
"conversationId": "web-conversation"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(mutate_status, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
mutated["root"]["node"]["conversationId"],
|
||||||
|
"web-conversation"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (delete_status, deleted) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"delete_layout",
|
||||||
|
json!({ "projectId": project_id, "layoutId": created_layout_id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(delete_status, StatusCode::OK);
|
||||||
|
assert!(deleted["activeId"].is_string());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn authorized_project_files_lists_relative_paths_and_hides_ideai() {
|
async fn authorized_project_files_lists_relative_paths_and_hides_ideai() {
|
||||||
let state = state();
|
let state = state();
|
||||||
@ -8556,6 +8799,27 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn web_invoke_routes_layout_commands() {
|
||||||
|
let state = state();
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
assert_invoke_commands_are_routed(
|
||||||
|
&state,
|
||||||
|
&cookie,
|
||||||
|
&[
|
||||||
|
"load_layout",
|
||||||
|
"mutate_layout",
|
||||||
|
"list_layouts",
|
||||||
|
"create_layout",
|
||||||
|
"rename_layout",
|
||||||
|
"delete_layout",
|
||||||
|
"set_active_layout",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn web_invoke_routes_profile_commands() {
|
async fn web_invoke_routes_profile_commands() {
|
||||||
let state = state();
|
let state = state();
|
||||||
|
|||||||
@ -35,6 +35,31 @@ function renderGrid(layout: MockLayoutGateway, projectId = "p1") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("LayoutGrid (with MockLayoutGateway)", () => {
|
describe("LayoutGrid (with MockLayoutGateway)", () => {
|
||||||
|
it("falls back to an empty workspace with a user-facing message when layout restore fails", async () => {
|
||||||
|
const layout = new MockLayoutGateway();
|
||||||
|
vi.spyOn(layout, "loadLayout").mockRejectedValueOnce(
|
||||||
|
new Error("unknown command: load_layout"),
|
||||||
|
);
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
renderGrid(layout);
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toContain(
|
||||||
|
"Impossible de restaurer l’espace de travail de ce projet.",
|
||||||
|
);
|
||||||
|
expect(alert.textContent).not.toContain("unknown command");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
"Layout restore failed",
|
||||||
|
expect.objectContaining({ message: "unknown command: load_layout" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a single-leaf layout without throwing", async () => {
|
it("renders a single-leaf layout without throwing", async () => {
|
||||||
const layout = new MockLayoutGateway();
|
const layout = new MockLayoutGateway();
|
||||||
expect(() => renderGrid(layout)).not.toThrow();
|
expect(() => renderGrid(layout)).not.toThrow();
|
||||||
|
|||||||
@ -20,9 +20,13 @@ import { useGateways } from "@/app/di";
|
|||||||
import {
|
import {
|
||||||
leaves,
|
leaves,
|
||||||
replaceCustomPluginLayoutWithTerminal,
|
replaceCustomPluginLayoutWithTerminal,
|
||||||
|
singleLeafTree,
|
||||||
splitOp,
|
splitOp,
|
||||||
} from "./layout";
|
} from "./layout";
|
||||||
|
|
||||||
|
const LAYOUT_RESTORE_ERROR =
|
||||||
|
"Impossible de restaurer l’espace de travail de ce projet. Un espace vide a été ouvert ; rechargez le projet pour réessayer.";
|
||||||
|
|
||||||
/** What the layout grid UI needs from this hook. */
|
/** What the layout grid UI needs from this hook. */
|
||||||
export interface LayoutViewModel {
|
export interface LayoutViewModel {
|
||||||
/** The current layout tree, or `null` until loaded. */
|
/** The current layout tree, or `null` until loaded. */
|
||||||
@ -109,13 +113,18 @@ export function useLayout(
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
gateway
|
gateway
|
||||||
.loadLayout(projectId, layoutId)
|
.loadLayout(projectId, layoutId)
|
||||||
.then((tree) => {
|
.then((tree) => {
|
||||||
if (!cancelled) applyTree(tree);
|
if (!cancelled) applyTree(tree);
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
if (!cancelled) setError(describe(e));
|
if (!cancelled) {
|
||||||
|
console.warn("Layout restore failed", e);
|
||||||
|
applyTree(singleLeafTree());
|
||||||
|
setError(LAYOUT_RESTORE_ERROR);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setBusy(false);
|
if (!cancelled) setBusy(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user