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}