greppy/web/
projects.rs

1//! Project selector API endpoints
2//!
3//! Provides endpoints for listing and switching between indexed projects.
4
5use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use std::path::PathBuf;
9use std::sync::{Arc, RwLock};
10
11use crate::core::config::Config;
12use crate::trace::trace_index_exists;
13
14// =============================================================================
15// TYPES
16// =============================================================================
17
18/// Project information for the selector dropdown
19#[derive(Serialize, Clone)]
20pub struct ProjectInfo {
21    /// Display name (folder name)
22    pub name: String,
23    /// Full path to project
24    pub path: String,
25    /// Whether this is the currently active project
26    pub active: bool,
27    /// Whether the index exists and is valid
28    pub indexed: bool,
29}
30
31/// Response for GET /api/projects
32#[derive(Serialize)]
33pub struct ProjectsResponse {
34    pub projects: Vec<ProjectInfo>,
35}
36
37/// Request body for POST /api/projects/switch
38#[derive(Deserialize)]
39pub struct SwitchProjectRequest {
40    pub path: String,
41}
42
43/// Response for POST /api/projects/switch
44#[derive(Serialize)]
45pub struct SwitchProjectResponse {
46    pub success: bool,
47    pub message: String,
48}
49
50/// Shared state for project management
51#[derive(Clone)]
52pub struct ProjectsState {
53    /// Currently active project path
54    pub active_path: Arc<RwLock<PathBuf>>,
55}
56
57// =============================================================================
58// HELPERS
59// =============================================================================
60
61/// Common locations to scan for .greppy directories
62fn get_scan_locations() -> Vec<PathBuf> {
63    let mut locations = Vec::new();
64
65    // Home directory
66    if let Some(home) = dirs::home_dir() {
67        locations.push(home.clone());
68        locations.push(home.join("Desktop"));
69        locations.push(home.join("Documents"));
70        locations.push(home.join("projects"));
71        locations.push(home.join("Projects"));
72        locations.push(home.join("code"));
73        locations.push(home.join("Code"));
74        locations.push(home.join("dev"));
75        locations.push(home.join("Dev"));
76        locations.push(home.join("src"));
77        locations.push(home.join("work"));
78        locations.push(home.join("Work"));
79        locations.push(home.join("repos"));
80        locations.push(home.join("Repos"));
81    }
82
83    // Current directory
84    if let Ok(cwd) = std::env::current_dir() {
85        locations.push(cwd);
86    }
87
88    locations
89}
90
91/// Scan a directory for projects with .greppy index
92fn scan_for_projects(dir: &PathBuf, max_depth: usize, found: &mut HashSet<PathBuf>) {
93    if max_depth == 0 || !dir.is_dir() {
94        return;
95    }
96
97    // Check if this directory has a .greppy folder
98    let greppy_dir = dir.join(".greppy");
99    if greppy_dir.exists() && greppy_dir.is_dir() {
100        found.insert(dir.clone());
101    }
102
103    // Scan subdirectories (skip hidden and common non-project dirs)
104    if let Ok(entries) = std::fs::read_dir(dir) {
105        for entry in entries.flatten() {
106            let path = entry.path();
107            if !path.is_dir() {
108                continue;
109            }
110
111            let name = path
112                .file_name()
113                .map(|n| n.to_string_lossy().to_string())
114                .unwrap_or_default();
115
116            // Skip hidden directories and common non-project directories
117            if name.starts_with('.')
118                || name == "node_modules"
119                || name == "target"
120                || name == "dist"
121                || name == "build"
122                || name == "__pycache__"
123                || name == "venv"
124                || name == ".venv"
125                || name == "vendor"
126            {
127                continue;
128            }
129
130            scan_for_projects(&path, max_depth - 1, found);
131        }
132    }
133}
134
135/// Find all indexed projects
136pub fn discover_projects(active_path: &PathBuf) -> Vec<ProjectInfo> {
137    let mut found = HashSet::new();
138
139    // Scan common locations
140    for location in get_scan_locations() {
141        if location.exists() {
142            scan_for_projects(&location, 3, &mut found);
143        }
144    }
145
146    // Also check greppy's registry if it exists
147    if let Ok(registry_path) = Config::registry_path() {
148        if registry_path.exists() {
149            if let Ok(content) = std::fs::read_to_string(&registry_path) {
150                if let Ok(registry) = serde_json::from_str::<serde_json::Value>(&content) {
151                    if let Some(projects) = registry.get("projects").and_then(|p| p.as_array()) {
152                        for project in projects {
153                            if let Some(path) = project.get("path").and_then(|p| p.as_str()) {
154                                let path_buf = PathBuf::from(path);
155                                if path_buf.exists() {
156                                    found.insert(path_buf);
157                                }
158                            }
159                        }
160                    }
161                }
162            }
163        }
164    }
165
166    // Convert to ProjectInfo
167    let mut projects: Vec<ProjectInfo> = found
168        .into_iter()
169        .map(|path| {
170            let name = path
171                .file_name()
172                .map(|n| n.to_string_lossy().to_string())
173                .unwrap_or_else(|| "unknown".to_string());
174
175            let is_active = path == *active_path;
176            let indexed = trace_index_exists(&path);
177
178            ProjectInfo {
179                name,
180                path: path.to_string_lossy().to_string(),
181                active: is_active,
182                indexed,
183            }
184        })
185        .collect();
186
187    // Sort: active first, then alphabetically by name
188    projects.sort_by(|a, b| {
189        if a.active && !b.active {
190            std::cmp::Ordering::Less
191        } else if !a.active && b.active {
192            std::cmp::Ordering::Greater
193        } else {
194            a.name.to_lowercase().cmp(&b.name.to_lowercase())
195        }
196    });
197
198    projects
199}
200
201// =============================================================================
202// ROUTE HANDLERS
203// =============================================================================
204
205/// GET /api/projects - List all discovered projects
206pub async fn api_projects(State(state): State<ProjectsState>) -> Json<ProjectsResponse> {
207    let active_path = state.active_path.read().unwrap().clone();
208    let projects = discover_projects(&active_path);
209    Json(ProjectsResponse { projects })
210}
211
212/// POST /api/projects/switch - Switch to a different project
213///
214/// Note: This endpoint signals that a switch is requested. The actual switch
215/// requires reloading the server with the new project, which the frontend
216/// handles by triggering a page reload.
217pub async fn api_switch_project(
218    State(state): State<ProjectsState>,
219    Json(request): Json<SwitchProjectRequest>,
220) -> impl IntoResponse {
221    let path = PathBuf::from(&request.path);
222
223    // Validate the project exists
224    if !path.exists() {
225        return (
226            StatusCode::NOT_FOUND,
227            Json(SwitchProjectResponse {
228                success: false,
229                message: format!("Project not found: {}", request.path),
230            }),
231        );
232    }
233
234    // Check if it has an index
235    if !trace_index_exists(&path) {
236        return (
237            StatusCode::BAD_REQUEST,
238            Json(SwitchProjectResponse {
239                success: false,
240                message: format!(
241                    "Project not indexed: {}. Run 'greppy index' first.",
242                    request.path
243                ),
244            }),
245        );
246    }
247
248    // Update the active path
249    {
250        let mut active = state.active_path.write().unwrap();
251        *active = path;
252    }
253
254    // Save to recent projects file
255    let _ = save_recent_project(&request.path);
256
257    (
258        StatusCode::OK,
259        Json(SwitchProjectResponse {
260            success: true,
261            message: format!("Switched to: {}", request.path),
262        }),
263    )
264}
265
266/// Save a project to the recent projects list
267fn save_recent_project(path: &str) -> std::io::Result<()> {
268    let config_dir = Config::greppy_home()
269        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
270
271    let recent_path = config_dir.join("web-recent-projects.json");
272
273    // Load existing recent projects
274    let mut recent: Vec<String> = if recent_path.exists() {
275        let content = std::fs::read_to_string(&recent_path)?;
276        serde_json::from_str(&content).unwrap_or_default()
277    } else {
278        Vec::new()
279    };
280
281    // Add to front, remove duplicates
282    recent.retain(|p| p != path);
283    recent.insert(0, path.to_string());
284
285    // Keep only last 10
286    recent.truncate(10);
287
288    // Save
289    let content = serde_json::to_string_pretty(&recent)?;
290    std::fs::write(&recent_path, content)?;
291
292    Ok(())
293}