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:
@@ -46,7 +46,7 @@ pub struct FileContents {
|
|||||||
/// Hard ceiling on a single viewer read, whatever the caller asks for. The tar
|
/// 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
|
/// path buffers the whole payload in host RAM, so a caller-supplied cap is not
|
||||||
/// something to take on trust.
|
/// 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]
|
#[tauri::command]
|
||||||
pub async fn list_container_files(
|
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
|
/// `what` names the parameter in the error, because these messages are shown to
|
||||||
/// a user who is looking at a folder, not at argv.
|
/// 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() {
|
if path.is_empty() {
|
||||||
return Err(format!("{} path cannot be empty", what));
|
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
|
/// 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
|
/// named in the refusal are the roots it writes to — and that a mis-aimed drop
|
||||||
/// cannot quietly land outside them.
|
/// 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)?;
|
validate_container_path(what, path)?;
|
||||||
if CONTAINER_WRITE_ROOTS
|
if CONTAINER_WRITE_ROOTS
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1178,12 +1178,12 @@ fn push_capped(buf: &mut String, frame: &[u8]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One regular file's bytes, pulled out of a container.
|
/// One regular file's bytes, pulled out of a container.
|
||||||
struct FetchedFile {
|
pub(crate) struct FetchedFile {
|
||||||
bytes: Vec<u8>,
|
pub(crate) bytes: Vec<u8>,
|
||||||
/// The size the tar header declared, i.e. the file's real size — which is
|
/// 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.
|
/// not `bytes.len()` once `max_bytes` has cut the read short.
|
||||||
size: u64,
|
pub(crate) size: u64,
|
||||||
truncated: bool,
|
pub(crate) truncated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a single regular file from a container as exact bytes.
|
/// 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
|
/// 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
|
/// read — landed in host RAM twice. This function buffers, so every caller of
|
||||||
/// it must name a ceiling.
|
/// it must name a ceiling.
|
||||||
async fn fetch_container_file(
|
pub(crate) async fn fetch_container_file(
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
container_path: &str,
|
container_path: &str,
|
||||||
max_bytes: u64,
|
max_bytes: u64,
|
||||||
@@ -1456,7 +1456,7 @@ pub async fn create_container_directory(
|
|||||||
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
|
/// 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
|
/// is the first thing to touch the container, so a stopped project fails inside
|
||||||
/// path *validation* and reads like the path was the problem.
|
/// 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 docker = get_docker()?;
|
||||||
let running = docker
|
let running = docker
|
||||||
.inspect_container(container_id, None)
|
.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
|
/// 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
|
/// this text ends up inside a toast that renders above every modal, and its
|
||||||
/// author is the container.
|
/// author is the container.
|
||||||
fn clip_container_text(text: &str) -> String {
|
pub(crate) fn clip_container_text(text: &str) -> String {
|
||||||
const MAX: usize = 200;
|
const MAX: usize = 200;
|
||||||
let flattened: String = text
|
let flattened: String = text
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
//! Filled in by Task N.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
//! Filled in by Task N.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
//! Filled in by Task N.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
//! Filled in by Task N.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
//! Filled in by Task N.
|
||||||
@@ -2,6 +2,7 @@ mod auth_bridge;
|
|||||||
mod browser_view;
|
mod browser_view;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod docker;
|
mod docker;
|
||||||
|
pub mod file_viewer;
|
||||||
mod install_helper;
|
mod install_helper;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod models;
|
mod models;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note } from "./types";
|
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerState } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -413,3 +413,22 @@ export const getMigrationState = (projectId: string) =>
|
|||||||
* Rejects with a string already phrased for a toast. */
|
* Rejects with a string already phrased for a toast. */
|
||||||
export const openUrlExternal = (url: string) =>
|
export const openUrlExternal = (url: string) =>
|
||||||
invoke<void>("open_url_external", { url });
|
invoke<void>("open_url_external", { url });
|
||||||
|
|
||||||
|
// ---- Terminal file viewer ----
|
||||||
|
|
||||||
|
export const openFileViewer = (
|
||||||
|
projectId: string,
|
||||||
|
path: string,
|
||||||
|
line?: number,
|
||||||
|
col?: number,
|
||||||
|
endLine?: number,
|
||||||
|
) => invoke<void>("open_file_viewer", { projectId, path, line, col, endLine });
|
||||||
|
|
||||||
|
export const viewerGetState = () => invoke<ViewerState>("viewer_get_state");
|
||||||
|
export const viewerReadFile = (maxBytes: number) =>
|
||||||
|
invoke<ViewerFile>("viewer_read_file", { maxBytes });
|
||||||
|
export const viewerPollFile = () => invoke<ViewerPoll>("viewer_poll_file");
|
||||||
|
export const viewerWriteFile = (contentsBase64: string, baseHash: string) =>
|
||||||
|
invoke<string>("viewer_write_file", { contentsBase64, baseHash });
|
||||||
|
export const viewerChooseFile = (index: number) =>
|
||||||
|
invoke<ViewerState>("viewer_choose_file", { index });
|
||||||
|
|||||||
@@ -954,3 +954,41 @@ export interface MigrationState {
|
|||||||
options: MigrationOptions;
|
options: MigrationOptions;
|
||||||
plan: MigrationPlan | null;
|
plan: MigrationPlan | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Terminal file viewer (commands/file_viewer_commands.rs) ----
|
||||||
|
|
||||||
|
export interface ViewerLocation {
|
||||||
|
line: number | null;
|
||||||
|
col: number | null;
|
||||||
|
end_line: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewerTargetState =
|
||||||
|
| { kind: "resolved"; container_path: string }
|
||||||
|
| { kind: "choose"; candidates: string[] }
|
||||||
|
| { kind: "not_found"; tried: string[] };
|
||||||
|
|
||||||
|
export interface ViewerState {
|
||||||
|
project_id: string;
|
||||||
|
project_name: string;
|
||||||
|
/** What was clicked, for the title and the not-found message. */
|
||||||
|
raw_path: string;
|
||||||
|
state: ViewerTargetState;
|
||||||
|
initial: ViewerLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewerFile {
|
||||||
|
contents_base64: string;
|
||||||
|
truncated: boolean;
|
||||||
|
size: number;
|
||||||
|
/** SHA-256 hex of the returned bytes; equals the file's hash when `truncated` is false. */
|
||||||
|
hash: string;
|
||||||
|
editable: boolean;
|
||||||
|
readonly_reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewerPoll {
|
||||||
|
exists: boolean;
|
||||||
|
hash: string | null;
|
||||||
|
size: number | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user