Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 119 additions & 30 deletions app/src/ai/skills/file_watchers/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,45 +14,143 @@ use warpui::AppContext;

use crate::warp_managed_paths_watcher::warp_managed_skill_dirs;

/// Finds all skill directories in a repository by querying the RepoMetadataModel tree.
/// Max directory depth walked below a lazy node when searching for provider skill dirs.
const MAX_LAZY_WALK_DEPTH: usize = 3;

/// Walks `dir` up to `MAX_LAZY_WALK_DEPTH` levels deep, probing each level for
/// provider skill directories. `.git` entries are pruned to avoid crossing nested
/// repository boundaries.
fn probe_lazy_subtree(
dir: &Path,
depth: usize,
result_set: &mut HashSet<PathBuf>,
result: &mut Vec<PathBuf>,
) {
for provider in SKILL_PROVIDER_DEFINITIONS.iter() {
let skills_path = dir.join(&provider.skills_path);
if !result_set.contains(&skills_path) && skills_path.is_dir() {
result_set.insert(skills_path.clone());
result.push(skills_path);
}
}

if depth >= MAX_LAZY_WALK_DEPTH {
return;
}

let Ok(entries) = std::fs::read_dir(dir) else {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This synchronous read_dir recursion runs from find_skill_directories_in_tree, which is called on model-context repo update paths; probing large ignored directories such as node_modules or target can block the app. Move the filesystem probing to a background task or keep it outside the model read path.

return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
// Prune .git to avoid crossing nested repository boundaries.
if path.file_name().and_then(|n| n.to_str()) == Some(".git") {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Skipping only the .git entry does not prune nested repository roots: the traversal still enters nested-repo/ and can discover nested-repo/.agents/skills before ignoring nested-repo/.git. Skip directories that contain a .git marker before probing or recursing into them.

continue;
}
probe_lazy_subtree(&path, depth + 1, result_set, result);
}
}

/// Returns all skill directories in the repo tree (e.g. `/repo/.agents/skills/`).
///
/// Uses two passes to handle gitignored provider directories:
///
/// - **Pass 1:** collect fully-loaded dirs ending with a known provider skills path.
/// - **Pass 2:** collect lazy-loaded dirs (`loaded: false`) and probe with `is_dir()`:
/// - *(a)* lazy dir is a provider root (`.agents`, `.claude`, …) → probe `{dir}/skills`.
/// - *(b)* lazy dir is an ancestor of a provider root → walk up to
/// `MAX_LAZY_WALK_DEPTH` levels, probing for provider paths at each level.
///
/// Returns a list of paths to skill directories (e.g., `/repo/.agents/skills/`, `/repo/sub/.claude/skills/`).
/// Pass 2 scope is bounded by the tree: lazy dirs only appear as children of indexed
/// parents, so the walk starts from directories the indexer already encountered.
pub fn find_skill_directories_in_tree(
repo_path: &Path,
repo_metadata: &RepoMetadataModel,
ctx: &AppContext,
) -> Vec<PathBuf> {
// Collect provider skills paths (e.g., ".agents/skills", ".claude/skills")
let skill_path_suffixes: Vec<&Path> = SKILL_PROVIDER_DEFINITIONS
let Some(id) = repo_metadata::RepositoryIdentifier::try_local(repo_path) else {
return Vec::new();
};

// Provider skills paths (e.g. ".agents/skills") and root names (e.g. ".agents").
let skill_path_suffixes: Vec<String> = SKILL_PROVIDER_DEFINITIONS
.iter()
.map(|p| p.skills_path.to_string_lossy().into_owned())
.collect();

let provider_root_names: HashSet<String> = SKILL_PROVIDER_DEFINITIONS
.iter()
.map(|p| p.skills_path.as_path())
.filter_map(|p| {
p.skills_path
.parent()
.and_then(Path::file_name)
.and_then(|n| n.to_str())
.map(str::to_owned)
})
.collect();

// Filter during traversal: only collect directories that end with a skill provider path.
// The filter rejects files and non-matching directories, avoiding intermediate allocations.
// Pass 1: loaded dirs whose path ends with a known provider skills suffix.
let suffixes_1 = skill_path_suffixes.clone();
let args = GetContentsArgs::default().with_filter(move |content| {
let RepoContent::Directory(dir) = content else {
return false;
};
skill_path_suffixes
suffixes_1
.iter()
.any(|suffix| dir.path.ends_with(&suffix.to_string_lossy()))
.any(|suffix| dir.path.ends_with(suffix.as_str()))
});

let Some(id) = repo_metadata::RepositoryIdentifier::try_local(repo_path) else {
return Vec::new();
};
repo_metadata
let mut result: Vec<PathBuf> = repo_metadata
.get_repo_contents(&id, args, ctx)
.unwrap_or_default()
.into_iter()
// Only directories should reach this iterator due to the GetContentsArgs::filter.
// Keep the File arm for exhaustive matching in case RepoContent grows new variants.
.map(|content| match content {
RepoContent::Directory(dir) => dir.path.to_local_path_lossy(),
RepoContent::File(f) => f.path.to_local_path_lossy(),
})
.collect()
.collect();

// Pass 2: probe lazy-loaded (gitignored) dirs.
let mut result_set: HashSet<PathBuf> = result.iter().cloned().collect();
let args_lazy = GetContentsArgs::default()
.include_ignored()
.with_filter(move |content| {
let RepoContent::Directory(dir) = content else {
return false;
};
!dir.loaded
Comment thread
rajgandhi1 marked this conversation as resolved.
});

let lazy_dirs: Vec<PathBuf> = repo_metadata
.get_repo_contents(&id, args_lazy, ctx)
.unwrap_or_default()
.into_iter()
.map(|content| match content {
RepoContent::Directory(dir) => dir.path.to_local_path_lossy(),
RepoContent::File(f) => f.path.to_local_path_lossy(),
})
.collect();

for lazy_dir in lazy_dirs {
let dir_name = lazy_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
if provider_root_names.contains(dir_name) {
// Case (a): lazy dir is a provider root — probe {dir}/skills.
let skills_path = lazy_dir.join("skills");
if !result_set.contains(&skills_path) && skills_path.is_dir() {
result_set.insert(skills_path.clone());
result.push(skills_path);
}
} else {
// Case (b): lazy dir may be an ancestor of a provider root — walk
// its subtree up to MAX_LAZY_WALK_DEPTH levels.
probe_lazy_subtree(&lazy_dir, 0, &mut result_set, &mut result);
}
}

result
}

