mod remote;
mod repositories;
mod worktrees;
mod writes;
use crate::server::app::AppState;
use crate::server::body::{parse_form, read_json_object, string_field};
use crate::server::errors::error;
use axum::extract::{Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use nomoreide_core::git_manager::{
ContentSearchOptions, ContentSearchResult, FileNameMatch, GitManager,
};
use serde::{Deserialize, Serialize};
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route("/api/git/search/files", get(search_files))
.route("/api/git/search/content", get(search_content))
.route("/api/git/status", get(status))
.route("/api/git/overview", get(overview))
.route("/api/git/files", get(files))
.route("/api/git/file-sizes", get(file_sizes))
.route("/api/git/file", get(file))
.route("/api/git/blame", get(blame))
.route("/api/git/commit", get(commit_diff))
.route("/api/git/commit/files", get(commit_files))
.route("/api/git/branches", get(branches))
.route("/api/git/identity", get(identity))
.route("/api/git/diff", get(diff))
.route("/api/git/graph", get(graph))
.route("/api/git/worktrees", get(worktrees))
.merge(writes::routes())
.merge(remote::routes())
.merge(worktrees::routes())
.merge(repositories::routes())
}
const DEFAULT_FILE_LIMIT: usize = 50;
const MAX_FILE_LIMIT: usize = 500;
const DEFAULT_CONTENT_LIMIT: usize = 500;
const MAX_CONTENT_LIMIT: usize = 2_000;
#[derive(Deserialize)]
struct FileQuery {
#[serde(default)]
q: Option<String>,
#[serde(default)]
limit: Option<String>,
}
#[derive(Deserialize)]
struct ContentQuery {
#[serde(default)]
q: Option<String>,
#[serde(default)]
regex: Option<String>,
#[serde(default)]
case: Option<String>,
#[serde(default)]
word: Option<String>,
#[serde(default)]
include: Option<String>,
#[serde(default)]
limit: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FileSearchEnvelope {
ok: bool,
files: Vec<FileNameMatch>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ContentSearchEnvelope {
ok: bool,
#[serde(flatten)]
result: ContentSearchResult,
}
async fn search_files(State(state): State<AppState>, Query(query): Query<FileQuery>) -> Response {
let cwd = state.workspace_cwd().await;
let limit = clamp(query.limit, DEFAULT_FILE_LIMIT, MAX_FILE_LIMIT);
match GitManager::search_files(&cwd, &query.q.unwrap_or_default(), limit).await {
Ok(files) => Json(FileSearchEnvelope { ok: true, files }).into_response(),
Err(reason) => error(StatusCode::BAD_REQUEST, &reason.to_string()),
}
}
async fn search_content(
State(state): State<AppState>,
Query(query): Query<ContentQuery>,
) -> Response {
let needle = query.q.unwrap_or_default();
if needle.trim().is_empty() {
return Json(ContentSearchEnvelope {
ok: true,
result: ContentSearchResult {
files: Vec::new(),
total_matches: 0,
truncated: false,
},
})
.into_response();
}
let options = ContentSearchOptions {
regex: flag(query.regex),
case_sensitive: flag(query.case),
whole_word: flag(query.word),
include: query.include.unwrap_or_default(),
limit: clamp(query.limit, DEFAULT_CONTENT_LIMIT, MAX_CONTENT_LIMIT),
};
let cwd = state.workspace_cwd().await;
match GitManager::search_content(&cwd, &needle, &options).await {
Ok(result) => Json(ContentSearchEnvelope { ok: true, result }).into_response(),
Err(reason) => error(StatusCode::BAD_REQUEST, &reason.to_string()),
}
}
fn flag(value: Option<String>) -> bool {
matches!(value.as_deref(), Some("1" | "true"))
}
fn clamp(value: Option<String>, default: usize, max: usize) -> usize {
value
.and_then(|limit| limit.parse::<usize>().ok())
.filter(|limit| *limit > 0)
.map_or(default, |limit| limit.min(max))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StatusEnvelope {
ok: bool,
status: nomoreide_core::git_manager::GitStatus,
}
async fn status(State(state): State<AppState>) -> Response {
let cwd = state.workspace_cwd().await;
match GitManager::status(&cwd).await {
Ok(status) => Json(StatusEnvelope { ok: true, status }).into_response(),
Err(reason) => error(StatusCode::BAD_REQUEST, &reason.to_string()),
}
}
const DEFAULT_BOARD_COLUMNS: usize = 4;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RepoOverview {
name: String,
path: String,
branch: String,
ahead: i32,
behind: i32,
files: Vec<nomoreide_core::git_manager::GitFileStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct OverviewEnvelope {
ok: bool,
repos: Vec<RepoOverview>,
board: Vec<String>,
}
async fn overview(State(state): State<AppState>) -> Response {
let config = match state.config_store.load().await {
Ok(config) => config,
Err(reason) => return error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()),
};
let reads: Vec<_> = config
.git_repositories
.iter()
.map(|repository| {
let name = repository.name.clone();
let registered = repository.path.clone();
let worktree = repository
.active_worktree_path
.clone()
.unwrap_or_else(|| registered.clone());
tokio::spawn(async move {
match GitManager::status(&worktree).await {
Ok(status) => RepoOverview {
name,
path: worktree,
branch: status.branch,
ahead: status.ahead,
behind: status.behind,
files: status.files,
error: None,
},
Err(reason) => RepoOverview {
name,
path: registered,
branch: String::new(),
ahead: 0,
behind: 0,
files: Vec::new(),
error: Some(reason.to_string()),
},
}
})
})
.collect();
let mut repos = Vec::with_capacity(reads.len());
for read in reads {
match read.await {
Ok(repo) => repos.push(repo),
Err(reason) => return error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()),
}
}
let board: Vec<String> = config
.git_board_repositories
.clone()
.unwrap_or_else(|| {
config
.git_repositories
.iter()
.take(DEFAULT_BOARD_COLUMNS)
.map(|repository| repository.name.clone())
.collect()
})
.into_iter()
.filter(|name| {
config
.git_repositories
.iter()
.any(|repository| &repository.name == name)
})
.collect();
Json(OverviewEnvelope {
ok: true,
repos,
board,
})
.into_response()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FilesEnvelope {
ok: bool,
files: Vec<String>,
}
async fn files(State(state): State<AppState>) -> Response {
let cwd = state.workspace_cwd().await;
match GitManager::list_tracked_files(&cwd).await {
Ok(files) => Json(FilesEnvelope { ok: true, files }).into_response(),
Err(reason) => error(StatusCode::BAD_REQUEST, &reason.to_string()),
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FileSizesEnvelope {
ok: bool,
files: Vec<nomoreide_core::git_manager::FileSizeRank>,
}
async fn file_sizes(State(state): State<AppState>) -> Response {
let cwd = state.workspace_cwd().await;
match GitManager::rank_files_by_size(&cwd).await {
Ok(files) => Json(FileSizesEnvelope { ok: true, files }).into_response(),
Err(reason) => error(StatusCode::BAD_REQUEST, &reason.to_string()),
}
}
#[derive(Deserialize)]
struct PathQuery {
#[serde(default)]
path: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FileEnvelope {
ok: bool,
content: String,
truncated: bool,
binary: bool,
size: u64,
}
async fn file(State(state): State<AppState>, Query(query): Query<PathQuery>) -> Response {
let path = query.path.unwrap_or_default();
let path = path.trim();
if path.is_empty() {
return error(StatusCode::BAD_REQUEST, "path is required");
}
let cwd = state.workspace_cwd().await;
match GitManager::read_tracked_file(&cwd, path).await {
Ok(file) => Json(FileEnvelope {
ok: true,
content: file.content,
truncated: file.truncated,
binary: file.binary,
size: file.size,
})
.into_response(),
Err(reason) => error(StatusCode::NOT_FOUND, &reason.to_string()),
}
}
async fn blame(State(state): State<AppState>, Query(query): Query<PathQuery>) -> Response {
let path = query.path.unwrap_or_default();
let path = path.trim();
if path.is_empty() {
return error(StatusCode::BAD_REQUEST, "path is required");
}
let cwd = state.workspace_cwd().await;
match GitManager::blame(&cwd, path).await {
Ok(lines) => Json(BlameEnvelope { ok: true, lines }).into_response(),
Err(reason) => error(StatusCode::BAD_REQUEST, &reason.to_string()),
}
}
#[derive(Serialize)]
struct BlameEnvelope {
ok: bool,
lines: Vec<nomoreide_core::git_manager::GitBlameLine>,
}
#[derive(Deserialize)]
struct CommitQuery {
#[serde(default)]
hash: Option<String>,
#[serde(default)]
file: Option<String>,
}
fn text_response(body: String) -> Response {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
(StatusCode::OK, headers, body).into_response()
}
async fn commit_diff(State(state): State<AppState>, Query(query): Query<CommitQuery>) -> Response {
let Some(hash) = query
.hash
.as_deref()
.map(str::trim)
.filter(|hash| !hash.is_empty())
else {
return error(StatusCode::BAD_REQUEST, "hash is required");
};
let file = query
.file
.as_deref()
.map(str::trim)
.filter(|file| !file.is_empty());
let cwd = state.workspace_cwd().await;
match GitManager::commit_diff(&cwd, hash, file).await {
Ok(diff) => text_response(diff),
Err(reason) => error(StatusCode::BAD_REQUEST, &reason.to_string()),
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CommitFilesEnvelope {
ok: bool,
files: Vec<nomoreide_core::git_manager::GitFileStatus>,
}
async fn commit_files(State(state): State<AppState>, Query(query): Query<CommitQuery>) -> Response {
let Some(hash) = query
.hash
.as_deref()
.map(str::trim)
.filter(|hash| !hash.is_empty())
else {
return error(StatusCode::BAD_REQUEST, "hash is required");
};
let cwd = state.workspace_cwd().await;
match GitManager::commit_files(&cwd, hash).await {
Ok(files) => Json(CommitFilesEnvelope { ok: true, files }).into_response(),
Err(reason) => error(StatusCode::BAD_REQUEST, &reason.to_string()),
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct BranchesEnvelope {
ok: bool,
branches: Vec<nomoreide_core::git_manager::GitBranch>,
}
async fn branches(State(state): State<AppState>) -> Response {
let cwd = state.workspace_cwd().await;
match GitManager::branches(&cwd).await {
Ok(branches) => Json(BranchesEnvelope { ok: true, branches }).into_response(),
Err(reason) => error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()),
}
}
#[derive(Deserialize)]
struct RepoQuery {
#[serde(default)]
repo: Option<String>,
}
async fn resolve_repo_cwd(
state: &AppState,
repo: Option<&str>,
) -> Result<(String, Option<nomoreide_core::config::GitRepoDef>), Response> {
let Some(name) = repo.map(str::trim).filter(|name| !name.is_empty()) else {
let config = state
.config_store
.load()
.await
.map_err(|reason| error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()))?;
let repository = config
.selected_git_repository
.as_ref()
.and_then(|selected| {
config
.git_repositories
.iter()
.find(|repo| &repo.name == selected)
})
.or_else(|| config.git_repositories.first())
.cloned();
return Ok((state.workspace_cwd().await, repository));
};
let config = state
.config_store
.load()
.await
.map_err(|reason| error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()))?;
let Some(repository) = config
.git_repositories
.iter()
.find(|repo| repo.name == name)
.cloned()
else {
return Err(error(
StatusCode::NOT_FOUND,
&format!("Unknown repository: {name}"),
));
};
let cwd = repository
.active_worktree_path
.clone()
.unwrap_or_else(|| repository.path.clone());
Ok((cwd, Some(repository)))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct IdentityEnvelope {
ok: bool,
#[serde(flatten)]
identity: nomoreide_core::git_identity::GitIdentityState,
}
async fn identity(State(state): State<AppState>, Query(query): Query<RepoQuery>) -> Response {
let (cwd, repository) = match resolve_repo_cwd(&state, query.repo.as_deref()).await {
Ok(resolved) => resolved,
Err(response) => return response,
};
let config = match state.config_store.load().await {
Ok(config) => config,
Err(reason) => return error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()),
};
let identity = nomoreide_core::git_identity::resolve_identity_state(
&state.config_store,
&config,
repository.as_ref(),
&cwd,
)
.await;
Json(IdentityEnvelope { ok: true, identity }).into_response()
}
#[derive(Deserialize)]
struct DiffQuery {
#[serde(default)]
repo: Option<String>,
#[serde(default)]
file: Option<String>,
}
async fn diff(State(state): State<AppState>, Query(query): Query<DiffQuery>) -> Response {
let (cwd, _repository) = match resolve_repo_cwd(&state, query.repo.as_deref()).await {
Ok(resolved) => resolved,
Err(response) => return response,
};
let Some(file) = query
.file
.as_deref()
.map(str::trim)
.filter(|file| !file.is_empty())
else {
return error(StatusCode::BAD_REQUEST, "file is required");
};
let status = match GitManager::status(&cwd).await {
Ok(status) => status,
Err(reason) => return error(StatusCode::BAD_REQUEST, &reason.to_string()),
};
let diff = match status.files.iter().find(|entry| entry.path == file) {
Some(entry) => GitManager::file_diff_for_status(&cwd, entry).await.ok(),
None => GitManager::diff(&cwd, Some(file)).await.ok(),
};
match diff {
Some(diff) => text_response(diff),
None => error(StatusCode::NOT_FOUND, "No changes or file not found."),
}
}
#[derive(Deserialize)]
struct GraphQuery {
#[serde(default)]
limit: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GraphEnvelope {
ok: bool,
commits: Vec<nomoreide_core::git_manager::GitGraphCommit>,
}
fn graph_limit(value: Option<String>) -> usize {
value
.and_then(|limit| limit.trim().parse::<f64>().ok())
.filter(|limit| limit.is_finite() && *limit > 0.0)
.map_or(200, |limit| (limit.floor() as usize).min(2000))
}
async fn graph(State(state): State<AppState>, Query(query): Query<GraphQuery>) -> Response {
let limit = graph_limit(query.limit);
let cwd = state.workspace_cwd().await;
match GitManager::graph_with_layout(&cwd, limit).await {
Ok(commits) => Json(GraphEnvelope { ok: true, commits }).into_response(),
Err(reason) => error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()),
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WorktreesEnvelope {
ok: bool,
active_path: String,
worktrees: Vec<nomoreide_core::git_manager::GitWorktree>,
}
async fn worktrees(State(state): State<AppState>) -> Response {
let config = match state.config_store.load().await {
Ok(config) => config,
Err(reason) => return error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()),
};
let repository = config
.selected_git_repository
.as_ref()
.and_then(|selected| {
config
.git_repositories
.iter()
.find(|repo| &repo.name == selected)
})
.or_else(|| config.git_repositories.first());
let Some(repository) = repository.cloned() else {
return error(StatusCode::NOT_FOUND, "No Git project is selected.");
};
match GitManager::worktrees(&repository.path).await {
Ok(worktrees) => {
let configured_active = repository
.active_worktree_path
.clone()
.unwrap_or_else(|| repository.path.clone());
let active_path = if worktrees
.iter()
.any(|worktree| paths_match(&worktree.path, &configured_active))
{
configured_active
} else {
repository.path.clone()
};
Json(WorktreesEnvelope {
ok: true,
active_path,
worktrees,
})
.into_response()
}
Err(reason) => error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()),
}
}
fn paths_match(a: &str, b: &str) -> bool {
lexically_resolve(a) == lexically_resolve(b)
}
fn lexically_resolve(path: &str) -> std::path::PathBuf {
let candidate = std::path::Path::new(path);
let absolute = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
std::env::current_dir().unwrap_or_default().join(candidate)
};
let mut out = std::path::PathBuf::new();
for component in absolute.components() {
match component {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}