Merge feature/ticket40-persist-restore-windows into develop
Persistance et restauration des fenêtres au redémarrage (#40). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -2341,7 +2341,7 @@ pub struct ViewWindowLifecycleEventDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum ViewPanel {
|
pub(crate) enum ViewPanel {
|
||||||
Projects,
|
Projects,
|
||||||
Context,
|
Context,
|
||||||
Work,
|
Work,
|
||||||
@ -2355,7 +2355,7 @@ enum ViewPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ViewPanel {
|
impl ViewPanel {
|
||||||
fn parse(raw: &str) -> Result<Self, ErrorDto> {
|
pub(crate) fn parse(raw: &str) -> Result<Self, ErrorDto> {
|
||||||
match raw {
|
match raw {
|
||||||
"projects" => Ok(Self::Projects),
|
"projects" => Ok(Self::Projects),
|
||||||
"context" => Ok(Self::Context),
|
"context" => Ok(Self::Context),
|
||||||
@ -2374,7 +2374,7 @@ impl ViewPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn as_str(self) -> &'static str {
|
pub(crate) const fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Projects => "projects",
|
Self::Projects => "projects",
|
||||||
Self::Context => "context",
|
Self::Context => "context",
|
||||||
@ -2389,7 +2389,7 @@ impl ViewPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn title(self) -> &'static str {
|
pub(crate) const fn title(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Projects => "Projects",
|
Self::Projects => "Projects",
|
||||||
Self::Context => "Project context",
|
Self::Context => "Project context",
|
||||||
@ -2405,15 +2405,15 @@ impl ViewPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String {
|
pub(crate) fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String {
|
||||||
format!("view-{}-{}", panel.as_str(), project_id.as_uuid().simple())
|
format!("view-{}-{}", panel.as_str(), project_id.as_uuid().simple())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String {
|
pub(crate) fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String {
|
||||||
format!("index.html?panel={}&project={}", panel.as_str(), project_id)
|
format!("index.html?panel={}&project={}", panel.as_str(), project_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_view_window_lifecycle(
|
pub(crate) fn emit_view_window_lifecycle(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
panel: ViewPanel,
|
panel: ViewPanel,
|
||||||
|
|||||||
@ -26,7 +26,15 @@ pub mod tickets;
|
|||||||
|
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use tauri::Manager;
|
use application::SnapshotOpenWindowsInput;
|
||||||
|
use domain::{
|
||||||
|
PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize,
|
||||||
|
PersistedWindowState, ProjectId,
|
||||||
|
};
|
||||||
|
use tauri::{
|
||||||
|
Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
|
|
||||||
@ -99,6 +107,9 @@ pub fn run() {
|
|||||||
window.on_window_event(move |event| {
|
window.on_window_event(move |event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||||
if let Some(state) = handle.try_state::<AppState>() {
|
if let Some(state) = handle.try_state::<AppState>() {
|
||||||
|
let open_windows = snapshot_open_webview_windows(&handle);
|
||||||
|
let window_snapshot =
|
||||||
|
std::sync::Arc::clone(&state.snapshot_open_windows);
|
||||||
let pty = std::sync::Arc::clone(&state.pty_port);
|
let pty = std::sync::Arc::clone(&state.pty_port);
|
||||||
// ORDER IS CRITICAL: freeze `agent_was_running` on every
|
// ORDER IS CRITICAL: freeze `agent_was_running` on every
|
||||||
// agent leaf of every open project FIRST, reading the live
|
// agent leaf of every open project FIRST, reading the live
|
||||||
@ -111,6 +122,11 @@ pub fn run() {
|
|||||||
let open_projects = state.open_project_ids();
|
let open_projects = state.open_project_ids();
|
||||||
let handles = state.terminal_sessions.handles();
|
let handles = state.terminal_sessions.handles();
|
||||||
tauri::async_runtime::block_on(async move {
|
tauri::async_runtime::block_on(async move {
|
||||||
|
let _ = window_snapshot
|
||||||
|
.execute(SnapshotOpenWindowsInput {
|
||||||
|
windows: open_windows,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
for project_id in open_projects {
|
for project_id in open_projects {
|
||||||
let _ = snapshot
|
let _ = snapshot
|
||||||
.execute(application::SnapshotRunningAgentsInput {
|
.execute(application::SnapshotRunningAgentsInput {
|
||||||
@ -130,6 +146,8 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restore_open_webview_windows(app.handle());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@ -270,9 +288,233 @@ fn should_close_with_main_window(label: &str) -> bool {
|
|||||||
label != "main"
|
label != "main"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn snapshot_open_webview_windows(handle: &tauri::AppHandle) -> Vec<PersistedWindowState> {
|
||||||
|
handle
|
||||||
|
.webview_windows()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(label, window)| snapshot_webview_window(&label, &window))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_webview_window(label: &str, window: &WebviewWindow) -> Option<PersistedWindowState> {
|
||||||
|
let (kind, panel, project_id, url) = persisted_window_identity(label)?;
|
||||||
|
let outer_position = window
|
||||||
|
.outer_position()
|
||||||
|
.ok()
|
||||||
|
.map(|p| PersistedWindowPosition { x: p.x, y: p.y });
|
||||||
|
let outer_size = window.outer_size().ok().map(|s| PersistedWindowSize {
|
||||||
|
width: s.width,
|
||||||
|
height: s.height,
|
||||||
|
});
|
||||||
|
let monitor = window.current_monitor().ok().flatten().map(|m| {
|
||||||
|
let position = m.position();
|
||||||
|
let size = m.size();
|
||||||
|
PersistedMonitorState {
|
||||||
|
name: m.name().cloned(),
|
||||||
|
scale_factor: Some(m.scale_factor()),
|
||||||
|
position: Some(PersistedWindowPosition {
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
}),
|
||||||
|
size: Some(PersistedWindowSize {
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(PersistedWindowState {
|
||||||
|
label: label.to_owned(),
|
||||||
|
kind,
|
||||||
|
panel,
|
||||||
|
project_id,
|
||||||
|
url,
|
||||||
|
visible: window.is_visible().unwrap_or(true),
|
||||||
|
maximized: window.is_maximized().unwrap_or(false),
|
||||||
|
fullscreen: window.is_fullscreen().unwrap_or(false),
|
||||||
|
outer_position,
|
||||||
|
outer_size,
|
||||||
|
monitor,
|
||||||
|
last_focused_at: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persisted_window_identity(
|
||||||
|
label: &str,
|
||||||
|
) -> Option<(
|
||||||
|
PersistedWindowKind,
|
||||||
|
Option<String>,
|
||||||
|
Option<ProjectId>,
|
||||||
|
Option<String>,
|
||||||
|
)> {
|
||||||
|
if label == "main" {
|
||||||
|
return Some((PersistedWindowKind::Main, None, None, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (panel, project_id) = 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)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persisted_view_identity_from_label(label: &str) -> Option<(commands::ViewPanel, ProjectId)> {
|
||||||
|
let rest = label.strip_prefix("view-")?;
|
||||||
|
let (panel, project_id) = rest.rsplit_once('-')?;
|
||||||
|
if project_id.len() != 32 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let panel = commands::ViewPanel::parse(panel).ok()?;
|
||||||
|
let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).ok()?);
|
||||||
|
Some((panel, project_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_open_webview_windows(handle: &tauri::AppHandle) {
|
||||||
|
let Some(state) = handle.try_state::<AppState>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let restore = std::sync::Arc::clone(&state.restore_open_windows);
|
||||||
|
let Ok(output) = tauri::async_runtime::block_on(async move { restore.execute().await }) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for window_state in output.windows {
|
||||||
|
match window_state.kind {
|
||||||
|
PersistedWindowKind::Main => {
|
||||||
|
if let Some(window) = handle.get_webview_window("main") {
|
||||||
|
apply_persisted_window_state(handle, &window, &window_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PersistedWindowKind::View => {
|
||||||
|
restore_view_window(handle, &window_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
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 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let url = state
|
||||||
|
.url
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| commands::view_window_url(panel, project_id));
|
||||||
|
|
||||||
|
let Ok(window) = WebviewWindowBuilder::new(handle, &state.label, WebviewUrl::App(url.into()))
|
||||||
|
.title(format!("IdeA - {}", panel.title()))
|
||||||
|
.inner_size(1120.0, 760.0)
|
||||||
|
.min_inner_size(720.0, 480.0)
|
||||||
|
.resizable(true)
|
||||||
|
.maximizable(true)
|
||||||
|
.minimizable(true)
|
||||||
|
.closable(true)
|
||||||
|
.decorations(true)
|
||||||
|
.visible(state.visible)
|
||||||
|
.build()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let event_app = handle.clone();
|
||||||
|
let event_label = state.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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
apply_persisted_window_state(handle, &window, state);
|
||||||
|
commands::emit_view_window_lifecycle(handle, "opened", panel, project_id, &state.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_persisted_window_state(
|
||||||
|
handle: &tauri::AppHandle,
|
||||||
|
window: &WebviewWindow,
|
||||||
|
state: &PersistedWindowState,
|
||||||
|
) {
|
||||||
|
if persisted_monitor_is_available(handle, state.monitor.as_ref()) {
|
||||||
|
if let Some(size) = state.outer_size {
|
||||||
|
let _ = window.set_size(PhysicalSize::new(size.width, size.height));
|
||||||
|
}
|
||||||
|
if let Some(position) = state.outer_position {
|
||||||
|
let _ = window.set_position(PhysicalPosition::new(position.x, position.y));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = window.center();
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.fullscreen {
|
||||||
|
let _ = window.set_fullscreen(true);
|
||||||
|
} else if state.maximized {
|
||||||
|
let _ = window.maximize();
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.visible {
|
||||||
|
let _ = window.show();
|
||||||
|
} else {
|
||||||
|
let _ = window.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persisted_monitor_is_available(
|
||||||
|
handle: &tauri::AppHandle,
|
||||||
|
monitor: Option<&PersistedMonitorState>,
|
||||||
|
) -> bool {
|
||||||
|
let Some(saved) = monitor else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let Ok(monitors) = handle.available_monitors() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
monitors.into_iter().any(|current| {
|
||||||
|
if let Some(saved_name) = saved.name.as_deref() {
|
||||||
|
if current.name().is_some_and(|name| name == saved_name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let position_matches = saved.position.is_some_and(|p| {
|
||||||
|
let current_position = current.position();
|
||||||
|
p.x == current_position.x && p.y == current_position.y
|
||||||
|
});
|
||||||
|
let size_matches = saved.size.is_some_and(|s| {
|
||||||
|
let current_size = current.size();
|
||||||
|
s.width == current_size.width && s.height == current_size.height
|
||||||
|
});
|
||||||
|
position_matches && size_matches
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::should_close_with_main_window;
|
use super::{persisted_view_identity_from_label, persisted_window_identity};
|
||||||
|
use super::{should_close_with_main_window, PersistedWindowKind};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn main_window_close_does_not_target_main_again() {
|
fn main_window_close_does_not_target_main_again() {
|
||||||
@ -290,4 +532,33 @@ mod tests {
|
|||||||
fn main_window_close_targets_other_auxiliary_windows() {
|
fn main_window_close_targets_other_auxiliary_windows() {
|
||||||
assert!(should_close_with_main_window("settings"));
|
assert!(should_close_with_main_window("settings"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_identity_accepts_main_and_stable_view_labels() {
|
||||||
|
let (kind, panel, project_id, url) = persisted_window_identity("main").unwrap();
|
||||||
|
assert_eq!(kind, PersistedWindowKind::Main);
|
||||||
|
assert!(panel.is_none());
|
||||||
|
assert!(project_id.is_none());
|
||||||
|
assert!(url.is_none());
|
||||||
|
|
||||||
|
let (panel, project_id) =
|
||||||
|
persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(panel.as_str(), "tickets");
|
||||||
|
assert_eq!(
|
||||||
|
project_id.to_string(),
|
||||||
|
"00000000-0000-0000-0000-00000000002a"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_identity_filters_unknown_or_headless_labels() {
|
||||||
|
assert!(persisted_window_identity("mcp-server").is_none());
|
||||||
|
assert!(persisted_window_identity("settings").is_none());
|
||||||
|
assert!(
|
||||||
|
persisted_window_identity("view-unknown-0000000000000000000000000000002a").is_none()
|
||||||
|
);
|
||||||
|
assert!(persisted_window_identity("view-tickets-not-a-project").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,13 +33,14 @@ use application::{
|
|||||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
||||||
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||||
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
||||||
ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile,
|
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog,
|
||||||
SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents,
|
SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||||
SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession,
|
SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent,
|
||||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint,
|
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions,
|
||||||
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
|
UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext,
|
||||||
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
|
UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||||
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
||||||
|
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -49,7 +50,7 @@ use domain::ports::{
|
|||||||
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
|
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
|
||||||
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore,
|
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore,
|
||||||
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore,
|
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore,
|
||||||
ToolInvoker, WakeError, WakeReason,
|
ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -71,13 +72,14 @@ use infrastructure::{
|
|||||||
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
||||||
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
|
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
|
||||||
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore,
|
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore,
|
||||||
Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe, IdeaiContextStore,
|
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe,
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime, LocalFileSystem,
|
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime,
|
||||||
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
|
||||||
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
|
||||||
SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketToolProvider,
|
||||||
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall,
|
||||||
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||||
|
VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::chat::ChatBridge;
|
use crate::chat::ChatBridge;
|
||||||
@ -900,6 +902,10 @@ pub struct AppState {
|
|||||||
// --- Windows (L10) ---
|
// --- Windows (L10) ---
|
||||||
/// Detach a tab into a new OS window (persists the workspace topology).
|
/// Detach a tab into a new OS window (persists the workspace topology).
|
||||||
pub move_tab: Arc<MoveTabToNewWindow>,
|
pub move_tab: Arc<MoveTabToNewWindow>,
|
||||||
|
/// Persist open Tauri webview-window state on main-window shutdown.
|
||||||
|
pub snapshot_open_windows: Arc<SnapshotOpenWindows>,
|
||||||
|
/// Load restorable Tauri webview-window state on startup.
|
||||||
|
pub restore_open_windows: Arc<RestoreOpenWindows>,
|
||||||
// --- Background tasks (B8) ---
|
// --- Background tasks (B8) ---
|
||||||
/// Spawn a command-backed first-class background task.
|
/// Spawn a command-backed first-class background task.
|
||||||
pub spawn_background_command: Arc<SpawnBackgroundCommand>,
|
pub spawn_background_command: Arc<SpawnBackgroundCommand>,
|
||||||
@ -1054,10 +1060,15 @@ impl AppState {
|
|||||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||||
app_data_dir.to_string_lossy().into_owned(),
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
));
|
));
|
||||||
|
let window_state_store = Arc::new(FsWindowStateStore::new(
|
||||||
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||||
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
|
));
|
||||||
|
|
||||||
// Port-typed handles for injection.
|
// Port-typed handles for injection.
|
||||||
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
|
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
|
||||||
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
|
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
|
||||||
|
let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
|
||||||
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
||||||
|
|
||||||
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
||||||
@ -2211,6 +2222,12 @@ impl AppState {
|
|||||||
Arc::clone(&store_port),
|
Arc::clone(&store_port),
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
));
|
));
|
||||||
|
let snapshot_open_windows =
|
||||||
|
Arc::new(SnapshotOpenWindows::new(Arc::clone(&window_state_port)));
|
||||||
|
let restore_open_windows = Arc::new(RestoreOpenWindows::new(
|
||||||
|
Arc::clone(&window_state_port),
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
health,
|
health,
|
||||||
@ -2340,6 +2357,8 @@ impl AppState {
|
|||||||
fs_port: Arc::clone(&fs_port),
|
fs_port: Arc::clone(&fs_port),
|
||||||
home_dir,
|
home_dir,
|
||||||
move_tab,
|
move_tab,
|
||||||
|
snapshot_open_windows,
|
||||||
|
restore_open_windows,
|
||||||
spawn_background_command,
|
spawn_background_command,
|
||||||
cancel_background_task,
|
cancel_background_task,
|
||||||
retry_background_task,
|
retry_background_task,
|
||||||
|
|||||||
@ -162,7 +162,10 @@ pub use ticket_assistant::{
|
|||||||
CloseTicketAssistant, CloseTicketAssistantInput, OpenTicketAssistant, OpenTicketAssistantInput,
|
CloseTicketAssistant, CloseTicketAssistantInput, OpenTicketAssistant, OpenTicketAssistantInput,
|
||||||
OpenTicketAssistantOutput,
|
OpenTicketAssistantOutput,
|
||||||
};
|
};
|
||||||
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
pub use window::{
|
||||||
|
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows,
|
||||||
|
RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput,
|
||||||
|
};
|
||||||
pub use workstate::{
|
pub use workstate::{
|
||||||
AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent,
|
AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent,
|
||||||
AttachLiveAgentInput, AttachLiveAgentOutput, BackgroundTaskKindLabel, ConversationLogProvider,
|
AttachLiveAgentInput, AttachLiveAgentOutput, BackgroundTaskKindLabel, ConversationLogProvider,
|
||||||
|
|||||||
@ -3,4 +3,7 @@
|
|||||||
|
|
||||||
mod usecases;
|
mod usecases;
|
||||||
|
|
||||||
pub use usecases::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
pub use usecases::{
|
||||||
|
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows,
|
||||||
|
RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput,
|
||||||
|
};
|
||||||
|
|||||||
@ -7,8 +7,10 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ids::{TabId, WindowId};
|
use domain::ids::{TabId, WindowId};
|
||||||
use domain::layout::Workspace;
|
use std::collections::HashSet;
|
||||||
use domain::ports::{IdGenerator, ProjectStore};
|
|
||||||
|
use domain::layout::{PersistedWindowKind, PersistedWindowState, WindowStateSnapshot, Workspace};
|
||||||
|
use domain::ports::{IdGenerator, ProjectStore, StoreError, WindowStateStore};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@ -71,3 +73,94 @@ impl MoveTabToNewWindow {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Input for [`SnapshotOpenWindows::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct SnapshotOpenWindowsInput {
|
||||||
|
/// Open windows captured by the presentation adapter.
|
||||||
|
pub windows: Vec<PersistedWindowState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists the latest open OS window snapshot.
|
||||||
|
pub struct SnapshotOpenWindows {
|
||||||
|
store: Arc<dyn WindowStateStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SnapshotOpenWindows {
|
||||||
|
/// Builds the use case from its store port.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn WindowStateStore>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves a versioned window snapshot.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: SnapshotOpenWindowsInput) -> Result<(), AppError> {
|
||||||
|
self.store
|
||||||
|
.save_window_state(&WindowStateSnapshot::new(input.windows))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`RestoreOpenWindows::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct RestoreOpenWindowsOutput {
|
||||||
|
/// Restorable windows, deduplicated by stable label.
|
||||||
|
pub windows: Vec<PersistedWindowState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads and filters the latest persisted OS window snapshot.
|
||||||
|
pub struct RestoreOpenWindows {
|
||||||
|
windows: Arc<dyn WindowStateStore>,
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on snapshot read failure.
|
||||||
|
pub async fn execute(&self) -> Result<RestoreOpenWindowsOutput, AppError> {
|
||||||
|
let snapshot = self.windows.load_window_state().await?;
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut windows = Vec::new();
|
||||||
|
|
||||||
|
for window in snapshot.windows {
|
||||||
|
if window.label.is_empty() || !seen.insert(window.label.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match self.projects.load_project(project_id).await {
|
||||||
|
Ok(_) => windows.push(window),
|
||||||
|
Err(StoreError::NotFound) => {}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RestoreOpenWindowsOutput { windows })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,13 +5,19 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ids::{ProjectId, TabId, WindowId};
|
use domain::ids::{ProjectId, TabId, WindowId};
|
||||||
use domain::layout::{LayoutNode, LayoutTree, LeafCell, Tab, Window, Workspace};
|
use domain::layout::{
|
||||||
use domain::ports::{IdGenerator, ProjectStore, StoreError};
|
LayoutNode, LayoutTree, LeafCell, PersistedWindowKind, PersistedWindowState, Tab, Window,
|
||||||
use domain::project::Project;
|
WindowStateSnapshot, Workspace,
|
||||||
use domain::NodeId;
|
};
|
||||||
|
use domain::ports::{IdGenerator, ProjectStore, StoreError, WindowStateStore};
|
||||||
|
use domain::project::{Project, ProjectPath};
|
||||||
|
use domain::{NodeId, RemoteRef};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{MoveTabToNewWindow, MoveTabToNewWindowInput};
|
use application::{
|
||||||
|
MoveTabToNewWindow, MoveTabToNewWindowInput, RestoreOpenWindows, SnapshotOpenWindows,
|
||||||
|
SnapshotOpenWindowsInput,
|
||||||
|
};
|
||||||
|
|
||||||
/// A `ProjectStore` fake that only implements the workspace persistence the use
|
/// A `ProjectStore` fake that only implements the workspace persistence the use
|
||||||
/// case needs (the project methods are never called here).
|
/// case needs (the project methods are never called here).
|
||||||
@ -47,6 +53,67 @@ impl IdGenerator for SeqIds {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct FakeWindowStateStore(Arc<Mutex<WindowStateSnapshot>>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WindowStateStore for FakeWindowStateStore {
|
||||||
|
async fn save_window_state(&self, snapshot: &WindowStateSnapshot) -> Result<(), StoreError> {
|
||||||
|
*self.0.lock().unwrap() = snapshot.clone();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_window_state(&self) -> Result<WindowStateSnapshot, StoreError> {
|
||||||
|
Ok(self.0.lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FakeProjectRegistry {
|
||||||
|
existing: Arc<Mutex<Vec<ProjectId>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeProjectRegistry {
|
||||||
|
fn new(existing: Vec<ProjectId>) -> Self {
|
||||||
|
Self {
|
||||||
|
existing: Arc::new(Mutex::new(existing)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProjectStore for FakeProjectRegistry {
|
||||||
|
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||||
|
if !self.existing.lock().unwrap().contains(&id) {
|
||||||
|
return Err(StoreError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Project {
|
||||||
|
id,
|
||||||
|
name: format!("project-{id}"),
|
||||||
|
root: ProjectPath::new(format!("/tmp/{id}")).unwrap(),
|
||||||
|
remote: RemoteRef::Local,
|
||||||
|
created_at: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_project(&self, _p: &Project) -> Result<(), StoreError> {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_workspace(&self, _ws: &Workspace) -> Result<(), StoreError> {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn tid(n: u128) -> TabId {
|
fn tid(n: u128) -> TabId {
|
||||||
TabId::from_uuid(Uuid::from_u128(n))
|
TabId::from_uuid(Uuid::from_u128(n))
|
||||||
}
|
}
|
||||||
@ -75,6 +142,40 @@ fn seeded() -> FakeStore {
|
|||||||
FakeStore(Arc::new(Mutex::new(ws)))
|
FakeStore(Arc::new(Mutex::new(ws)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persisted_main() -> PersistedWindowState {
|
||||||
|
PersistedWindowState {
|
||||||
|
label: "main".to_owned(),
|
||||||
|
kind: PersistedWindowKind::Main,
|
||||||
|
panel: None,
|
||||||
|
project_id: None,
|
||||||
|
url: None,
|
||||||
|
visible: true,
|
||||||
|
maximized: false,
|
||||||
|
fullscreen: false,
|
||||||
|
outer_position: None,
|
||||||
|
outer_size: None,
|
||||||
|
monitor: None,
|
||||||
|
last_focused_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persisted_view(label: &str, project_id: ProjectId) -> PersistedWindowState {
|
||||||
|
PersistedWindowState {
|
||||||
|
label: label.to_owned(),
|
||||||
|
kind: PersistedWindowKind::View,
|
||||||
|
panel: Some("tickets".to_owned()),
|
||||||
|
project_id: Some(project_id),
|
||||||
|
url: Some(format!("index.html?panel=tickets&project={project_id}")),
|
||||||
|
visible: true,
|
||||||
|
maximized: false,
|
||||||
|
fullscreen: false,
|
||||||
|
outer_position: None,
|
||||||
|
outer_size: None,
|
||||||
|
monitor: None,
|
||||||
|
last_focused_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn detaches_tab_and_persists_workspace() {
|
async fn detaches_tab_and_persists_workspace() {
|
||||||
let store = seeded();
|
let store = seeded();
|
||||||
@ -112,3 +213,50 @@ async fn unknown_tab_is_not_found() {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn snapshot_open_windows_persists_the_supplied_snapshot() {
|
||||||
|
let store = FakeWindowStateStore::default();
|
||||||
|
let uc = SnapshotOpenWindows::new(Arc::new(store.clone()));
|
||||||
|
let windows = vec![persisted_main()];
|
||||||
|
|
||||||
|
uc.execute(SnapshotOpenWindowsInput {
|
||||||
|
windows: windows.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let saved = store.load_window_state().await.unwrap();
|
||||||
|
assert_eq!(saved.version, domain::WINDOW_STATE_SNAPSHOT_VERSION);
|
||||||
|
assert_eq!(saved.windows, windows);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restore_open_windows_deduplicates_and_filters_missing_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 store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![
|
||||||
|
persisted_main(),
|
||||||
|
persisted_main(),
|
||||||
|
persisted_view(valid_label, existing),
|
||||||
|
persisted_view(valid_label, existing),
|
||||||
|
persisted_view("view-tickets-0000000000000000000000000000002b", missing),
|
||||||
|
incomplete,
|
||||||
|
]))));
|
||||||
|
let projects = FakeProjectRegistry::new(vec![existing]);
|
||||||
|
let uc = RestoreOpenWindows::new(Arc::new(store), Arc::new(projects));
|
||||||
|
|
||||||
|
let out = uc.execute().await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
out.windows
|
||||||
|
.iter()
|
||||||
|
.map(|w| w.label.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["main", valid_label]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1025,3 +1025,119 @@ impl Workspace {
|
|||||||
Ok(Self { windows })
|
Ok(Self { windows })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current persisted schema version for OS window state.
|
||||||
|
pub const WINDOW_STATE_SNAPSHOT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Persisted kind of an IdeA OS window.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum PersistedWindowKind {
|
||||||
|
/// The primary IdeA window.
|
||||||
|
Main,
|
||||||
|
/// A detached project view window.
|
||||||
|
View,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical top-left position of a window or monitor.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PersistedWindowPosition {
|
||||||
|
/// X coordinate in physical pixels.
|
||||||
|
pub x: i32,
|
||||||
|
/// Y coordinate in physical pixels.
|
||||||
|
pub y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical size of a window or monitor.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PersistedWindowSize {
|
||||||
|
/// Width in physical pixels.
|
||||||
|
pub width: u32,
|
||||||
|
/// Height in physical pixels.
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort monitor descriptor captured with a window.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PersistedMonitorState {
|
||||||
|
/// Human-readable monitor name when the platform exposes one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Scale factor reported by the windowing system.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub scale_factor: Option<f64>,
|
||||||
|
/// Monitor origin in the global desktop coordinate space.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub position: Option<PersistedWindowPosition>,
|
||||||
|
/// Monitor physical size.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub size: Option<PersistedWindowSize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted state of one IdeA webview window.
|
||||||
|
#[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>`.
|
||||||
|
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`].
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub project_id: Option<crate::ids::ProjectId>,
|
||||||
|
/// App URL loaded in the window.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// Whether the window was visible.
|
||||||
|
pub visible: bool,
|
||||||
|
/// Whether the window was maximized.
|
||||||
|
pub maximized: bool,
|
||||||
|
/// Whether the window was fullscreen.
|
||||||
|
pub fullscreen: bool,
|
||||||
|
/// Outer window position.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub outer_position: Option<PersistedWindowPosition>,
|
||||||
|
/// Outer window size.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub outer_size: Option<PersistedWindowSize>,
|
||||||
|
/// Current monitor at snapshot time.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub monitor: Option<PersistedMonitorState>,
|
||||||
|
/// Optional focus recency, left unset until focus tracking exists.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_focused_at: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted snapshot of the IdeA OS windows.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WindowStateSnapshot {
|
||||||
|
/// Schema version.
|
||||||
|
pub version: u32,
|
||||||
|
/// Captured windows.
|
||||||
|
pub windows: Vec<PersistedWindowState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowStateSnapshot {
|
||||||
|
/// Builds a versioned snapshot from captured windows.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(windows: Vec<PersistedWindowState>) -> Self {
|
||||||
|
Self {
|
||||||
|
version: WINDOW_STATE_SNAPSHOT_VERSION,
|
||||||
|
windows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WindowStateSnapshot {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -168,7 +168,9 @@ pub use git::GitRepository;
|
|||||||
|
|
||||||
pub use layout::{
|
pub use layout::{
|
||||||
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
|
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
|
||||||
SplitContainer, Tab, WeightedChild, Window, Workspace,
|
PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize,
|
||||||
|
PersistedWindowState, SplitContainer, Tab, WeightedChild, Window, WindowStateSnapshot,
|
||||||
|
Workspace, WINDOW_STATE_SNAPSHOT_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use events::{DomainEvent, OrchestrationSource};
|
pub use events::{DomainEvent, OrchestrationSource};
|
||||||
@ -201,4 +203,5 @@ pub use ports::{
|
|||||||
OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
|
OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
|
||||||
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
|
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
|
||||||
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, TemplateStore,
|
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, TemplateStore,
|
||||||
|
WindowStateStore,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1417,6 +1417,26 @@ pub trait ProjectStore: Send + Sync {
|
|||||||
async fn load_workspace(&self) -> Result<crate::layout::Workspace, StoreError>;
|
async fn load_workspace(&self) -> Result<crate::layout::Workspace, StoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Machine-local persistence of open OS window state.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait WindowStateStore: Send + Sync {
|
||||||
|
/// Saves the latest open-window snapshot.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] on persistence failure.
|
||||||
|
async fn save_window_state(
|
||||||
|
&self,
|
||||||
|
snapshot: &crate::layout::WindowStateSnapshot,
|
||||||
|
) -> Result<(), StoreError>;
|
||||||
|
|
||||||
|
/// Loads the latest open-window snapshot.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] on unexpected I/O or deserialisation failure. A missing
|
||||||
|
/// snapshot is represented as an empty default snapshot.
|
||||||
|
async fn load_window_state(&self) -> Result<crate::layout::WindowStateSnapshot, StoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// CRUD for the configured [`AgentProfile`]s in the global IDE store
|
/// CRUD for the configured [`AgentProfile`]s in the global IDE store
|
||||||
/// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the
|
/// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the
|
||||||
/// single generic [`AgentRuntime`] adapter (Open/Closed).
|
/// single generic [`AgentRuntime`] adapter (Open/Closed).
|
||||||
|
|||||||
@ -3,8 +3,9 @@
|
|||||||
//! window is dropped; an active moved tab hands activity back to a sibling.
|
//! window is dropped; an active moved tab hands activity back to a sibling.
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
LayoutError, LayoutNode, LayoutTree, LeafCell, NodeId, ProjectId, Tab, TabId, Window, WindowId,
|
LayoutError, LayoutNode, LayoutTree, LeafCell, NodeId, PersistedMonitorState,
|
||||||
Workspace,
|
PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize, PersistedWindowState,
|
||||||
|
ProjectId, Tab, TabId, Window, WindowId, WindowStateSnapshot, Workspace,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -94,3 +95,43 @@ fn move_non_active_tab_leaves_source_active_unchanged() {
|
|||||||
assert_eq!(source.active_tab, tid(1), "active tab unchanged");
|
assert_eq!(source.active_tab, tid(1), "active tab unchanged");
|
||||||
assert_eq!(source.tabs.len(), 1);
|
assert_eq!(source.tabs.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_state_snapshot_round_trips_with_camel_case_schema() {
|
||||||
|
let snapshot = WindowStateSnapshot::new(vec![PersistedWindowState {
|
||||||
|
label: "view-tickets-0000000000000000000000000000002a".to_owned(),
|
||||||
|
kind: PersistedWindowKind::View,
|
||||||
|
panel: Some("tickets".to_owned()),
|
||||||
|
project_id: Some(ProjectId::from_uuid(Uuid::from_u128(42))),
|
||||||
|
url: Some(
|
||||||
|
"index.html?panel=tickets&project=00000000-0000-0000-0000-00000000002a".to_owned(),
|
||||||
|
),
|
||||||
|
visible: true,
|
||||||
|
maximized: false,
|
||||||
|
fullscreen: false,
|
||||||
|
outer_position: Some(PersistedWindowPosition { x: 12, y: 34 }),
|
||||||
|
outer_size: Some(PersistedWindowSize {
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
}),
|
||||||
|
monitor: Some(PersistedMonitorState {
|
||||||
|
name: Some("HDMI-1".to_owned()),
|
||||||
|
scale_factor: Some(1.25),
|
||||||
|
position: Some(PersistedWindowPosition { x: 0, y: 0 }),
|
||||||
|
size: Some(PersistedWindowSize {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
last_focused_at: Some(123),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&snapshot).unwrap();
|
||||||
|
assert!(json.contains("\"projectId\""), "json was {json}");
|
||||||
|
assert!(json.contains("\"outerPosition\""), "json was {json}");
|
||||||
|
assert!(json.contains("\"scaleFactor\""), "json was {json}");
|
||||||
|
assert!(!json.contains("project_id"), "no snake_case leak: {json}");
|
||||||
|
|
||||||
|
let decoded: WindowStateSnapshot = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(decoded, snapshot);
|
||||||
|
}
|
||||||
|
|||||||
@ -96,8 +96,8 @@ pub use store::{
|
|||||||
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||||
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
||||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMemoryStore,
|
FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMemoryStore,
|
||||||
FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, HashEmbedder,
|
FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
|
||||||
IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall,
|
FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo,
|
||||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||||
VECTOR_ONNX_ENABLED,
|
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,6 +15,7 @@ mod project;
|
|||||||
mod skill;
|
mod skill;
|
||||||
mod template;
|
mod template;
|
||||||
mod vector;
|
mod vector;
|
||||||
|
mod window_state;
|
||||||
|
|
||||||
pub use background_task::{BackgroundTaskReconcileReport, FsBackgroundTaskStore};
|
pub use background_task::{BackgroundTaskReconcileReport, FsBackgroundTaskStore};
|
||||||
pub use context::IdeaiContextStore;
|
pub use context::IdeaiContextStore;
|
||||||
@ -35,3 +36,4 @@ pub use project::FsProjectStore;
|
|||||||
pub use skill::FsSkillStore;
|
pub use skill::FsSkillStore;
|
||||||
pub use template::FsTemplateStore;
|
pub use template::FsTemplateStore;
|
||||||
pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall};
|
pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall};
|
||||||
|
pub use window_state::FsWindowStateStore;
|
||||||
|
|||||||
68
crates/infrastructure/src/store/window_state.rs
Normal file
68
crates/infrastructure/src/store/window_state.rs
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
//! JSON-file implementation of the [`WindowStateStore`] port.
|
||||||
|
//!
|
||||||
|
//! The snapshot is machine-local and lives under the app-data directory:
|
||||||
|
//! `<app_data_dir>/windows.json`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use domain::layout::WindowStateSnapshot;
|
||||||
|
use domain::ports::{FileSystem, RemotePath, StoreError, WindowStateStore};
|
||||||
|
|
||||||
|
/// File name of the persisted OS window-state snapshot.
|
||||||
|
const WINDOWS_FILE: &str = "windows.json";
|
||||||
|
|
||||||
|
/// JSON-file implementation of [`WindowStateStore`].
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FsWindowStateStore {
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
app_data_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsWindowStateStore {
|
||||||
|
/// Builds the store from an injected filesystem and app-data directory.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
fs,
|
||||||
|
app_data_dir: app_data_dir.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> RemotePath {
|
||||||
|
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||||
|
RemotePath::new(format!("{base}/{WINDOWS_FILE}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_dir(&self) -> Result<(), StoreError> {
|
||||||
|
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
||||||
|
self.fs
|
||||||
|
.create_dir_all(&dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WindowStateStore for FsWindowStateStore {
|
||||||
|
async fn save_window_state(&self, snapshot: &WindowStateSnapshot) -> Result<(), StoreError> {
|
||||||
|
self.ensure_dir().await?;
|
||||||
|
let bytes = serde_json::to_vec_pretty(snapshot)
|
||||||
|
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||||
|
self.fs
|
||||||
|
.write(&self.path(), &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_window_state(&self) -> Result<WindowStateSnapshot, StoreError> {
|
||||||
|
match self.fs.read(&self.path()).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||||
|
}
|
||||||
|
Err(domain::ports::FsError::NotFound(_)) => Ok(WindowStateSnapshot::default()),
|
||||||
|
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
crates/infrastructure/tests/window_state_store.rs
Normal file
60
crates/infrastructure/tests/window_state_store.rs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
//! Integration tests for [`FsWindowStateStore`] against a real temp directory.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::layout::{PersistedWindowKind, PersistedWindowState, WindowStateSnapshot};
|
||||||
|
use domain::ports::{FileSystem, WindowStateStore};
|
||||||
|
use infrastructure::{FsWindowStateStore, LocalFileSystem};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
fn new() -> Self {
|
||||||
|
let path = std::env::temp_dir().join(format!("idea-window-store-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_data_dir(&self) -> String {
|
||||||
|
self.0.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn window_state_save_then_load_roundtrips() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||||
|
let store = FsWindowStateStore::new(fs, tmp.app_data_dir());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.load_window_state().await.unwrap(),
|
||||||
|
WindowStateSnapshot::default()
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot = WindowStateSnapshot::new(vec![PersistedWindowState {
|
||||||
|
label: "main".to_owned(),
|
||||||
|
kind: PersistedWindowKind::Main,
|
||||||
|
panel: None,
|
||||||
|
project_id: None,
|
||||||
|
url: None,
|
||||||
|
visible: true,
|
||||||
|
maximized: true,
|
||||||
|
fullscreen: false,
|
||||||
|
outer_position: None,
|
||||||
|
outer_size: None,
|
||||||
|
monitor: None,
|
||||||
|
last_focused_at: None,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
store.save_window_state(&snapshot).await.unwrap();
|
||||||
|
assert_eq!(store.load_window_state().await.unwrap(), snapshot);
|
||||||
|
assert!(tmp.0.join("windows.json").exists());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user