feat(viewer): IPC types, wrappers and Rust module skeleton for the file viewer

Task 0 of the terminal file viewer plan: the shared interfaces that seven
later tasks build against in parallel. Adds ViewerLocation/ViewerTargetState/
ViewerState/ViewerFile/ViewerPoll to types.ts and their invoke() wrappers to
tauri-commands.ts, creates the file_viewer Rust module (mod.rs with
MAX_VIEWER_WINDOWS/VIEWER_LABEL_PREFIX/is_viewer_label, plus placeholder
registry/resolve/poll/write/window submodules), wires it into lib.rs, and
loosens visibility on the file_commands.rs helpers the viewer commands will
reuse (MAX_READ_BYTES, validate_container_path, validate_container_write_path,
FetchedFile, fetch_container_file, require_running, clip_container_text).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 20:56:33 -07:00
co-authored by Claude Opus 5.5
parent 20befa1320
commit 3c1d120305
10 changed files with 110 additions and 11 deletions
+10 -10
View File
@@ -46,7 +46,7 @@ pub struct FileContents {
/// Hard ceiling on a single viewer read, whatever the caller asks for. The tar
/// path buffers the whole payload in host RAM, so a caller-supplied cap is not
/// something to take on trust.
const MAX_READ_BYTES: u64 = 8 * 1024 * 1024;
pub(crate) const MAX_READ_BYTES: u64 = 8 * 1024 * 1024;
#[tauri::command]
pub async fn list_container_files(
@@ -352,7 +352,7 @@ const CONTAINER_WRITE_ROOTS: &[&str] = &["/workspace", "/home/claude", "/tmp"];
///
/// `what` names the parameter in the error, because these messages are shown to
/// a user who is looking at a folder, not at argv.
fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
pub(crate) fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
if path.is_empty() {
return Err(format!("{} path cannot be empty", what));
}
@@ -394,7 +394,7 @@ fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
/// directly. What it buys is that the *panel* keeps its promise — the roots
/// named in the refusal are the roots it writes to — and that a mis-aimed drop
/// cannot quietly land outside them.
fn validate_container_write_path(what: &str, path: &str) -> Result<(), String> {
pub(crate) fn validate_container_write_path(what: &str, path: &str) -> Result<(), String> {
validate_container_path(what, path)?;
if CONTAINER_WRITE_ROOTS
.iter()
@@ -1178,12 +1178,12 @@ fn push_capped(buf: &mut String, frame: &[u8]) {
}
/// One regular file's bytes, pulled out of a container.
struct FetchedFile {
bytes: Vec<u8>,
pub(crate) struct FetchedFile {
pub(crate) bytes: Vec<u8>,
/// The size the tar header declared, i.e. the file's real size — which is
/// not `bytes.len()` once `max_bytes` has cut the read short.
size: u64,
truncated: bool,
pub(crate) size: u64,
pub(crate) truncated: bool,
}
/// Fetch a single regular file from a container as exact bytes.
@@ -1202,7 +1202,7 @@ struct FetchedFile {
/// file — or the whole *directory tree*, since the type check happens after the
/// read — landed in host RAM twice. This function buffers, so every caller of
/// it must name a ceiling.
async fn fetch_container_file(
pub(crate) async fn fetch_container_file(
container_id: &str,
container_path: &str,
max_bytes: u64,
@@ -1456,7 +1456,7 @@ pub async fn create_container_directory(
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
/// is the first thing to touch the container, so a stopped project fails inside
/// path *validation* and reads like the path was the problem.
async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
let docker = get_docker()?;
let running = docker
.inspect_container(container_id, None)
@@ -2011,7 +2011,7 @@ async fn upload_one(
/// call site for why each of those three matters; the short version is that
/// this text ends up inside a toast that renders above every modal, and its
/// author is the container.
fn clip_container_text(text: &str) -> String {
pub(crate) fn clip_container_text(text: &str) -> String {
const MAX: usize = 200;
let flattened: String = text
.trim()
+36
View File
@@ -0,0 +1,36 @@
//! The terminal file viewer: one OS window per clicked path.
//!
//! Every window is a `file-viewer-<n>` label registered in [`registry::ViewerRegistry`];
//! the commands in `commands/file_viewer_commands.rs` gate on the label and act only on
//! the caller's own entry, which is why nothing here takes a path from a window.
pub mod poll;
pub mod registry;
pub mod resolve;
pub mod window;
pub mod write;
/// Spec §3: the 21st click is refused with a toast.
pub const MAX_VIEWER_WINDOWS: usize = 20;
pub const VIEWER_LABEL_PREFIX: &str = "file-viewer-";
pub fn is_viewer_label(label: &str) -> bool {
label
.strip_prefix(VIEWER_LABEL_PREFIX)
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_numbered_viewer_labels_pass() {
assert!(is_viewer_label("file-viewer-1"));
assert!(is_viewer_label("file-viewer-20"));
assert!(!is_viewer_label("file-viewer-"));
assert!(!is_viewer_label("file-viewer-x"));
assert!(!is_viewer_label("main"));
assert!(!is_viewer_label("browser-view-abc"));
}
}
+1
View File
@@ -0,0 +1 @@
//! Filled in by Task N.
@@ -0,0 +1 @@
//! Filled in by Task N.
+1
View File
@@ -0,0 +1 @@
//! Filled in by Task N.
+1
View File
@@ -0,0 +1 @@
//! Filled in by Task N.
+1
View File
@@ -0,0 +1 @@
//! Filled in by Task N.
+1
View File
@@ -2,6 +2,7 @@ mod auth_bridge;
mod browser_view;
mod commands;
mod docker;
pub mod file_viewer;
mod install_helper;
mod logging;
mod models;