|
|
|
|
@ -4,8 +4,9 @@
|
|
|
|
|
//! [`AppState`], map `Result<Output, AppError>` to `Result<ResponseDto,
|
|
|
|
|
//! ErrorDto>`. No business logic lives here.
|
|
|
|
|
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use tauri::ipc::Channel;
|
|
|
|
|
use tauri::State;
|
|
|
|
|
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
|
|
|
|
|
|
|
|
|
use crate::dto::DismissEmbedderSuggestionRequestDto;
|
|
|
|
|
use application::{
|
|
|
|
|
@ -2167,6 +2168,277 @@ pub async fn git_graph(
|
|
|
|
|
use crate::dto::{parse_tab_id, MoveTabResultDto};
|
|
|
|
|
use application::MoveTabToNewWindowInput;
|
|
|
|
|
|
|
|
|
|
/// Event emitted for detached view-window lifecycle changes.
|
|
|
|
|
pub const VIEW_WINDOW_LIFECYCLE_EVENT: &str = "view-window://lifecycle";
|
|
|
|
|
|
|
|
|
|
/// 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)`.
|
|
|
|
|
pub label: String,
|
|
|
|
|
/// App URL loaded by the panel-only window.
|
|
|
|
|
pub url: String,
|
|
|
|
|
/// Whether the command reused and focused an existing window.
|
|
|
|
|
pub already_open: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Response returned by `close_view_window`.
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct CloseViewWindowResponseDto {
|
|
|
|
|
/// Stable Tauri window label for this `(panel, projectId)`.
|
|
|
|
|
pub label: String,
|
|
|
|
|
/// `true` when a live window was found and close was requested.
|
|
|
|
|
pub closed: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Payload emitted on `view-window://lifecycle`.
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct ViewWindowLifecycleEventDto {
|
|
|
|
|
/// Lifecycle kind: `"opened"`, `"focused"` or `"closed"`.
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
enum ViewPanel {
|
|
|
|
|
Projects,
|
|
|
|
|
Context,
|
|
|
|
|
Work,
|
|
|
|
|
Tickets,
|
|
|
|
|
Agents,
|
|
|
|
|
Templates,
|
|
|
|
|
Skills,
|
|
|
|
|
Permissions,
|
|
|
|
|
Memory,
|
|
|
|
|
Git,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ViewPanel {
|
|
|
|
|
fn parse(raw: &str) -> Result<Self, ErrorDto> {
|
|
|
|
|
match raw {
|
|
|
|
|
"projects" => Ok(Self::Projects),
|
|
|
|
|
"context" => Ok(Self::Context),
|
|
|
|
|
"work" => Ok(Self::Work),
|
|
|
|
|
"tickets" => Ok(Self::Tickets),
|
|
|
|
|
"agents" => Ok(Self::Agents),
|
|
|
|
|
"templates" => Ok(Self::Templates),
|
|
|
|
|
"skills" => Ok(Self::Skills),
|
|
|
|
|
"permissions" => Ok(Self::Permissions),
|
|
|
|
|
"memory" => Ok(Self::Memory),
|
|
|
|
|
"git" => Ok(Self::Git),
|
|
|
|
|
_ => Err(ErrorDto {
|
|
|
|
|
code: "INVALID".to_owned(),
|
|
|
|
|
message: format!("invalid view panel: {raw}"),
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fn as_str(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Projects => "projects",
|
|
|
|
|
Self::Context => "context",
|
|
|
|
|
Self::Work => "work",
|
|
|
|
|
Self::Tickets => "tickets",
|
|
|
|
|
Self::Agents => "agents",
|
|
|
|
|
Self::Templates => "templates",
|
|
|
|
|
Self::Skills => "skills",
|
|
|
|
|
Self::Permissions => "permissions",
|
|
|
|
|
Self::Memory => "memory",
|
|
|
|
|
Self::Git => "git",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fn title(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Projects => "Projects",
|
|
|
|
|
Self::Context => "Project context",
|
|
|
|
|
Self::Work => "Work state",
|
|
|
|
|
Self::Tickets => "Tickets",
|
|
|
|
|
Self::Agents => "Agents",
|
|
|
|
|
Self::Templates => "Templates",
|
|
|
|
|
Self::Skills => "Skills",
|
|
|
|
|
Self::Permissions => "Permissions",
|
|
|
|
|
Self::Memory => "Memory",
|
|
|
|
|
Self::Git => "Git",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String {
|
|
|
|
|
format!("view-{}-{}", panel.as_str(), project_id.as_uuid().simple())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String {
|
|
|
|
|
format!("index.html?panel={}&project={}", panel.as_str(), project_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn emit_view_window_lifecycle(
|
|
|
|
|
app: &AppHandle,
|
|
|
|
|
kind: &'static str,
|
|
|
|
|
panel: ViewPanel,
|
|
|
|
|
project_id: domain::ProjectId,
|
|
|
|
|
label: &str,
|
|
|
|
|
) {
|
|
|
|
|
let _ = app.emit(
|
|
|
|
|
VIEW_WINDOW_LIFECYCLE_EVENT,
|
|
|
|
|
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.
|
|
|
|
|
///
|
|
|
|
|
/// 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.
|
|
|
|
|
///
|
|
|
|
|
/// # 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
|
|
|
|
|
/// 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);
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
return Ok(OpenViewWindowResponseDto {
|
|
|
|
|
label,
|
|
|
|
|
url,
|
|
|
|
|
already_open: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let window = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.clone().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)
|
|
|
|
|
.build()
|
|
|
|
|
.map_err(internal_window_error)?;
|
|
|
|
|
|
|
|
|
|
let event_app = app.clone();
|
|
|
|
|
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(&app, "opened", panel, project_id, &label);
|
|
|
|
|
|
|
|
|
|
Ok(OpenViewWindowResponseDto {
|
|
|
|
|
label,
|
|
|
|
|
url,
|
|
|
|
|
already_open: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `close_view_window` — request closing the detached OS window for one project view.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel or malformed project
|
|
|
|
|
/// id, `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 Some(window) = app.get_webview_window(&label) else {
|
|
|
|
|
return Ok(CloseViewWindowResponseDto {
|
|
|
|
|
label,
|
|
|
|
|
closed: false,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.close().map_err(internal_window_error)?;
|
|
|
|
|
Ok(CloseViewWindowResponseDto {
|
|
|
|
|
label,
|
|
|
|
|
closed: true,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn internal_window_error(error: impl std::fmt::Display) -> ErrorDto {
|
|
|
|
|
ErrorDto {
|
|
|
|
|
code: "INTERNAL".to_owned(),
|
|
|
|
|
message: error.to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn view_window_rejects_unknown_panel() {
|
|
|
|
|
let err = ViewPanel::parse("terminal").unwrap_err();
|
|
|
|
|
|
|
|
|
|
assert_eq!(err.code, "INVALID");
|
|
|
|
|
assert!(err.message.contains("invalid view panel: terminal"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn view_window_lifecycle_payload_is_camel_case() {
|
|
|
|
|
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("project_id"), "no snake_case leak: {json}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `move_tab_to_new_window` — detach a tab into a brand-new OS window.
|
|
|
|
|
///
|
|
|
|
|
/// Applies the workspace topology change (the tab is *moved*, not duplicated)
|
|
|
|
|
|