Merge feature/ticket47-panel-only-windows into develop
fix(windows): restauration panel-only des fenêtres détachées suivant le projet en focus (#47) Backend + frontend : fenêtres/panneaux détachés restaurés en mode panel-only (sans project_id figé), suivant le projet en focus de la fenêtre principale via l'event focused-project. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -262,6 +262,8 @@ pub fn run() {
|
||||
commands::read_memory_index,
|
||||
commands::recall_memory,
|
||||
commands::resolve_memory_links,
|
||||
commands::set_focused_project,
|
||||
commands::get_focused_project,
|
||||
commands::open_view_window,
|
||||
commands::close_view_window,
|
||||
commands::move_tab_to_new_window,
|
||||
@ -351,25 +353,26 @@ fn persisted_window_identity(
|
||||
return Some((PersistedWindowKind::Main, None, None, None));
|
||||
}
|
||||
|
||||
let (panel, project_id) = persisted_view_identity_from_label(label)?;
|
||||
let panel = persisted_view_identity_from_label(label)?;
|
||||
Some((
|
||||
PersistedWindowKind::View,
|
||||
Some(panel.as_str().to_owned()),
|
||||
Some(project_id),
|
||||
Some(commands::view_window_url(panel, project_id)),
|
||||
None,
|
||||
Some(commands::view_window_url(panel)),
|
||||
))
|
||||
}
|
||||
|
||||
fn persisted_view_identity_from_label(label: &str) -> Option<(commands::ViewPanel, ProjectId)> {
|
||||
fn persisted_view_identity_from_label(label: &str) -> Option<commands::ViewPanel> {
|
||||
let rest = label.strip_prefix("view-")?;
|
||||
let (panel, project_id) = rest.rsplit_once('-')?;
|
||||
if project_id.len() != 32 {
|
||||
return None;
|
||||
if let Ok(panel) = commands::ViewPanel::parse(rest) {
|
||||
return Some(panel);
|
||||
}
|
||||
|
||||
let panel = commands::ViewPanel::parse(panel).ok()?;
|
||||
let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).ok()?);
|
||||
Some((panel, project_id))
|
||||
let (panel, project_id) = rest.rsplit_once('-')?;
|
||||
if project_id.len() != 32 || Uuid::parse_str(project_id).is_err() {
|
||||
return None;
|
||||
}
|
||||
commands::ViewPanel::parse(panel).ok()
|
||||
}
|
||||
|
||||
fn restore_open_webview_windows(handle: &tauri::AppHandle) {
|
||||
@ -396,30 +399,21 @@ fn restore_open_webview_windows(handle: &tauri::AppHandle) {
|
||||
}
|
||||
|
||||
fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState) {
|
||||
if handle.get_webview_window(&state.label).is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(panel) = state
|
||||
.panel
|
||||
.as_deref()
|
||||
.and_then(|raw| commands::ViewPanel::parse(raw).ok())
|
||||
.or_else(|| persisted_view_identity_from_label(&state.label))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(project_id) = state.project_id else {
|
||||
return;
|
||||
};
|
||||
let expected_label = commands::view_window_label(panel, project_id);
|
||||
if expected_label != state.label {
|
||||
let label = commands::view_window_label(panel);
|
||||
if handle.get_webview_window(&label).is_some() {
|
||||
return;
|
||||
}
|
||||
let url = state
|
||||
.url
|
||||
.clone()
|
||||
.unwrap_or_else(|| commands::view_window_url(panel, project_id));
|
||||
let url = commands::view_window_url(panel);
|
||||
|
||||
let Ok(window) = WebviewWindowBuilder::new(handle, &state.label, WebviewUrl::App(url.into()))
|
||||
let Ok(window) = WebviewWindowBuilder::new(handle, &label, WebviewUrl::App(url.into()))
|
||||
.title(format!("IdeA - {}", panel.title()))
|
||||
.inner_size(1120.0, 760.0)
|
||||
.min_inner_size(720.0, 480.0)
|
||||
@ -435,21 +429,15 @@ fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState)
|
||||
};
|
||||
|
||||
let event_app = handle.clone();
|
||||
let event_label = state.label.clone();
|
||||
let event_label = label.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
commands::emit_view_window_lifecycle(
|
||||
&event_app,
|
||||
"closed",
|
||||
panel,
|
||||
project_id,
|
||||
&event_label,
|
||||
);
|
||||
commands::emit_view_window_lifecycle(&event_app, "closed", panel, &event_label);
|
||||
}
|
||||
});
|
||||
|
||||
apply_persisted_window_state(handle, &window, state);
|
||||
commands::emit_view_window_lifecycle(handle, "opened", panel, project_id, &state.label);
|
||||
commands::emit_view_window_lifecycle(handle, "opened", panel, &label);
|
||||
}
|
||||
|
||||
fn apply_persisted_window_state(
|
||||
@ -541,17 +529,30 @@ mod tests {
|
||||
assert!(project_id.is_none());
|
||||
assert!(url.is_none());
|
||||
|
||||
let (panel, project_id) =
|
||||
persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a")
|
||||
.unwrap();
|
||||
let (kind, panel, project_id, url) =
|
||||
persisted_window_identity("view-tickets-0000000000000000000000000000002a").unwrap();
|
||||
|
||||
assert_eq!(panel.as_str(), "tickets");
|
||||
assert_eq!(kind, PersistedWindowKind::View);
|
||||
assert_eq!(panel.as_deref(), Some("tickets"));
|
||||
assert!(project_id.is_none());
|
||||
assert_eq!(url.as_deref(), Some("index.html?panel=tickets"));
|
||||
assert_eq!(
|
||||
project_id.to_string(),
|
||||
"00000000-0000-0000-0000-00000000002a"
|
||||
persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"tickets"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_identity_accepts_new_panel_only_view_labels() {
|
||||
let (kind, panel, project_id, url) = persisted_window_identity("view-agents").unwrap();
|
||||
assert_eq!(kind, PersistedWindowKind::View);
|
||||
assert_eq!(panel.as_deref(), Some("agents"));
|
||||
assert!(project_id.is_none());
|
||||
assert_eq!(url.as_deref(), Some("index.html?panel=agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_identity_filters_unknown_or_headless_labels() {
|
||||
assert!(persisted_window_identity("mcp-server").is_none());
|
||||
|
||||
@ -62,6 +62,7 @@ use domain::{
|
||||
InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource, Project, ProjectId,
|
||||
TaskId, TicketId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -89,6 +90,18 @@ use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
|
||||
use crate::pty::PtyBridge;
|
||||
use crate::tickets::AppTicketToolProvider;
|
||||
|
||||
/// Focused project snapshot shared with detached panel windows.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FocusedProjectDto {
|
||||
/// Project id as a string.
|
||||
pub id: String,
|
||||
/// Project display name.
|
||||
pub name: String,
|
||||
/// Absolute project root.
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
use infrastructure::StdioTransport;
|
||||
|
||||
use interprocess::local_socket::tokio::Listener as LocalSocketListener;
|
||||
@ -907,6 +920,8 @@ pub struct AppState {
|
||||
pub snapshot_open_windows: Arc<SnapshotOpenWindows>,
|
||||
/// Load restorable Tauri webview-window state on startup.
|
||||
pub restore_open_windows: Arc<RestoreOpenWindows>,
|
||||
/// Project currently focused by the main window; panel-only windows follow it.
|
||||
focused_project: Mutex<Option<FocusedProjectDto>>,
|
||||
// --- Background tasks (B8) ---
|
||||
/// Spawn a command-backed first-class background task.
|
||||
pub spawn_background_command: Arc<SpawnBackgroundCommand>,
|
||||
@ -2376,6 +2391,7 @@ impl AppState {
|
||||
move_tab,
|
||||
snapshot_open_windows,
|
||||
restore_open_windows,
|
||||
focused_project: Mutex::new(None),
|
||||
spawn_background_command,
|
||||
cancel_background_task,
|
||||
retry_background_task,
|
||||
@ -2383,6 +2399,23 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the focused project state. `None` means no project is focused/open.
|
||||
pub fn set_focused_project(&self, project: Option<FocusedProjectDto>) {
|
||||
*self
|
||||
.focused_project
|
||||
.lock()
|
||||
.expect("focused project mutex poisoned") = project;
|
||||
}
|
||||
|
||||
/// Returns the current focused project, if any.
|
||||
#[must_use]
|
||||
pub fn get_focused_project(&self) -> Option<FocusedProjectDto> {
|
||||
self.focused_project
|
||||
.lock()
|
||||
.expect("focused project mutex poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already
|
||||
/// running for it (idempotent). The watcher tails the project's
|
||||
/// `.ideai/requests/` tree and dispatches each request through the shared
|
||||
|
||||
@ -10,7 +10,7 @@ use domain::ids::{TabId, WindowId};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use domain::layout::{PersistedWindowKind, PersistedWindowState, WindowStateSnapshot, Workspace};
|
||||
use domain::ports::{IdGenerator, ProjectStore, StoreError, WindowStateStore};
|
||||
use domain::ports::{IdGenerator, ProjectStore, WindowStateStore};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -115,21 +115,24 @@ pub struct RestoreOpenWindowsOutput {
|
||||
/// Loads and filters the latest persisted OS window snapshot.
|
||||
pub struct RestoreOpenWindows {
|
||||
windows: Arc<dyn WindowStateStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
_projects: Arc<dyn ProjectStore>,
|
||||
}
|
||||
|
||||
impl RestoreOpenWindows {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(windows: Arc<dyn WindowStateStore>, projects: Arc<dyn ProjectStore>) -> Self {
|
||||
Self { windows, projects }
|
||||
Self {
|
||||
windows,
|
||||
_projects: projects,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads restorable windows.
|
||||
///
|
||||
/// Views are ignored if their project no longer exists or if their persisted
|
||||
/// identity is incomplete. Duplicate labels are ignored after the first
|
||||
/// occurrence.
|
||||
/// 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.
|
||||
@ -146,17 +149,10 @@ impl RestoreOpenWindows {
|
||||
match window.kind {
|
||||
PersistedWindowKind::Main => windows.push(window),
|
||||
PersistedWindowKind::View => {
|
||||
let Some(project_id) = window.project_id else {
|
||||
continue;
|
||||
};
|
||||
if window.panel.is_none() || window.url.is_none() {
|
||||
if window.panel.is_none() {
|
||||
continue;
|
||||
}
|
||||
match self.projects.load_project(project_id).await {
|
||||
Ok(_) => windows.push(window),
|
||||
Err(StoreError::NotFound) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
windows.push(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -232,12 +232,14 @@ async fn snapshot_open_windows_persists_the_supplied_snapshot() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_open_windows_deduplicates_and_filters_missing_projects() {
|
||||
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 incomplete = persisted_view("view-tickets-0000000000000000000000000000002c", existing);
|
||||
incomplete.url = None;
|
||||
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(),
|
||||
@ -245,9 +247,10 @@ async fn restore_open_windows_deduplicates_and_filters_missing_projects() {
|
||||
persisted_view(valid_label, existing),
|
||||
persisted_view(valid_label, existing),
|
||||
persisted_view("view-tickets-0000000000000000000000000000002b", missing),
|
||||
incomplete,
|
||||
url_missing,
|
||||
no_panel,
|
||||
]))));
|
||||
let projects = FakeProjectRegistry::new(vec![existing]);
|
||||
let projects = FakeProjectRegistry::new(vec![]);
|
||||
let uc = RestoreOpenWindows::new(Arc::new(store), Arc::new(projects));
|
||||
|
||||
let out = uc.execute().await.unwrap();
|
||||
@ -257,6 +260,11 @@ async fn restore_open_windows_deduplicates_and_filters_missing_projects() {
|
||||
.iter()
|
||||
.map(|w| w.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["main", valid_label]
|
||||
vec![
|
||||
"main",
|
||||
valid_label,
|
||||
"view-tickets-0000000000000000000000000000002b",
|
||||
"view-tickets-0000000000000000000000000000002c"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -1081,15 +1081,14 @@ pub struct PersistedMonitorState {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersistedWindowState {
|
||||
/// Stable Tauri label. For detached views this is
|
||||
/// `view-<panel>-<projectId-simple>`.
|
||||
/// Stable Tauri label. For detached views this is `view-<panel>`.
|
||||
pub label: String,
|
||||
/// Window kind.
|
||||
pub kind: PersistedWindowKind,
|
||||
/// Detached view panel id. Present only for [`PersistedWindowKind::View`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub panel: Option<String>,
|
||||
/// Detached view project id. Present only for [`PersistedWindowKind::View`].
|
||||
/// Legacy detached view project id. Ignored by panel-only restore.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_id: Option<crate::ids::ProjectId>,
|
||||
/// App URL loaded in the window.
|
||||
|
||||
76
frontend/src/adapters/focusedProject.test.ts
Normal file
76
frontend/src/adapters/focusedProject.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const invoke = vi.fn();
|
||||
const listen = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invoke(...args),
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: (...args: unknown[]) => listen(...args),
|
||||
}));
|
||||
|
||||
import { TauriFocusedProjectGateway } from "./focusedProject";
|
||||
|
||||
describe("TauriFocusedProjectGateway (#47)", () => {
|
||||
beforeEach(() => {
|
||||
invoke.mockReset().mockResolvedValue(null);
|
||||
listen.mockReset();
|
||||
});
|
||||
|
||||
it("set/get relay to the backend focused-project commands", async () => {
|
||||
const gw = new TauriFocusedProjectGateway();
|
||||
|
||||
await gw.setFocusedProject({ id: "p1", name: "alpha", root: "/p/a" });
|
||||
expect(invoke).toHaveBeenCalledWith("set_focused_project", {
|
||||
project: { id: "p1", name: "alpha", root: "/p/a" },
|
||||
});
|
||||
|
||||
await gw.setFocusedProject(null);
|
||||
expect(invoke).toHaveBeenCalledWith("set_focused_project", {
|
||||
project: null,
|
||||
});
|
||||
|
||||
invoke.mockResolvedValueOnce({ id: "p2", name: "beta", root: "/p/b" });
|
||||
await expect(gw.getFocusedProject()).resolves.toEqual({
|
||||
id: "p2",
|
||||
name: "beta",
|
||||
root: "/p/b",
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("get_focused_project");
|
||||
|
||||
// A `null`/undefined focus normalises to `null`.
|
||||
invoke.mockResolvedValueOnce(null);
|
||||
await expect(gw.getFocusedProject()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("onFocusedProjectChanged listens on the channel and forwards the project (or null)", async () => {
|
||||
let raw: ((e: { payload: unknown }) => void) | undefined;
|
||||
const unlisten = vi.fn();
|
||||
listen.mockImplementation((_name: string, cb: typeof raw) => {
|
||||
raw = cb;
|
||||
return Promise.resolve(unlisten);
|
||||
});
|
||||
|
||||
const gw = new TauriFocusedProjectGateway();
|
||||
const handler = vi.fn();
|
||||
const off = await gw.onFocusedProjectChanged(handler);
|
||||
|
||||
expect(listen).toHaveBeenCalledWith(
|
||||
"focused-project://changed",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
raw?.({ payload: { project: { id: "p1", name: "alpha", root: "/p/a" } } });
|
||||
expect(handler).toHaveBeenLastCalledWith({
|
||||
id: "p1",
|
||||
name: "alpha",
|
||||
root: "/p/a",
|
||||
});
|
||||
|
||||
raw?.({ payload: { project: null } });
|
||||
expect(handler).toHaveBeenLastCalledWith(null);
|
||||
|
||||
off();
|
||||
expect(unlisten).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
48
frontend/src/adapters/focusedProject.ts
Normal file
48
frontend/src/adapters/focusedProject.ts
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Tauri adapter for {@link FocusedProjectGateway} (ticket #47). Bridges the
|
||||
* main-window ⇄ detached-panel focused-project channel to the backend commands
|
||||
* and event. Like the sibling adapters, this is the only layer allowed to touch
|
||||
* `@tauri-apps/api`; components reach it exclusively through the port.
|
||||
*
|
||||
* Command/event names and payloads mirror the backend contract owned by
|
||||
* DevBackend: `set_focused_project` / `get_focused_project` and the
|
||||
* `focused-project://changed` event whose payload is `{ project: FocusedProject
|
||||
* | null }`.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import type { Unsubscribe } from "@/domain";
|
||||
import type { FocusedProject, FocusedProjectGateway } from "@/ports";
|
||||
|
||||
/** The Tauri event carrying focused-project transitions. */
|
||||
const FOCUSED_PROJECT_CHANGED = "focused-project://changed";
|
||||
|
||||
/** Backend payload for `focused-project://changed`. */
|
||||
interface FocusedProjectChanged {
|
||||
project: FocusedProject | null;
|
||||
}
|
||||
|
||||
export class TauriFocusedProjectGateway implements FocusedProjectGateway {
|
||||
async setFocusedProject(project: FocusedProject | null): Promise<void> {
|
||||
// The backend command takes `project: Option<FocusedProjectDto>`; `null`
|
||||
// clears the focus (no project open).
|
||||
await invoke("set_focused_project", { project });
|
||||
}
|
||||
|
||||
async getFocusedProject(): Promise<FocusedProject | null> {
|
||||
const project = await invoke<FocusedProject | null>("get_focused_project");
|
||||
return project ?? null;
|
||||
}
|
||||
|
||||
async onFocusedProjectChanged(
|
||||
handler: (project: FocusedProject | null) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
const unlisten = await listen<FocusedProjectChanged>(
|
||||
FOCUSED_PROJECT_CHANGED,
|
||||
(e) => handler(e.payload.project ?? null),
|
||||
);
|
||||
return unlisten;
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,7 @@ import { TauriWorkStateGateway } from "./workState";
|
||||
import { TauriConversationGateway } from "./conversation";
|
||||
import { TauriTicketGateway } from "./ticket";
|
||||
import { TauriWindowGateway } from "./window";
|
||||
import { TauriFocusedProjectGateway } from "./focusedProject";
|
||||
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
|
||||
|
||||
function notImplemented(what: string): never {
|
||||
@ -68,6 +69,7 @@ export function createTauriGateways(): Gateways {
|
||||
conversation: new TauriConversationGateway(),
|
||||
ticket: new TauriTicketGateway(),
|
||||
window: new TauriWindowGateway(),
|
||||
focusedProject: new TauriFocusedProjectGateway(),
|
||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||
};
|
||||
}
|
||||
@ -91,5 +93,6 @@ export {
|
||||
TauriConversationGateway,
|
||||
TauriTicketGateway,
|
||||
TauriWindowGateway,
|
||||
TauriFocusedProjectGateway,
|
||||
LocalStorageUiPreferencesGateway,
|
||||
};
|
||||
|
||||
@ -91,6 +91,8 @@ import type {
|
||||
UiPreferencesGateway,
|
||||
ViewWindowClosed,
|
||||
WindowGateway,
|
||||
FocusedProject,
|
||||
FocusedProjectGateway,
|
||||
WorkStateGateway,
|
||||
} from "@/ports";
|
||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||
@ -1094,27 +1096,25 @@ class MockRemoteGateway implements RemoteGateway {
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory {@link WindowGateway} for tests/dev (#23). Tracks which views are
|
||||
* "detached" in a set keyed by `panel|projectId`, and lets tests simulate the
|
||||
* OS closing a window via {@link simulateOsClose}. `closeViewWindow` also emits
|
||||
* the close event, mirroring the backend (a programmatic close still notifies).
|
||||
* In-memory {@link WindowGateway} for tests/dev (#23, panel-only in #47). Tracks
|
||||
* which views are "detached" in a set keyed by `panel` alone (a panel-only
|
||||
* window follows the focused project, so no project id is part of the key), and
|
||||
* lets tests simulate the OS closing a window via {@link simulateOsClose}.
|
||||
* `closeViewWindow` also emits the close event, mirroring the backend (a
|
||||
* programmatic close still notifies).
|
||||
*/
|
||||
export class MockWindowGateway implements WindowGateway {
|
||||
/** Currently-open detached windows, keyed `panel|projectId`. */
|
||||
/** Currently-open detached windows, keyed by `panel`. */
|
||||
readonly open = new Set<string>();
|
||||
private readonly listeners = new Set<(e: ViewWindowClosed) => void>();
|
||||
|
||||
private key(panel: string, projectId: string): string {
|
||||
return `${panel}|${projectId}`;
|
||||
async openViewWindow(panel: string): Promise<void> {
|
||||
this.open.add(panel);
|
||||
}
|
||||
|
||||
async openViewWindow(panel: string, projectId: string): Promise<void> {
|
||||
this.open.add(this.key(panel, projectId));
|
||||
}
|
||||
|
||||
async closeViewWindow(panel: string, projectId: string): Promise<void> {
|
||||
if (this.open.delete(this.key(panel, projectId))) {
|
||||
this.emit({ panel, projectId });
|
||||
async closeViewWindow(panel: string): Promise<void> {
|
||||
if (this.open.delete(panel)) {
|
||||
this.emit({ panel });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1128,9 +1128,9 @@ export class MockWindowGateway implements WindowGateway {
|
||||
}
|
||||
|
||||
/** Test hook: simulate the user closing the OS window (fires the event). */
|
||||
simulateOsClose(panel: string, projectId: string): void {
|
||||
this.open.delete(this.key(panel, projectId));
|
||||
this.emit({ panel, projectId });
|
||||
simulateOsClose(panel: string): void {
|
||||
this.open.delete(panel);
|
||||
this.emit({ panel });
|
||||
}
|
||||
|
||||
private emit(event: ViewWindowClosed): void {
|
||||
@ -1138,6 +1138,38 @@ export class MockWindowGateway implements WindowGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory {@link FocusedProjectGateway} for tests/dev (#47). Holds the current
|
||||
* focus in memory and fans changes out to subscribers — the mock counterpart to
|
||||
* the backend's shared focused-project state + `focused-project://changed`
|
||||
* event. `setFocusedProject` always notifies (mirrors the backend emit), so a
|
||||
* detached-window test can drive a panel window's focus transitions.
|
||||
*/
|
||||
export class MockFocusedProjectGateway implements FocusedProjectGateway {
|
||||
private focused: FocusedProject | null = null;
|
||||
private readonly listeners = new Set<
|
||||
(project: FocusedProject | null) => void
|
||||
>();
|
||||
|
||||
async setFocusedProject(project: FocusedProject | null): Promise<void> {
|
||||
this.focused = project;
|
||||
for (const l of this.listeners) l(project);
|
||||
}
|
||||
|
||||
async getFocusedProject(): Promise<FocusedProject | null> {
|
||||
return this.focused;
|
||||
}
|
||||
|
||||
async onFocusedProjectChanged(
|
||||
handler: (project: FocusedProject | null) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
this.listeners.add(handler);
|
||||
return () => {
|
||||
this.listeners.delete(handler);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-filled reference catalogue the mock serves — mirror of the backend's
|
||||
* **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode
|
||||
@ -2596,6 +2628,7 @@ export function createMockGateways(): Gateways {
|
||||
conversation: new MockConversationGateway(),
|
||||
ticket: new MockTicketGateway(systemGateway),
|
||||
window: new MockWindowGateway(),
|
||||
focusedProject: new MockFocusedProjectGateway(),
|
||||
uiPreferences: new MockUiPreferencesGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ describe("createMockGateways", () => {
|
||||
"agent",
|
||||
"conversation",
|
||||
"embedder",
|
||||
"focusedProject",
|
||||
"git",
|
||||
"input",
|
||||
"layout",
|
||||
|
||||
@ -17,17 +17,15 @@ describe("TauriWindowGateway (#23)", () => {
|
||||
listen.mockReset();
|
||||
});
|
||||
|
||||
it("open/close relay camelCase payloads to the backend commands", async () => {
|
||||
it("open/close relay panel-only payloads to the backend commands (#47)", async () => {
|
||||
const gw = new TauriWindowGateway();
|
||||
await gw.openViewWindow("tickets", "proj-1");
|
||||
await gw.openViewWindow("tickets");
|
||||
expect(invoke).toHaveBeenCalledWith("open_view_window", {
|
||||
panel: "tickets",
|
||||
projectId: "proj-1",
|
||||
});
|
||||
await gw.closeViewWindow("git", "proj-2");
|
||||
await gw.closeViewWindow("git");
|
||||
expect(invoke).toHaveBeenCalledWith("close_view_window", {
|
||||
panel: "git",
|
||||
projectId: "proj-2",
|
||||
});
|
||||
});
|
||||
|
||||
@ -51,19 +49,19 @@ describe("TauriWindowGateway (#23)", () => {
|
||||
|
||||
// opened / focused are dropped…
|
||||
raw?.({
|
||||
payload: { kind: "opened", panel: "git", projectId: "p", label: "view-git-p" },
|
||||
payload: { kind: "opened", panel: "git", label: "view-git" },
|
||||
});
|
||||
raw?.({
|
||||
payload: { kind: "focused", panel: "git", projectId: "p", label: "view-git-p" },
|
||||
payload: { kind: "focused", panel: "git", label: "view-git" },
|
||||
});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
// …only closed reaches the port handler, narrowed to { panel, projectId }.
|
||||
// …only closed reaches the port handler, narrowed to { panel } (#47).
|
||||
raw?.({
|
||||
payload: { kind: "closed", panel: "git", projectId: "p", label: "view-git-p" },
|
||||
payload: { kind: "closed", panel: "git", label: "view-git" },
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith({ panel: "git", projectId: "p" });
|
||||
expect(handler).toHaveBeenCalledWith({ panel: "git" });
|
||||
|
||||
off();
|
||||
expect(unlisten).toHaveBeenCalled();
|
||||
|
||||
@ -19,34 +19,38 @@ import type { ViewWindowClosed, WindowGateway } from "@/ports";
|
||||
/** The single Tauri event carrying detached-window lifecycle transitions. */
|
||||
const VIEW_WINDOW_LIFECYCLE = "view-window://lifecycle";
|
||||
|
||||
/** Backend lifecycle payload (discriminated on `kind`). */
|
||||
/**
|
||||
* Backend lifecycle payload (discriminated on `kind`). Ticket #47 dropped the
|
||||
* `projectId` field — a detached window is panel-only and follows the focused
|
||||
* project, so lifecycle events carry only `panel` + `label`.
|
||||
*/
|
||||
interface ViewWindowLifecycle {
|
||||
kind: "opened" | "focused" | "closed";
|
||||
panel: string;
|
||||
projectId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export class TauriWindowGateway implements WindowGateway {
|
||||
async openViewWindow(panel: string, projectId: string): Promise<void> {
|
||||
// camelCase args (Tauri maps them to the command's `panel` / `project_id`).
|
||||
await invoke("open_view_window", { panel, projectId });
|
||||
async openViewWindow(panel: string): Promise<void> {
|
||||
// Panel-only window (#47): no project id — the window follows the focused
|
||||
// project published on `focused-project://changed`.
|
||||
await invoke("open_view_window", { panel });
|
||||
}
|
||||
|
||||
async closeViewWindow(panel: string, projectId: string): Promise<void> {
|
||||
await invoke("close_view_window", { panel, projectId });
|
||||
async closeViewWindow(panel: string): Promise<void> {
|
||||
await invoke("close_view_window", { panel });
|
||||
}
|
||||
|
||||
async onViewWindowClosed(
|
||||
handler: (event: ViewWindowClosed) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
// One backend channel for opened/focused/closed; the port only surfaces the
|
||||
// close, so drop the other transitions and forward `{ panel, projectId }`.
|
||||
// close, so drop the other transitions and forward `{ panel }`.
|
||||
const unlisten = await listen<ViewWindowLifecycle>(
|
||||
VIEW_WINDOW_LIFECYCLE,
|
||||
(e) => {
|
||||
if (e.payload.kind !== "closed") return;
|
||||
handler({ panel: e.payload.panel, projectId: e.payload.projectId });
|
||||
handler({ panel: e.payload.panel });
|
||||
},
|
||||
);
|
||||
return unlisten;
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
/**
|
||||
* #23 — the panel-only entry. `parseViewWindowParams` validates the
|
||||
* `?panel=&project=` query, and `ViewWindow` resolves the project through the
|
||||
* gateway then renders only the requested view (here: the tickets view).
|
||||
* #23/#47 — the panel-only entry. `parseViewWindowParams` validates the
|
||||
* `?panel=` query (no project id anymore), and `ViewWindow` follows the main
|
||||
* window's focused project: it shows an "open a project" shell while no project
|
||||
* is focused and mounts the requested view once one is, without ever opening a
|
||||
* project itself.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockFocusedProjectGateway,
|
||||
MockProjectGateway,
|
||||
MockSystemGateway,
|
||||
MockTicketGateway,
|
||||
@ -16,45 +19,124 @@ import {
|
||||
import type { Gateways } from "@/ports";
|
||||
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
||||
|
||||
describe("parseViewWindowParams (#23)", () => {
|
||||
it("parses a valid panel + project", () => {
|
||||
describe("parseViewWindowParams (#47)", () => {
|
||||
it("parses a valid panel-only query (ignores any legacy project param)", () => {
|
||||
expect(parseViewWindowParams("?panel=tickets")).toEqual({
|
||||
panel: "tickets",
|
||||
});
|
||||
// A restored legacy URL that still carries `project` parses to panel only.
|
||||
expect(parseViewWindowParams("?panel=tickets&project=p-1")).toEqual({
|
||||
panel: "tickets",
|
||||
projectId: "p-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a missing project, missing panel, or the non-detachable projects panel", () => {
|
||||
expect(parseViewWindowParams("?panel=tickets")).toBeNull();
|
||||
it("rejects a missing panel or the non-detachable projects panel", () => {
|
||||
expect(parseViewWindowParams("?project=p-1")).toBeNull();
|
||||
expect(parseViewWindowParams("?panel=projects&project=p-1")).toBeNull();
|
||||
expect(parseViewWindowParams("?panel=bogus&project=p-1")).toBeNull();
|
||||
expect(parseViewWindowParams("?panel=projects")).toBeNull();
|
||||
expect(parseViewWindowParams("?panel=bogus")).toBeNull();
|
||||
expect(parseViewWindowParams("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ViewWindow (#23)", () => {
|
||||
it("resolves the project and renders only the requested view", async () => {
|
||||
describe("ViewWindow (#47)", () => {
|
||||
it("shows the 'open a project' shell when no project is focused", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const gateways = {
|
||||
system,
|
||||
project: new MockProjectGateway(),
|
||||
agent: new MockAgentGateway(),
|
||||
ticket: new MockTicketGateway(system),
|
||||
focusedProject: new MockFocusedProjectGateway(),
|
||||
} as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ViewWindow panel="tickets" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
// The panel title chrome is present, but no project-scoped view mounts…
|
||||
expect(screen.getByRole("heading", { name: "Tickets" })).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("search tickets")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the view for the focused project and follows focus changes", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const project = new MockProjectGateway();
|
||||
const created = await project.createProject("alpha", "/p/a");
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
const gateways = {
|
||||
system,
|
||||
project,
|
||||
agent: new MockAgentGateway(),
|
||||
ticket: new MockTicketGateway(system),
|
||||
focusedProject,
|
||||
} as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ViewWindow panel="tickets" projectId={created.id} />
|
||||
<ViewWindow panel="tickets" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
// The window chrome shows the panel title and the resolved project name.
|
||||
expect(screen.getByRole("heading", { name: "Tickets" })).toBeTruthy();
|
||||
// No focus yet → shell.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(),
|
||||
);
|
||||
|
||||
// The main window publishes a focused project → the window follows it.
|
||||
await act(async () => {
|
||||
await focusedProject.setFocusedProject({
|
||||
id: created.id,
|
||||
name: "alpha",
|
||||
root: "/p/a",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("· alpha")).toBeTruthy());
|
||||
// The tickets view itself mounted (its search box is present).
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("search tickets")).toBeTruthy(),
|
||||
);
|
||||
|
||||
// Focus cleared (project closed) → back to the shell, view unmounts.
|
||||
await act(async () => {
|
||||
await focusedProject.setFocusedProject(null);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("search tickets")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the current focus at mount (restored window with a live focus)", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const project = new MockProjectGateway();
|
||||
const created = await project.createProject("beta", "/p/b");
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
// Focus already set before the window mounts (main window opened first).
|
||||
await focusedProject.setFocusedProject({
|
||||
id: created.id,
|
||||
name: "beta",
|
||||
root: "/p/b",
|
||||
});
|
||||
const gateways = {
|
||||
system,
|
||||
project,
|
||||
agent: new MockAgentGateway(),
|
||||
ticket: new MockTicketGateway(system),
|
||||
focusedProject,
|
||||
} as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ViewWindow panel="tickets" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("· beta")).toBeTruthy());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("search tickets")).toBeTruthy(),
|
||||
);
|
||||
|
||||
@ -1,22 +1,28 @@
|
||||
/**
|
||||
* `ViewWindow` — the panel-only entry (ticket #23).
|
||||
* `ViewWindow` — the panel-only entry (ticket #23, reworked in #47).
|
||||
*
|
||||
* A detached OS window loads the same SPA with `?panel=<panel>&project=<id>`;
|
||||
* A detached OS window loads the same SPA with `?panel=<panel>` (no project id);
|
||||
* {@link main} routes to this component instead of the full {@link App} when a
|
||||
* `panel` param is present. It re-instantiates the DI (same adapters/gateways
|
||||
* as the main window — views are gateway/read-model-driven, so no heavy state
|
||||
* is transferred) and renders **only** the requested view for the given
|
||||
* project.
|
||||
* `panel` param is present. It re-instantiates the DI (same adapters/gateways as
|
||||
* the main window — views are gateway/read-model-driven, so no heavy state is
|
||||
* transferred) and renders **only** the requested view for the project the main
|
||||
* window currently has in focus.
|
||||
*
|
||||
* Pure presentation over the ports: it resolves the project through the
|
||||
* {@link ProjectGateway} (for its name/root) and hands off to the shared
|
||||
* {@link ViewPanelBody}. No `invoke()`, no cross-window messaging.
|
||||
* Focus, not embedding (#47): the window never carries — nor reopens — a project
|
||||
* id. It reads the current focus from the {@link FocusedProjectGateway} at mount
|
||||
* and then tracks {@link FocusedProjectGateway.onFocusedProjectChanged}. While no
|
||||
* project is focused it mounts **no** project-scoped panel and shows an "open a
|
||||
* project" shell, so a window restored at restart never surfaces a stale
|
||||
* project's data.
|
||||
*
|
||||
* Pure presentation over the ports: no `invoke()`, no cross-window messaging,
|
||||
* and crucially no `openProject()` — opening a project is the main window's job.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { Project } from "@/domain";
|
||||
import { Panel, Spinner } from "@/shared";
|
||||
import type { FocusedProject } from "@/ports";
|
||||
import { Panel } from "@/shared";
|
||||
import { PANEL_TITLE, type PanelId } from "@/features/projects";
|
||||
import { ViewPanelBody, type ViewPanelId } from "@/features/projects";
|
||||
import { useGateways } from "./di";
|
||||
@ -26,49 +32,59 @@ const DETACHABLE = new Set<string>(
|
||||
(Object.keys(PANEL_TITLE) as PanelId[]).filter((p) => p !== "projects"),
|
||||
);
|
||||
|
||||
/** Parsed `?panel=&project=` params, or `null` when absent/invalid. */
|
||||
/** Parsed `?panel=` params, or `null` when absent/invalid. */
|
||||
export interface ViewWindowParams {
|
||||
panel: ViewPanelId;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** Reads and validates the panel-only params from a query string. */
|
||||
/** Reads and validates the panel-only param from a query string (#47: no project). */
|
||||
export function parseViewWindowParams(
|
||||
search: string,
|
||||
): ViewWindowParams | null {
|
||||
const params = new URLSearchParams(search);
|
||||
const panel = params.get("panel");
|
||||
const projectId = params.get("project");
|
||||
if (!panel || !projectId || !DETACHABLE.has(panel)) return null;
|
||||
return { panel: panel as ViewPanelId, projectId };
|
||||
if (!panel || !DETACHABLE.has(panel)) return null;
|
||||
return { panel: panel as ViewPanelId };
|
||||
}
|
||||
|
||||
export interface ViewWindowProps {
|
||||
panel: ViewPanelId;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function ViewWindow({ panel, projectId }: ViewWindowProps) {
|
||||
const { project } = useGateways();
|
||||
const [resolved, setResolved] = useState<Project | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export function ViewWindow({ panel }: ViewWindowProps) {
|
||||
const { focusedProject } = useGateways();
|
||||
// The focused project this window follows. `undefined` = not yet resolved
|
||||
// (initial read in flight); `null` = resolved, no project focused.
|
||||
const [focus, setFocus] = useState<FocusedProject | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Resolve the project to get its root (the agents panel needs it) and name
|
||||
// (window title). Opening is idempotent on the backend registry.
|
||||
// Read the current focus at mount, then track changes. Never opens a project:
|
||||
// the window is a pure follower of the main window's focus (#47).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
project
|
||||
.openProject(projectId)
|
||||
.then((p) => {
|
||||
if (!cancelled) setResolved(p);
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
focusedProject
|
||||
.getFocusedProject()
|
||||
.then((project) => {
|
||||
if (!cancelled) setFocus(project);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setError(describeError(e));
|
||||
.catch(() => {
|
||||
if (!cancelled) setFocus(null);
|
||||
});
|
||||
void focusedProject
|
||||
.onFocusedProjectChanged((project) => {
|
||||
if (!cancelled) setFocus(project);
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
else unsubscribe = un;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [project, projectId]);
|
||||
}, [focusedProject]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-canvas text-content">
|
||||
@ -76,35 +92,28 @@ export function ViewWindow({ panel, projectId }: ViewWindowProps) {
|
||||
<h1 className="text-sm font-semibold text-content">
|
||||
{PANEL_TITLE[panel]}
|
||||
</h1>
|
||||
{resolved && (
|
||||
<span className="truncate text-xs text-muted">· {resolved.name}</span>
|
||||
{focus && (
|
||||
<span className="truncate text-xs text-muted">· {focus.name}</span>
|
||||
)}
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{error ? (
|
||||
<Panel className="border-danger/40">
|
||||
<p className="text-sm text-danger">Error: {error}</p>
|
||||
</Panel>
|
||||
) : resolved ? (
|
||||
{focus ? (
|
||||
// Only mount a project-scoped panel when a project is actually focused
|
||||
// — the panels require a real `projectRoot`, never a placeholder.
|
||||
<ViewPanelBody
|
||||
panel={panel}
|
||||
projectId={resolved.id}
|
||||
projectRoot={resolved.root}
|
||||
projectId={focus.id}
|
||||
projectRoot={focus.root}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">
|
||||
Ouvrez un projet dans la fenêtre principale pour afficher ce
|
||||
panneau.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function describeError(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as { message: unknown }).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
@ -13,15 +13,16 @@ if (!root) {
|
||||
throw new Error("missing #root element");
|
||||
}
|
||||
|
||||
// #23: a detached view window loads the SPA with `?panel=&project=`. When those
|
||||
// params are present and valid, render only that view; otherwise the full app.
|
||||
// #23/#47: a detached view window loads the SPA with `?panel=` (panel-only, no
|
||||
// project id). When the param is present and valid, render only that view — it
|
||||
// follows the main window's focused project; otherwise the full app.
|
||||
const viewParams = parseViewWindowParams(window.location.search);
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<DIProvider>
|
||||
{viewParams ? (
|
||||
<ViewWindow panel={viewParams.panel} projectId={viewParams.projectId} />
|
||||
<ViewWindow panel={viewParams.panel} />
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
MockAgentGateway,
|
||||
MockGitGateway,
|
||||
MockProfileGateway,
|
||||
MockFocusedProjectGateway,
|
||||
MockProjectGateway,
|
||||
MockSystemGateway,
|
||||
MockTemplateGateway,
|
||||
@ -24,7 +25,7 @@ import { ProjectsView } from "./ProjectsView";
|
||||
|
||||
async function renderWithProject() {
|
||||
const project = new MockProjectGateway();
|
||||
const created = await project.createProject("alpha", "/p/a");
|
||||
await project.createProject("alpha", "/p/a");
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const windowGateway = new MockWindowGateway();
|
||||
const gateways = {
|
||||
@ -36,6 +37,7 @@ async function renderWithProject() {
|
||||
git: new MockGitGateway(),
|
||||
workState: new MockWorkStateGateway(),
|
||||
window: windowGateway,
|
||||
focusedProject: new MockFocusedProjectGateway(),
|
||||
} as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
@ -46,7 +48,7 @@ async function renderWithProject() {
|
||||
await waitFor(() =>
|
||||
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
|
||||
);
|
||||
return { windowGateway, projectId: created.id };
|
||||
return { windowGateway };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -62,7 +64,7 @@ function pickPlacement(panelTitle: string, placementLabel: string) {
|
||||
|
||||
describe("ProjectsView detach to OS window (#23)", () => {
|
||||
it("« Fenêtre détachée » detaches a view: opens the OS window and marks the slot detached", async () => {
|
||||
const { windowGateway, projectId } = await renderWithProject();
|
||||
const { windowGateway } = await renderWithProject();
|
||||
|
||||
// A docked view is visible in the main window first…
|
||||
pickPlacement("Git", "Ancré à gauche");
|
||||
@ -72,7 +74,7 @@ describe("ProjectsView detach to OS window (#23)", () => {
|
||||
pickPlacement("Git", "Fenêtre détachée");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
|
||||
expect(windowGateway.open.has("git")).toBe(true),
|
||||
);
|
||||
// The slot is detached ⇒ nothing renders for Git in the main window.
|
||||
await waitFor(() =>
|
||||
@ -82,11 +84,11 @@ describe("ProjectsView detach to OS window (#23)", () => {
|
||||
});
|
||||
|
||||
it("re-toggles the placement out of detached when the OS window closes", async () => {
|
||||
const { windowGateway, projectId } = await renderWithProject();
|
||||
const { windowGateway } = await renderWithProject();
|
||||
|
||||
pickPlacement("Git", "Fenêtre détachée");
|
||||
await waitFor(() =>
|
||||
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
|
||||
expect(windowGateway.open.has("git")).toBe(true),
|
||||
);
|
||||
|
||||
// The « Fenêtre détachée » submenu leaf shows the active marker (●) while
|
||||
@ -103,7 +105,7 @@ describe("ProjectsView detach to OS window (#23)", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); // close
|
||||
|
||||
// The backend reports the OS window closed ⇒ the slot leaves "detached".
|
||||
windowGateway.simulateOsClose("git", projectId);
|
||||
windowGateway.simulateOsClose("git");
|
||||
openGitSubmenu();
|
||||
await waitFor(() =>
|
||||
expect(detachedLeaf().textContent).not.toContain("●"),
|
||||
|
||||
78
frontend/src/features/projects/ProjectsView.focus.test.tsx
Normal file
78
frontend/src/features/projects/ProjectsView.focus.test.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* #47 — `ProjectsView` is the single writer of the focused-project channel: it
|
||||
* publishes the active project (or `null` when none is active) so detached
|
||||
* panel-only windows can follow it. This drives the open → publish → close →
|
||||
* publish-null flow and asserts what reaches the {@link FocusedProjectGateway}.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, within, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockFocusedProjectGateway,
|
||||
MockGitGateway,
|
||||
MockProfileGateway,
|
||||
MockProjectGateway,
|
||||
MockSystemGateway,
|
||||
MockTemplateGateway,
|
||||
MockWindowGateway,
|
||||
MockWorkStateGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { FocusedProject, Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { ProjectsView } from "./ProjectsView";
|
||||
|
||||
async function renderView() {
|
||||
const project = new MockProjectGateway();
|
||||
const created = await project.createProject("alpha", "/p/a");
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
// Record every published focus so we can assert the sequence.
|
||||
const published: (FocusedProject | null)[] = [];
|
||||
await focusedProject.onFocusedProjectChanged((p) => published.push(p));
|
||||
const gateways = {
|
||||
system: new MockSystemGateway(),
|
||||
project,
|
||||
agent: agentGateway,
|
||||
profile: new MockProfileGateway(),
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
git: new MockGitGateway(),
|
||||
workState: new MockWorkStateGateway(),
|
||||
window: new MockWindowGateway(),
|
||||
focusedProject,
|
||||
} as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectsView />
|
||||
</DIProvider>,
|
||||
);
|
||||
return { focusedProject, published, projectId: created.id };
|
||||
}
|
||||
|
||||
describe("ProjectsView focus publishing (#47)", () => {
|
||||
it("publishes the active project on open and null when it is closed", async () => {
|
||||
const { focusedProject, published, projectId } = await renderView();
|
||||
|
||||
// Mount with no active project publishes a `null` focus.
|
||||
await waitFor(() => expect(published).toContainEqual(null));
|
||||
|
||||
// Open the project → publishes the { id, name, root } snapshot.
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
|
||||
await waitFor(() =>
|
||||
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(focusedProject.getFocusedProject()).resolves.toEqual({
|
||||
id: projectId,
|
||||
name: "alpha",
|
||||
root: "/p/a",
|
||||
}),
|
||||
);
|
||||
|
||||
// Close the tab → back to no active project → publishes `null`.
|
||||
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
|
||||
await waitFor(() =>
|
||||
expect(focusedProject.getFocusedProject()).resolves.toBeNull(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -102,7 +102,7 @@ function isTerminalBackgroundTaskEvent(
|
||||
|
||||
export function ProjectsView() {
|
||||
const vm = useProjects();
|
||||
const { system, window: windowGateway } = useGateways();
|
||||
const { system, window: windowGateway, focusedProject } = useGateways();
|
||||
const [name, setName] = useState("");
|
||||
const [root, setRoot] = useState("");
|
||||
// Placement of every open view (#22): each panel is "closed" (absent),
|
||||
@ -141,6 +141,17 @@ export function ProjectsView() {
|
||||
setViewerConversationId(null);
|
||||
}, [active?.id]);
|
||||
|
||||
// Publish the focused project (#47) so detached panel-only windows follow the
|
||||
// main window: they render this project, or an "open a project" shell when
|
||||
// none is active. Publish on EVERY change of `active` — including
|
||||
// switching-away to null — so a restored panel window never shows a stale
|
||||
// project. Optional-chained: unit tests may omit this gateway.
|
||||
useEffect(() => {
|
||||
void focusedProject?.setFocusedProject(
|
||||
active ? { id: active.id, name: active.name, root: active.root } : null,
|
||||
);
|
||||
}, [focusedProject, active?.id, active?.name, active?.root]);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
@ -227,15 +238,16 @@ export function ProjectsView() {
|
||||
});
|
||||
}
|
||||
|
||||
// Detach a view into its own OS window (#23): ask the backend to open the
|
||||
// window, then mark the slot "detached" so nothing renders for it in the main
|
||||
// window. Requires an active project (the window is project-scoped). On
|
||||
// failure the placement is left untouched (no ghost "detached" slot).
|
||||
// Detach a view into its own OS window (#23, panel-only in #47): ask the
|
||||
// backend to open the window, then mark the slot "detached" so nothing renders
|
||||
// for it in the main window. The window is panel-only and follows the focused
|
||||
// project (published above), so no project id is passed. Requires an active
|
||||
// project only so a detached window opens onto something rather than the empty
|
||||
// shell. On failure the placement is left untouched (no ghost "detached" slot).
|
||||
function detachPanel(panel: PanelId) {
|
||||
if (!active) return;
|
||||
const projectId = active.id;
|
||||
void windowGateway
|
||||
.openViewWindow(panel, projectId)
|
||||
.openViewWindow(panel)
|
||||
.then(() => setPlacement(panel, "detached"))
|
||||
.catch(() => {
|
||||
/* window failed to open; keep the current placement */
|
||||
|
||||
@ -937,40 +937,84 @@ export interface TicketGateway {
|
||||
* Payload of the OS window-lifecycle event that fires when a detached view
|
||||
* window closes (ticket #23). Lets the main window re-toggle the view's
|
||||
* placement out of `"detached"`.
|
||||
*
|
||||
* Ticket #47: a detached window is now **panel-only** and follows the main
|
||||
* window's focused project rather than embedding a project id, so the close
|
||||
* event no longer carries a `projectId` — the placement is keyed by `panel`.
|
||||
*/
|
||||
export interface ViewWindowClosed {
|
||||
/** The detached panel id (a `PanelId`, kept as `string` at the port seam). */
|
||||
panel: string;
|
||||
/** The project the detached window was showing. */
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaching a View into its own OS window (ticket #23).
|
||||
* Detaching a View into its own OS window (ticket #23, reworked in #47).
|
||||
*
|
||||
* The backend owns the Tauri `WebviewWindow` lifecycle (create/focus/close,
|
||||
* anti-duplicate registry). This port is the UI's only door to it — components
|
||||
* never call `invoke()` directly. `panel` is a `PanelId`-shaped string; it is
|
||||
* typed as `string` here so ports need not depend on the `features/` layer.
|
||||
*
|
||||
* A detached window is **panel-only**: it no longer carries a project id and
|
||||
* instead follows the focused project published through
|
||||
* {@link FocusedProjectGateway}. Windows are therefore keyed by `panel` alone.
|
||||
*/
|
||||
export interface WindowGateway {
|
||||
/**
|
||||
* Opens — or focuses, if already open — a separate OS window rendering only
|
||||
* `panel` for `projectId` (the backend's `open_view_window` command).
|
||||
* `panel` (the backend's `open_view_window` command). The window follows the
|
||||
* main window's focused project; no project id is passed.
|
||||
*/
|
||||
openViewWindow(panel: string, projectId: string): Promise<void>;
|
||||
/** Closes the detached window for `panel`/`projectId` if one is open. */
|
||||
closeViewWindow(panel: string, projectId: string): Promise<void>;
|
||||
openViewWindow(panel: string): Promise<void>;
|
||||
/** Closes the detached window for `panel` if one is open. */
|
||||
closeViewWindow(panel: string): Promise<void>;
|
||||
/**
|
||||
* Subscribes to detached-window close events (OS close or programmatic). The
|
||||
* handler receives the `{ panel, projectId }` that closed. Returns an
|
||||
* unsubscribe.
|
||||
* handler receives the `{ panel }` that closed. Returns an unsubscribe.
|
||||
*/
|
||||
onViewWindowClosed(
|
||||
handler: (event: ViewWindowClosed) => void,
|
||||
): Promise<Unsubscribe>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A focused-project snapshot (ticket #47). Mirrors the backend
|
||||
* `FocusedProjectDto` — the minimal identity a panel-only window needs to mount
|
||||
* a project-scoped view (id for the read-models, name for chrome, root as the
|
||||
* agents panel's cwd base).
|
||||
*/
|
||||
export interface FocusedProject {
|
||||
id: string;
|
||||
name: string;
|
||||
root: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The focused-project channel between the main window and detached panel-only
|
||||
* windows (ticket #47).
|
||||
*
|
||||
* The main window is the single writer: it publishes the currently active
|
||||
* project (or `null` when none is open) via {@link setFocusedProject}. Detached
|
||||
* windows are readers: they read the current focus at mount
|
||||
* ({@link getFocusedProject}) then track changes via
|
||||
* {@link onFocusedProjectChanged}. This decouples a restored panel window from
|
||||
* any stale embedded project id — with no focus it shows an "open a project"
|
||||
* shell and mounts no project-scoped panel.
|
||||
*/
|
||||
export interface FocusedProjectGateway {
|
||||
/** Publishes the focused project (or `null` when no project is active). */
|
||||
setFocusedProject(project: FocusedProject | null): Promise<void>;
|
||||
/** Reads the current focused project; `null` when none is focused. */
|
||||
getFocusedProject(): Promise<FocusedProject | null>;
|
||||
/**
|
||||
* Subscribes to focused-project changes. The handler receives the new focus
|
||||
* (or `null`). Returns an unsubscribe.
|
||||
*/
|
||||
onFocusedProjectChanged(
|
||||
handler: (project: FocusedProject | null) => void,
|
||||
): Promise<Unsubscribe>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin UI-preferences port (ticket #29). Persists small, per-surface pieces of
|
||||
* UI state (like the ticket filters) so they survive a restart / reopen. This is
|
||||
@ -1018,5 +1062,6 @@ export interface Gateways {
|
||||
conversation: ConversationGateway;
|
||||
ticket: TicketGateway;
|
||||
window: WindowGateway;
|
||||
focusedProject: FocusedProjectGateway;
|
||||
uiPreferences: UiPreferencesGateway;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user