feat: add main features

Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
2026-06-06 01:27:01 +02:00
parent 55b3bee2c8
commit 307ae71857
273 changed files with 48740 additions and 0 deletions

View File

@ -0,0 +1,172 @@
//! [`IdeaiContextStore`] — file implementation of the [`AgentContextStore`] port
//! (ARCHITECTURE §5, §9.1).
//!
//! Persists, **inside the project root**, the agent contexts and the manifest
//! that together make a project's agents travel with the code:
//!
//! ```text
//! <project_root>/
//! └── .ideai/
//! ├── agents.json # AgentManifest { version, agents: [ManifestEntry, ...] }
//! └── agents/
//! ├── backend-dev.md # an agent's context (md_path = "agents/backend-dev.md")
//! └── ...
//! ```
//!
//! All I/O goes through the [`FileSystem`] port (here the `RemoteHost`'s
//! filesystem — [`crate::LocalFileSystem`] locally, an SFTP/WSL one later), so the
//! store is **location-neutral**: the very same adapter serves a local project or
//! a project hosted over SSH/WSL (Liskov, ARCHITECTURE §1.2).
//!
//! `md_path` values are interpreted **relative to `.ideai/`** (so a manifest
//! `md_path` of `agents/backend-dev.md` maps to
//! `<root>/.ideai/agents/backend-dev.md`), matching the documented schema.
use std::sync::Arc;
use async_trait::async_trait;
use domain::agent::AgentManifest;
use domain::ids::AgentId;
use domain::markdown::MarkdownDoc;
use domain::ports::{AgentContextStore, FileSystem, FsError, RemotePath, StoreError};
use domain::project::{Project, ProjectPath};
/// The `.ideai/` directory name inside a project root.
const IDEAI_DIR: &str = ".ideai";
/// The agent-manifest file name inside `.ideai/`.
const AGENTS_FILE: &str = "agents.json";
/// Current schema version written into a freshly-created manifest.
const MANIFEST_VERSION: u32 = 1;
/// File-backed [`AgentContextStore`], composing a [`FileSystem`] port.
///
/// Cheap to clone (everything is behind `Arc`); the composition root constructs
/// one per resolved `RemoteHost` and shares it across the agent use cases.
#[derive(Clone)]
pub struct IdeaiContextStore {
fs: Arc<dyn FileSystem>,
}
impl IdeaiContextStore {
/// Builds the store from an injected [`FileSystem`] (the project's
/// `RemoteHost` filesystem).
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// Joins a project root with a relative segment using a POSIX separator
/// (valid on every target; `tokio::fs` accepts `/` on Windows too).
fn join(root: &ProjectPath, rel: &str) -> String {
let base = root.as_str().trim_end_matches(['/', '\\']);
format!("{base}/{rel}")
}
/// Absolute path of the manifest file for a project.
fn manifest_path(project: &Project) -> RemotePath {
RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{AGENTS_FILE}")))
}
/// Absolute path of an agent context `.md` from its (`.ideai/`-relative)
/// `md_path`.
fn context_path(project: &Project, md_path: &str) -> RemotePath {
RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{md_path}")))
}
/// Resolves the `md_path` of an agent from the manifest, or
/// [`StoreError::NotFound`] if the agent is unknown to this project.
async fn md_path_of(&self, project: &Project, agent: &AgentId) -> Result<String, StoreError> {
let manifest = self.load_manifest(project).await?;
manifest
.entries
.into_iter()
.find(|e| &e.agent_id == agent)
.map(|e| e.md_path)
.ok_or(StoreError::NotFound)
}
/// Ensures the directory holding `path` exists (creates `.ideai/` and any
/// nested `agents/` parent before a write).
async fn ensure_parent(&self, path: &RemotePath) -> Result<(), StoreError> {
let raw = path.as_str();
let dir = match raw.rfind(['/', '\\']) {
Some(idx) => &raw[..idx],
None => return Ok(()),
};
self.fs
.create_dir_all(&RemotePath::new(dir.to_owned()))
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}
#[async_trait]
impl AgentContextStore for IdeaiContextStore {
async fn read_context(
&self,
project: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
let md_path = self.md_path_of(project, agent).await?;
let path = Self::context_path(project, &md_path);
match self.fs.read(&path).await {
Ok(bytes) => {
let content = String::from_utf8(bytes)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
Ok(MarkdownDoc::new(content))
}
Err(FsError::NotFound(_)) => Err(StoreError::NotFound),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
async fn write_context(
&self,
project: &Project,
agent: &AgentId,
md: &MarkdownDoc,
) -> Result<(), StoreError> {
let md_path = self.md_path_of(project, agent).await?;
let path = Self::context_path(project, &md_path);
self.ensure_parent(&path).await?;
self.fs
.write(&path, md.as_str().as_bytes())
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
async fn load_manifest(&self, project: &Project) -> Result<AgentManifest, StoreError> {
let path = Self::manifest_path(project);
match self.fs.read(&path).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
// No manifest yet: a project simply has no agents — return the empty
// default rather than erroring (mirrors the project-store policy).
Err(FsError::NotFound(_)) => Ok(AgentManifest {
version: MANIFEST_VERSION,
entries: Vec::new(),
}),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
async fn save_manifest(
&self,
project: &Project,
manifest: &AgentManifest,
) -> Result<(), StoreError> {
let path = Self::manifest_path(project);
self.ensure_parent(&path).await?;
let mut bytes = serde_json::to_vec_pretty(manifest)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
bytes.push(b'\n');
self.fs
.write(&path, &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}

View File

@ -0,0 +1,15 @@
//! Filesystem-backed persistence stores (ARCHITECTURE §5, §9.2).
//!
//! L2 ships [`FsProjectStore`], implementing the domain [`ProjectStore`] port:
//! the known-projects **registry** and the **workspace** are stored as plain
//! JSON files in the app data directory (machine-local, outside any project).
mod context;
mod profile;
mod project;
mod template;
pub use context::IdeaiContextStore;
pub use profile::FsProfileStore;
pub use project::FsProjectStore;
pub use template::FsTemplateStore;

View File

@ -0,0 +1,149 @@
//! [`FsProfileStore`] — JSON file implementation of the [`ProfileStore`] port.
//!
//! Persists the configured [`AgentProfile`]s in the global IDE store
//! (ARCHITECTURE §9.2):
//!
//! ```text
//! <app_data_dir>/
//! └── profiles.json # { version, profiles: [AgentProfile, ...] }
//! ```
//!
//! Each profile item is exactly the declarative profile of CONTEXT §9
//! (`id, name, command, args, contextInjection{strategy,…}, detect, cwd`). The
//! existence of `profiles.json` is what marks the first run as *done* (see
//! [`ProfileStore::is_configured`]).
//!
//! Like [`super::FsProjectStore`], the store is Tauri-agnostic: the app-data
//! directory is resolved by the composition root and injected as a plain path,
//! and all I/O goes through the [`FileSystem`] port.
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ids::ProfileId;
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError};
use domain::profile::AgentProfile;
/// File name of the profiles store inside the app-data dir.
const PROFILES_FILE: &str = "profiles.json";
/// Current schema version of the profiles file.
const PROFILES_VERSION: u32 = 1;
/// On-disk shape of `profiles.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProfilesDoc {
/// Schema version.
version: u32,
/// All configured profiles.
profiles: Vec<AgentProfile>,
}
impl Default for ProfilesDoc {
fn default() -> Self {
Self {
version: PROFILES_VERSION,
profiles: Vec::new(),
}
}
}
/// JSON-file implementation of the [`ProfileStore`] port.
///
/// Cheap to clone (everything is behind `Arc`); built once at the composition
/// root and shared across use cases.
#[derive(Clone)]
pub struct FsProfileStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsProfileStore {
/// Builds the store from an injected [`FileSystem`] and the app-data
/// directory path (resolved by the composition root). The directory is
/// created lazily on first write.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
/// Joins the app-data dir with the profiles file name (POSIX separator, valid
/// on every target — `tokio::fs` accepts `/` on Windows too).
fn path(&self) -> RemotePath {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
RemotePath::new(format!("{base}/{PROFILES_FILE}"))
}
/// Reads and parses the doc, returning an empty default if the file is absent.
async fn read_doc(&self) -> Result<ProfilesDoc, StoreError> {
match self.fs.read(&self.path()).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
Err(domain::ports::FsError::NotFound(_)) => Ok(ProfilesDoc::default()),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
/// Writes the doc, ensuring the app-data dir exists first.
async fn write_doc(&self, doc: &ProfilesDoc) -> Result<(), StoreError> {
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
self.fs
.create_dir_all(&dir)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
let bytes =
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
self.fs
.write(&self.path(), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}
#[async_trait]
impl ProfileStore for FsProfileStore {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
Ok(self.read_doc().await?.profiles)
}
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
let mut doc = self.read_doc().await?;
if let Some(slot) = doc.profiles.iter_mut().find(|p| p.id == profile.id) {
*slot = profile.clone();
} else {
doc.profiles.push(profile.clone());
}
self.write_doc(&doc).await
}
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
let mut doc = self.read_doc().await?;
let before = doc.profiles.len();
doc.profiles.retain(|p| p.id != id);
if doc.profiles.len() == before {
return Err(StoreError::NotFound);
}
self.write_doc(&doc).await
}
async fn is_configured(&self) -> Result<bool, StoreError> {
self.fs
.exists(&self.path())
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
async fn mark_configured(&self) -> Result<(), StoreError> {
// Write the current doc back (an empty default when nothing exists),
// which materialises `profiles.json` and records first-run completion.
let doc = self.read_doc().await?;
self.write_doc(&doc).await
}
}

View File

@ -0,0 +1,156 @@
//! [`FsProjectStore`] — JSON file implementation of the [`ProjectStore`] port.
//!
//! Persistence layout (under the injected app-data directory, ARCHITECTURE §9.2):
//!
//! ```text
//! <app_data_dir>/
//! ├── projects.json # the known-projects registry { version, projects: [Project, ...] }
//! └── workspace.json # the persisted Workspace (windows/tabs/layouts)
//! ```
//!
//! The store does **not** know about Tauri: the app-data directory is resolved
//! by the composition root and handed in as a plain path (Dependency Inversion).
//! All I/O goes through the [`FileSystem`] port (here [`LocalFileSystem`]) so the
//! store stays decoupled from `tokio::fs` directly and is reusable as-is.
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ids::ProjectId;
use domain::layout::Workspace;
use domain::ports::{FileSystem, ProjectStore, RemotePath, StoreError};
use domain::project::Project;
/// File name of the known-projects registry inside the app-data dir.
const REGISTRY_FILE: &str = "projects.json";
/// File name of the persisted workspace inside the app-data dir.
const WORKSPACE_FILE: &str = "workspace.json";
/// Current schema version of the registry file.
const REGISTRY_VERSION: u32 = 1;
/// On-disk shape of the registry file.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Registry {
/// Schema version.
version: u32,
/// All known projects.
projects: Vec<Project>,
}
/// JSON-file implementation of the [`ProjectStore`] port.
///
/// Cheap to clone (everything is behind `Arc`); the composition root constructs
/// it once and shares it across use cases.
#[derive(Clone)]
pub struct FsProjectStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsProjectStore {
/// Builds the store from an injected [`FileSystem`] and the app-data
/// directory path (resolved by the composition root, e.g. via the Tauri path
/// API). The directory is created lazily on first write.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
/// Joins the app-data dir with a file name using a POSIX separator (valid on
/// every target — `tokio::fs` accepts `/` on Windows too).
fn path(&self, file: &str) -> RemotePath {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
RemotePath::new(format!("{base}/{file}"))
}
/// Reads and parses the registry, returning an empty one if the file does
/// not exist yet.
async fn read_registry(&self) -> Result<Registry, StoreError> {
let path = self.path(REGISTRY_FILE);
match self.fs.read(&path).await {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| StoreError::Serialization(e.to_string())),
Err(domain::ports::FsError::NotFound(_)) => Ok(Registry {
version: REGISTRY_VERSION,
projects: Vec::new(),
}),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
/// Writes the registry, ensuring the app-data dir exists first.
async fn write_registry(&self, registry: &Registry) -> Result<(), StoreError> {
self.ensure_dir().await?;
let bytes = serde_json::to_vec_pretty(registry)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
self.fs
.write(&self.path(REGISTRY_FILE), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
/// Creates the app-data directory and all missing parents.
async fn ensure_dir(&self) -> Result<(), StoreError> {
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
self.fs
.create_dir_all(&dir)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}
#[async_trait]
impl ProjectStore for FsProjectStore {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
Ok(self.read_registry().await?.projects)
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
self.read_registry()
.await?
.projects
.into_iter()
.find(|p| p.id == id)
.ok_or(StoreError::NotFound)
}
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
let mut registry = self.read_registry().await?;
if let Some(slot) = registry.projects.iter_mut().find(|p| p.id == project.id) {
*slot = project.clone();
} else {
registry.projects.push(project.clone());
}
self.write_registry(&registry).await
}
async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> {
self.ensure_dir().await?;
let bytes = serde_json::to_vec_pretty(workspace)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
self.fs
.write(&self.path(WORKSPACE_FILE), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
let path = self.path(WORKSPACE_FILE);
match self.fs.read(&path).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
// No workspace persisted yet: return the empty default.
Err(domain::ports::FsError::NotFound(_)) => Ok(Workspace::default()),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
}

View File

@ -0,0 +1,225 @@
//! [`FsTemplateStore`] — file implementation of the [`TemplateStore`] port
//! (ARCHITECTURE §5, §9.2).
//!
//! Templates live in the **global IDE store** (machine-local app-data dir, *not*
//! inside any project): the Markdown content travels as a diffable `.md`, with a
//! small JSON index carrying the metadata (version, hash) needed to list and
//! version templates without parsing every `.md`:
//!
//! ```text
//! <app_data_dir>/templates/
//! ├── index.json # { version, templates: [{ id, name, version, contentHash, defaultProfileId }] }
//! └── md/
//! └── <id>.md # a template's Markdown content
//! ```
//!
//! `contentHash` is a stable digest of the `.md` content, recorded so a future
//! spike can detect **out-of-app edits** (ARCHITECTURE §13.9); it is not part of
//! the [`AgentTemplate`] domain entity. Like the other stores, all I/O goes
//! through the [`FileSystem`] port, so the adapter is Tauri-agnostic.
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ids::{ProfileId, TemplateId};
use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore};
use domain::template::{AgentTemplate, TemplateVersion};
/// Directory (under app-data) holding the templates store.
const TEMPLATES_DIR: &str = "templates";
/// Index file name inside the templates dir.
const INDEX_FILE: &str = "index.json";
/// Current schema version of the index file.
const INDEX_VERSION: u32 = 1;
/// One metadata row in `index.json` (the `.md` content lives separately).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexEntry {
id: TemplateId,
name: String,
version: TemplateVersion,
content_hash: String,
default_profile_id: ProfileId,
}
/// On-disk shape of `templates/index.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexDoc {
version: u32,
templates: Vec<IndexEntry>,
}
impl Default for IndexDoc {
fn default() -> Self {
Self {
version: INDEX_VERSION,
templates: Vec::new(),
}
}
}
/// A stable, dependency-free digest of Markdown content for out-of-app edit
/// detection. `DefaultHasher::new()` uses fixed keys, so this is deterministic
/// across runs and platforms (unlike a `RandomState`-seeded hasher).
fn content_hash(md: &MarkdownDoc) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
md.as_str().hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
/// File-backed [`TemplateStore`], composing a [`FileSystem`] port.
#[derive(Clone)]
pub struct FsTemplateStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsTemplateStore {
/// Builds the store from an injected [`FileSystem`] and the app-data dir
/// (resolved by the composition root). Directories are created on first write.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
/// `<app>/templates`.
fn dir(&self) -> String {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
format!("{base}/{TEMPLATES_DIR}")
}
/// `<app>/templates/index.json`.
fn index_path(&self) -> RemotePath {
RemotePath::new(format!("{}/{INDEX_FILE}", self.dir()))
}
/// `<app>/templates/md/<id>.md`.
fn md_path(&self, id: TemplateId) -> RemotePath {
RemotePath::new(format!("{}/md/{id}.md", self.dir()))
}
/// Reads the index, returning an empty default if absent.
async fn read_index(&self) -> Result<IndexDoc, StoreError> {
match self.fs.read(&self.index_path()).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
Err(domain::ports::FsError::NotFound(_)) => Ok(IndexDoc::default()),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
/// Writes the index, ensuring `templates/` exists.
async fn write_index(&self, doc: &IndexDoc) -> Result<(), StoreError> {
self.fs
.create_dir_all(&RemotePath::new(self.dir()))
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
let bytes =
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
self.fs
.write(&self.index_path(), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
/// Reconstructs the [`AgentTemplate`] for an index entry by reading its `.md`.
async fn load(&self, entry: &IndexEntry) -> Result<AgentTemplate, StoreError> {
let bytes = self
.fs
.read(&self.md_path(entry.id))
.await
.map_err(|e| match e {
domain::ports::FsError::NotFound(_) => StoreError::NotFound,
other => StoreError::Io(other.to_string()),
})?;
let content =
String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
// The domain entity carries the authoritative version/name from the index;
// we reconstruct it directly (no public mutator needed) since every field
// is known and already validated when it was first saved.
Ok(AgentTemplate {
id: entry.id,
name: entry.name.clone(),
content_md: MarkdownDoc::new(content),
version: entry.version,
default_profile_id: entry.default_profile_id,
})
}
}
#[async_trait]
impl TemplateStore for FsTemplateStore {
async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError> {
let index = self.read_index().await?;
let mut out = Vec::with_capacity(index.templates.len());
for entry in &index.templates {
out.push(self.load(entry).await?);
}
Ok(out)
}
async fn get(&self, id: TemplateId) -> Result<AgentTemplate, StoreError> {
let index = self.read_index().await?;
let entry = index
.templates
.iter()
.find(|e| e.id == id)
.ok_or(StoreError::NotFound)?;
self.load(entry).await
}
async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError> {
// (1) Write the Markdown content.
self.fs
.create_dir_all(&RemotePath::new(format!("{}/md", self.dir())))
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
self.fs
.write(
&self.md_path(template.id),
template.content_md.as_str().as_bytes(),
)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
// (2) Upsert the index metadata.
let mut index = self.read_index().await?;
let row = IndexEntry {
id: template.id,
name: template.name.clone(),
version: template.version,
content_hash: content_hash(&template.content_md),
default_profile_id: template.default_profile_id,
};
if let Some(slot) = index.templates.iter_mut().find(|e| e.id == template.id) {
*slot = row;
} else {
index.templates.push(row);
}
self.write_index(&index).await
}
async fn delete(&self, id: TemplateId) -> Result<(), StoreError> {
let mut index = self.read_index().await?;
let before = index.templates.len();
index.templates.retain(|e| e.id != id);
if index.templates.len() == before {
return Err(StoreError::NotFound);
}
// The orphaned `md/<id>.md` is left on disk (the FileSystem port exposes no
// delete); the index no longer references it, so it is effectively gone.
self.write_index(&index).await
}
}