/// Reads all skills from the given skill directories.
Expand All @@ -70,22 +168,19 @@ pub fn is_skill_file(path: &Path) -> bool {
}

static SKILL_PROVIDER_PATHS: LazyLock<HashSet<String>> = LazyLock::new(|| {
// Collect the skill provider paths from the definitions
SKILL_PROVIDER_DEFINITIONS
.iter()
.map(|p| p.skills_path.to_string_lossy().to_string())
.collect()
});

// Pattern: {prefix}/{provider_path}/{skill-name}/SKILL.md
// where provider_path is 2 parts (e.g., ".agents/skills") and skill-name is 1 part
// Matches {prefix}/{provider_path}/{skill-name}/SKILL.md (provider_path is 2 components).
#[cfg(not(target_os = "windows"))]
static SKILL_FILE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(.+)/([^/]+/[^/]+)/[^/]+/SKILL\.md$")
.expect("Failed to compile skill file pattern")
});

// On windows, the path separator is \
#[cfg(target_os = "windows")]
static SKILL_FILE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(.+)\\([^\\]+\\[^\\]+)\\[^\\]+\\SKILL\.md$")
Expand Down Expand Up @@ -120,19 +215,13 @@ pub fn extract_skill_parent_directory(path: &Path) -> Result<PathBuf, Error> {
Err(anyhow::anyhow!("Not a skill path: {}", path.display()))
}

/// Check if this path is a skill directory under a home directory provider path
/// E.g. ~/.agents/skills/skill-name
/// Returns true if `path` is a skill directory under a home provider path
/// (e.g. `~/.agents/skills/skill-name`).
pub fn is_home_skill_directory(path: &Path) -> bool {
let parent_directory = path.parent();
if let Some(parent_directory) = parent_directory {
is_home_provider_path(parent_directory)
} else {
false
}
path.parent().is_some_and(is_home_provider_path)
}

/// Check if this path is a home directory provider path
/// E.g. ~/.agents/skills
/// Returns true if `path` is a home provider skills path (e.g. `~/.agents/skills`).
pub fn is_home_provider_path(path: &Path) -> bool {
SKILL_PROVIDER_DEFINITIONS.iter().any(|provider| {
if provider.provider == SkillProvider::Warp {
Expand Down
Loading