fix(windows): restauration panel-only des fenêtres détachées suivant le projet en focus (#47)

Partie backend. Les fenêtres/panneaux détachés étaient restaurés avec un
project_id figé au moment du détachement, si bien qu'ils restaient collés à
un projet mort ou incohérent après redémarrage. Ils sont désormais restaurés
en mode panel-only, sans project_id figé, et suivent le projet en focus de la
fenêtre principale via un event focused-project exposé par la couche fenêtre.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 12:37:25 +02:00
parent 1e0cb16ead
commit 6387fac34f
6 changed files with 156 additions and 107 deletions

View File

@ -62,7 +62,7 @@ use crate::dto::{
UpdateTemplateRequestDto, WriteTerminalRequestDto,
};
use crate::pty::{PtyBridge, PtyChunk};
use crate::state::AppState;
use crate::state::{AppState, FocusedProjectDto};
use domain::{SkillRef, SkillScope};
/// `health` — trivial command validating the full IPC pipeline
@ -2303,12 +2303,14 @@ use application::MoveTabToNewWindowInput;
/// Event emitted for detached view-window lifecycle changes.
pub const VIEW_WINDOW_LIFECYCLE_EVENT: &str = "view-window://lifecycle";
/// Event emitted when the main-window focused project changes.
pub const FOCUSED_PROJECT_CHANGED_EVENT: &str = "focused-project://changed";
/// Response returned by `open_view_window`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenViewWindowResponseDto {
/// Stable Tauri window label for this `(panel, projectId)`.
/// Stable Tauri window label for this panel.
pub label: String,
/// App URL loaded by the panel-only window.
pub url: String,
@ -2320,7 +2322,7 @@ pub struct OpenViewWindowResponseDto {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CloseViewWindowResponseDto {
/// Stable Tauri window label for this `(panel, projectId)`.
/// Stable Tauri window label for this panel.
pub label: String,
/// `true` when a live window was found and close was requested.
pub closed: bool,
@ -2334,12 +2336,18 @@ pub struct ViewWindowLifecycleEventDto {
pub kind: &'static str,
/// Panel id rendered by the detached window.
pub panel: String,
/// Project id rendered by the detached window.
pub project_id: String,
/// Stable Tauri window label.
pub label: String,
}
/// Payload emitted on `focused-project://changed`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FocusedProjectChangedEventDto {
/// Focused project, or `null` when no project is focused.
pub project: Option<FocusedProjectDto>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ViewPanel {
Projects,
@ -2405,19 +2413,18 @@ impl ViewPanel {
}
}
pub(crate) fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String {
format!("view-{}-{}", panel.as_str(), project_id.as_uuid().simple())
pub(crate) fn view_window_label(panel: ViewPanel) -> String {
format!("view-{}", panel.as_str())
}
pub(crate) fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String {
format!("index.html?panel={}&project={}", panel.as_str(), project_id)
pub(crate) fn view_window_url(panel: ViewPanel) -> String {
format!("index.html?panel={}", panel.as_str())
}
pub(crate) fn emit_view_window_lifecycle(
app: &AppHandle,
kind: &'static str,
panel: ViewPanel,
project_id: domain::ProjectId,
label: &str,
) {
let _ = app.emit(
@ -2425,39 +2432,56 @@ pub(crate) fn emit_view_window_lifecycle(
ViewWindowLifecycleEventDto {
kind,
panel: panel.as_str().to_owned(),
project_id: project_id.to_string(),
label: label.to_owned(),
},
);
}
/// `open_view_window` — open or focus a detached OS window for one project view.
/// `set_focused_project` — update the backend focused-project state and notify panels.
#[tauri::command]
pub async fn set_focused_project(
app: AppHandle,
project: Option<FocusedProjectDto>,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state.set_focused_project(project.clone());
app.emit(
FOCUSED_PROJECT_CHANGED_EVENT,
FocusedProjectChangedEventDto { project },
)
.map_err(internal_window_error)
}
/// `get_focused_project` — read the backend focused-project state.
#[tauri::command]
pub async fn get_focused_project(
state: State<'_, AppState>,
) -> Result<Option<FocusedProjectDto>, ErrorDto> {
Ok(state.get_focused_project())
}
/// `open_view_window` — open or focus a detached OS window for one panel.
///
/// The window is a normal decorated, resizable system window. Its webview loads
/// `index.html?panel=<panel>&project=<projectId>` so the frontend can boot a
/// panel-only shell with the regular gateways and read models.
/// `index.html?panel=<panel>` so the frontend can boot a panel-only shell that
/// follows the backend focused-project state.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel or malformed project
/// id, `NOT_FOUND`/`STORE` if the project cannot be loaded, `INTERNAL` if Tauri
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri
/// fails to create/focus the window).
#[tauri::command]
pub async fn open_view_window(
app: AppHandle,
panel: String,
project_id: String,
state: State<'_, AppState>,
) -> Result<OpenViewWindowResponseDto, ErrorDto> {
let panel = ViewPanel::parse(&panel)?;
let project = resolve_project(&project_id, &state).await?;
let project_id = project.id;
let label = view_window_label(panel, project_id);
let url = view_window_url(panel, project_id);
let label = view_window_label(panel);
let url = view_window_url(panel);
if let Some(window) = app.get_webview_window(&label) {
window.show().map_err(internal_window_error)?;
window.set_focus().map_err(internal_window_error)?;
emit_view_window_lifecycle(&app, "focused", panel, project_id, &label);
emit_view_window_lifecycle(&app, "focused", panel, &label);
return Ok(OpenViewWindowResponseDto {
label,
url,
@ -2481,10 +2505,10 @@ pub async fn open_view_window(
let event_label = label.clone();
window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { .. } = event {
emit_view_window_lifecycle(&event_app, "closed", panel, project_id, &event_label);
emit_view_window_lifecycle(&event_app, "closed", panel, &event_label);
}
});
emit_view_window_lifecycle(&app, "opened", panel, project_id, &label);
emit_view_window_lifecycle(&app, "opened", panel, &label);
Ok(OpenViewWindowResponseDto {
label,
@ -2493,20 +2517,18 @@ pub async fn open_view_window(
})
}
/// `close_view_window` — request closing the detached OS window for one project view.
/// `close_view_window` — request closing the detached OS window for one panel.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel or malformed project
/// id, `INTERNAL` if Tauri fails to close the window).
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri
/// fails to close the window).
#[tauri::command]
pub async fn close_view_window(
app: AppHandle,
panel: String,
project_id: String,
) -> Result<CloseViewWindowResponseDto, ErrorDto> {
let panel = ViewPanel::parse(&panel)?;
let project_id = parse_project_id(&project_id)?;
let label = view_window_label(panel, project_id);
let label = view_window_label(panel);
let Some(window) = app.get_webview_window(&label) else {
return Ok(CloseViewWindowResponseDto {
label,
@ -2531,22 +2553,13 @@ fn internal_window_error(error: impl std::fmt::Display) -> ErrorDto {
#[cfg(test)]
mod view_window_tests {
use super::*;
use domain::ProjectId;
use uuid::Uuid;
#[test]
fn view_window_label_and_url_are_stable() {
let project_id = ProjectId::from_uuid(Uuid::from_u128(0x123));
let panel = ViewPanel::Tickets;
assert_eq!(
view_window_label(panel, project_id),
"view-tickets-00000000000000000000000000000123"
);
assert_eq!(
view_window_url(panel, project_id),
"index.html?panel=tickets&project=00000000-0000-0000-0000-000000000123"
);
assert_eq!(view_window_label(panel), "view-tickets");
assert_eq!(view_window_url(panel), "index.html?panel=tickets");
}
#[test]
@ -2562,12 +2575,11 @@ mod view_window_tests {
let payload = ViewWindowLifecycleEventDto {
kind: "closed",
panel: "tickets".to_owned(),
project_id: Uuid::from_u128(7).to_string(),
label: "view-tickets-7".to_owned(),
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("\"projectId\""), "json was {json}");
assert!(!json.contains("projectId"), "json was {json}");
assert!(!json.contains("project_id"), "no snake_case leak: {json}");
}
}