Files
galaxy/app/src/terminal/view/init_project/model.rs
T

599 lines
20 KiB
Rust

use std::path::{Path, PathBuf};
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
use ai::project_context::model::ProjectContextModel;
use enum_iterator::Sequence;
use lsp::supported_servers::LSPServerType;
#[cfg(not(target_family = "wasm"))]
use repo_metadata::repositories::DetectedRepositories;
use warpui::{Entity, ModelContext, SingletonEntity as _};
use crate::{
ai::persisted_workspace::PersistedWorkspace,
settings::CodeSettings,
terminal::view::init_project::{
lsp_server_selector::LSPServerInfo, CodebaseIndexingResult, CreateEnvironmentResult,
InitActionResult, LanguageServersResult, ProjectScopedRulesResult, FILES_TO_CHECK,
LINKABLE_FILES,
},
workspaces::user_workspaces::UserWorkspaces,
};
const INIT_STEP_COUNT: usize = enum_iterator::cardinality::<InitStepKind>();
/// Steps in fixed order - index determines display order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Sequence)]
#[repr(usize)]
pub enum InitStepKind {
Welcome = 0,
CodebaseContext = 1,
LanguageServers = 2,
ProjectScopedRules = 3,
CreateEnvironment = 4,
}
/// Data needed for views to render ready steps, used to create [`InitStepBlock`]s
#[derive(Clone, Debug)]
pub enum InitStepData {
CodebaseContext {
pwd_path: PathBuf,
},
LanguageServers {
servers: Vec<LSPServerInfo>,
repo_path: PathBuf,
},
ProjectScopedRules {
linkable_files: Vec<PathBuf>,
},
CreateEnvironment,
}
/// Status of a step in the /init flow
pub enum InitStepStatus {
/// Async computation in progress (determining if step is needed)
Pending,
/// Ready for user interaction (contains data for view to render)
Ready(InitStepData),
/// User initiated action, e.g. AI generating WARP.md
Running,
/// Done (accepted, skipped, or auto-completed)
Completed(InitActionResult),
}
impl InitStepStatus {
pub fn is_pending(&self) -> bool {
matches!(self, InitStepStatus::Pending)
}
pub fn is_completed(&self) -> bool {
matches!(self, InitStepStatus::Completed(_))
}
pub fn is_running(&self) -> bool {
matches!(self, InitStepStatus::Running)
}
}
pub struct InitStep {
pub kind: InitStepKind,
pub status: InitStepStatus,
}
impl InitStep {
fn new_pending(kind: InitStepKind) -> Self {
Self {
kind,
status: InitStepStatus::Pending,
}
}
fn new_ready(kind: InitStepKind, data: InitStepData) -> Self {
Self {
kind,
status: InitStepStatus::Ready(data),
}
}
fn new_completed(kind: InitStepKind, result: InitActionResult) -> Self {
Self {
kind,
status: InitStepStatus::Completed(result),
}
}
}
pub struct InitProjectModel {
/// Fixed-size array of steps. None = step disabled/not applicable.
/// Index corresponds to InitStepKind discriminant.
steps: [Option<InitStep>; INIT_STEP_COUNT],
/// Index of current step shown to user
current_step_index: usize,
/// Whether the /init was cancelled
is_cancelled: bool,
/// If project already has all setup done before /init started
is_already_setup: bool,
/// Root path for this /init session
#[cfg(feature = "local_fs")]
root_path: PathBuf,
path_env_var: Option<String>,
}
impl InitProjectModel {
/// Create new InitProjectModel
pub fn new(
pwd_path: PathBuf,
path_env_var: Option<String>,
ctx: &mut ModelContext<Self>,
) -> Self {
let is_already_setup = !Self::should_have_available_steps(&pwd_path, ctx);
Self {
steps: [None, None, None, None, None],
current_step_index: 0,
is_cancelled: false,
is_already_setup,
#[cfg(feature = "local_fs")]
root_path: pwd_path,
path_env_var,
}
}
/// Start the /init flow: compute steps, emit welcome step, progress to next
pub fn start(&mut self, ctx: &mut ModelContext<Self>) {
#[cfg(feature = "local_fs")]
let pwd_path = self.root_path.clone();
#[cfg(not(feature = "local_fs"))]
let pwd_path = PathBuf::new();
// Welcome step is always Completed immediately (no async needed)
self.set_step(
InitStepKind::Welcome,
Some(InitStep::new_completed(
InitStepKind::Welcome,
InitActionResult::Welcome,
)),
);
// Start async computations for subsequent steps
self.compute_codebase_context_step(&pwd_path, ctx);
if self.path_env_var.is_some() {
self.compute_language_servers_step(&pwd_path, ctx);
}
self.compute_project_scoped_rules_step(&pwd_path, ctx);
// CreateEnvironment step is always Ready (no async computation)
self.set_step(
InitStepKind::CreateEnvironment,
Some(InitStep::new_ready(
InitStepKind::CreateEnvironment,
InitStepData::CreateEnvironment,
)),
);
// Emit welcome step immediately, then progress to next
ctx.emit(InitProjectModelEvent::InsertStep(InitStepKind::Welcome));
self.maybe_emit_next_step(ctx);
}
/// Check if there are any steps that need user action
pub fn should_have_available_steps(path: &Path, ctx: &warpui::AppContext) -> bool {
// Note that we consider auto-indexing setting to true to satisfy the codebase context step.
// This avoids the potential race condition with the banner showing just when we start auto-indexing.
let has_pending_codebase_context = UserWorkspaces::as_ref(ctx)
.is_codebase_context_enabled(ctx)
&& CodebaseIndexManager::as_ref(ctx)
.get_codebase_index_status_for_path(path, ctx)
.is_none()
&& !*CodeSettings::as_ref(ctx).auto_indexing_enabled;
let has_pending_project_scoped_rules = ProjectContextModel::as_ref(ctx)
.find_applicable_rules(path)
.is_none();
has_pending_codebase_context || has_pending_project_scoped_rules
}
pub fn get_step(&self, kind: InitStepKind) -> Option<&InitStep> {
self.steps
.get(kind as usize)
.and_then(|maybe_step| maybe_step.as_ref())
}
fn get_step_mut(&mut self, kind: InitStepKind) -> Option<&mut InitStep> {
self.steps
.get_mut(kind as usize)
.and_then(|maybe_step| maybe_step.as_mut())
}
fn set_step(&mut self, kind: InitStepKind, step: Option<InitStep>) {
if let Some(slot) = self.steps.get_mut(kind as usize) {
*slot = step;
}
}
pub fn is_cancelled(&self) -> bool {
self.is_cancelled
}
pub fn is_already_setup(&self) -> bool {
self.is_already_setup
}
pub fn is_completed(&self) -> bool {
if self.is_cancelled {
return true;
}
// All non-None steps must be Completed
self.steps.iter().all(|step| {
step.as_ref()
.map(|s| s.status.is_completed())
.unwrap_or(true)
})
}
/// Returns true if /init is still active (not completed and not cancelled)
pub fn is_active(&self) -> bool {
!self.is_completed() && !self.is_cancelled
}
#[cfg(feature = "local_fs")]
pub fn root_path(&self) -> &Path {
&self.root_path
}
pub fn path_env_var(&self) -> Option<&String> {
self.path_env_var.as_ref()
}
/// Mark a step as completed with the given result
pub fn mark_step_completed(
&mut self,
kind: InitStepKind,
result: InitActionResult,
ctx: &mut ModelContext<Self>,
) {
if let Some(step) = self.get_step_mut(kind) {
step.status = InitStepStatus::Completed(result);
ctx.emit(InitProjectModelEvent::StepCompleted(kind));
self.maybe_emit_next_step(ctx);
}
}
/// Mark a step as Running
pub fn mark_step_running(&mut self, kind: InitStepKind, ctx: &mut ModelContext<Self>) {
if let Some(step) = self.get_step_mut(kind) {
step.status = InitStepStatus::Running;
ctx.notify();
}
}
/// Disable the regenerate button for project scoped rules (after user clicks it)
pub fn disable_regenerate_button(&mut self) {
if let Some(step) = self.get_step_mut(InitStepKind::ProjectScopedRules) {
match &mut step.status {
InitStepStatus::Completed(InitActionResult::ProjectScopedRules(
ProjectScopedRulesResult::GenerateNew {
button_disabled, ..
},
))
| InitStepStatus::Completed(InitActionResult::ProjectScopedRules(
ProjectScopedRulesResult::AlreadyExists { button_disabled },
)) => {
*button_disabled = true;
}
_ => {}
}
}
}
/// Cancel the /init flow. This will notify subscribed blocks to rerender.
pub fn cancel(&mut self, ctx: &mut ModelContext<Self>) {
self.is_cancelled = true;
// Mark Ready or Running steps as cancelled/skipped
for step in self.steps.iter_mut().flatten() {
if matches!(
step.status,
InitStepStatus::Ready(_) | InitStepStatus::Running
) {
let skipped_result = match step.kind {
InitStepKind::Welcome => continue,
InitStepKind::CodebaseContext => {
InitActionResult::CodebaseContext(CodebaseIndexingResult::Skipped)
}
InitStepKind::LanguageServers => {
InitActionResult::LanguageServers(LanguageServersResult::Skipped)
}
InitStepKind::ProjectScopedRules => {
InitActionResult::ProjectScopedRules(ProjectScopedRulesResult::Skipped)
}
InitStepKind::CreateEnvironment => {
InitActionResult::CreateEnvironment(CreateEnvironmentResult::Skipped)
}
};
step.status = InitStepStatus::Completed(skipped_result);
}
}
ctx.emit(InitProjectModelEvent::Cancelled);
}
/// Check if next step should be emitted, emit if ready.
/// Only emits if current step is Completed (or None).
fn maybe_emit_next_step(&mut self, ctx: &mut ModelContext<Self>) {
if self.is_cancelled {
return;
}
// Check current step is Completed or None before advancing
let current_step_completed = self.steps[self.current_step_index]
.as_ref()
.map(|s| s.status.is_completed())
.unwrap_or(true);
if !current_step_completed {
return;
}
let next_index = self.current_step_index + 1;
if next_index >= INIT_STEP_COUNT {
ctx.emit(InitProjectModelEvent::InitCompleted);
return;
}
match &self.steps[next_index] {
None => {
// Step disabled/not applicable, skip to next
self.current_step_index = next_index;
self.maybe_emit_next_step(ctx);
}
Some(step) if !step.status.is_pending() => {
// Ready to show (or auto-completed)
self.current_step_index = next_index;
ctx.emit(InitProjectModelEvent::InsertStep(step.kind));
// If auto-completed, immediately try next step
if step.status.is_completed() {
self.maybe_emit_next_step(ctx);
}
}
Some(_) => {
// Still Pending, wait for async to finish
}
}
}
fn compute_codebase_context_step(&mut self, pwd_path: &Path, ctx: &mut ModelContext<Self>) {
if !UserWorkspaces::as_ref(ctx).is_codebase_context_enabled(ctx) {
// Feature disabled, leave as None
return;
}
let codebase_index_manager = CodebaseIndexManager::handle(ctx);
let is_indexed = codebase_index_manager
.as_ref(ctx)
.get_codebase_index_status_for_path(pwd_path, ctx)
.is_some();
if is_indexed {
// Already indexed, mark as completed
self.set_step(
InitStepKind::CodebaseContext,
Some(InitStep::new_completed(
InitStepKind::CodebaseContext,
InitActionResult::CodebaseContext(CodebaseIndexingResult::Accepted),
)),
);
} else {
// Ready for user interaction
self.set_step(
InitStepKind::CodebaseContext,
Some(InitStep::new_ready(
InitStepKind::CodebaseContext,
InitStepData::CodebaseContext {
pwd_path: pwd_path.to_path_buf(),
},
)),
);
}
}
fn compute_language_servers_step(&mut self, pwd_path: &Path, ctx: &mut ModelContext<Self>) {
// Start as Pending
self.set_step(
InitStepKind::LanguageServers,
Some(InitStep::new_pending(InitStepKind::LanguageServers)),
);
let pwd_path = pwd_path.to_path_buf();
#[cfg(not(target_family = "wasm"))]
let repo_root = DetectedRepositories::as_ref(ctx)
.get_root_for_path(&pwd_path)
.unwrap_or_else(|| pwd_path.clone());
#[cfg(target_family = "wasm")]
let repo_root = pwd_path.clone();
let repo_root_for_callback = repo_root.clone();
let executor = lsp::CommandBuilder::new(self.path_env_var.clone());
let http_client =
crate::server::server_api::ServerApiProvider::as_ref(ctx).get_http_client();
ctx.spawn(
async move {
let mut relevant_servers = Vec::new();
for server_type in LSPServerType::all() {
let candidate = server_type.candidate(http_client.clone());
let should_suggest = candidate
.should_suggest_for_repo(&repo_root, &executor)
.await;
if should_suggest {
let is_installed = candidate.is_installed(&executor).await;
relevant_servers.push(LSPServerInfo {
server_type,
is_installed,
});
}
}
relevant_servers
},
move |me, relevant_servers, ctx| {
let repo_root = repo_root_for_callback;
if relevant_servers.is_empty() {
// No relevant servers, mark step as None (skip it)
me.set_step(InitStepKind::LanguageServers, None);
me.maybe_emit_next_step(ctx);
return;
}
// Check if already enabled and filter
let enabled_server_types: Vec<LSPServerType> = {
let enabled_lsp_servers =
PersistedWorkspace::as_ref(ctx).enabled_lsp_servers(&pwd_path);
enabled_lsp_servers
.map(|servers| servers.collect())
.unwrap_or_default()
};
let filtered_servers: Vec<LSPServerInfo> = relevant_servers
.into_iter()
.filter(|info| !enabled_server_types.contains(&info.server_type))
.collect();
if filtered_servers.is_empty() {
// All relevant servers already enabled, auto-complete
me.set_step(
InitStepKind::LanguageServers,
Some(InitStep::new_completed(
InitStepKind::LanguageServers,
InitActionResult::LanguageServers(LanguageServersResult::Accepted {
enabled_servers: enabled_server_types,
servers_to_install: Vec::new(),
}),
)),
);
} else {
// Ready for user interaction
me.set_step(
InitStepKind::LanguageServers,
Some(InitStep::new_ready(
InitStepKind::LanguageServers,
InitStepData::LanguageServers {
servers: filtered_servers,
repo_path: repo_root,
},
)),
);
}
me.maybe_emit_next_step(ctx);
},
);
}
fn compute_project_scoped_rules_step(&mut self, pwd_path: &Path, ctx: &mut ModelContext<Self>) {
// Start as Pending
self.set_step(
InitStepKind::ProjectScopedRules,
Some(InitStep::new_pending(InitStepKind::ProjectScopedRules)),
);
let path_to_check: Vec<PathBuf> = FILES_TO_CHECK
.iter()
.chain(LINKABLE_FILES.iter())
.map(|name| pwd_path.join(name))
.collect();
ctx.spawn(
async move {
let mut exists = vec![];
for path in path_to_check {
if path.exists() {
exists.push(path);
}
}
exists
},
move |me, existing_files, ctx| {
let has_agents_md = existing_files.iter().any(|p| {
p.file_name()
.map(|n| {
let name = n.to_string_lossy().to_lowercase();
name == "agents.md" || name == "warp.md"
})
.unwrap_or(false)
});
if has_agents_md {
// Already has AGENTS.md or WARP.md, mark as completed
me.set_step(
InitStepKind::ProjectScopedRules,
Some(InitStep::new_completed(
InitStepKind::ProjectScopedRules,
InitActionResult::ProjectScopedRules(
ProjectScopedRulesResult::AlreadyExists {
button_disabled: false,
},
),
)),
);
} else {
// Collect linkable files
let linkable_files: Vec<PathBuf> = existing_files
.into_iter()
.filter(|p| {
p.file_name()
.map(|n| {
let name = n.to_string_lossy();
LINKABLE_FILES.iter().any(|lf| name.ends_with(lf))
})
.unwrap_or(false)
})
.collect();
me.set_step(
InitStepKind::ProjectScopedRules,
Some(InitStep::new_ready(
InitStepKind::ProjectScopedRules,
InitStepData::ProjectScopedRules { linkable_files },
)),
);
}
me.maybe_emit_next_step(ctx);
},
);
}
}
/// Events emitted by InitProjectModel
#[derive(Debug, Clone)]
pub enum InitProjectModelEvent {
/// Insert a new step block into the terminal view
InsertStep(InitStepKind),
/// A step was completed
StepCompleted(InitStepKind),
/// The /init flow was cancelled
Cancelled,
/// All steps completed
InitCompleted,
/// Trigger AGENTS.md generation slash command
GenerateProjectRules,
/// Trigger AGENTS.md regeneration
RegenerateProjectRules,
/// View codebase context status
ViewCodebaseContextStatus,
/// Language server installed and enabled
LanguageServerInstalledAndEnabled,
/// Trigger create environment slash command
CreateEnvironment,
/// Cloud environment was created
EnvironmentCreated,
}
impl Entity for InitProjectModel {
type Event = InitProjectModelEvent;
}