use crate::prelude::*;
use axum::{
extract::{Query, State},
http::StatusCode,
Json,
};
use std::{
collections::HashMap,
fs,
path::{Component, Path, PathBuf},
};
use super::{fleet::workspace_root, sessions::ErrorResponse};
use crate::fleet::{config::GitModules, error::SuperGitError};
use crate::git_backend::FileStatusKind;
use crate::web::github;
use crate::web::handlers::fleet::RepoDetailedStatus;
use crate::web::ops::OpKind;
use crate::web::state::WebState;
use crate::workspace_state::WorkspaceState;
use axum::extract::State as AxumState;
use axum::response::sse::{Event, KeepAlive, Sse};
use futures::stream::Stream;
use std::convert::Infallible;
#[derive(Debug, Deserialize)]
pub struct WorkspacePathQuery {
#[serde(default)]
path: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceUpdateRequest {
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceUpdateResponse {
pub path: String,
pub version: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceEntryKind {
File,
Directory,
#[serde(rename = "error")]
Error,
}
#[derive(Debug, Serialize, Clone)]
pub struct WorkspaceSubmoduleInfo {
pub name: String,
pub version: Option<String>,
pub github_repo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceEntry {
pub path: String,
pub name: String,
pub kind: WorkspaceEntryKind,
pub hidden: bool,
pub submodule: Option<WorkspaceSubmoduleInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_status: Option<FileStatusKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_status: Option<RepoDetailedStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceTreeResponse {
pub root: String,
pub path: String,
pub entries: Vec<WorkspaceEntry>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceFileResponse {
pub root: String,
pub path: String,
pub content: String,
pub mtime_secs: u64,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceFileSaveRequest {
pub path: String,
pub content: String,
pub expected_mtime_secs: u64,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceCreateFileRequest {
pub path: String,
#[serde(default)]
pub content: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceCreateDirRequest {
pub path: String,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceDeleteRequest {
pub path: String,
#[serde(default)]
pub recursive: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceRenameRequest {
pub from: String,
pub to: String,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceCopyRequest {
pub from: String,
pub to: String,
}
fn parent_dir_path(relative: &Path) -> String {
match relative.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_string_lossy().into_owned(),
_ => String::new(),
}
}
fn conflict_error(error: String) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::CONFLICT, Json(ErrorResponse { error }))
}
pub async fn create_file(
State(state): State<WebState>,
Json(body): Json<WorkspaceCreateFileRequest>,
) -> Result<Json<WorkspaceFileResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let relative = sanitize_relative_path(&body.path)?;
if relative.as_os_str().is_empty() {
return Err(bad_request("file path must not be empty".to_string()));
}
let target = resolve_workspace_path(&root, &relative)?;
let content = body.content.unwrap_or_default();
if target.exists() {
return Err(conflict_error(format!(
"path '{}' already exists",
display_relative_path(&relative)
)));
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(internal_error)?;
}
fs::write(&target, content.as_bytes()).map_err(internal_error)?;
let mtime_secs = fs::metadata(&target)
.map(|m| {
m.modified()
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs())
.unwrap_or(0)
})
.unwrap_or(0);
let _ = state.file_changed_tx.send(crate::web::state::FileChangedEvent {
path: display_relative_path(&relative),
mtime_secs: Some(mtime_secs),
});
let parent_path = parent_dir_path(&relative);
let _ = state.workspace_changed_tx.send(vec![parent_path]);
Ok(Json(WorkspaceFileResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
content,
mtime_secs,
}))
}
#[derive(Debug, Serialize)]
pub struct WorkspaceCreateDirResponse {
pub root: String,
pub path: String,
}
pub async fn create_directory(
State(state): State<WebState>,
Json(body): Json<WorkspaceCreateDirRequest>,
) -> Result<Json<WorkspaceCreateDirResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let relative = sanitize_relative_path(&body.path)?;
if relative.as_os_str().is_empty() {
return Err(bad_request("directory path must not be empty".to_string()));
}
let target = resolve_workspace_path(&root, &relative)?;
if target.exists() {
return Err(conflict_error(format!(
"path '{}' already exists",
display_relative_path(&relative)
)));
}
fs::create_dir_all(&target).map_err(internal_error)?;
let parent_path = parent_dir_path(&relative);
let _ = state.workspace_changed_tx.send(vec![parent_path]);
Ok(Json(WorkspaceCreateDirResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
}))
}
pub async fn delete_entry(
State(state): State<WebState>,
Json(body): Json<WorkspaceDeleteRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let relative = sanitize_relative_path(&body.path)?;
if relative.as_os_str().is_empty() {
return Err(bad_request("path must not be empty".to_string()));
}
let target = resolve_workspace_path(&root, &relative)?;
let recursive = body.recursive.unwrap_or(false);
let rel_str = display_relative_path(&relative);
let label = format!("delete {}", rel_str);
let parent_path = parent_dir_path(&relative);
let rel_str_for_closure = rel_str.clone();
let op_handle = state.ops.start_op(OpKind::FileOperation, label).await;
let result = tokio::task::spawn_blocking(move || -> Result<(), (StatusCode, Json<ErrorResponse>)> {
let rel_str = rel_str_for_closure;
let meta = fs::metadata(&target).map_err(|_| {
bad_request(format!("path '{}' does not exist", rel_str))
})?;
if meta.is_dir() {
if !recursive {
let is_empty = fs::read_dir(&target)
.map_err(internal_error)?
.next()
.is_none();
if !is_empty {
return Err(bad_request(format!(
"directory '{}' is not empty; set recursive=true to delete",
rel_str
)));
}
fs::remove_dir(&target).map_err(internal_error)?;
} else {
fs::remove_dir_all(&target).map_err(internal_error)?;
}
} else {
fs::remove_file(&target).map_err(internal_error)?;
}
Ok(())
})
.await
.map_err(|e| internal_error(format!("delete task panicked: {e}")));
match result {
Ok(Ok(())) => {
op_handle.complete().await;
let _ = state.file_changed_tx.send(crate::web::state::FileChangedEvent {
path: rel_str,
mtime_secs: None,
});
let _ = state.workspace_changed_tx.send(vec![parent_path]);
Ok(Json(serde_json::json!({ "deleted": true })))
}
Ok(Err(e)) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
Err(e) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
}
}
pub async fn rename_entry(
State(state): State<WebState>,
Json(body): Json<WorkspaceRenameRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let from_rel = sanitize_relative_path(&body.from)?;
let to_rel = sanitize_relative_path(&body.to)?;
if from_rel.as_os_str().is_empty() {
return Err(bad_request("from path must not be empty".to_string()));
}
if to_rel.as_os_str().is_empty() {
return Err(bad_request("to path must not be empty".to_string()));
}
let from_target = resolve_workspace_path(&root, &from_rel)?;
let to_target = resolve_workspace_path(&root, &to_rel)?;
if to_target.exists() {
return Err(conflict_error(format!(
"destination '{}' already exists",
display_relative_path(&to_rel)
)));
}
let from_str = display_relative_path(&from_rel);
let to_str = display_relative_path(&to_rel);
let label = format!("rename {} → {}", from_str, to_str);
let from_parent = parent_dir_path(&from_rel);
let to_parent = parent_dir_path(&to_rel);
let op_handle = state.ops.start_op(OpKind::FileOperation, label).await;
if let Some(parent) = to_target.parent() {
if let Err(e) = fs::create_dir_all(parent) {
let msg = e.to_string();
op_handle.fail(msg.clone()).await;
return Err(internal_error(msg));
}
}
let result = tokio::task::spawn_blocking(move || {
fs::rename(&from_target, &to_target).map_err(internal_error)
})
.await
.map_err(|e| internal_error(format!("rename task panicked: {e}")));
match result {
Ok(Ok(())) => {
op_handle.complete().await;
let _ = state.file_changed_tx.send(crate::web::state::FileChangedEvent {
path: from_str,
mtime_secs: None,
});
let mut changed_dirs = vec![from_parent, to_parent];
changed_dirs.dedup();
let _ = state.workspace_changed_tx.send(changed_dirs);
Ok(Json(serde_json::json!({ "renamed": true, "to": to_str })))
}
Ok(Err(e)) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
Err(e) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
}
}
pub async fn copy_entry(
State(state): State<WebState>,
Json(body): Json<WorkspaceCopyRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let from_rel = sanitize_relative_path(&body.from)?;
let to_rel = sanitize_relative_path(&body.to)?;
if from_rel.as_os_str().is_empty() {
return Err(bad_request("from path must not be empty".to_string()));
}
if to_rel.as_os_str().is_empty() {
return Err(bad_request("to path must not be empty".to_string()));
}
let from_target = resolve_workspace_path(&root, &from_rel)?;
let to_target = resolve_workspace_path(&root, &to_rel)?;
if to_target.exists() {
return Err(conflict_error(format!(
"destination '{}' already exists",
display_relative_path(&to_rel)
)));
}
let from_str = display_relative_path(&from_rel);
let to_str = display_relative_path(&to_rel);
let label = format!("copy {} → {}", from_str, to_str);
let to_parent = parent_dir_path(&to_rel);
let to_str_clone = to_str.clone();
let op_handle = state.ops.start_op(OpKind::FileOperation, label).await;
let result = tokio::task::spawn_blocking(move || -> Result<(), (StatusCode, Json<ErrorResponse>)> {
let meta = fs::metadata(&from_target).map_err(|_| {
bad_request(format!("source '{}' does not exist", from_str))
})?;
if let Some(parent) = to_target.parent() {
fs::create_dir_all(parent).map_err(internal_error)?;
}
if meta.is_dir() {
copy_dir_recursive(&from_target, &to_target).map_err(internal_error)?;
} else {
fs::copy(&from_target, &to_target).map_err(internal_error)?;
}
Ok(())
})
.await
.map_err(|e| internal_error(format!("copy task panicked: {e}")));
match result {
Ok(Ok(())) => {
op_handle.complete().await;
let _ = state.workspace_changed_tx.send(vec![to_parent]);
Ok(Json(serde_json::json!({ "copied": true, "to": to_str_clone })))
}
Ok(Err(e)) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
Err(e) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
}
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let dest_path = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&entry.path(), &dest_path)?;
} else {
fs::copy(entry.path(), &dest_path)?;
}
}
Ok(())
}
pub async fn list_tree(
State(state): State<WebState>,
Query(query): Query<WorkspacePathQuery>,
) -> Result<Json<WorkspaceTreeResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let relative = sanitize_relative_path(query.path.as_deref().unwrap_or(""))?;
let target = resolve_workspace_path(&root, &relative)?;
let git_snapshot: HashMap<String, RepoDetailedStatus> =
state.git_status_snapshot.read().await.clone();
if !target.exists() {
return Ok(Json(WorkspaceTreeResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
entries: vec![WorkspaceEntry {
path: display_relative_path(&relative),
name: relative
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
kind: WorkspaceEntryKind::Error,
hidden: false,
submodule: None,
git_status: None,
repo_status: None,
repo_path: None,
}],
}));
}
let (root, relative, entries) = tokio::task::spawn_blocking(move || {
let metadata = fs::metadata(&target).map_err(internal_error)?;
if !metadata.is_dir() {
return Err(bad_request(format!(
"workspace path '{}' is not a directory",
display_relative_path(&relative)
)));
}
let mut raw_entries = fs::read_dir(&target)
.map_err(internal_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(internal_error)?;
raw_entries.sort_by(|left, right| {
let left_kind = left.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let right_kind = right.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
match right_kind.cmp(&left_kind) {
std::cmp::Ordering::Equal => left.file_name().cmp(&right.file_name()),
ordering => ordering,
}
});
let visible_directory_paths = raw_entries
.iter()
.filter_map(|entry| {
let file_type = entry.file_type().ok()?;
if !file_type.is_dir() {
return None;
}
let name = entry.file_name().to_string_lossy().into_owned();
Some(join_relative_path(&relative, &name))
})
.collect::<Vec<_>>();
let is_root = relative.as_os_str().is_empty();
let submodules = if is_root {
load_workspace_submodules(&root, &visible_directory_paths)
} else {
HashMap::new()
};
let uninitialized_submodules = if is_root {
load_uninitialized_submodules(&root, &visible_directory_paths)
} else {
vec![]
};
let relative_str = display_relative_path(&relative);
let owning_repo: Option<&RepoDetailedStatus> = git_snapshot
.values()
.find(|r| {
if r.is_superproject {
relative_str.is_empty()
} else {
relative_str == r.path || relative_str.starts_with(&format!("{}/", r.path))
}
});
let (file_status_map, repo_path_prefix): (HashMap<String, FileStatusKind>, &str) =
if let Some(repo) = owning_repo {
let prefix = if repo.is_superproject { "" } else { &repo.path };
let map = repo
.files
.iter()
.map(|f| {
let ws_path = if prefix.is_empty() {
f.path.clone()
} else {
format!("{prefix}/{}", f.path)
};
(ws_path, f.status)
})
.collect();
(map, prefix)
} else {
(HashMap::new(), "")
};
let mut entries = raw_entries
.into_iter()
.map(|entry| {
let file_type = entry.file_type().map_err(internal_error)?;
let file_name = entry.file_name().to_string_lossy().into_owned();
let entry_relative = join_relative_path(&relative, &file_name);
let submodule = if file_type.is_dir() {
submodules.get(&entry_relative).cloned()
} else {
None
};
let (git_status, repo_status, repo_path) = if file_type.is_dir() {
if submodule.is_some() {
let rs = git_snapshot.get(&entry_relative).cloned();
(None, rs, None)
} else {
let dir_status =
aggregate_dir_status(&entry_relative, &file_status_map);
(dir_status, None, None)
}
} else {
let fs = file_status_map.get(&entry_relative).copied();
let rp = if repo_path_prefix.is_empty() {
None
} else {
Some(repo_path_prefix.to_string())
};
(fs, None, rp)
};
Ok(WorkspaceEntry {
path: entry_relative.clone(),
name: submodule
.as_ref()
.map(|s| s.name.clone())
.unwrap_or(file_name),
kind: if file_type.is_dir() {
WorkspaceEntryKind::Directory
} else {
WorkspaceEntryKind::File
},
hidden: is_hidden_name(&entry_relative),
submodule,
git_status,
repo_status,
repo_path,
})
})
.collect::<Result<Vec<_>, _>>()?;
for sub in &uninitialized_submodules {
let rs = git_snapshot.get(&sub.path).cloned();
entries.push(WorkspaceEntry {
path: sub.path.clone(),
name: sub.name.clone(),
kind: WorkspaceEntryKind::Directory,
hidden: is_hidden_name(&sub.path),
submodule: Some(WorkspaceSubmoduleInfo {
name: sub.name.clone(),
version: None,
github_repo: sub.github_repo.clone(),
error: Some("submodule not initialized".to_string()),
}),
git_status: None,
repo_status: rs,
repo_path: None,
});
}
entries.sort_by(compare_workspace_entries);
Ok((root, relative, entries))
})
.await
.map_err(|e| internal_error(format!("list_tree task panicked: {e}")))??;
Ok(Json(WorkspaceTreeResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
entries,
}))
}
fn aggregate_dir_status(
dir_path: &str,
file_status_map: &HashMap<String, FileStatusKind>,
) -> Option<FileStatusKind> {
let prefix = format!("{dir_path}/");
let mut worst: Option<FileStatusKind> = None;
for (path, &status) in file_status_map {
if !path.starts_with(&prefix) {
continue;
}
if status == FileStatusKind::Ignored {
continue;
}
worst = Some(match worst {
None => status,
Some(current) => higher_priority_status(current, status),
});
if worst == Some(FileStatusKind::Conflict) {
return worst;
}
}
worst
}
fn higher_priority_status(a: FileStatusKind, b: FileStatusKind) -> FileStatusKind {
if status_priority(b) > status_priority(a) { b } else { a }
}
fn status_priority(s: FileStatusKind) -> u8 {
match s {
FileStatusKind::Conflict => 6,
FileStatusKind::Deleted => 5,
FileStatusKind::Modified => 4,
FileStatusKind::Staged => 3,
FileStatusKind::Renamed => 2,
FileStatusKind::Untracked => 1,
FileStatusKind::Ignored => 0,
}
}
pub async fn workspace_watch(
State(state): State<WebState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let mut git_rx = state.git_status_tx.subscribe();
let mut file_rx = state.file_changed_tx.subscribe();
let mut ws_changed_rx = state.workspace_changed_tx.subscribe();
let stream = async_stream::stream! {
yield Ok(Event::default().event("connected").data("{}"));
loop {
tokio::select! {
git_result = git_rx.recv() => {
match git_result {
Ok(repo) => {
let paths = affected_tree_paths(&repo.path);
if let Ok(json) = serde_json::to_string(&paths) {
yield Ok(Event::default().event("changed").data(json));
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let paths: Vec<String> = vec!["".to_string()];
if let Ok(json) = serde_json::to_string(&paths) {
yield Ok(Event::default().event("changed").data(json));
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
file_result = file_rx.recv() => {
match file_result {
Ok(event) => {
if let Ok(json) = serde_json::to_string(&event) {
yield Ok(Event::default().event("file_changed").data(json));
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let event = crate::web::state::FileChangedEvent { path: "".to_string(), mtime_secs: None };
if let Ok(json) = serde_json::to_string(&event) {
yield Ok(Event::default().event("file_changed").data(json));
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
ws_result = ws_changed_rx.recv() => {
match ws_result {
Ok(paths) => {
if let Ok(json) = serde_json::to_string(&paths) {
yield Ok(Event::default().event("changed").data(json));
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let paths: Vec<String> = vec!["".to_string()];
if let Ok(json) = serde_json::to_string(&paths) {
yield Ok(Event::default().event("changed").data(json));
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}
fn affected_tree_paths(repo_path: &str) -> Vec<String> {
if repo_path == "." {
return vec!["".to_string()];
}
let mut paths = vec!["".to_string()];
if let Some(parent) = std::path::Path::new(repo_path).parent() {
let p = parent.to_string_lossy();
if !p.is_empty() && p != "." {
paths.push(p.into_owned());
}
}
paths.dedup();
paths
}
fn load_workspace_submodules(
root: &Path,
visible_directory_paths: &[String],
) -> HashMap<String, WorkspaceSubmoduleInfo> {
let gitmodules = match GitModules::parse(root) {
Ok(gm) => gm,
Err(SuperGitError::NoSubmodules) => return HashMap::new(),
Err(e) => {
tracing::warn!("Failed to parse .gitmodules: {e}");
return HashMap::new();
}
};
let visible_directory_paths = visible_directory_paths
.iter()
.cloned()
.collect::<std::collections::HashSet<_>>();
let mut submodules = HashMap::with_capacity(gitmodules.submodules.len());
for submodule in gitmodules.submodules {
let relative_path = submodule.path.to_string_lossy().into_owned();
if !visible_directory_paths.contains(&relative_path) {
continue;
}
let submodule_root = root.join(&submodule.path);
let version = crate::state::read_version_from_path(&submodule_root)
.map(|version| version.full_version)
.ok();
let github_repo = github::parse_github_url(&submodule.url)
.map(|(owner, repo)| format!("{owner}/{repo}"));
let (name, error) = match read_submodule_project_name(&submodule_root) {
Some(name) => (name, None),
None => (submodule.name, None),
};
submodules.insert(
relative_path,
WorkspaceSubmoduleInfo {
name,
version,
github_repo,
error,
},
);
}
submodules
}
struct UninitializedSubmodule {
path: String,
name: String,
github_repo: Option<String>,
}
fn load_uninitialized_submodules(
root: &Path,
existing_directory_paths: &[String],
) -> Vec<UninitializedSubmodule> {
let gitmodules = match GitModules::parse(root) {
Ok(gm) => gm,
Err(SuperGitError::NoSubmodules) => return Vec::new(),
Err(_) => return Vec::new(),
};
let existing: std::collections::HashSet<_> = existing_directory_paths.iter().collect();
let mut result = Vec::new();
for submodule in gitmodules.submodules {
let relative_path = submodule.path.to_string_lossy().into_owned();
if existing.contains(&relative_path) {
continue;
}
let github_repo = github::parse_github_url(&submodule.url)
.map(|(owner, repo)| format!("{owner}/{repo}"));
result.push(UninitializedSubmodule {
path: relative_path,
name: submodule.name,
github_repo,
});
}
result
}
fn read_submodule_project_name(submodule_root: &Path) -> Option<String> {
let state_file = submodule_root.join(".wsb").join("state.json");
if !state_file.exists() {
return None;
}
let content = fs::read_to_string(&state_file).ok()?;
let state = serde_json::from_str::<WorkspaceState>(&content).ok()?;
state.project_name.and_then(|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
pub async fn read_file(
State(_state): State<WebState>,
Query(query): Query<WorkspacePathQuery>,
) -> Result<Json<WorkspaceFileResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let requested = query.path.as_deref().unwrap_or("").trim();
if requested.is_empty() {
return Err(bad_request("workspace file path is required".to_string()));
}
let relative = sanitize_relative_path(requested)?;
let target = resolve_workspace_path(&root, &relative)?;
let (root, relative, content, mtime_secs) = tokio::task::spawn_blocking(move || {
let metadata = fs::metadata(&target).map_err(internal_error)?;
if !metadata.is_file() {
return Err(bad_request(format!(
"workspace path '{}' is not a file",
display_relative_path(&relative)
)));
}
let mtime_secs = metadata
.modified()
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs())
.unwrap_or(0);
let bytes = fs::read(&target).map_err(internal_error)?;
let content = String::from_utf8(bytes).map_err(|_| {
bad_request(format!(
"workspace file '{}' is not valid UTF-8 text",
display_relative_path(&relative)
))
})?;
Ok((root, relative, content, mtime_secs))
})
.await
.map_err(|e| internal_error(format!("read_file task panicked: {e}")))??;
Ok(Json(WorkspaceFileResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
content,
mtime_secs,
}))
}
pub async fn save_file(
State(state): State<WebState>,
Json(body): Json<WorkspaceFileSaveRequest>,
) -> Result<Json<WorkspaceFileResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let relative = sanitize_relative_path(&body.path)?;
let target = resolve_workspace_path(&root, &relative)?;
let content = body.content.clone();
let expected_mtime = body.expected_mtime_secs;
let (root, relative, new_mtime_secs) = tokio::task::spawn_blocking(move || {
let metadata = fs::metadata(&target).map_err(internal_error)?;
if !metadata.is_file() {
return Err(bad_request(format!(
"workspace path '{}' is not a file",
display_relative_path(&relative)
)));
}
let disk_mtime = metadata
.modified()
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs())
.unwrap_or(0);
if disk_mtime > expected_mtime {
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: format!(
"file '{}' was modified on disk (disk mtime {}, client mtime {}); \
reload before saving",
display_relative_path(&relative),
disk_mtime,
expected_mtime
),
}),
));
}
fs::write(&target, content.as_bytes()).map_err(internal_error)?;
let new_mtime = fs::metadata(&target)
.map(|m| m.modified().map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()).unwrap_or(0))
.unwrap_or(0);
Ok((root, relative, new_mtime))
})
.await
.map_err(|e| internal_error(format!("save_file task panicked: {e}")))??;
let event = crate::web::state::FileChangedEvent {
path: display_relative_path(&relative),
mtime_secs: Some(new_mtime_secs),
};
let _ = state.file_changed_tx.send(event);
Ok(Json(WorkspaceFileResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
content: body.content,
mtime_secs: new_mtime_secs,
}))
}
pub async fn run_update(
AxumState(_state): AxumState<WebState>,
Json(body): Json<WorkspaceUpdateRequest>,
) -> Result<Json<WorkspaceUpdateResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let relative = sanitize_relative_path(body.path.as_deref().unwrap_or(""))?;
validate_update_target(&root, &relative)?;
let target = resolve_workspace_path(&root, &relative)?;
let metadata = fs::metadata(&target).map_err(internal_error)?;
if !metadata.is_dir() {
return Err(bad_request(format!(
"workspace path '{}' is not a directory",
display_relative_path(&relative)
)));
}
let display_path = display_relative_path(&relative);
let version = crate::workspace_update::run_workspace_update_async(&target)
.await
.map_err(internal_error)?
.version;
Ok(Json(WorkspaceUpdateResponse {
path: display_path,
version,
}))
}
fn sanitize_relative_path(raw: &str) -> Result<PathBuf, (StatusCode, Json<ErrorResponse>)> {
let path = Path::new(raw);
if path.is_absolute() {
return Err(bad_request(format!(
"workspace path '{}' must be relative to the workspace root",
raw
)));
}
let mut relative = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(part) => relative.push(part),
Component::ParentDir => {
return Err(bad_request(format!(
"workspace path '{}' must not traverse outside the workspace root",
raw
)))
}
Component::RootDir | Component::Prefix(_) => {
return Err(bad_request(format!(
"workspace path '{}' must be relative to the workspace root",
raw
)))
}
}
}
Ok(relative)
}
fn validate_update_target(
root: &Path,
relative: &Path,
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
if relative.as_os_str().is_empty() {
return Ok(());
}
let gitmodules = match GitModules::parse(root) {
Ok(gitmodules) => gitmodules,
Err(SuperGitError::NoSubmodules) => {
return Err(bad_request(format!(
"workspace path '{}' is not a submodule root",
relative.display()
)))
}
Err(error) => return Err(internal_error(error)),
};
let relative_str = relative.to_string_lossy();
if gitmodules
.submodules
.iter()
.any(|submodule| submodule.path == relative)
{
return Ok(());
}
Err(bad_request(format!(
"workspace path '{}' is not a submodule root",
relative_str
)))
}
fn resolve_workspace_path(
root: &Path,
relative: &Path,
) -> Result<PathBuf, (StatusCode, Json<ErrorResponse>)> {
let target = root.join(relative);
if !target.starts_with(root) {
return Err(bad_request(format!(
"workspace path '{}' resolved outside the workspace root",
display_relative_path(relative)
)));
}
Ok(target)
}
fn join_relative_path(base: &Path, name: &str) -> String {
if base.as_os_str().is_empty() {
name.to_string()
} else {
base.join(name).to_string_lossy().into_owned()
}
}
fn display_relative_path(path: &Path) -> String {
if path.as_os_str().is_empty() {
String::new()
} else {
path.to_string_lossy().into_owned()
}
}
fn compare_workspace_entries(left: &WorkspaceEntry, right: &WorkspaceEntry) -> std::cmp::Ordering {
let left_is_directory = left.kind == WorkspaceEntryKind::Directory;
let right_is_directory = right.kind == WorkspaceEntryKind::Directory;
match right_is_directory.cmp(&left_is_directory) {
std::cmp::Ordering::Equal => {
let left_is_submodule = left.submodule.is_some();
let right_is_submodule = right.submodule.is_some();
match right_is_submodule.cmp(&left_is_submodule) {
std::cmp::Ordering::Equal => left.name.cmp(&right.name),
ordering => ordering,
}
}
ordering => ordering,
}
}
fn is_hidden_name(path: &str) -> bool {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with('.'))
}
fn bad_request(error: String) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error }))
}
fn internal_error(error: impl ToString) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: error.to_string(),
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn sanitize_relative_path_rejects_parent_traversal() {
let error = sanitize_relative_path("../secret.txt").unwrap_err();
assert_eq!(error.0, StatusCode::BAD_REQUEST);
assert!(error.1.error.contains("must not traverse outside"));
}
#[test]
fn list_tree_sorts_directories_before_files() {
let temp = TempDir::new().unwrap();
fs::create_dir(temp.path().join("src")).unwrap();
fs::write(temp.path().join("Cargo.toml"), "wsb").unwrap();
fs::write(temp.path().join("README.md"), "docs").unwrap();
let mut entries = fs::read_dir(temp.path())
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
entries.sort_by(|left, right| {
let left_kind = left.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let right_kind = right.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
match right_kind.cmp(&left_kind) {
std::cmp::Ordering::Equal => left.file_name().cmp(&right.file_name()),
ordering => ordering,
}
});
let names = entries
.into_iter()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(names, vec!["src", "Cargo.toml", "README.md"]);
}
#[test]
fn resolve_workspace_path_stays_under_root() {
let temp = TempDir::new().unwrap();
let relative = match sanitize_relative_path("src/lib.rs") {
Ok(value) => value,
Err(_) => panic!("expected relative path to sanitize"),
};
let resolved = match resolve_workspace_path(temp.path(), &relative) {
Ok(value) => value,
Err(_) => panic!("expected workspace path to resolve under root"),
};
assert!(resolved.starts_with(temp.path()));
assert!(resolved.ends_with("src/lib.rs"));
}
#[test]
fn hidden_entries_are_detected_from_leaf_name() {
assert!(is_hidden_name(".git"));
assert!(is_hidden_name("dir/.env"));
assert!(!is_hidden_name("src/lib.rs"));
}
#[test]
fn load_workspace_submodules_reads_versions_from_version_txt() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "core"]
path = libs/core
url = https://github.com/example/core.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("libs/core")).unwrap();
fs::write(temp.path().join("libs/core/version.txt"), "0.414.56214\n").unwrap();
let submodules =
load_workspace_submodules(temp.path(), &[String::from("libs/core")]);
assert_eq!(submodules.len(), 1);
assert_eq!(submodules["libs/core"].name, "core");
assert_eq!(submodules["libs/core"].version.as_deref(), Some("0.414.56214"));
}
#[test]
fn load_workspace_submodules_omits_version_when_unavailable() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "thesis"]
path = thesis
url = https://github.com/example/thesis.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("thesis")).unwrap();
let submodules = load_workspace_submodules(temp.path(), &[String::from("thesis")]);
assert_eq!(submodules.len(), 1);
assert_eq!(submodules["thesis"].name, "thesis");
assert_eq!(submodules["thesis"].version, None);
}
#[test]
fn read_submodule_project_name_uses_workspace_state_when_present() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".wsb")).unwrap();
fs::write(
temp.path().join(".wsb/state.json"),
r#"{
"version": 1,
"project_root": "/tmp/example",
"project_name": "Capdag Core",
"tools": {},
"wstemplate_entries": [],
"completion_hint_shown": false
}"#,
)
.unwrap();
assert_eq!(
read_submodule_project_name(temp.path()).as_deref(),
Some("Capdag Core")
);
}
#[test]
fn compare_workspace_entries_places_submodules_before_other_nodes() {
let mut entries = vec![
WorkspaceEntry {
path: "README.md".to_string(),
name: "README.md".to_string(),
kind: WorkspaceEntryKind::File,
hidden: false,
submodule: None,
git_status: None,
repo_status: None,
repo_path: None,
},
WorkspaceEntry {
path: "src".to_string(),
name: "src".to_string(),
kind: WorkspaceEntryKind::Directory,
hidden: false,
submodule: None,
git_status: None,
repo_status: None,
repo_path: None,
},
WorkspaceEntry {
path: "capdag".to_string(),
name: "capdag".to_string(),
kind: WorkspaceEntryKind::Directory,
hidden: false,
submodule: Some(WorkspaceSubmoduleInfo {
name: "capdag".to_string(),
version: Some("0.414.56214".to_string()),
github_repo: None,
error: None,
}),
git_status: None,
repo_status: None,
repo_path: None,
},
];
entries.sort_by(compare_workspace_entries);
assert_eq!(
entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>(),
vec!["capdag", "src", "README.md"]
);
}
#[test]
fn load_workspace_submodules_returns_empty_for_non_root_path() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "child"]
path = child
url = https://github.com/example/child.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("child")).unwrap();
let submodule_visible_dirs = vec!["child/foo".to_string(), "child/src".to_string()];
let result = load_workspace_submodules(temp.path(), &submodule_visible_dirs);
assert!(
result.is_empty(),
"root .gitmodules must not inject submodules into subdirectory listings, got: {result:?}"
);
}
#[test]
fn load_uninitialized_submodules_only_reports_missing_root_children() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "sub"]
path = sub
url = https://github.com/example/sub.git
[submodule "initialized"]
path = initialized
url = https://github.com/example/initialized.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("initialized")).unwrap();
let root_visible_dirs = vec!["initialized".to_string()];
let result = load_uninitialized_submodules(temp.path(), &root_visible_dirs);
assert_eq!(result.len(), 1, "only 'sub' is uninitialized");
assert_eq!(result[0].path, "sub");
}
}