isokit: web Studio (engine as WASM, projects in the browser or a live folder)

- isokit-wasm: wasm-bindgen build of the engine; a project in memory behind
  the same JSON API as `isokit studio`, plus the MCP tools
- isokit-project: Store trait (FsStore folder, MemStore memory) under a
  generic Workspace; Project::from_files; shared Studio API module (api.rs)
  used by serve.rs and the WASM build; web_time for clocks in the browser
- studio.js runs on a pluggable backend (window.IsoKitBackend); no Studio
  keys while a dialog is open
- web/: projects in IndexedDB, new/demo/delete, zip export/import (stored and
  deflated), folder import by drop or picker, folders opened live via File
  System Access with outside edits followed, code editor, page build and
  preview, two tabs in step, isokit.mcp() and WebMCP registration
- tools/build_web.py -> dist/web (static), tools/check_web.py (44 checks)
This commit is contained in:
grabowski committed 2026-10-22 17:00:42 +07:00
1 parent 564aeb75f6
commit 5eb60d2a0c
20 files changed
+1950 -178

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
//! The Studio's JSON API, shared by `isokit studio` (HTTP, `/api/*`) and the
//! web Studio (WASM): the same requests and answers on both.
use crate::ops::Op;
use crate::store::Store;
use crate::workspace::Workspace;
use serde_json::{json, Map, Value};
/// The document and the undo state.
pub fn state<S: Store>(ws: &Workspace<S>) -> Value {
// the version goes out as a string: u64 does not fit a JS number
json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() })
}
/// Project facts: title, levels, parameters, catalog, materials, errors.
pub fn meta<S: Store>(ws: &Workspace<S>) -> Value {
let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default();
v["dir"] = json!(ws.dir.display().to_string());
v["load_error"] = json!(ws.load_error);
v["script_error"] = json!(ws.project.runtime.as_ref().err());
v
}
/// One operation, or `{ "ops": [...] }` as one undo step. Answers the new
/// state with `ok` and the id of the object made or changed, or `error`.
pub fn op<S: Store>(ws: &mut Workspace<S>, body: &Value) -> Value {
let r = if let Some(list) = body.get("ops").and_then(|v| v.as_array()) {
let ops: Result<Vec<Op>, String> = list
.iter()
.map(|v| serde_json::from_value::<Op>(v.clone()).map_err(|e| e.to_string()))
.collect();
ops.and_then(|l| ws.apply_all(l))
.map(|o| o.last().and_then(|x| x.id.clone()))
} else {
serde_json::from_value::<Op>(body.clone())
.map_err(|e| format!("bad operation: {e}"))
.and_then(|op| ws.apply(op))
.map(|o| o.id)
};
match r {
Ok(id) => {
let mut s = state(ws);
s["ok"] = json!(true);
s["id"] = json!(id);
s
}
Err(e) => json!({ "error": e }),
}
}
pub fn undo<S: Store>(ws: &mut Workspace<S>, redo: bool) -> Value {
let r = if redo { ws.redo() } else { ws.undo() };
match r {
Ok(_) => state(ws),
Err(e) => json!({ "error": e }),
}
}
/// One view for the viewer (`level` default: the project's default level).
pub fn render<S: Store>(
ws: &Workspace<S>,
level: Option<&str>,
rot: u8,
cfg: Option<&Map<String, Value>>,
dev: bool,
) -> Value {
let p = &ws.project;
let level = level
.map(str::to_string)
.unwrap_or_else(|| p.manifest.default_level());
match p.render(&level, rot, cfg, dev) {
Ok(v) => serde_json::to_value(v).unwrap_or_default(),
Err(e) => json!({ "error": e }),
}
}
+73 -42
View File
@@ -13,13 +13,17 @@
//! [`Project`] loads and renders it; [`ops`] edits the document; [`page`]
//! exports HTML/SVG; [`check`] lints it; [`workspace::Workspace`] keeps a
//! project loaded across edits (undo, reload on file changes) for the Studio
//! and the MCP server ([`mcp`]).
//! and the MCP server ([`mcp`]). Where the files live is a [`store::Store`]:
//! a folder on disk, or memory (the web Studio, WASM). [`api`] is the JSON
//! API both Studio front ends speak.
pub mod api;
pub mod assets;
pub mod check;
pub mod mcp;
pub mod ops;
pub mod page;
pub mod store;
pub mod workspace;
use isokit_core::{render, Info, RenderOpts};
@@ -29,12 +33,16 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
use web_time::Instant;
pub use isokit_script;
pub const MANIFEST: &str = "isokit.toml";
/// Project files by forward-slash relative path ("isokit.toml",
/// "lib/kiosk.rhai"): what a project is made of, wherever it is stored.
pub type Files = BTreeMap<String, String>;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct LevelDef {
pub id: String,
@@ -213,9 +221,18 @@ fn rd(path: &Path) -> Result<String, Error> {
std::fs::read_to_string(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))
}
/// Every `*.rhai` below `dir` (skipping dist, target and dot folders),
/// keyed by forward-slash relative path.
fn collect_sources(dir: &Path) -> Result<BTreeMap<String, String>, Error> {
/// Folders a project walk skips.
pub fn skip_dir(name: &str) -> bool {
name.starts_with('.') || name == "dist" || name == "target" || name == "node_modules"
}
/// Files that make up a project (scripts, manifest, themes, documents).
pub fn is_project_file(name: &str) -> bool {
name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json")
}
/// Every project file below `dir` (skipping dist, target and dot folders).
pub fn read_dir_files(dir: &Path) -> Result<Files, Error> {
let mut out = BTreeMap::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
@@ -224,14 +241,10 @@ fn collect_sources(dir: &Path) -> Result<BTreeMap<String, String>, Error> {
let p = ent.path();
let name = ent.file_name().to_string_lossy().to_string();
if p.is_dir() {
if !name.starts_with('.')
&& name != "dist"
&& name != "target"
&& name != "node_modules"
{
if !skip_dir(&name) {
stack.push(p);
}
} else if name.ends_with(".rhai") {
} else if is_project_file(&name) {
let rel = p
.strip_prefix(dir)
.unwrap_or(&p)
@@ -259,35 +272,49 @@ impl Project {
/// document stays editable and [`Project::runtime`] holds the error.
pub fn load(dir: impl AsRef<Path>) -> Result<Project, Error> {
let dir = dir.as_ref().to_path_buf();
let mpath = dir.join(MANIFEST);
let manifest: Manifest = if mpath.exists() {
toml::from_str(&rd(&mpath)?).map_err(|e| Error::Manifest(e.to_string()))?
} else {
if !dir.join(MANIFEST).exists() {
return Err(Error::Io(format!(
"{} not found (create a project with `isokit new`)",
mpath.display()
dir.join(MANIFEST).display()
)));
}
let files = read_dir_files(&dir)?;
Project::from_files(dir, &files)
}
/// Load a project from its files (no disk access). `dir` names it in
/// messages and is where the CLI writes builds.
pub fn from_files(dir: impl Into<PathBuf>, files: &Files) -> Result<Project, Error> {
let dir = dir.into();
let get = |name: &str| {
files
.get(name)
.ok_or_else(|| Error::Io(format!("{name} not found")))
};
let manifest: Manifest =
toml::from_str(get(MANIFEST)?).map_err(|e| Error::Manifest(e.to_string()))?;
let mut layers = vec![];
for t in &manifest.theme {
layers.push((t.clone(), rd(&dir.join(t))?));
layers.push((t.clone(), get(t)?.clone()));
}
let refs: Vec<(&str, &str)> = layers
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect();
let theme = isokit_theme::load(&refs).map_err(Error::Theme)?;
let dpath = dir.join(manifest.doc_path());
let doc: Doc = if dpath.exists() {
serde_json::from_str(&rd(&dpath)?)
.map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))?
} else {
Doc {
let doc: Doc = match files.get(&manifest.doc_path()) {
Some(text) => serde_json::from_str(text)
.map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))?,
None => Doc {
version: 1,
..Default::default()
}
},
};
let sources = collect_sources(&dir)?;
let sources: BTreeMap<String, String> = files
.iter()
.filter(|(k, _)| k.ends_with(".rhai"))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let main = match &manifest.script {
Some(s) => Some(s.clone()),
None if sources.contains_key("scene.rhai") => Some("scene.rhai".into()),
@@ -402,16 +429,12 @@ impl Project {
})
}
/// Write the document (pretty JSON, atomically).
pub fn save_doc(&self) -> Result<(), Error> {
let path = self.dir.join(self.manifest.doc_path());
/// The document as saved: pretty JSON with a final newline.
pub fn doc_text(&self) -> Result<String, Error> {
let mut s =
serde_json::to_string_pretty(&self.doc).map_err(|e| Error::Doc(e.to_string()))?;
s.push('\n');
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, s).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?;
std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
Ok(())
Ok(s)
}
}
@@ -430,15 +453,10 @@ pub fn version(dir: &Path) -> u64 {
let p = ent.path();
let name = ent.file_name().to_string_lossy().to_string();
if p.is_dir() {
if !name.starts_with('.')
&& name != "dist"
&& name != "target"
&& name != "node_modules"
{
if !skip_dir(&name) {
stack.push(p);
}
} else if name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json")
{
} else if is_project_file(&name) {
if let Ok(m) = ent.metadata() {
files.push((p.to_string_lossy().to_string(), m.len(), m.modified().ok()));
}
@@ -450,6 +468,19 @@ pub fn version(dir: &Path) -> u64 {
h.finish()
}
/// The files of a new project with a starter scene.
pub fn scaffold_files(title: &str) -> Files {
let title = title.replace('\\', "").replace('"', "'");
let mut f = Files::new();
f.insert(
MANIFEST.into(),
assets::NEW_MANIFEST.replace("{title}", &title),
);
f.insert("scene.json".into(), assets::NEW_DOC.into());
f.insert("theme.toml".into(), assets::NEW_THEME.into());
f
}
/// Write a new project folder with a starter scene.
pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> {
if dir.join(MANIFEST).exists() {
@@ -462,9 +493,9 @@ pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> {
let w = |name: &str, body: &str| {
std::fs::write(dir.join(name), body).map_err(|e| Error::Io(format!("{name}: {e}")))
};
w(MANIFEST, &assets::NEW_MANIFEST.replace("{title}", title))?;
w("scene.json", assets::NEW_DOC)?;
w("theme.toml", assets::NEW_THEME)?;
for (name, body) in scaffold_files(title) {
w(&name, &body)?;
}
w("lib/README.md", assets::NEW_LIB_README)?;
Ok(())
}
+10 -15
View File
@@ -6,6 +6,7 @@
use crate::check::check;
use crate::ops::Op;
use crate::page;
use crate::store::Store;
use crate::workspace::Workspace;
use serde_json::{json, Map, Value};
@@ -85,7 +86,7 @@ fn tool_error(msg: impl Into<String>) -> Value {
json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true })
}
fn boxes(ws: &Workspace) -> Map<String, Value> {
fn boxes<S: Store>(ws: &Workspace<S>) -> Map<String, Value> {
let p = &ws.project;
match p.render(&p.manifest.default_level(), 0, None, false) {
Ok(v) => v
@@ -97,7 +98,7 @@ fn boxes(ws: &Workspace) -> Map<String, Value> {
}
}
fn object_json(ws: &Workspace, id: &str, bx: &Map<String, Value>) -> Option<Value> {
fn object_json<S: Store>(ws: &Workspace<S>, id: &str, bx: &Map<String, Value>) -> Option<Value> {
let o = ws.project.doc.get(id)?;
let mut v = serde_json::to_value(o).ok()?;
if let Some(b) = bx.get(id) {
@@ -111,7 +112,7 @@ fn str_arg(a: &Map<String, Value>, k: &str) -> Option<String> {
}
/// Run one tool.
pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Value {
pub fn call_tool<S: Store>(ws: &mut Workspace<S>, name: &str, a: &Map<String, Value>) -> Value {
ws.refresh();
match name {
"project_info" => {
@@ -287,18 +288,12 @@ pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Valu
let out = str_arg(a, "out")
.or_else(|| p.manifest.build.out.clone())
.unwrap_or_else(|| "dist/index.html".into());
let path = p.dir.join(&out);
match page::static_page(p) {
Err(e) => tool_error(e.to_string()),
Ok(html) => {
if let Some(d) = path.parent() {
let _ = std::fs::create_dir_all(d);
}
match std::fs::write(&path, html) {
Ok(_) => text(json!({ "path": path.display().to_string() })),
Err(e) => tool_error(format!("{}: {e}", path.display())),
}
}
Ok(html) => match ws.write_file(&out, &html) {
Ok(path) => text(json!({ "path": path.display().to_string() })),
Err(e) => tool_error(e),
},
}
}
"undo" => match ws.undo() {
@@ -313,7 +308,7 @@ pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Valu
}
}
fn render_warnings(ws: &Workspace) -> Vec<String> {
fn render_warnings<S: Store>(ws: &Workspace<S>) -> Vec<String> {
let p = &ws.project;
p.render(&p.manifest.default_level(), 0, None, false)
.map(|v| v.warnings)
@@ -321,7 +316,7 @@ fn render_warnings(ws: &Workspace) -> Vec<String> {
}
/// Handle one JSON-RPC message.
pub fn handle(ws: &mut Workspace, msg: &Value) -> Option<Value> {
pub fn handle<S: Store>(ws: &mut Workspace<S>, msg: &Value) -> Option<Value> {
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(json!({}));
+3 -2
View File
@@ -69,8 +69,9 @@ pub fn svg(p: &Project, level: &str, rot: u8, light: bool) -> Result<String, Scr
/// Today's date (UTC) as YYYY-MM-DD, without a date crate.
pub fn today() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
// web_time: std's clock traps in the browser (wasm32-unknown-unknown)
let secs = web_time::SystemTime::now()
.duration_since(web_time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
+197
View File
@@ -0,0 +1,197 @@
//! Where a project's files live. [`FsStore`]: a folder on disk (the CLI,
//! `isokit studio`, `isokit mcp`). [`MemStore`]: memory, filled and drained
//! by a host (the web Studio keeps the files in the browser or in a folder
//! the user opened, and persists what the workspace writes).
use crate::{is_project_file, read_dir_files, Error, Files, MANIFEST};
use std::path::{Path, PathBuf};
pub trait Store {
/// The project's name in messages (a folder path, or a label).
fn dir(&self) -> PathBuf;
/// All project files.
fn read(&self) -> Result<Files, Error>;
/// Changes whenever a file is edited, added or removed.
fn version(&self) -> u64;
/// Write one file (relative path).
fn write(&mut self, path: &str, body: &str) -> Result<(), Error>;
}
/// A project folder on disk.
pub struct FsStore {
pub dir: PathBuf,
}
impl FsStore {
pub fn new(dir: impl AsRef<Path>) -> FsStore {
FsStore {
dir: dir.as_ref().to_path_buf(),
}
}
}
/// A relative path that stays inside the project (no `..`, no root).
fn safe_rel(path: &str) -> Result<String, Error> {
let p = path.replace('\\', "/");
let p = p.trim_start_matches("./");
if p.is_empty()
|| p.starts_with('/')
|| p.contains(':')
|| p.split('/').any(|s| s == ".." || s.is_empty())
{
return Err(Error::Io(format!("{path}: not a path inside the project")));
}
Ok(p.to_string())
}
impl Store for FsStore {
fn dir(&self) -> PathBuf {
self.dir.clone()
}
fn read(&self) -> Result<Files, Error> {
if !self.dir.join(MANIFEST).exists() {
return Err(Error::Io(format!(
"{} not found (create a project with `isokit new`)",
self.dir.join(MANIFEST).display()
)));
}
read_dir_files(&self.dir)
}
fn version(&self) -> u64 {
crate::version(&self.dir)
}
/// Atomic: a temporary file renamed over the old one.
fn write(&mut self, path: &str, body: &str) -> Result<(), Error> {
let path = self.dir.join(safe_rel(path)?);
if let Some(d) = path.parent() {
std::fs::create_dir_all(d).map_err(|e| Error::Io(format!("{}: {e}", d.display())))?;
}
let mut tmp = path.clone().into_os_string();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
std::fs::write(&tmp, body).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?;
std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
Ok(())
}
}
/// Project files in memory. The host fills it ([`MemStore::set`] for edits
/// made outside the workspace) and persists what the workspace wrote
/// ([`MemStore::take_written`]).
#[derive(Default)]
pub struct MemStore {
pub name: String,
files: Files,
version: u64,
written: Vec<String>,
}
impl MemStore {
pub fn new(name: impl Into<String>, files: Files) -> MemStore {
MemStore {
name: name.into(),
files,
version: 1,
written: vec![],
}
}
pub fn files(&self) -> &Files {
&self.files
}
/// An edit from outside (code editor, another tab, a watched folder):
/// the workspace reloads on its next refresh. `None` removes the file.
pub fn set(&mut self, path: &str, body: Option<String>) -> Result<(), Error> {
let p = safe_rel(path)?;
match body {
Some(b) => {
if self.files.get(&p) == Some(&b) {
return Ok(());
}
self.files.insert(p, b);
}
None => {
if self.files.remove(&p).is_none() {
return Ok(());
}
}
}
self.version += 1;
Ok(())
}
/// Paths the workspace wrote since the last call (deduplicated, in order).
pub fn take_written(&mut self) -> Vec<String> {
let mut out: Vec<String> = vec![];
for p in self.written.drain(..) {
if !out.contains(&p) {
out.push(p);
}
}
out
}
}
impl Store for MemStore {
fn dir(&self) -> PathBuf {
PathBuf::from(&self.name)
}
fn read(&self) -> Result<Files, Error> {
if !self.files.contains_key(MANIFEST) {
return Err(Error::Io(format!("{MANIFEST} not found")));
}
Ok(self.files.clone())
}
fn version(&self) -> u64 {
self.version
}
/// Exports (dist/index.html) do not change the version: like on disk,
/// only project files make the workspace reload.
fn write(&mut self, path: &str, body: &str) -> Result<(), Error> {
let p = safe_rel(path)?;
if is_project_file(&p) {
self.version += 1;
}
self.files.insert(p.clone(), body.to_string());
self.written.push(p);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_stay_inside_the_project() {
assert_eq!(safe_rel("lib/a.rhai").unwrap(), "lib/a.rhai");
assert_eq!(safe_rel("./scene.json").unwrap(), "scene.json");
assert_eq!(safe_rel("dist\\index.html").unwrap(), "dist/index.html");
for bad in ["../x", "/etc/passwd", "C:/x", "a//b", "", "lib/../../x"] {
assert!(safe_rel(bad).is_err(), "{bad}");
}
}
#[test]
fn mem_store_tracks_versions_and_writes() {
let mut m = MemStore::new("t", Files::new());
assert!(m.read().is_err(), "no manifest yet");
let v0 = m.version();
m.set("isokit.toml", Some("title = \"t\"".into())).unwrap();
assert!(m.version() > v0);
let v1 = m.version();
m.set("isokit.toml", Some("title = \"t\"".into())).unwrap();
assert_eq!(m.version(), v1, "same content is not a change");
m.write("scene.json", "{}").unwrap();
m.write("scene.json", "{ }").unwrap();
let v2 = m.version();
assert!(v2 > v1);
m.write("dist/index.html", "<html>").unwrap();
assert_eq!(m.version(), v2, "an export is not a project change");
assert_eq!(
m.take_written(),
vec!["scene.json".to_string(), "dist/index.html".to_string()]
);
assert!(m.take_written().is_empty());
m.set("scene.json", None).unwrap();
assert!(!m.files().contains_key("scene.json"));
}
}
+40 -15
View File
@@ -1,16 +1,18 @@
//! A project kept loaded across edits: reload on file changes, save after
//! every operation, undo and redo. The Studio server and the MCP server
//! each hold one.
//! every operation, undo and redo. The Studio server, the MCP server and the
//! web Studio each hold one; the [`Store`] says where the files live.
use crate::ops::{self, Op, Outcome};
use crate::{version, Error, Project};
use crate::store::{FsStore, Store};
use crate::{Error, Project};
use isokit_script::doc::Doc;
use std::path::{Path, PathBuf};
pub struct Workspace {
pub struct Workspace<S: Store = FsStore> {
pub dir: PathBuf,
pub store: S,
pub project: Project,
/// Fingerprint of the files the loaded project was read from.
/// Version of the files the loaded project was read from.
pub version: u64,
/// Error of the last reload attempt (the previous project stays loaded).
pub load_error: Option<String>,
@@ -20,13 +22,21 @@ pub struct Workspace {
const UNDO_DEPTH: usize = 200;
impl Workspace {
impl Workspace<FsStore> {
/// A project folder on disk.
pub fn open(dir: impl AsRef<Path>) -> Result<Workspace, Error> {
let dir = dir.as_ref().to_path_buf();
let project = Project::load(&dir)?;
let version = version(&dir);
Workspace::with_store(FsStore::new(dir))
}
}
impl<S: Store> Workspace<S> {
pub fn with_store(store: S) -> Result<Workspace<S>, Error> {
let dir = store.dir();
let project = Project::from_files(dir.clone(), &store.read()?)?;
let version = store.version();
Ok(Workspace {
dir,
store,
project,
version,
load_error: None,
@@ -35,15 +45,19 @@ impl Workspace {
})
}
/// Reload when a project file changed on disk (another editor, an agent,
/// a git pull). Returns true when something was reloaded.
/// Reload when a project file changed in the store (another editor, an
/// agent, a git pull). Returns true when something was reloaded.
pub fn refresh(&mut self) -> bool {
let v = version(&self.dir);
let v = self.store.version();
if v == self.version {
return false;
}
self.version = v;
match Project::load(&self.dir) {
match self
.store
.read()
.and_then(|f| Project::from_files(self.dir.clone(), &f))
{
Ok(p) => {
if p.doc != self.project.doc {
self.push_undo(self.project.doc.clone());
@@ -65,11 +79,22 @@ impl Workspace {
}
fn save(&mut self) -> Result<(), String> {
self.project.save_doc().map_err(|e| e.to_string())?;
self.version = version(&self.dir);
let text = self.project.doc_text().map_err(|e| e.to_string())?;
let path = self.project.manifest.doc_path();
self.store.write(&path, &text).map_err(|e| e.to_string())?;
self.version = self.store.version();
Ok(())
}
/// Write a file that is not the document (an exported page), relative
/// to the project. Returns the path as the store names it.
pub fn write_file(&mut self, path: &str, body: &str) -> Result<PathBuf, String> {
self.refresh();
self.store.write(path, body).map_err(|e| e.to_string())?;
self.version = self.store.version();
Ok(self.dir.join(path))
}
/// Apply an operation to the document and save it.
pub fn apply(&mut self, op: Op) -> Result<Outcome, String> {
self.refresh();