fix: fix some displays and features

This commit is contained in:
2026-06-06 17:06:45 +02:00
parent 2332b7f815
commit 3be55795a6
31 changed files with 3118 additions and 30 deletions

View File

@ -32,4 +32,6 @@ pub use process::LocalProcessSpawner;
pub use pty::PortablePtyAdapter;
pub use remote::{remote_host, LocalHost};
pub use runtime::CliAgentRuntime;
pub use store::{FsProfileStore, FsProjectStore, FsTemplateStore, IdeaiContextStore};
pub use store::{
FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, IdeaiContextStore,
};

View File

@ -7,9 +7,11 @@
mod context;
mod profile;
mod project;
mod skill;
mod template;
pub use context::IdeaiContextStore;
pub use profile::FsProfileStore;
pub use project::FsProjectStore;
pub use skill::FsSkillStore;
pub use template::FsTemplateStore;

View File

@ -0,0 +1,263 @@
//! [`FsSkillStore`] — file implementation of the [`SkillStore`] port
//! (ARCHITECTURE §14.2, L12).
//!
//! Skills are reusable, model-agnostic workflows. They live in **two isolated
//! scopes**, each backed by its own directory but the same on-disk shape
//! (mirroring [`crate::store::FsTemplateStore`]): a small JSON index carrying the
//! metadata needed to list without parsing every `.md`, plus the Markdown bodies
//! under `md/`:
//!
//! ```text
//! <app_data_dir>/skills/ # SkillScope::Global (shared across projects)
//! <project_root>/.ideai/skills/ # SkillScope::Project (travels with the code)
//! ├── index.json # { version, skills: [{ id, name, contentHash }] }
//! └── md/
//! └── <id>.md # a skill's Markdown content
//! ```
//!
//! The two scopes never bleed into one another: a `Project` skill is invisible to
//! a `Global` listing and vice-versa, because each resolves to a different
//! directory. All I/O goes through the [`FileSystem`] port, so the adapter is
//! location-neutral (a project hosted over SSH/WSL works unchanged) and
//! Tauri-agnostic.
//!
//! Like the template store, [`delete`](SkillStore::delete) drops the index row
//! and leaves the orphaned `md/<id>.md` on disk (the [`FileSystem`] port exposes
//! no remove); since listing is index-driven, the skill is effectively gone.
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ids::SkillId;
use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
use domain::project::ProjectPath;
use domain::skill::{Skill, SkillScope};
/// Directory (under app-data) holding the global skills store.
const GLOBAL_SKILLS_DIR: &str = "skills";
/// The `.ideai/` directory name inside a project root.
const IDEAI_DIR: &str = ".ideai";
/// Sub-path of the project-scoped skills store inside `.ideai/`.
const PROJECT_SKILLS_DIR: &str = "skills";
/// Index file name inside a skills 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: SkillId,
name: String,
content_hash: String,
}
/// On-disk shape of a scope's `index.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexDoc {
version: u32,
skills: Vec<IndexEntry>,
}
impl Default for IndexDoc {
fn default() -> Self {
Self {
version: INDEX_VERSION,
skills: Vec::new(),
}
}
}
/// A stable, dependency-free digest of Markdown content for out-of-app edit
/// detection — deterministic across runs and platforms (fixed-key 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 [`SkillStore`], composing a [`FileSystem`] port. Holds only the
/// machine-global app-data dir; the project root for [`SkillScope::Project`] is
/// supplied **per call**, so a single instance serves every open project
/// (mirroring [`crate::store::IdeaiContextStore`]).
#[derive(Clone)]
pub struct FsSkillStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsSkillStore {
/// Builds the store from an injected [`FileSystem`] and the global app-data
/// dir (used for [`SkillScope::Global`]). 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(),
}
}
/// Root directory of a scope's store. `root` is ignored for `Global`.
fn dir(&self, scope: SkillScope, root: &ProjectPath) -> String {
match scope {
SkillScope::Global => {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
format!("{base}/{GLOBAL_SKILLS_DIR}")
}
SkillScope::Project => {
let base = root.as_str().trim_end_matches(['/', '\\']);
format!("{base}/{IDEAI_DIR}/{PROJECT_SKILLS_DIR}")
}
}
}
/// `<scope-dir>/index.json`.
fn index_path(&self, scope: SkillScope, root: &ProjectPath) -> RemotePath {
RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(scope, root)))
}
/// `<scope-dir>/md/<id>.md`.
fn md_path(&self, scope: SkillScope, root: &ProjectPath, id: SkillId) -> RemotePath {
RemotePath::new(format!("{}/md/{id}.md", self.dir(scope, root)))
}
/// Reads a scope's index, returning an empty default if absent.
async fn read_index(
&self,
scope: SkillScope,
root: &ProjectPath,
) -> Result<IndexDoc, StoreError> {
match self.fs.read(&self.index_path(scope, root)).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 a scope's index, ensuring its directory exists.
async fn write_index(
&self,
scope: SkillScope,
root: &ProjectPath,
doc: &IndexDoc,
) -> Result<(), StoreError> {
self.fs
.create_dir_all(&RemotePath::new(self.dir(scope, root)))
.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(scope, root), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
/// Reconstructs the [`Skill`] for an index entry by reading its `.md`.
async fn load(
&self,
scope: SkillScope,
root: &ProjectPath,
entry: &IndexEntry,
) -> Result<Skill, StoreError> {
let bytes = self
.fs
.read(&self.md_path(scope, root, 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()))?;
Skill::new(entry.id, entry.name.clone(), MarkdownDoc::new(content), scope)
.map_err(|e| StoreError::Serialization(e.to_string()))
}
}
#[async_trait]
impl SkillStore for FsSkillStore {
async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
let index = self.read_index(scope, root).await?;
let mut out = Vec::with_capacity(index.skills.len());
for entry in &index.skills {
out.push(self.load(scope, root, entry).await?);
}
Ok(out)
}
async fn get(
&self,
scope: SkillScope,
root: &ProjectPath,
id: SkillId,
) -> Result<Skill, StoreError> {
let index = self.read_index(scope, root).await?;
let entry = index
.skills
.iter()
.find(|e| e.id == id)
.ok_or(StoreError::NotFound)?;
self.load(scope, root, entry).await
}
async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError> {
let scope = skill.scope;
// (1) Write the Markdown content.
self.fs
.create_dir_all(&RemotePath::new(format!("{}/md", self.dir(scope, root))))
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
self.fs
.write(
&self.md_path(scope, root, skill.id),
skill.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(scope, root).await?;
let row = IndexEntry {
id: skill.id,
name: skill.name.clone(),
content_hash: content_hash(&skill.content_md),
};
if let Some(slot) = index.skills.iter_mut().find(|e| e.id == skill.id) {
*slot = row;
} else {
index.skills.push(row);
}
self.write_index(scope, root, &index).await
}
async fn delete(
&self,
scope: SkillScope,
root: &ProjectPath,
id: SkillId,
) -> Result<(), StoreError> {
let mut index = self.read_index(scope, root).await?;
let before = index.skills.len();
index.skills.retain(|e| e.id != id);
if index.skills.len() == before {
return Err(StoreError::NotFound);
}
// The orphaned `md/<id>.md` is left on disk (no FileSystem delete); the
// index no longer references it, so it is effectively gone.
self.write_index(scope, root, &index).await
}
}