1use 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#[derive(Serialize, Clone)]
20pub struct ProjectInfo {
21 pub name: String,
23 pub path: String,
25 pub active: bool,
27 pub indexed: bool,
29}
30
31#[derive(Serialize)]
33pub struct ProjectsResponse {
34 pub projects: Vec<ProjectInfo>,
35}
36
37#[derive(Deserialize)]
39pub struct SwitchProjectRequest {
40 pub path: String,
41}
42
43#[derive(Serialize)]
45pub struct SwitchProjectResponse {
46 pub success: bool,
47 pub message: String,
48}
49
50#[derive(Clone)]
52pub struct ProjectsState {
53 pub active_path: Arc<RwLock<PathBuf>>,
55}
56
57fn get_scan_locations() -> Vec<PathBuf> {
63 let mut locations = Vec::new();
64
65 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 if let Ok(cwd) = std::env::current_dir() {
85 locations.push(cwd);
86 }
87
88 locations
89}
90
91fn 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 let greppy_dir = dir.join(".greppy");
99 if greppy_dir.exists() && greppy_dir.is_dir() {
100 found.insert(dir.clone());
101 }
102
103 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 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
135pub fn discover_projects(active_path: &PathBuf) -> Vec<ProjectInfo> {
137 let mut found = HashSet::new();
138
139 for location in get_scan_locations() {
141 if location.exists() {
142 scan_for_projects(&location, 3, &mut found);
143 }
144 }
145
146 if let Ok(registry_path) = Config::registry_path() {
148 if registry_path.exists() {
149 if let Ok(content) = std::fs::read_to_string(®istry_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 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 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
201pub 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
212pub 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 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 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 {
250 let mut active = state.active_path.write().unwrap();
251 *active = path;
252 }
253
254 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
266fn 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 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 recent.retain(|p| p != path);
283 recent.insert(0, path.to_string());
284
285 recent.truncate(10);
287
288 let content = serde_json::to_string_pretty(&recent)?;
290 std::fs::write(&recent_path, content)?;
291
292 Ok(())
293}