feat(viewer): label-gated viewer commands and window creation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<Project, String> {
|
||||||
|
state
|
||||||
|
.projects_store
|
||||||
|
.get(project_id)
|
||||||
|
.ok_or_else(|| "This project no longer exists.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn running_container_of(project: &Project) -> Result<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<u32>,
|
||||||
|
col: Option<u32>,
|
||||||
|
end_line: Option<u32>,
|
||||||
|
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<String> = 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<ViewerState, String> {
|
||||||
|
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<ViewerState, String> {
|
||||||
|
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<ViewerFile, String> {
|
||||||
|
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<ViewerPoll, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod auth_token_commands;
|
|||||||
pub mod aws_commands;
|
pub mod aws_commands;
|
||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
pub mod file_commands;
|
pub mod file_commands;
|
||||||
|
pub mod file_viewer_commands;
|
||||||
pub mod gateway_commands;
|
pub mod gateway_commands;
|
||||||
pub mod help_commands;
|
pub mod help_commands;
|
||||||
pub mod inspect_commands;
|
pub mod inspect_commands;
|
||||||
|
|||||||
@@ -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::<ViewerRegistry>().remove(&label_owned);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ pub fn run() {
|
|||||||
lifecycle,
|
lifecycle,
|
||||||
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
|
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
})
|
})
|
||||||
|
.manage(file_viewer::registry::ViewerRegistry::default())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
Ok(icon) => {
|
Ok(icon) => {
|
||||||
@@ -546,6 +547,13 @@ pub fn run() {
|
|||||||
commands::file_commands::read_container_file,
|
commands::file_commands::read_container_file,
|
||||||
commands::file_commands::rename_container_path,
|
commands::file_commands::rename_container_path,
|
||||||
commands::file_commands::create_container_directory,
|
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
|
// AWS
|
||||||
commands::aws_commands::aws_sso_refresh,
|
commands::aws_commands::aws_sso_refresh,
|
||||||
// Updates
|
// Updates
|
||||||
|
|||||||
Reference in New Issue
Block a user