diff --git a/app/src-tauri/src/commands/file_viewer_commands.rs b/app/src-tauri/src/commands/file_viewer_commands.rs new file mode 100644 index 0000000..388c4ce --- /dev/null +++ b/app/src-tauri/src/commands/file_viewer_commands.rs @@ -0,0 +1,320 @@ +//! IPC for the terminal file viewer. Every command here is gated on the calling +//! window's label and reads its target from the registry — no path, no label, no +//! project id crosses IPC from a viewer window. See spec §6. + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine as _; +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager, State}; + +use crate::commands::file_commands::{ + fetch_container_file, require_running, validate_container_write_path, MAX_READ_BYTES, +}; +use crate::file_viewer::is_viewer_label; +use crate::file_viewer::poll::{poll_file, ViewerPoll}; +use crate::file_viewer::registry::{Location, ViewerRegistry, ViewerTarget, ViewerTargetState}; +use crate::file_viewer::resolve::{candidate_paths, probe_candidates}; +use crate::file_viewer::window::open_viewer_window; +use crate::file_viewer::write::{sha256_hex, write_file, MAX_WRITE_BYTES}; +use crate::models::Project; +use crate::AppState; + +pub const GOTO_EVENT: &str = "file-viewer-goto"; + +#[derive(Clone, Debug, Serialize)] +pub struct ViewerState { + pub project_id: String, + pub project_name: String, + pub raw_path: String, + pub state: ViewerTargetState, + pub initial: Location, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ViewerFile { + pub contents_base64: String, + pub truncated: bool, + pub size: u64, + pub hash: String, + pub editable: bool, + pub readonly_reason: Option, +} + +fn require_main(window_label: &str) -> Result<(), String> { + if window_label == "main" { + Ok(()) + } else { + Err("Only the main window can open files.".into()) + } +} + +fn require_viewer(window_label: &str) -> Result { + if is_viewer_label(window_label) { + Ok(window_label.to_string()) + } else { + Err("This command belongs to a file window.".into()) + } +} + +fn viewer_state_of(_label: &str, target: ViewerTarget) -> ViewerState { + ViewerState { + project_id: target.project_id, + project_name: target.project_name, + raw_path: target.raw_path, + state: target.state, + initial: target.initial, + } +} + +fn window_title(raw_path: &str, project_name: &str) -> String { + let base = raw_path.trim_end_matches('/').rsplit('/').next().unwrap_or(raw_path); + format!("{} — {}", base, project_name) +} + +/// Refuses a save payload before decoding it: base64 of at most +/// [`MAX_WRITE_BYTES`] is at most `4 * ceil(MAX_WRITE_BYTES / 3)` characters. +/// `write_file` enforces the cap on the decoded bytes too; this stops a +/// compromised viewer from making the app allocate and decode an arbitrarily +/// large string first. +fn check_encoded_len(encoded_len: usize) -> Result<(), String> { + if encoded_len > MAX_WRITE_BYTES.div_ceil(3) * 4 { + return Err("Files over 1 MiB are read-only in the viewer.".into()); + } + Ok(()) +} + +/// The caller's registry entry, or a sentence. +fn own_target( + window: &tauri::Window, + registry: &ViewerRegistry, +) -> Result<(String, ViewerTarget), String> { + let label = require_viewer(window.label())?; + let target = registry + .get(&label) + .ok_or_else(|| "This file window is no longer registered.".to_string())?; + Ok((label, target)) +} + +fn resolved_path(target: &ViewerTarget) -> Result { + match &target.state { + ViewerTargetState::Resolved { container_path } => Ok(container_path.clone()), + _ => Err("Choose a file first.".into()), + } +} + +/// The one place a viewer command looks up its project (P14). +fn project_of(state: &AppState, project_id: &str) -> Result { + state + .projects_store + .get(project_id) + .ok_or_else(|| "This project no longer exists.".to_string()) +} + +async fn running_container_of(project: &Project) -> Result { + let container_id = project.container_id.clone().ok_or_else(|| { + "Start the project before opening files — they live in its container.".to_string() + })?; + require_running(&container_id, "opening files").await?; + Ok(container_id) +} + +/// The container of the project a viewer window belongs to, if it is running. +async fn running_container_for(state: &AppState, target: &ViewerTarget) -> Result { + running_container_of(&project_of(state, &target.project_id)?).await +} + +// Nine parameters are fixed by the IPC contract (P10); four injected by Tauri. +#[allow(clippy::too_many_arguments)] +#[tauri::command] +pub async fn open_file_viewer( + project_id: String, + path: String, + line: Option, + col: Option, + end_line: Option, + window: tauri::Window, + app: AppHandle, + registry: State<'_, ViewerRegistry>, + state: State<'_, AppState>, +) -> Result<(), String> { + require_main(window.label())?; + let project = project_of(&state, &project_id)?; + let container_id = running_container_of(&project).await?; + + let mounts: Vec = project.paths.iter().map(|p| p.mount_name.clone()).collect(); + let candidates = candidate_paths(&path, &mounts)?; + let matches = probe_candidates(&container_id, &candidates).await?; + let initial = Location { line, col, end_line }; + + let target_state = match matches.len() { + 0 => ViewerTargetState::NotFound { tried: candidates }, + 1 => { + let container_path = matches[0].clone(); + if let Some(label) = registry.find_open(&project_id, &container_path) { + if let Some(existing) = app.get_webview_window(&label) { + let _ = existing.unminimize(); + let _ = existing.set_focus(); + let _ = app.emit_to(label.as_str(), GOTO_EVENT, initial); + return Ok(()); + } + // Registered but windowless: a stale entry. Drop it and open afresh. + registry.remove(&label); + } + ViewerTargetState::Resolved { container_path } + } + _ => ViewerTargetState::Choose { candidates: matches }, + }; + + let title = window_title(&path, &project.name); + let label = registry.reserve(ViewerTarget { + project_id, + project_name: project.name.clone(), + raw_path: path, + state: target_state, + initial, + })?; + if let Err(e) = open_viewer_window(&app, &label, &title) { + registry.remove(&label); + return Err(e); + } + Ok(()) +} + +#[tauri::command] +pub async fn viewer_get_state( + window: tauri::Window, + registry: State<'_, ViewerRegistry>, +) -> Result { + let (label, target) = own_target(&window, ®istry)?; + Ok(viewer_state_of(&label, target)) +} + +#[tauri::command] +pub async fn viewer_choose_file( + index: usize, + window: tauri::Window, + registry: State<'_, ViewerRegistry>, +) -> Result { + let (label, target) = own_target(&window, ®istry)?; + let chosen = match &target.state { + ViewerTargetState::Choose { candidates } => candidates + .get(index) + .cloned() + .ok_or_else(|| "That choice is no longer available.".to_string())?, + _ => return Err("This window is not choosing a file.".into()), + }; + let updated = + registry.set_state(&label, ViewerTargetState::Resolved { container_path: chosen })?; + Ok(viewer_state_of(&label, updated)) +} + +#[tauri::command] +pub async fn viewer_read_file( + max_bytes: u64, + window: tauri::Window, + registry: State<'_, ViewerRegistry>, + state: State<'_, AppState>, +) -> Result { + let (_label, target) = own_target(&window, ®istry)?; + let path = resolved_path(&target)?; + let container_id = running_container_for(&state, &target).await?; + let cap = max_bytes.clamp(1, MAX_READ_BYTES); + let fetched = fetch_container_file(&container_id, &path, cap).await?; + let (editable, readonly_reason) = match validate_container_write_path("File", &path) { + Ok(()) => (true, None), + Err(reason) => (false, Some(reason)), + }; + Ok(ViewerFile { + hash: sha256_hex(&fetched.bytes), + contents_base64: BASE64.encode(&fetched.bytes), + truncated: fetched.truncated, + size: fetched.size, + editable, + readonly_reason, + }) +} + +#[tauri::command] +pub async fn viewer_poll_file( + window: tauri::Window, + registry: State<'_, ViewerRegistry>, + state: State<'_, AppState>, +) -> Result { + let (_label, target) = own_target(&window, ®istry)?; + let path = resolved_path(&target)?; + let container_id = running_container_for(&state, &target).await?; + poll_file(&container_id, &path).await +} + +/// Errors from `write_file` pass through unchanged: the frontend matches the +/// `"conflict: "` and `"gone: "` prefixes, and anything else (a read-only file, +/// a full disk) is already a sentence it shows as is. +#[tauri::command] +pub async fn viewer_write_file( + contents_base64: String, + base_hash: String, + window: tauri::Window, + registry: State<'_, ViewerRegistry>, + state: State<'_, AppState>, +) -> Result { + let (_label, target) = own_target(&window, ®istry)?; + let path = resolved_path(&target)?; + validate_container_write_path("File", &path)?; + check_encoded_len(contents_base64.len())?; + let bytes = BASE64 + .decode(contents_base64.as_bytes()) + .map_err(|_| "The editor sent malformed content.".to_string())?; + let container_id = running_container_for(&state, &target).await?; + write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_is_main_only_and_viewer_commands_are_viewer_only() { + assert!(require_main("main").is_ok()); + assert!(require_main("file-viewer-1").is_err()); + assert!(require_main("browser-view-x").is_err()); + assert_eq!(require_viewer("file-viewer-7").unwrap(), "file-viewer-7"); + assert!(require_viewer("main").is_err()); + assert!(require_viewer("file-viewer-").is_err()); + } + + #[test] + fn the_title_is_basename_then_project() { + assert_eq!(window_title("app/src/lib/urlRelay.ts", "Triple-C"), "urlRelay.ts — Triple-C"); + assert_eq!(window_title("/workspace/x/README.md", "x"), "README.md — x"); + assert_eq!(window_title("Makefile", "p"), "Makefile — p"); + } + + #[test] + fn viewer_state_serialises_the_ipc_shape() { + let target = ViewerTarget { + project_id: "pid".into(), + project_name: "P".into(), + raw_path: "src/a.rs".into(), + state: ViewerTargetState::Resolved { container_path: "/workspace/p/src/a.rs".into() }, + initial: Location { line: Some(3), col: Some(2), end_line: None }, + }; + let json = serde_json::to_value(viewer_state_of("file-viewer-1", target)).unwrap(); + assert_eq!(json["project_id"], "pid"); + assert_eq!(json["state"]["kind"], "resolved"); + assert_eq!(json["state"]["container_path"], "/workspace/p/src/a.rs"); + assert_eq!(json["initial"]["line"], 3); + assert!(json["initial"]["end_line"].is_null()); + } + + #[test] + fn the_encoded_length_is_capped_before_decoding() { + let at_cap = BASE64.encode(vec![0u8; MAX_WRITE_BYTES]); + assert!(check_encoded_len(at_cap.len()).is_ok()); + // MAX + 1 and MAX + 2 bytes pad to the same length as MAX; `write_file`'s + // decoded check refuses those. The first size this bound itself refuses: + let over_cap = BASE64.encode(vec![0u8; MAX_WRITE_BYTES + 3]); + assert!(check_encoded_len(over_cap.len()).is_err()); + assert!(check_encoded_len(at_cap.len() + 1).is_err()); + assert!(check_encoded_len(0).is_ok()); + } +} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 6ba4199..f58af28 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod auth_token_commands; pub mod aws_commands; pub mod docker_commands; pub mod file_commands; +pub mod file_viewer_commands; pub mod gateway_commands; pub mod help_commands; pub mod inspect_commands; diff --git a/app/src-tauri/src/file_viewer/window.rs b/app/src-tauri/src/file_viewer/window.rs index 9482e55..26786a8 100644 --- a/app/src-tauri/src/file_viewer/window.rs +++ b/app/src-tauri/src/file_viewer/window.rs @@ -1 +1,27 @@ -//! Filled in by Task N. +//! The viewer window itself. Mirrors `browser_view/popout.rs`, with two differences: +//! the URL is the app's own second entry (`WebviewUrl::App`), so the capability in +//! `capabilities/file-viewer.json` applies; and the registry entry is removed on +//! `Destroyed`, which fires for both the X button (after JS calls `destroy()`) and a +//! Rust-side `destroy()`. + +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent}; + +use super::registry::ViewerRegistry; + +pub fn open_viewer_window(app: &AppHandle, label: &str, title: &str) -> Result<(), String> { + let window = WebviewWindowBuilder::new(app, label, WebviewUrl::App("viewer.html".into())) + .title(title) + .inner_size(900.0, 700.0) + .min_inner_size(480.0, 320.0) + .build() + .map_err(|e| format!("Could not open the file window: {}", e))?; + + let app_for_event = app.clone(); + let label_owned = label.to_string(); + window.on_window_event(move |event| { + if let WindowEvent::Destroyed = event { + app_for_event.state::().remove(&label_owned); + } + }); + Ok(()) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 143b50b..9867e0e 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -241,6 +241,7 @@ pub fn run() { lifecycle, pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)), }) + .manage(file_viewer::registry::ViewerRegistry::default()) .setup(move |app| { match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) { Ok(icon) => { @@ -546,6 +547,13 @@ pub fn run() { commands::file_commands::read_container_file, commands::file_commands::rename_container_path, commands::file_commands::create_container_directory, + // Terminal file viewer + commands::file_viewer_commands::open_file_viewer, + commands::file_viewer_commands::viewer_get_state, + commands::file_viewer_commands::viewer_choose_file, + commands::file_viewer_commands::viewer_read_file, + commands::file_viewer_commands::viewer_poll_file, + commands::file_viewer_commands::viewer_write_file, // AWS commands::aws_commands::aws_sso_refresh, // Updates