1#[cfg(test)]
5mod tests;
6
7use std::cell::{Cell, RefCell};
8use std::ffi::OsString;
9use std::io::{ErrorKind, Read, Write};
10use std::panic::{self, AssertUnwindSafe};
11use std::path::{Path, PathBuf};
12use std::rc::{Rc, Weak};
13
14use crabshell_reactive::{ReadSignal, Signal, untrack};
15use crabshell_ui::Cx;
16use inotify::EventMask;
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19
20use crate::Shared;
21use crate::lines::panic_message;
22use crate::watch::{self, Watch};
23
24pub(crate) const MAX_FILE: u64 = 16 * 1024 * 1024;
27
28pub(crate) struct Files(pub(crate) Rc<Shared>);
31
32struct Follow {
39 shared: Rc<Shared>,
40 path: PathBuf,
41 watch: RefCell<Option<Watch>>,
42 changed: Box<dyn Fn()>,
44}
45
46impl Follow {
47 fn new(shared: &Rc<Shared>, path: PathBuf, changed: impl Fn() + 'static) -> Rc<Follow> {
50 let follow = Rc::new(Follow {
51 shared: Rc::clone(shared),
52 path,
53 watch: RefCell::new(None),
54 changed: Box::new(changed),
55 });
56 follow.rewatch();
57 follow
58 }
59
60 fn rewatch(self: &Rc<Self>) {
63 self.watch.replace(None);
64 let (Some(dir), Some(name)) = (self.path.parent(), self.path.file_name()) else {
65 return;
66 };
67 let mut dir = if dir.as_os_str().is_empty() {
68 Path::new(".")
69 } else {
70 dir
71 };
72 let mut next: OsString = name.to_owned();
73 let own = dir.is_dir();
74 while !dir.is_dir() {
75 let (Some(above), Some(name)) = (dir.parent(), dir.file_name()) else {
76 return;
77 };
78 next = name.to_owned();
79 dir = if above.as_os_str().is_empty() {
80 Path::new(".")
81 } else {
82 above
83 };
84 }
85 let weak = Rc::downgrade(self);
86 let listener = move |change: &watch::Change| {
87 let Some(follow) = weak.upgrade() else {
88 return;
89 };
90 let moved = EventMask::DELETE_SELF | EventMask::MOVE_SELF;
91 match &change.name {
92 None => {
94 if change.mask.intersects(moved) || change.mask.contains(EventMask::Q_OVERFLOW) {
95 follow.rewatch();
96 }
97 (follow.changed)();
98 }
99 Some(changed) if *changed == next && own => {
102 if !change.mask.contains(EventMask::CREATE) {
103 (follow.changed)();
104 }
105 }
106 Some(changed) if *changed == next => {
108 follow.rewatch();
109 (follow.changed)();
110 }
111 Some(_) => {}
112 }
113 };
114 match watch::watch(&self.shared, dir, listener) {
115 Ok(watch) => {
116 self.watch.replace(Some(watch));
117 }
118 Err(error) => {
119 let path = self.path.display();
120 tracing::info!(%path, %error, "can't watch the file, so changes to it aren't seen");
121 }
122 }
123 }
124}
125
126struct Keep<T>(#[allow(dead_code, reason = "held for its drop")] Rc<T>);
128
129#[derive(Clone)]
132pub struct FileView {
133 inner: Rc<ViewInner>,
134}
135
136struct ViewInner {
137 path: PathBuf,
138 follow: RefCell<Option<Rc<Follow>>>,
139 text: Signal<Option<String>>,
141 logged: RefCell<Option<ErrorKind>>,
143}
144
145pub(crate) fn file_view(cx: &Cx, path: PathBuf) -> FileView {
147 let inner = Rc::new(ViewInner {
148 text: cx.signal(None),
149 follow: RefCell::new(None),
150 path,
151 logged: RefCell::new(None),
152 });
153 inner.reread();
154 if let Some(files) = cx.service::<Files>() {
155 let weak = Rc::downgrade(&inner);
156 let follow = Follow::new(&files.0, inner.path.clone(), move || {
157 if let Some(inner) = Weak::upgrade(&weak) {
158 inner.reread();
159 }
160 });
161 inner.follow.replace(Some(follow));
162 }
163 cx.owner().signal(Keep(Rc::clone(&inner)));
165 FileView { inner }
166}
167
168impl FileView {
169 pub fn text(&self) -> ReadSignal<Option<String>> {
172 self.inner.text.read_only()
173 }
174
175 pub fn write(&self, text: impl AsRef<str>) {
181 let text = text.as_ref();
182 match write_whole(&self.inner.path, text.as_bytes()) {
183 Ok(()) => set(self.inner.text, Some(text.to_owned())),
184 Err(error) => {
185 let path = self.inner.path.display();
186 tracing::warn!(%path, %error, "couldn't write the file");
187 }
188 }
189 }
190}
191
192impl ViewInner {
193 fn reread(&self) {
195 let text = match read_text(&self.path) {
196 Ok(text) => {
197 self.logged.replace(None);
198 Some(text)
199 }
200 Err(error) => {
201 if self.logged.replace(Some(error.kind())) != Some(error.kind()) {
202 let path = self.path.display();
203 tracing::info!(%path, %error, "can't read the file");
204 }
205 None
206 }
207 };
208 set(self.text, text);
209 }
210}
211
212pub(crate) fn json_file<T>(cx: &Cx, path: PathBuf, default: T) -> Signal<T>
214where
215 T: Serialize + DeserializeOwned + PartialEq + 'static,
216{
217 let value = cx.signal(default);
218 let file = Rc::new(JsonInner {
219 path,
220 value,
221 on_disk: RefCell::new(None),
223 logged: RefCell::new(None),
224 follow: RefCell::new(None),
225 });
226 file.reread();
227 if let Some(files) = cx.service::<Files>() {
228 let weak = Rc::downgrade(&file);
229 let follow = Follow::new(&files.0, file.path.clone(), move || {
230 if let Some(file) = Weak::upgrade(&weak) {
231 file.reread();
232 }
233 });
234 file.follow.replace(Some(follow));
235 }
236 let start = untrack(|| value.try_with(|value| file.to_json(value))).flatten();
239 let writer = Rc::clone(&file);
240 let first = Cell::new(true);
241 cx.effect(move || {
242 let text = value.with(|value| writer.to_json(value));
243 let unset = first.replace(false) && start.is_some() && text == start;
244 if let (Some(text), false) = (text, unset) {
245 untrack(|| writer.write(text));
246 }
247 })
248 .named(format!("cx.json_file {}", file.path.display()));
249 value
250}
251
252struct JsonInner<T: 'static> {
253 path: PathBuf,
254 value: Signal<T>,
255 on_disk: RefCell<Option<String>>,
256 logged: RefCell<Option<String>>,
258 follow: RefCell<Option<Rc<Follow>>>,
259}
260
261impl<T> JsonInner<T>
262where
263 T: Serialize + DeserializeOwned + PartialEq + 'static,
264{
265 fn reread(&self) {
270 let text = match read_text(&self.path) {
271 Ok(text) => text,
272 Err(error) => {
273 self.log_once(format!("can't read the file: {error}"));
274 return;
275 }
276 };
277 let applied = panic::catch_unwind(AssertUnwindSafe(|| {
278 let read = serde_json::from_str::<T>(&text)?;
279 let normal = serde_json::to_string_pretty(&read).unwrap_or_else(|_| text.clone());
280 self.on_disk.replace(Some(normal));
281 set(self.value, read);
282 serde_json::Result::Ok(())
283 }));
284 match applied {
285 Ok(Ok(())) => {
286 self.logged.replace(None);
287 }
288 Ok(Err(error)) => self.log_once(format!("the file isn't the JSON expected: {error}")),
289 Err(payload) => {
290 let message = panic_message(payload.as_ref());
291 self.log_once(format!("reading it panicked: {message}"));
292 }
293 }
294 }
295
296 fn to_json(&self, value: &T) -> Option<String> {
299 let path = self.path.display();
300 match panic::catch_unwind(AssertUnwindSafe(|| serde_json::to_string_pretty(value))) {
301 Ok(Ok(text)) => Some(text),
302 Ok(Err(error)) => {
303 tracing::warn!(%path, %error, "couldn't turn the value into JSON");
304 None
305 }
306 Err(payload) => {
307 let message = panic_message(payload.as_ref());
308 tracing::error!("cx.json_file {path}: turning the value into JSON panicked: {message}");
309 None
310 }
311 }
312 }
313
314 fn write(&self, text: String) {
315 if self.on_disk.borrow().as_deref() == Some(text.as_str()) {
316 return;
317 }
318 match write_whole(&self.path, format!("{text}\n").as_bytes()) {
319 Ok(()) => {
320 self.on_disk.replace(Some(text));
321 }
322 Err(error) => {
323 let path = self.path.display();
324 tracing::warn!(%path, %error, "couldn't write the file");
325 }
326 }
327 }
328
329 fn log_once(&self, problem: String) {
330 if self.logged.borrow().as_deref() != Some(problem.as_str()) {
331 let path = self.path.display();
332 tracing::info!(%path, problem, "the JSON file keeps its value");
333 self.logged.replace(Some(problem));
334 }
335 }
336}
337
338fn read_text(path: &Path) -> std::io::Result<String> {
340 let file = std::fs::File::open(path)?;
341 let mut bytes = Vec::new();
342 file.take(MAX_FILE + 1).read_to_end(&mut bytes)?;
343 if bytes.len() as u64 > MAX_FILE {
344 return Err(std::io::Error::other("it is over 16 MiB"));
345 }
346 String::from_utf8(bytes)
347 .map_err(|_| std::io::Error::new(ErrorKind::InvalidData, "it isn't UTF-8"))
348}
349
350fn write_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
355 use std::os::unix::fs::OpenOptionsExt;
356
357 if let Some(dir) = path.parent()
358 && !dir.as_os_str().is_empty()
359 && !dir.exists()
360 {
361 std::fs::create_dir_all(dir)?;
362 }
363 let target = match std::fs::canonicalize(path) {
364 Ok(target) => target,
365 Err(error) if error.kind() == ErrorKind::NotFound => path.to_owned(),
366 Err(error) => return Err(error),
367 };
368 let name = target
369 .file_name()
370 .ok_or_else(|| std::io::Error::other("the path names no file"))?;
371 let mut partial_name = std::ffi::OsString::from(".");
372 partial_name.push(name);
373 partial_name.push(".crabshell-partial");
374 let partial = target.with_file_name(partial_name);
375 match std::fs::remove_file(&partial) {
376 Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
377 _ => {}
378 }
379 let mode = std::fs::metadata(&target).map_or(0o644, |meta| {
380 use std::os::unix::fs::PermissionsExt;
381 meta.permissions().mode() & 0o7777
382 });
383 let mut file = std::fs::OpenOptions::new()
385 .write(true)
386 .create_new(true)
387 .mode(mode)
388 .open(&partial)?;
389 let written = file.write_all(bytes).and_then(|()| file.sync_all());
390 drop(file);
391 if let Err(error) = written.and_then(|()| std::fs::rename(&partial, &target)) {
392 let _ = std::fs::remove_file(&partial);
393 return Err(error);
394 }
395 Ok(())
396}
397
398fn set<T: PartialEq + 'static>(signal: Signal<T>, value: T) {
401 if untrack(|| signal.try_with(|old| *old != value)) == Some(true) {
402 signal.try_set(value);
403 }
404}