Skip to main content

qcode/ui/workspace/
files.rs

1//! The workspace's files: the file manager of the side panel, the framework's own, bound to a
2//! workspace, and reading a document of the workspace for a tab that shows it.
3//!
4//! The manager's state lives in each open workspace. Its messages carry the workspace's id, so an
5//! answer from the background (a folder read, an operation done, a change seen on disk) reaches
6//! the workspace it was for, even after another one was opened.
7
8use std::path::Path;
9
10use qframe::prelude::*;
11use qframe::widgets::FileManagerMsg;
12
13use super::{Msg, OpenWorkspace, WorkspaceScreen};
14
15/// Turns the file manager's messages into the screen's own, for the workspace `id`.
16pub(super) fn wrap(id: &str) -> impl Fn(FileManagerMsg) -> Msg + Send + Sync + Clone + 'static {
17    let id = id.to_owned();
18    move |message| Msg::Files(id.clone(), message)
19}
20
21/// Hands a message of the file manager to the workspace `id`; one for a workspace that has been
22/// closed meanwhile changes nothing.
23pub(super) fn update(screen: &mut WorkspaceScreen, id: &str, message: FileManagerMsg) -> Command<Msg> {
24    // The person is working in the tree, so a tab still waiting for its container is owed nothing;
25    // answers from the background are not the person and leave the debt standing.
26    let answer = matches!(
27        message,
28        FileManagerMsg::Read(..)
29            | FileManagerMsg::Listed(..)
30            | FileManagerMsg::Work(_)
31            | FileManagerMsg::Done(_)
32            | FileManagerMsg::Refresh
33            | FileManagerMsg::DropCut
34    );
35    if !answer {
36        screen.owed_focus = None;
37    }
38    let Some(workspace) = screen.workspace_mut(id) else { return Command::none() };
39    workspace.files.update(message, wrap(id))
40}
41
42/// Reads the workspace folder the first time, and every folder the tree shows again after that:
43/// the files may have changed while the screen was away.
44pub(super) fn load(workspace: &mut OpenWorkspace) -> Command<Msg> {
45    let wrap = wrap(workspace.id());
46    workspace.files.load(wrap)
47}
48
49/// Reads again every folder the tree shows, after something outside the manager changed the
50/// workspace folder, such as a restore from a backup.
51pub(super) fn refresh(workspace: &mut OpenWorkspace) -> Command<Msg> {
52    let wrap = wrap(workspace.id());
53    workspace.files.update(FileManagerMsg::Refresh, wrap)
54}
55
56/// The key of the entry at `path` under the workspace folder `root`, the way the tabs and the
57/// session name a file: the folders below the root joined by `/`.
58///
59/// A path that is not under the root has no key, and gives an empty one, which
60/// [`is_inside`] refuses wherever a key becomes a path.
61#[must_use]
62pub(super) fn key_of(root: &Path, path: &Path) -> String {
63    let Ok(below) = path.strip_prefix(root) else { return String::new() };
64    below.components().map(|part| part.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join("/")
65}
66
67/// Whether `key` names an entry inside the tree's root: a path of plain names, none of them empty,
68/// `.` or `..`, and not starting at `/`.
69///
70/// Keys come from the tree itself, which only ever joins the names a folder listed, but they also
71/// come back from the session file, which anyone can edit. A key that climbs out of the workspace
72/// would open a file of the host in a tab, or hand the container a path outside the one folder it
73/// was given, so it is refused wherever a key becomes a path.
74#[must_use]
75pub fn is_inside(key: &str) -> bool {
76    !key.is_empty() && !key.contains('\0') && key.split('/').all(|part| !part.is_empty() && part != "." && part != "..")
77}
78
79/// The name of the entry `key`, without the folders before it.
80#[must_use]
81pub fn name(key: &str) -> &str {
82    key.rsplit('/').next().unwrap_or(key)
83}
84
85/// Why a document of the workspace could not be read.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum DocumentTrouble {
88    /// There is no file there any more.
89    Missing,
90    /// The file is a link that leads out of the workspace folder.
91    Outside,
92    /// What the operating system said.
93    Unreadable(String),
94}
95
96/// Reads the text of the file at `path`, which must lie inside `root` once every link on the way
97/// is followed.
98///
99/// The key was checked already; what is checked here is the one thing a key cannot show: a link
100/// in the workspace that points somewhere else on this machine. Text that is not UTF-8 is shown
101/// with its broken bytes replaced rather than not at all.
102///
103/// This touches the disk, so it belongs on a background thread.
104///
105/// # Errors
106///
107/// When the file is gone, leads out of `root`, or cannot be read.
108pub fn read_document(path: &Path, root: &Path) -> Result<String, DocumentTrouble> {
109    let trouble = |error: std::io::Error| match error.kind() {
110        std::io::ErrorKind::NotFound => DocumentTrouble::Missing,
111        _ => DocumentTrouble::Unreadable(error.to_string()),
112    };
113    let real = path.canonicalize().map_err(trouble)?;
114    let root = root.canonicalize().map_err(trouble)?;
115    if !real.starts_with(&root) {
116        return Err(DocumentTrouble::Outside);
117    }
118    let bytes = std::fs::read(&real).map_err(trouble)?;
119    Ok(String::from_utf8_lossy(&bytes).into_owned())
120}