mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 08:52:26 +00:00
Merge main into feat/script-include-directives
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
//! The Scripts folder: the `.js` and `.jsx` files File › Scripts lists, with each subfolder as a
|
||||
//! submenu ([`list`]), and the read of one of them when it is chosen ([`read`]).
|
||||
//!
|
||||
//! A listing reads names and file types only, never a script's text. It follows no link: a symbolic
|
||||
//! link or a Windows junction is left out, whether it is a file, a folder or the Scripts folder
|
||||
//! itself, so a listed script is a plain file in plain folders under the Scripts folder. Names that
|
||||
//! start with a dot, files and folders Windows marks hidden or system, names that are not Unicode
|
||||
//! and names longer than [`MAX_NAME_BYTES`] are left out too, and so is a folder with no script in
|
||||
//! it.
|
||||
//!
|
||||
//! The values here are VectorCraft's own limits: [`MAX_ENTRIES`], [`MAX_SCRIPTS`], [`MAX_FOLDERS`],
|
||||
//! [`MAX_DEPTH`], [`MAX_NAME_BYTES`], [`MAX_LABEL_CHARS`] and [`MAX_SCAN`]. A listing that reaches
|
||||
//! one of them, or that cannot read a subfolder or an entry of a folder, keeps what it found and
|
||||
//! says so ([`Listing::truncated`]).
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cmd::scripts::runs;
|
||||
|
||||
/// The most folder entries one listing looks at, in the Scripts folder and its subfolders together.
|
||||
pub const MAX_ENTRIES: usize = 2048;
|
||||
/// The most scripts one listing holds.
|
||||
pub const MAX_SCRIPTS: usize = 512;
|
||||
/// The most subfolders one listing reads.
|
||||
pub const MAX_FOLDERS: usize = 128;
|
||||
/// How many levels of subfolders a listing reads below the Scripts folder.
|
||||
pub const MAX_DEPTH: usize = 4;
|
||||
/// The longest file or folder name a listing takes, in UTF-8 bytes.
|
||||
pub const MAX_NAME_BYTES: usize = 255;
|
||||
/// The most characters of a label; a longer one ends in `…`.
|
||||
pub const MAX_LABEL_CHARS: usize = 128;
|
||||
/// How long one listing may take before it stops with what it found.
|
||||
pub const MAX_SCAN: Duration = Duration::from_millis(250);
|
||||
|
||||
/// The limits of one listing.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
pub entries: usize,
|
||||
pub scripts: usize,
|
||||
pub folders: usize,
|
||||
pub depth: usize,
|
||||
pub time: Duration,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits { entries: MAX_ENTRIES, scripts: MAX_SCRIPTS, folders: MAX_FOLDERS, depth: MAX_DEPTH, time: MAX_SCAN }
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of a listing.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Entry {
|
||||
/// A script: its label (the file name without its extension) and the file's path as the folder
|
||||
/// gave it.
|
||||
Script { label: String, path: PathBuf },
|
||||
/// A subfolder with at least one script in it or below it: its label (the folder's name) and
|
||||
/// its rows.
|
||||
Folder { label: String, entries: Vec<Entry> },
|
||||
}
|
||||
|
||||
/// What a Scripts folder holds.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Listing {
|
||||
/// The rows of the folder itself, sorted ([`list_with`]).
|
||||
pub entries: Vec<Entry>,
|
||||
/// Some scripts may be missing: a limit was reached or a subfolder could not be read.
|
||||
pub truncated: bool,
|
||||
/// The folder is there and could not be read, or is a link.
|
||||
pub unreadable: bool,
|
||||
}
|
||||
|
||||
impl Listing {
|
||||
/// Every script with the labels of the folders it is in, in menu order.
|
||||
pub fn scripts(&self) -> Vec<(Vec<&str>, &str, &Path)> {
|
||||
fn walk<'a>(entries: &'a [Entry], folders: &mut Vec<&'a str>, out: &mut Vec<(Vec<&'a str>, &'a str, &'a Path)>) {
|
||||
for e in entries {
|
||||
match e {
|
||||
Entry::Script { label, path } => out.push((folders.clone(), label, path)),
|
||||
Entry::Folder { label, entries } => {
|
||||
folders.push(label);
|
||||
walk(entries, folders, out);
|
||||
folders.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
walk(&self.entries, &mut vec![], &mut out);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// What the folder `root` holds, within the default [`Limits`]. A folder that does not exist gives
|
||||
/// an empty listing.
|
||||
pub fn list(root: &Path) -> Listing {
|
||||
let start = std::time::Instant::now();
|
||||
list_with(root, &Limits::default(), &mut || start.elapsed())
|
||||
}
|
||||
|
||||
/// What the folder `root` holds, within `limits`; `elapsed` says how long the listing has taken.
|
||||
/// Each level is sorted by label without regard to case, then by label, then by file name, with
|
||||
/// scripts and subfolders mixed. Two rows of one level never share a label unless both were cut:
|
||||
/// a script whose label another row has shows its whole file name.
|
||||
pub fn list_with(root: &Path, limits: &Limits, elapsed: &mut dyn FnMut() -> Duration) -> Listing {
|
||||
let unreadable = Listing { unreadable: true, ..Listing::default() };
|
||||
match std::fs::symlink_metadata(root) {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Listing::default(),
|
||||
Err(_) => return unreadable,
|
||||
Ok(m) if m.file_type().is_symlink() || !m.is_dir() => return unreadable,
|
||||
Ok(_) => {}
|
||||
}
|
||||
// Paths leave as text (menu params, `script.listInstalled`).
|
||||
if root.to_str().is_none() {
|
||||
return unreadable;
|
||||
}
|
||||
let mut scan = Scan { limits, elapsed, visited: 0, scripts: 0, folders: 0, truncated: false, stopped: false };
|
||||
match scan.level(root, 0) {
|
||||
Some(entries) => Listing { entries, truncated: scan.truncated, unreadable: false },
|
||||
None => unreadable,
|
||||
}
|
||||
}
|
||||
|
||||
/// A script or subfolder found in one folder, before the subfolders are read.
|
||||
struct Found {
|
||||
name: String,
|
||||
label: String,
|
||||
folder: bool,
|
||||
}
|
||||
|
||||
struct Scan<'a> {
|
||||
limits: &'a Limits,
|
||||
elapsed: &'a mut dyn FnMut() -> Duration,
|
||||
visited: usize,
|
||||
scripts: usize,
|
||||
folders: usize,
|
||||
truncated: bool,
|
||||
/// The entry or time limit was reached: nothing more is read.
|
||||
stopped: bool,
|
||||
}
|
||||
|
||||
impl Scan<'_> {
|
||||
/// The rows of the folder `dir`, which is `depth` levels below the Scripts folder; `None` when
|
||||
/// it cannot be read.
|
||||
fn level(&mut self, dir: &Path, depth: usize) -> Option<Vec<Entry>> {
|
||||
let mut found: Vec<Found> = vec![];
|
||||
for entry in std::fs::read_dir(dir).ok()? {
|
||||
if self.visited >= self.limits.entries || (self.elapsed)() > self.limits.time {
|
||||
(self.truncated, self.stopped) = (true, true);
|
||||
break;
|
||||
}
|
||||
self.visited += 1;
|
||||
let Ok(entry) = entry else {
|
||||
self.truncated = true;
|
||||
continue;
|
||||
};
|
||||
let Ok(name) = entry.file_name().into_string() else { continue };
|
||||
let Ok(kind) = entry.file_type() else {
|
||||
self.truncated = true;
|
||||
continue;
|
||||
};
|
||||
if name.len() > MAX_NAME_BYTES || name.starts_with('.') || kind.is_symlink() || hidden(&entry) {
|
||||
continue;
|
||||
}
|
||||
if kind.is_dir() {
|
||||
found.push(Found { label: shown(&name), name, folder: true });
|
||||
} else if kind.is_file() && runs(&name) {
|
||||
if self.scripts >= self.limits.scripts {
|
||||
self.truncated = true;
|
||||
continue;
|
||||
}
|
||||
self.scripts += 1;
|
||||
let stem = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem);
|
||||
found.push(Found { label: shown(stem), name, folder: false });
|
||||
}
|
||||
}
|
||||
// A script whose label another row of this folder has shows its whole file name, until no
|
||||
// label is shared. Each pass gives at least one more script its whole file name, and names
|
||||
// are unique within a folder, so this ends within `found.len()` passes.
|
||||
loop {
|
||||
let mut count = std::collections::HashMap::<String, usize>::new();
|
||||
for f in &found {
|
||||
*count.entry(f.label.clone()).or_default() += 1;
|
||||
}
|
||||
let mut renamed = false;
|
||||
for f in found.iter_mut().filter(|f| !f.folder) {
|
||||
if count.get(&f.label).is_some_and(|n| *n > 1) {
|
||||
let whole = shown(&f.name);
|
||||
if f.label != whole {
|
||||
f.label = whole;
|
||||
renamed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !renamed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
found.sort_by(|a, b| (a.label.to_lowercase(), &a.label, &a.name).cmp(&(b.label.to_lowercase(), &b.label, &b.name)));
|
||||
let mut out = Vec::with_capacity(found.len());
|
||||
for f in found {
|
||||
let path = dir.join(&f.name);
|
||||
if !f.folder {
|
||||
out.push(Entry::Script { label: f.label, path });
|
||||
continue;
|
||||
}
|
||||
if self.stopped {
|
||||
continue;
|
||||
}
|
||||
if depth >= self.limits.depth || self.folders >= self.limits.folders {
|
||||
self.truncated = true;
|
||||
continue;
|
||||
}
|
||||
self.folders += 1;
|
||||
match self.level(&path, depth + 1) {
|
||||
Some(entries) if entries.is_empty() => {}
|
||||
Some(entries) => out.push(Entry::Folder { label: f.label, entries }),
|
||||
None => self.truncated = true,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` as a menu shows it: control and bidirectional formatting characters written as escapes,
|
||||
/// and at most [`MAX_LABEL_CHARS`] characters.
|
||||
fn shown(name: &str) -> String {
|
||||
vectorcraft_plugins::permissions::display_text(name, MAX_LABEL_CHARS)
|
||||
}
|
||||
|
||||
/// Does Windows mark `entry` hidden or system?
|
||||
fn hidden(entry: &std::fs::DirEntry) -> bool {
|
||||
entry.metadata().is_ok_and(|m| hidden_meta(&m))
|
||||
}
|
||||
|
||||
/// Does Windows mark the file or folder of `m` hidden or system?
|
||||
#[cfg(windows)]
|
||||
fn hidden_meta(m: &std::fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt as _;
|
||||
const HIDDEN_OR_SYSTEM: u32 = 0x2 | 0x4;
|
||||
m.file_attributes() & HIDDEN_OR_SYSTEM != 0
|
||||
}
|
||||
|
||||
/// Elsewhere a hidden file is one whose name starts with a dot, which the listing leaves out by name.
|
||||
#[cfg(not(windows))]
|
||||
fn hidden_meta(_: &std::fs::Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Is `path` a folder (`folder`) or a file, and not a link? With `shown`, also not one Windows
|
||||
/// marks hidden or system, which a listing leaves out.
|
||||
fn plain(path: &Path, folder: bool, shown: bool) -> bool {
|
||||
std::fs::symlink_metadata(path)
|
||||
.is_ok_and(|m| !m.file_type().is_symlink() && (if folder { m.is_dir() } else { m.is_file() }) && !(shown && hidden_meta(&m)))
|
||||
}
|
||||
|
||||
/// Is `path` a script a listing of `root` can hold now: a `.js` or `.jsx` file at most
|
||||
/// [`MAX_DEPTH`] folders below `root`, where `root`, each folder on the way and the file are there
|
||||
/// and none is a link, and neither the file nor a folder below `root` is marked hidden or system?
|
||||
pub fn holds(root: &Path, path: &Path) -> bool {
|
||||
let Ok(rest) = path.strip_prefix(root) else { return false };
|
||||
let names: Option<Vec<&str>> = rest
|
||||
.components()
|
||||
.map(|c| match c {
|
||||
Component::Normal(name) => name.to_str().filter(|n| !n.starts_with('.') && n.len() <= MAX_NAME_BYTES),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let Some(names) = names else { return false };
|
||||
let Some((file, folders)) = names.split_last() else { return false };
|
||||
if folders.len() > MAX_DEPTH || !runs(file) || !plain(root, true, false) {
|
||||
return false;
|
||||
}
|
||||
let mut at = root.to_path_buf();
|
||||
for folder in folders {
|
||||
at.push(folder);
|
||||
if !plain(&at, true, true) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
at.push(file);
|
||||
plain(&at, false, true)
|
||||
}
|
||||
|
||||
/// The first `max` bytes of the script at `path`, or `None` when [`holds`] does not hold for it
|
||||
/// before the read and again after it, or it cannot be read. The two checks leave no time in which
|
||||
/// a link stood in for the file or one of its folders for the whole read; a program that swaps one
|
||||
/// in and back out between a check and the open can still have another file read.
|
||||
pub fn read(root: &Path, path: &Path, max: usize) -> Option<Vec<u8>> {
|
||||
if !holds(root, path) {
|
||||
return None;
|
||||
}
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
if !file.metadata().is_ok_and(|m| m.is_file()) {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = vec![];
|
||||
file.take(max as u64).read_to_end(&mut bytes).ok()?;
|
||||
holds(root, path).then_some(bytes)
|
||||
}
|
||||
@@ -50,6 +50,8 @@ use crate::{
|
||||
Clipboard, DocState, DrawMode, EngineError, Journal, NodeId, PaintDefaults, Result, Session, cmd, command_specs, guard, waits_for_tool_edit,
|
||||
};
|
||||
|
||||
pub mod folder;
|
||||
|
||||
/// The most bytes of source a run takes, as a file or as `source`.
|
||||
pub const MAX_SOURCE_BYTES: usize = 8 << 20;
|
||||
/// The instructions a run may execute when `maxFuel` is left out.
|
||||
|
||||
Reference in New Issue
Block a user