diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index 779c3cf..6d1ab0c 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -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, +pub(crate) struct FetchedFile { + pub(crate) bytes: Vec, /// 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() diff --git a/app/src-tauri/src/file_viewer/mod.rs b/app/src-tauri/src/file_viewer/mod.rs new file mode 100644 index 0000000..dd430ba --- /dev/null +++ b/app/src-tauri/src/file_viewer/mod.rs @@ -0,0 +1,36 @@ +//! The terminal file viewer: one OS window per clicked path. +//! +//! Every window is a `file-viewer-` 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")); + } +} diff --git a/app/src-tauri/src/file_viewer/poll.rs b/app/src-tauri/src/file_viewer/poll.rs new file mode 100644 index 0000000..9482e55 --- /dev/null +++ b/app/src-tauri/src/file_viewer/poll.rs @@ -0,0 +1 @@ +//! Filled in by Task N. diff --git a/app/src-tauri/src/file_viewer/registry.rs b/app/src-tauri/src/file_viewer/registry.rs new file mode 100644 index 0000000..9482e55 --- /dev/null +++ b/app/src-tauri/src/file_viewer/registry.rs @@ -0,0 +1 @@ +//! Filled in by Task N. diff --git a/app/src-tauri/src/file_viewer/resolve.rs b/app/src-tauri/src/file_viewer/resolve.rs new file mode 100644 index 0000000..9482e55 --- /dev/null +++ b/app/src-tauri/src/file_viewer/resolve.rs @@ -0,0 +1 @@ +//! Filled in by Task N. diff --git a/app/src-tauri/src/file_viewer/window.rs b/app/src-tauri/src/file_viewer/window.rs new file mode 100644 index 0000000..9482e55 --- /dev/null +++ b/app/src-tauri/src/file_viewer/window.rs @@ -0,0 +1 @@ +//! Filled in by Task N. diff --git a/app/src-tauri/src/file_viewer/write.rs b/app/src-tauri/src/file_viewer/write.rs new file mode 100644 index 0000000..9482e55 --- /dev/null +++ b/app/src-tauri/src/file_viewer/write.rs @@ -0,0 +1 @@ +//! Filled in by Task N. diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 3e38a2e..143b50b 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -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; diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index a3ea226..298e439 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ 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 export const checkDocker = () => invoke("check_docker"); @@ -413,3 +413,22 @@ export const getMigrationState = (projectId: string) => * Rejects with a string already phrased for a toast. */ export const openUrlExternal = (url: string) => invoke("open_url_external", { url }); + +// ---- Terminal file viewer ---- + +export const openFileViewer = ( + projectId: string, + path: string, + line?: number, + col?: number, + endLine?: number, +) => invoke("open_file_viewer", { projectId, path, line, col, endLine }); + +export const viewerGetState = () => invoke("viewer_get_state"); +export const viewerReadFile = (maxBytes: number) => + invoke("viewer_read_file", { maxBytes }); +export const viewerPollFile = () => invoke("viewer_poll_file"); +export const viewerWriteFile = (contentsBase64: string, baseHash: string) => + invoke("viewer_write_file", { contentsBase64, baseHash }); +export const viewerChooseFile = (index: number) => + invoke("viewer_choose_file", { index }); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index f332171..f6fb9fa 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -954,3 +954,41 @@ export interface MigrationState { options: MigrationOptions; 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; +}