mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
* Add document-format crate * Adapt document-format to merged graph-storage and consolidate errors * Fix resource write ordering, codec error masking, and export docs * Remove document-format RFC; tracked in a dedicated PR * Restructure document-format into gdd/persist/export/resource modules Split the 963-line lib.rs by responsibility: persist path into persist.rs, resource I/O into resource.rs, export engine into export.rs. Rename the GddV1 layout struct to GddV1Layout and add a GddV1 = Gdd<GddV1Layout> alias. * Feature-split document-format and graph-storage for minimal builds * Address PR review: O(1) history-delta lookup and no-default-features test build * Address PR review: propagate apply_hot_op persist error and assert resource hash * Make decompression from archive streaming * Share archive export body between export and export_to_bytes * Review cleanup * Export documents with un-retired hot ops losslessly * Persist last_broadcast_rev in session.json, drop unused last_retired_at * Move peer_id from manifest to session.json * Rename gesture to interaction in document-format storage layer
160 lines
6.1 KiB
Rust
160 lines
6.1 KiB
Rust
//! Resource I/O on the [`Gdd`] handle: the content-addressed byte store that backs raster images,
|
|
//! fonts, embedded WASM, and proto-node declarations. Registration goes through the session as an
|
|
//! `AddResource` delta; the bytes live in the working copy's `resources/<hash>` directory.
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use document_container::{AnyContainer, AsyncContainer, ByteHolder, ContainerError};
|
|
use graphene_resource::ResourceFuture;
|
|
use graphene_resource::{LoadResource, Resource, ResourceHash, ResourceStorage};
|
|
|
|
use crate::Gdd;
|
|
use crate::error::Error;
|
|
use crate::layout::Layout;
|
|
|
|
impl<L: Layout> Gdd<L> {
|
|
pub async fn read_resource(&self, hash: &ResourceHash) -> Result<ByteHolder, ContainerError> {
|
|
self.working.read(&self.layout.resource_path(hash)).await
|
|
}
|
|
|
|
/// Register a resource under `id` and store its bytes. Commits an `AddResource` delta (a single
|
|
/// `DataSource::Embedded` source resolved to the content hash) through the session so the registry
|
|
/// records the resource and the entry replicates, then writes the bytes into the working copy's
|
|
/// content-addressed store. The caller owns `id` allocation.
|
|
pub fn add_resource(&mut self, id: graph_storage::ResourceId, bytes: &[u8]) -> Result<(), Error> {
|
|
let hash = ResourceHash::from(bytes);
|
|
|
|
self.working.write_non_blocking(&self.layout.resource_path(&hash), bytes)?;
|
|
|
|
let hot_ops = self.session.stage_embedded_resource(id, hash)?;
|
|
self.append_and_retire(&hot_ops, false)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Like [`add_resource`](Self::add_resource) but copies the bytes from a filesystem `src` rather
|
|
/// than buffering them. Folder backends use `fs::copy` (CoW on supported filesystems); other
|
|
/// backends fall back to read-then-write. Native-only: there is no filesystem source path on wasm.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub fn add_resource_from_path(&mut self, id: graph_storage::ResourceId, hash: ResourceHash, src: &Path) -> Result<(), Error> {
|
|
let dest_path = self.layout.resource_path(&hash);
|
|
if let AnyContainer::Folder(folder) = self.working.as_ref() {
|
|
let full = folder.root().join(&dest_path);
|
|
if let Some(parent) = full.parent() {
|
|
std::fs::create_dir_all(parent).map_err(ContainerError::Io)?;
|
|
}
|
|
std::fs::copy(src, &full).map_err(ContainerError::Io)?;
|
|
} else {
|
|
let bytes = std::fs::read(src).map_err(ContainerError::Io)?;
|
|
// The folder fast path trusts the caller's hash to avoid reading the file; here we've read
|
|
// the bytes anyway, so verify the hash matches and flag a content-addressing bug in debug.
|
|
debug_assert_eq!(hash, ResourceHash::from(bytes.as_slice()), "add_resource_from_path hash does not match the file at {src:?}");
|
|
self.working.write_non_blocking(&dest_path, &bytes)?;
|
|
}
|
|
|
|
let hot_ops = self.session.stage_embedded_resource(id, hash)?;
|
|
self.append_and_retire(&hot_ops, false)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn has_resource(&self, hash: &ResourceHash) -> bool {
|
|
self.working.exists(&self.layout.resource_path(hash)).await
|
|
}
|
|
|
|
pub fn remove_resource(&self, hash: &ResourceHash) -> Result<(), ContainerError> {
|
|
self.working.remove_non_blocking(&self.layout.resource_path(hash))
|
|
}
|
|
|
|
/// Enumerate every resource currently in the working copy. Paths that don't parse as a
|
|
/// `ResourceHash` (foreign files dropped into the resources directory) are silently skipped.
|
|
pub async fn resource_hashes(&self) -> Result<Vec<ResourceHash>, ContainerError> {
|
|
let dir = self.layout.resources_dir();
|
|
if !self.working.list_dirs("").await?.iter().any(|d| d == dir) {
|
|
return Ok(Vec::new());
|
|
}
|
|
let entries = self.working.list(dir).await?;
|
|
let prefix = format!("{dir}/");
|
|
let mut hashes = Vec::with_capacity(entries.len());
|
|
for entry in entries {
|
|
let Some(name) = entry.strip_prefix(&prefix) else { continue };
|
|
if let Ok(hash) = name.parse::<ResourceHash>() {
|
|
hashes.push(hash);
|
|
}
|
|
}
|
|
Ok(hashes)
|
|
}
|
|
|
|
pub fn resource_proxy(&self) -> ResourceProxy<L>
|
|
where
|
|
L: Clone,
|
|
{
|
|
ResourceProxy(self.working.clone(), self.layout.clone())
|
|
}
|
|
}
|
|
|
|
impl<L: Layout + Send + Sync> LoadResource for Gdd<L> {
|
|
fn load(&self, hash: ResourceHash) -> ResourceFuture<'_> {
|
|
Box::pin(async move {
|
|
let bytes = self.working.read(&self.layout.resource_path(&hash)).await.ok()?;
|
|
Some(Resource::new(bytes))
|
|
})
|
|
}
|
|
}
|
|
|
|
pub struct ResourceProxy<T: Layout>(Arc<AnyContainer>, T);
|
|
|
|
impl<L: Layout + Send + Sync> LoadResource for ResourceProxy<L> {
|
|
fn load(&self, hash: ResourceHash) -> ResourceFuture<'_> {
|
|
Box::pin(async move {
|
|
let bytes = self.0.read(&self.1.resource_path(&hash)).await.ok()?;
|
|
Some(Resource::new(bytes))
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<L: Layout + Send + Sync> ResourceStorage for Gdd<L> {
|
|
fn store(&self, data: &[u8]) -> ResourceHash {
|
|
let hash = ResourceHash::from(data);
|
|
if let Err(error) = self.working.write_non_blocking(&self.layout.resource_path(&hash), data) {
|
|
log::error!("ResourceStorage::store failed for {hash}: {error}");
|
|
}
|
|
hash
|
|
}
|
|
|
|
fn contains(&self, hash: &ResourceHash) -> bool {
|
|
self.working.exists_non_blocking(&self.layout.resource_path(hash))
|
|
}
|
|
|
|
fn garbage_collect(&self, used: &[ResourceHash]) {
|
|
// `garbage_collect` is synchronous but listing resources is async, so the native path blocks on
|
|
// it. That's unavailable on wasm (single-threaded; `block_on` would deadlock). The editor never
|
|
// uses `Gdd` as the runtime `ResourceStorage` on wasm (it GCs the app-global cache instead), so
|
|
// this is an unreachable configuration there rather than a missing feature.
|
|
#[cfg(target_family = "wasm")]
|
|
{
|
|
let _ = used;
|
|
log::error!("ResourceStorage::garbage_collect is not supported for Gdd on wasm");
|
|
}
|
|
#[cfg(not(target_family = "wasm"))]
|
|
{
|
|
let kept: std::collections::HashSet<&ResourceHash> = used.iter().collect();
|
|
let hashes = match futures::executor::block_on(self.resource_hashes()) {
|
|
Ok(hashes) => hashes,
|
|
Err(error) => {
|
|
log::error!("Failed to list resources during garbage_collect: {error}");
|
|
return;
|
|
}
|
|
};
|
|
for hash in hashes {
|
|
if kept.contains(&hash) {
|
|
continue;
|
|
}
|
|
if let Err(error) = self.working.remove_non_blocking(&self.layout.resource_path(&hash)) {
|
|
log::error!("ResourceStorage::garbage_collect failed to remove {hash}: {error}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|