Terminal file viewer/editor + per-window app-command lockdown #60
@@ -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()
|
||||
|
||||
@@ -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 commands;
|
||||
mod docker;
|
||||
pub mod file_viewer;
|
||||
mod install_helper;
|
||||
mod logging;
|
||||
mod models;
|
||||
|
||||
@@ -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<boolean>("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<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;
|
||||
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