#[cfg(test)]
mod tests;
use std::cell::{Cell, RefCell};
use std::ffi::OsString;
use std::io::{ErrorKind, Read, Write};
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::rc::{Rc, Weak};
use crabshell_reactive::{ReadSignal, Signal, untrack};
use crabshell_ui::Cx;
use inotify::EventMask;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::Shared;
use crate::lines::panic_message;
use crate::watch::{self, Watch};
pub(crate) const MAX_FILE: u64 = 16 * 1024 * 1024;
pub(crate) struct Files(pub(crate) Rc<Shared>);
struct Follow {
shared: Rc<Shared>,
path: PathBuf,
watch: RefCell<Option<Watch>>,
changed: Box<dyn Fn()>,
}
impl Follow {
fn new(shared: &Rc<Shared>, path: PathBuf, changed: impl Fn() + 'static) -> Rc<Follow> {
let follow = Rc::new(Follow {
shared: Rc::clone(shared),
path,
watch: RefCell::new(None),
changed: Box::new(changed),
});
follow.rewatch();
follow
}
fn rewatch(self: &Rc<Self>) {
self.watch.replace(None);
let (Some(dir), Some(name)) = (self.path.parent(), self.path.file_name()) else {
return;
};
let mut dir = if dir.as_os_str().is_empty() {
Path::new(".")
} else {
dir
};
let mut next: OsString = name.to_owned();
let own = dir.is_dir();
while !dir.is_dir() {
let (Some(above), Some(name)) = (dir.parent(), dir.file_name()) else {
return;
};
next = name.to_owned();
dir = if above.as_os_str().is_empty() {
Path::new(".")
} else {
above
};
}
let weak = Rc::downgrade(self);
let listener = move |change: &watch::Change| {
let Some(follow) = weak.upgrade() else {
return;
};
let moved = EventMask::DELETE_SELF | EventMask::MOVE_SELF;
match &change.name {
None => {
if change.mask.intersects(moved) || change.mask.contains(EventMask::Q_OVERFLOW) {
follow.rewatch();
}
(follow.changed)();
}
Some(changed) if *changed == next && own => {
if !change.mask.contains(EventMask::CREATE) {
(follow.changed)();
}
}
Some(changed) if *changed == next => {
follow.rewatch();
(follow.changed)();
}
Some(_) => {}
}
};
match watch::watch(&self.shared, dir, listener) {
Ok(watch) => {
self.watch.replace(Some(watch));
}
Err(error) => {
let path = self.path.display();
tracing::info!(%path, %error, "can't watch the file, so changes to it aren't seen");
}
}
}
}
struct Keep<T>(#[allow(dead_code, reason = "held for its drop")] Rc<T>);
#[derive(Clone)]
pub struct FileView {
inner: Rc<ViewInner>,
}
struct ViewInner {
path: PathBuf,
follow: RefCell<Option<Rc<Follow>>>,
text: Signal<Option<String>>,
logged: RefCell<Option<ErrorKind>>,
}
pub(crate) fn file_view(cx: &Cx, path: PathBuf) -> FileView {
let inner = Rc::new(ViewInner {
text: cx.signal(None),
follow: RefCell::new(None),
path,
logged: RefCell::new(None),
});
inner.reread();
if let Some(files) = cx.service::<Files>() {
let weak = Rc::downgrade(&inner);
let follow = Follow::new(&files.0, inner.path.clone(), move || {
if let Some(inner) = Weak::upgrade(&weak) {
inner.reread();
}
});
inner.follow.replace(Some(follow));
}
cx.owner().signal(Keep(Rc::clone(&inner)));
FileView { inner }
}
impl FileView {
pub fn text(&self) -> ReadSignal<Option<String>> {
self.inner.text.read_only()
}
pub fn write(&self, text: impl AsRef<str>) {
let text = text.as_ref();
match write_whole(&self.inner.path, text.as_bytes()) {
Ok(()) => set(self.inner.text, Some(text.to_owned())),
Err(error) => {
let path = self.inner.path.display();
tracing::warn!(%path, %error, "couldn't write the file");
}
}
}
}
impl ViewInner {
fn reread(&self) {
let text = match read_text(&self.path) {
Ok(text) => {
self.logged.replace(None);
Some(text)
}
Err(error) => {
if self.logged.replace(Some(error.kind())) != Some(error.kind()) {
let path = self.path.display();
tracing::info!(%path, %error, "can't read the file");
}
None
}
};
set(self.text, text);
}
}
pub(crate) fn json_file<T>(cx: &Cx, path: PathBuf, default: T) -> Signal<T>
where
T: Serialize + DeserializeOwned + PartialEq + 'static,
{
let value = cx.signal(default);
let file = Rc::new(JsonInner {
path,
value,
on_disk: RefCell::new(None),
logged: RefCell::new(None),
follow: RefCell::new(None),
});
file.reread();
if let Some(files) = cx.service::<Files>() {
let weak = Rc::downgrade(&file);
let follow = Follow::new(&files.0, file.path.clone(), move || {
if let Some(file) = Weak::upgrade(&weak) {
file.reread();
}
});
file.follow.replace(Some(follow));
}
let start = untrack(|| value.try_with(|value| file.to_json(value))).flatten();
let writer = Rc::clone(&file);
let first = Cell::new(true);
cx.effect(move || {
let text = value.with(|value| writer.to_json(value));
let unset = first.replace(false) && start.is_some() && text == start;
if let (Some(text), false) = (text, unset) {
untrack(|| writer.write(text));
}
})
.named(format!("cx.json_file {}", file.path.display()));
value
}
struct JsonInner<T: 'static> {
path: PathBuf,
value: Signal<T>,
on_disk: RefCell<Option<String>>,
logged: RefCell<Option<String>>,
follow: RefCell<Option<Rc<Follow>>>,
}
impl<T> JsonInner<T>
where
T: Serialize + DeserializeOwned + PartialEq + 'static,
{
fn reread(&self) {
let text = match read_text(&self.path) {
Ok(text) => text,
Err(error) => {
self.log_once(format!("can't read the file: {error}"));
return;
}
};
let applied = panic::catch_unwind(AssertUnwindSafe(|| {
let read = serde_json::from_str::<T>(&text)?;
let normal = serde_json::to_string_pretty(&read).unwrap_or_else(|_| text.clone());
self.on_disk.replace(Some(normal));
set(self.value, read);
serde_json::Result::Ok(())
}));
match applied {
Ok(Ok(())) => {
self.logged.replace(None);
}
Ok(Err(error)) => self.log_once(format!("the file isn't the JSON expected: {error}")),
Err(payload) => {
let message = panic_message(payload.as_ref());
self.log_once(format!("reading it panicked: {message}"));
}
}
}
fn to_json(&self, value: &T) -> Option<String> {
let path = self.path.display();
match panic::catch_unwind(AssertUnwindSafe(|| serde_json::to_string_pretty(value))) {
Ok(Ok(text)) => Some(text),
Ok(Err(error)) => {
tracing::warn!(%path, %error, "couldn't turn the value into JSON");
None
}
Err(payload) => {
let message = panic_message(payload.as_ref());
tracing::error!("cx.json_file {path}: turning the value into JSON panicked: {message}");
None
}
}
}
fn write(&self, text: String) {
if self.on_disk.borrow().as_deref() == Some(text.as_str()) {
return;
}
match write_whole(&self.path, format!("{text}\n").as_bytes()) {
Ok(()) => {
self.on_disk.replace(Some(text));
}
Err(error) => {
let path = self.path.display();
tracing::warn!(%path, %error, "couldn't write the file");
}
}
}
fn log_once(&self, problem: String) {
if self.logged.borrow().as_deref() != Some(problem.as_str()) {
let path = self.path.display();
tracing::info!(%path, problem, "the JSON file keeps its value");
self.logged.replace(Some(problem));
}
}
}
fn read_text(path: &Path) -> std::io::Result<String> {
let file = std::fs::File::open(path)?;
let mut bytes = Vec::new();
file.take(MAX_FILE + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_FILE {
return Err(std::io::Error::other("it is over 16 MiB"));
}
String::from_utf8(bytes)
.map_err(|_| std::io::Error::new(ErrorKind::InvalidData, "it isn't UTF-8"))
}
fn write_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
if let Some(dir) = path.parent()
&& !dir.as_os_str().is_empty()
&& !dir.exists()
{
std::fs::create_dir_all(dir)?;
}
let target = match std::fs::canonicalize(path) {
Ok(target) => target,
Err(error) if error.kind() == ErrorKind::NotFound => path.to_owned(),
Err(error) => return Err(error),
};
let name = target
.file_name()
.ok_or_else(|| std::io::Error::other("the path names no file"))?;
let mut partial_name = std::ffi::OsString::from(".");
partial_name.push(name);
partial_name.push(".crabshell-partial");
let partial = target.with_file_name(partial_name);
match std::fs::remove_file(&partial) {
Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
_ => {}
}
let mode = std::fs::metadata(&target).map_or(0o644, |meta| {
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o7777
});
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(&partial)?;
let written = file.write_all(bytes).and_then(|()| file.sync_all());
drop(file);
if let Err(error) = written.and_then(|()| std::fs::rename(&partial, &target)) {
let _ = std::fs::remove_file(&partial);
return Err(error);
}
Ok(())
}
fn set<T: PartialEq + 'static>(signal: Signal<T>, value: T) {
if untrack(|| signal.try_with(|old| *old != value)) == Some(true) {
signal.try_set(value);
}
}