diff --git a/oxen-rust/src/cli/src/cmd/workspace.rs b/oxen-rust/src/cli/src/cmd/workspace.rs index 8d33c6774..e15b77e06 100644 --- a/oxen-rust/src/cli/src/cmd/workspace.rs +++ b/oxen-rust/src/cli/src/cmd/workspace.rs @@ -25,6 +25,9 @@ pub use download::WorkspaceDownloadCmd; pub mod list; pub use list::WorkspaceListCmd; +pub mod ls; +pub use ls::WorkspaceLsCmd; + pub mod restore; pub use restore::WorkspaceRestoreCmd; @@ -96,6 +99,7 @@ impl WorkspaceCmd { Box::new(WorkspaceDiffCmd), Box::new(WorkspaceDeleteCmd), Box::new(WorkspaceListCmd), + Box::new(WorkspaceLsCmd), Box::new(WorkspaceRmCmd), Box::new(WorkspaceRestoreCmd), Box::new(WorkspaceStatusCmd), diff --git a/oxen-rust/src/cli/src/cmd/workspace/ls.rs b/oxen-rust/src/cli/src/cmd/workspace/ls.rs new file mode 100644 index 000000000..430d3931b --- /dev/null +++ b/oxen-rust/src/cli/src/cmd/workspace/ls.rs @@ -0,0 +1,137 @@ +use async_trait::async_trait; +use clap::{Arg, ArgMatches, Command}; + +use liboxen::api; +use liboxen::error; +use liboxen::error::OxenError; +use liboxen::model::LocalRepository; +use liboxen::util; + +use std::path::PathBuf; + +use crate::cmd::RunCmd; +pub const NAME: &str = "ls"; +pub struct WorkspaceLsCmd; + +#[async_trait] +impl RunCmd for WorkspaceLsCmd { + fn name(&self) -> &str { + NAME + } + + fn args(&self) -> Command { + Command::new(NAME) + .about("List files in a workspace directory") + .arg( + Arg::new("workspace-id") + .long("workspace-id") + .short('w') + .required_unless_present("workspace-name") + .conflicts_with("workspace-name") + .help("The workspace_id of the workspace"), + ) + .arg( + Arg::new("workspace-name") + .long("workspace-name") + .short('n') + .required_unless_present("workspace-id") + .conflicts_with("workspace-id") + .help("The name of the workspace"), + ) + .arg(Arg::new("path").help("Directory path to list (defaults to root)")) + .arg( + Arg::new("page") + .long("page") + .short('p') + .help("Page number") + .default_value("1") + .action(clap::ArgAction::Set), + ) + .arg( + Arg::new("page-size") + .long("page-size") + .help("Number of entries per page") + .default_value("100") + .action(clap::ArgAction::Set), + ) + } + + async fn run(&self, args: &ArgMatches) -> Result<(), OxenError> { + let workspace_id = args.get_one::("workspace-id"); + let workspace_name = args.get_one::("workspace-name"); + let workspace_identifier = match workspace_id { + Some(id) => id, + None => { + if let Some(name) = workspace_name { + name + } else { + return Err(OxenError::basic_str( + "Either workspace-id or workspace-name must be provided.", + )); + } + } + }; + + let path = args + .get_one::("path") + .map(PathBuf::from) + .unwrap_or_default(); + + let page: usize = args + .get_one::("page") + .expect("Must supply page") + .parse() + .expect("page must be a valid integer"); + let page_size: usize = args + .get_one::("page-size") + .expect("Must supply page-size") + .parse() + .expect("page-size must be a valid integer"); + + let repo_dir = util::fs::get_repo_root_from_current_dir() + .ok_or(OxenError::basic_str(error::NO_REPO_FOUND))?; + let repository = LocalRepository::from_dir(&repo_dir)?; + let remote_repo = api::client::repositories::get_default_remote(&repository).await?; + + let result = api::client::workspaces::ls::list( + &remote_repo, + workspace_identifier, + &path, + page, + page_size, + ) + .await?; + + // Print entries + for entry in &result.entries { + let prefix = if entry.is_dir() { "d " } else { " " }; + let status = match &entry { + liboxen::view::entries::EMetadataEntry::WorkspaceMetadataEntry(ws) => { + ws.changes.as_ref().map_or("", |c| match c.status { + liboxen::model::StagedEntryStatus::Added => " [added]", + liboxen::model::StagedEntryStatus::Modified => " [modified]", + liboxen::model::StagedEntryStatus::Removed => " [removed]", + _ => "", + }) + } + _ => "", + }; + println!( + "{}{}{} ({} bytes)", + prefix, + entry.filename(), + status, + entry.size() + ); + } + + if result.total_pages > 1 { + println!( + "\nPage {}/{} ({} total entries)", + result.page_number, result.total_pages, result.total_entries + ); + } + + Ok(()) + } +} diff --git a/oxen-rust/src/lib/src/api/client/workspaces.rs b/oxen-rust/src/lib/src/api/client/workspaces.rs index af4ad92d0..6fc03835c 100644 --- a/oxen-rust/src/lib/src/api/client/workspaces.rs +++ b/oxen-rust/src/lib/src/api/client/workspaces.rs @@ -2,6 +2,7 @@ pub mod changes; pub mod commits; pub mod data_frames; pub mod files; +pub mod ls; use std::path::Path; diff --git a/oxen-rust/src/lib/src/api/client/workspaces/ls.rs b/oxen-rust/src/lib/src/api/client/workspaces/ls.rs new file mode 100644 index 000000000..4f9ff4704 --- /dev/null +++ b/oxen-rust/src/lib/src/api/client/workspaces/ls.rs @@ -0,0 +1,152 @@ +use std::path::Path; + +use crate::api; +use crate::api::client; +use crate::error::OxenError; +use crate::model::RemoteRepository; +use crate::view::entries::PaginatedDirEntriesResponse; +use crate::view::PaginatedDirEntries; + +pub async fn list( + remote_repo: &RemoteRepository, + workspace_id: impl AsRef, + directory: impl AsRef, + page: usize, + page_size: usize, +) -> Result { + let workspace_id = workspace_id.as_ref(); + let path_str = directory.as_ref().to_str().unwrap(); + let uri = if path_str.is_empty() || path_str == "." { + format!("/workspaces/{workspace_id}/ls?page={page}&page_size={page_size}") + } else { + format!("/workspaces/{workspace_id}/ls/{path_str}?page={page}&page_size={page_size}") + }; + let url = api::endpoint::url_from_repo(remote_repo, &uri)?; + log::debug!("workspaces::ls url: {url}"); + + let client = client::new_for_url(&url)?; + let res = client.get(&url).send().await?; + let body = client::parse_json_body(&url, res).await?; + let response: PaginatedDirEntriesResponse = serde_json::from_str(&body).map_err(|err| { + OxenError::basic_str(format!( + "api::workspaces::ls error parsing response from {url}\n\nErr {err:?}\n\n{body}" + )) + })?; + Ok(response.entries) +} + +#[cfg(test)] +mod tests { + use crate::config::UserConfig; + use crate::constants::DEFAULT_BRANCH_NAME; + use crate::error::OxenError; + use crate::test; + use crate::view::entries::EMetadataEntry; + use crate::{api, constants}; + + #[tokio::test] + async fn test_workspace_ls_root() -> Result<(), OxenError> { + test::run_remote_repo_test_bounding_box_csv_pushed(|_local_repo, remote_repo| async move { + let workspace_id = UserConfig::identifier()?; + let _workspace = + api::client::workspaces::create(&remote_repo, DEFAULT_BRANCH_NAME, &workspace_id) + .await?; + + let result = api::client::workspaces::ls::list( + &remote_repo, + &workspace_id, + "", + constants::DEFAULT_PAGE_NUM, + constants::DEFAULT_PAGE_SIZE, + ) + .await?; + + assert!(!result.entries.is_empty(), "Should return entries at root"); + + Ok(remote_repo) + }) + .await + } + + #[tokio::test] + async fn test_workspace_ls_with_additions() -> Result<(), OxenError> { + test::run_remote_repo_test_bounding_box_csv_pushed(|local_repo, remote_repo| async move { + let workspace_id = UserConfig::identifier()?; + let _workspace = + api::client::workspaces::create(&remote_repo, DEFAULT_BRANCH_NAME, &workspace_id) + .await?; + + // Upload a file to the workspace + let test_file = local_repo.path.join("new_file.txt"); + crate::util::fs::write_to_path(&test_file, "new content")?; + api::client::workspaces::files::upload_single_file( + &remote_repo, + &workspace_id, + "", + test_file, + ) + .await?; + + let result = api::client::workspaces::ls::list( + &remote_repo, + &workspace_id, + "", + constants::DEFAULT_PAGE_NUM, + constants::DEFAULT_PAGE_SIZE, + ) + .await?; + + // Find the added file + let added = result + .entries + .iter() + .find(|e| e.filename() == "new_file.txt"); + assert!(added.is_some(), "Should find newly added file"); + + if let Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) = added { + assert!(ws_entry.changes.is_some(), "Added file should have changes"); + } + + Ok(remote_repo) + }) + .await + } + + #[tokio::test] + async fn test_workspace_ls_with_removals() -> Result<(), OxenError> { + test::run_remote_repo_test_bounding_box_csv_pushed(|_local_repo, remote_repo| async move { + let workspace_id = UserConfig::identifier()?; + let _workspace = + api::client::workspaces::create(&remote_repo, DEFAULT_BRANCH_NAME, &workspace_id) + .await?; + + // Remove a file from the workspace + let rm_path = std::path::Path::new("annotations") + .join("train") + .join("bounding_box.csv"); + api::client::workspaces::changes::rm(&remote_repo, &workspace_id, &rm_path).await?; + + let result = api::client::workspaces::ls::list( + &remote_repo, + &workspace_id, + "annotations/train", + constants::DEFAULT_PAGE_NUM, + constants::DEFAULT_PAGE_SIZE, + ) + .await?; + + // The removed file should NOT appear in the listing + let removed = result + .entries + .iter() + .find(|e| e.filename() == "bounding_box.csv"); + assert!( + removed.is_none(), + "Removed file should not appear in workspace ls" + ); + + Ok(remote_repo) + }) + .await + } +} diff --git a/oxen-rust/src/lib/src/core/v_latest/entries.rs b/oxen-rust/src/lib/src/core/v_latest/entries.rs index 65cf69322..35c7d0b86 100644 --- a/oxen-rust/src/lib/src/core/v_latest/entries.rs +++ b/oxen-rust/src/lib/src/core/v_latest/entries.rs @@ -285,7 +285,8 @@ pub fn dir_entries_with_depth( Ok(entries) } -fn dir_node_to_metadata_entry( +/// Getting a directory's own metadata entry. +pub(crate) fn dir_node_to_metadata_entry( repo: &LocalRepository, node: &MerkleTreeNode, parsed_resource: &ParsedResource, @@ -300,29 +301,33 @@ fn dir_node_to_metadata_entry( return Ok(None); }; - if let std::collections::hash_map::Entry::Vacant(e) = - found_commits.entry(*dir_node.last_commit_id()) - { + let commit = match found_commits.entry(*dir_node.last_commit_id()) { + std::collections::hash_map::Entry::Vacant(e) => { let _perf_commit = crate::perf_guard!("core::entries::get_commit_by_hash"); let commit = repositories::commits::get_by_hash(repo, dir_node.last_commit_id())?.ok_or( OxenError::commit_id_does_not_exist(dir_node.last_commit_id().to_string()), )?; - e.insert(commit); - } + let v = e.insert(commit); + v.as_ref() + }, + std::collections::hash_map::Entry::Occupied(e) => e.get(), + }; - let commit = found_commits.get(dir_node.last_commit_id()).unwrap(); - let mut parsed_resource = parsed_resource.clone(); - if should_append_resource { - parsed_resource.resource = parsed_resource.resource.join(dir_node.name()); - parsed_resource.path = parsed_resource.path.join(dir_node.name()); - } + let parsed_resource = { + let mut parsed_resource = parsed_resource.clone(); + if should_append_resource { + parsed_resource.resource = parsed_resource.resource.join(dir_node.name()); + parsed_resource.path = parsed_resource.path.join(dir_node.name()); + } + parsed_resource + }; Ok(Some(MetadataEntry { filename: dir_node.name().to_string(), hash: dir_node.hash().to_string(), is_dir: true, latest_commit: Some(commit.clone()), - resource: Some(parsed_resource.clone()), + resource: Some(parsed_resource), size: dir_node.num_bytes(), data_type: EntryDataType::Dir, mime_type: "inode/directory".to_string(), diff --git a/oxen-rust/src/lib/src/core/v_latest/workspaces/files.rs b/oxen-rust/src/lib/src/core/v_latest/workspaces/files.rs index 0ce675649..7d25957b3 100644 --- a/oxen-rust/src/lib/src/core/v_latest/workspaces/files.rs +++ b/oxen-rust/src/lib/src/core/v_latest/workspaces/files.rs @@ -3,7 +3,7 @@ use futures::StreamExt; use parking_lot::Mutex; use reqwest::header::HeaderValue; use reqwest::Client; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs::File; use std::io::{Read, Write}; use std::path::{Component, Path, PathBuf}; @@ -17,16 +17,181 @@ use crate::core::v_latest::add::{ stage_file_with_hash, }; use crate::error::OxenError; +use crate::model::entry::metadata_entry::{WorkspaceChanges, WorkspaceMetadataEntry}; use crate::model::file::TempFilePathNew; use crate::model::merkle_tree::node::EMerkleTreeNode; use crate::model::merkle_tree::node::MerkleTreeNode; use crate::model::user::User; use crate::model::workspace::Workspace; -use crate::model::{Branch, Commit, StagedEntryStatus}; -use crate::model::{LocalRepository, NewCommitBody}; +use crate::model::{Branch, Commit, MerkleHash, ParsedResource, StagedEntry, StagedEntryStatus}; +use crate::model::{LocalRepository, MetadataEntry, NewCommitBody}; +use crate::opts::PaginateOpts; use crate::repositories; use crate::util; -use crate::view::{ErrorFileInfo, FileWithHash}; +use crate::view::entries::EMetadataEntry; +use crate::view::{ErrorFileInfo, FileWithHash, PaginatedDirEntries}; + +/// List the effective view of files in a workspace directory. +/// +/// Merges commit tree entries with workspace changes (additions, modifications, +/// removals) *before* paginating. Removed files are excluded from the output. +pub fn list( + workspace: &Workspace, + directory: impl AsRef, + paginate_opts: &PaginateOpts, +) -> Result { + let directory = directory.as_ref(); + let base_repo = &workspace.base_repo; + let commit = &workspace.commit; + + // Build a ParsedResource for generating MetadataEntry objects + let parsed_resource = ParsedResource { + commit: Some(commit.clone()), + branch: None, + workspace: Some(workspace.clone()), + path: directory.to_path_buf(), + version: PathBuf::from(&commit.id), + resource: directory.to_path_buf(), + }; + + // Step 1: Get commit tree entries for this directory (if it exists) + let maybe_dir = repositories::tree::get_dir_with_children(base_repo, commit, directory, None)?; + + let mut found_commits: HashMap = HashMap::new(); + let commit_entries: Vec = if let Some(ref dir) = maybe_dir { + // only get the entries in the directory => that's why depth=0 + core::v_latest::entries::dir_entries_with_depth( + base_repo, + dir, + directory, + &parsed_resource, + &mut found_commits, + 0, + )? + } else { + Vec::new() + }; + + // Step 2: Get workspace status for this directory + let workspace_changes = + repositories::workspaces::status::status_from_dir(workspace, directory)?; + + // Step 3: Build status maps from staged files + let status_map = workspace_changes + .staged_files + .into_iter() + .filter_map(|(file_path, entry)| { + let res = file_path.file_name().map(|name| (name.to_string_lossy().to_string(), entry)); + if res.is_none() { + log::warn!("[skip] Could not retrieve file name for: '{file_path:?}' in workspace: {}", workspace.id) + } + res + }) + .collect::>(); + + // Step 4: Apply workspace changes to commit entries + let merged_entries = commit_entries.into_iter() + .filter_map(|entry| { + let filename = &entry.filename; + match status_map.get(filename).map(|staged_entry| staged_entry.status) { + + Some(status @ (StagedEntryStatus::Added | StagedEntryStatus::Modified)) => { + let mut ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); + ws_entry.changes = Some(WorkspaceChanges { + status: status.clone(), + }); + Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) + }, + + Some(StagedEntryStatus::Unmodified) | None => { + // treat no status (shouldn't happen!) as + let ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); + Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) + }, + + // Don't show removed files + Some(StagedEntryStatus::Removed) => None, + } + }); + + + // Step 5: Add workspace additions + for (file_path_str, status) in additions.iter() { + if *status != StagedEntryStatus::Added { + continue; + } + let file_path = Path::new(file_path_str); + + let staged_node = with_staged_db_manager(&workspace.workspace_repo, |staged_db_manager| { + staged_db_manager.read_from_staged_db(file_path) + })?; + + let Some(staged_node) = staged_node else { + log::warn!("Staged node in status not present in staged db: {file_path_str}"); + continue; + }; + + let metadata = match staged_node.node.node { + EMerkleTreeNode::File(ref file_node) => { + repositories::metadata::from_file_node(base_repo, file_node, commit)? + } + EMerkleTreeNode::Directory(ref dir_node) => { + repositories::metadata::from_dir_node(base_repo, dir_node, commit)? + } + _ => { + log::warn!("Unexpected node type found in staged db for {file_path_str}"); + continue; + } + }; + + let mut ws_entry = WorkspaceMetadataEntry::from_metadata_entry(metadata); + ws_entry.changes = Some(WorkspaceChanges { + status: StagedEntryStatus::Added, + }); + merged_entries.push(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)); + } + + // Step 6: Sort — directories first, then alphabetically by filename + merged_entries.sort_by(|a, b| { + b.is_dir() + .cmp(&a.is_dir()) + .then_with(|| a.filename().cmp(b.filename())) + }); + + // Step 7: Paginate + let (page_entries, pagination) = util::paginate( + merged_entries, + paginate_opts.page_num, + paginate_opts.page_size, + ); + + // Step 8: Build dir metadata entry for the directory itself + let dir_entry = if let Some(ref dir) = maybe_dir { + let dir_metadata = core::v_latest::entries::dir_node_to_metadata_entry( + base_repo, + dir, + &parsed_resource, + &mut found_commits, + false, + )?; + dir_metadata.map(|e| { + EMetadataEntry::WorkspaceMetadataEntry(WorkspaceMetadataEntry::from_metadata_entry(e)) + }) + } else { + None + }; + + Ok(PaginatedDirEntries { + dir: dir_entry, + entries: page_entries, + resource: None, + metadata: None, + page_size: paginate_opts.page_size, + page_number: paginate_opts.page_num, + total_pages: pagination.total_pages, + total_entries: pagination.total_entries, + }) +} const BUFFER_SIZE_THRESHOLD: usize = 262144; // 256kb const MAX_CONTENT_LENGTH: u64 = 1024 * 1024 * 1024; // 1GB limit diff --git a/oxen-rust/src/lib/src/repositories/workspaces/files.rs b/oxen-rust/src/lib/src/repositories/workspaces/files.rs index ad11cd943..7dd2af08c 100644 --- a/oxen-rust/src/lib/src/repositories/workspaces/files.rs +++ b/oxen-rust/src/lib/src/repositories/workspaces/files.rs @@ -5,10 +5,23 @@ use crate::model::file::TempFilePathNew; use crate::model::Commit; use crate::model::Workspace; use crate::model::{Branch, User}; +use crate::opts::PaginateOpts; use crate::view::ErrorFileInfo; +use crate::view::PaginatedDirEntries; use std::path::{Path, PathBuf}; +pub fn list( + workspace: &Workspace, + directory: impl AsRef, + paginate_opts: &PaginateOpts, +) -> Result { + match workspace.base_repo.min_version() { + MinOxenVersion::V0_10_0 => panic!("v0.10.0 no longer supported"), + _ => core::v_latest::workspaces::files::list(workspace, directory, paginate_opts), + } +} + pub fn exists(workspace: &Workspace, path: impl AsRef) -> Result { match workspace.base_repo.min_version() { MinOxenVersion::V0_10_0 => panic!("v0.10.0 no longer supported"), @@ -97,8 +110,10 @@ mod tests { use crate::config::UserConfig; use crate::error::OxenError; use crate::model::NewCommitBody; + use crate::opts::PaginateOpts; use crate::repositories::{self, workspaces}; use crate::test; + use crate::view::entries::EMetadataEntry; #[tokio::test] async fn test_mv_file_in_workspace() -> Result<(), OxenError> { @@ -238,4 +253,303 @@ mod tests { }) .await } + + #[tokio::test] + async fn test_list_workspace_files_no_changes() -> Result<(), OxenError> { + if std::env::consts::OS == "windows" { + return Ok(()); + } + + test::run_training_data_repo_test_fully_committed_async(|repo| async move { + let branch = repositories::branches::current_branch(&repo)?.unwrap(); + let commit = repositories::commits::get_by_id(&repo, &branch.commit_id)?.unwrap(); + let workspace = + repositories::workspaces::create(&repo, &commit, "test-list-no-changes", true)?; + + let paginate_opts = PaginateOpts { + page_num: 1, + page_size: 100, + }; + let result = workspaces::files::list(&workspace, Path::new(""), &paginate_opts)?; + + // Should have entries from the commit tree + assert!( + !result.entries.is_empty(), + "Should return commit tree entries" + ); + + // All entries should be WorkspaceMetadataEntry with no changes + for entry in &result.entries { + if let EMetadataEntry::WorkspaceMetadataEntry(ws_entry) = entry { + assert!( + ws_entry.changes.is_none(), + "No workspace changes should be present" + ); + } else { + panic!("Expected WorkspaceMetadataEntry"); + } + } + + Ok(()) + }) + .await + } + + #[tokio::test] + async fn test_list_workspace_files_with_added_file() -> Result<(), OxenError> { + if std::env::consts::OS == "windows" { + return Ok(()); + } + + test::run_empty_local_repo_test_async(|repo| async move { + // Create a file and commit + let file = repo.path.join("original.txt"); + crate::util::fs::write_to_path(&file, "original content")?; + repositories::add(&repo, &file).await?; + let commit = repositories::commit(&repo, "Add original.txt")?; + + let workspace = + repositories::workspaces::create(&repo, &commit, "test-list-added", true)?; + + // Add a new file to the workspace + let new_file = workspace.dir().join("added.txt"); + crate::util::fs::write_to_path(&new_file, "new content")?; + workspaces::files::add(&workspace, &new_file).await?; + + let paginate_opts = PaginateOpts { + page_num: 1, + page_size: 100, + }; + let result = workspaces::files::list(&workspace, Path::new(""), &paginate_opts)?; + + // Should have 2 entries: original.txt + added.txt + assert_eq!(result.entries.len(), 2, "Should have original + added file"); + assert_eq!(result.total_entries, 2); + + // Find the added file + let added = result + .entries + .iter() + .find(|e| e.filename() == "added.txt") + .expect("Should find added.txt"); + if let EMetadataEntry::WorkspaceMetadataEntry(ws_entry) = added { + assert!(ws_entry.changes.is_some(), "Added file should have changes"); + assert_eq!( + ws_entry.changes.as_ref().unwrap().status, + crate::model::StagedEntryStatus::Added + ); + } else { + panic!("Expected WorkspaceMetadataEntry"); + } + + Ok(()) + }) + .await + } + + #[tokio::test] + async fn test_list_workspace_files_with_removed_file() -> Result<(), OxenError> { + if std::env::consts::OS == "windows" { + return Ok(()); + } + + test::run_empty_local_repo_test_async(|repo| async move { + // Create two files and commit + let file1 = repo.path.join("keep.txt"); + let file2 = repo.path.join("remove_me.txt"); + crate::util::fs::write_to_path(&file1, "keep")?; + crate::util::fs::write_to_path(&file2, "remove")?; + repositories::add(&repo, &file1).await?; + repositories::add(&repo, &file2).await?; + let commit = repositories::commit(&repo, "Add two files")?; + + let workspace = + repositories::workspaces::create(&repo, &commit, "test-list-removed", true)?; + + // Remove the file from the workspace (path relative to repo root) + let rm_path = Path::new("remove_me.txt"); + workspaces::files::rm(&workspace, rm_path).await?; + + let paginate_opts = PaginateOpts { + page_num: 1, + page_size: 100, + }; + let result = workspaces::files::list(&workspace, Path::new(""), &paginate_opts)?; + + // Should have only 1 entry — the removed file is filtered out + assert_eq!( + result.entries.len(), + 1, + "Removed file should be filtered out" + ); + assert_eq!(result.total_entries, 1); + assert_eq!(result.entries[0].filename(), "keep.txt"); + + Ok(()) + }) + .await + } + + #[tokio::test] + async fn test_list_workspace_files_with_modified_file() -> Result<(), OxenError> { + if std::env::consts::OS == "windows" { + return Ok(()); + } + + test::run_empty_local_repo_test_async(|repo| async move { + let file = repo.path.join("data.txt"); + crate::util::fs::write_to_path(&file, "original")?; + repositories::add(&repo, &file).await?; + let commit = repositories::commit(&repo, "Add data.txt")?; + + let workspace = + repositories::workspaces::create(&repo, &commit, "test-list-modified", true)?; + + // Modify the file in the workspace + let workspace_file = workspace.dir().join("data.txt"); + crate::util::fs::write_to_path(&workspace_file, "modified content")?; + workspaces::files::add(&workspace, &workspace_file).await?; + + let paginate_opts = PaginateOpts { + page_num: 1, + page_size: 100, + }; + let result = workspaces::files::list(&workspace, Path::new(""), &paginate_opts)?; + + assert_eq!(result.entries.len(), 1); + let entry = &result.entries[0]; + if let EMetadataEntry::WorkspaceMetadataEntry(ws_entry) = entry { + assert!( + ws_entry.changes.is_some(), + "Modified file should have changes" + ); + assert_eq!( + ws_entry.changes.as_ref().unwrap().status, + crate::model::StagedEntryStatus::Modified + ); + } else { + panic!("Expected WorkspaceMetadataEntry"); + } + + Ok(()) + }) + .await + } + + #[tokio::test] + async fn test_list_workspace_files_subdirectory() -> Result<(), OxenError> { + if std::env::consts::OS == "windows" { + return Ok(()); + } + + test::run_training_data_repo_test_fully_committed_async(|repo| async move { + let branch = repositories::branches::current_branch(&repo)?.unwrap(); + let commit = repositories::commits::get_by_id(&repo, &branch.commit_id)?.unwrap(); + let workspace = + repositories::workspaces::create(&repo, &commit, "test-list-subdir", true)?; + + let paginate_opts = PaginateOpts { + page_num: 1, + page_size: 100, + }; + let result = + workspaces::files::list(&workspace, Path::new("annotations"), &paginate_opts)?; + + // Should return entries inside "annotations" directory + assert!( + !result.entries.is_empty(), + "Should have entries in annotations directory" + ); + + Ok(()) + }) + .await + } + + #[tokio::test] + async fn test_list_workspace_files_added_file_new_dir() -> Result<(), OxenError> { + if std::env::consts::OS == "windows" { + return Ok(()); + } + + test::run_empty_local_repo_test_async(|repo| async move { + let file = repo.path.join("root.txt"); + crate::util::fs::write_to_path(&file, "root file")?; + repositories::add(&repo, &file).await?; + let commit = repositories::commit(&repo, "Add root.txt")?; + + let workspace = + repositories::workspaces::create(&repo, &commit, "test-list-new-dir", true)?; + + // Add a file to a new directory that doesn't exist in the commit + let new_dir = workspace.dir().join("new_dir"); + std::fs::create_dir_all(&new_dir)?; + let new_file = new_dir.join("new_file.txt"); + crate::util::fs::write_to_path(&new_file, "new file in new dir")?; + workspaces::files::add(&workspace, &new_file).await?; + + // List at root — should show root.txt and the new directory entry + let paginate_opts = PaginateOpts { + page_num: 1, + page_size: 100, + }; + let result = workspaces::files::list(&workspace, Path::new(""), &paginate_opts)?; + + // Should have root.txt + new_dir (directory entry from addition) + let filenames: Vec<&str> = result.entries.iter().map(|e| e.filename()).collect(); + assert!(filenames.contains(&"root.txt"), "Should contain root.txt"); + + Ok(()) + }) + .await + } + + #[tokio::test] + async fn test_list_workspace_files_pagination() -> Result<(), OxenError> { + if std::env::consts::OS == "windows" { + return Ok(()); + } + + test::run_training_data_repo_test_fully_committed_async(|repo| async move { + let branch = repositories::branches::current_branch(&repo)?.unwrap(); + let commit = repositories::commits::get_by_id(&repo, &branch.commit_id)?.unwrap(); + let workspace = + repositories::workspaces::create(&repo, &commit, "test-list-paginate", true)?; + + // Use a small page size to test pagination + let paginate_opts = PaginateOpts { + page_num: 1, + page_size: 2, + }; + let result = workspaces::files::list(&workspace, Path::new(""), &paginate_opts)?; + + assert_eq!(result.entries.len(), 2, "Should respect page_size"); + assert_eq!(result.page_size, 2); + assert_eq!(result.page_number, 1); + assert!( + result.total_entries > 2, + "Training data repo should have more than 2 entries at root" + ); + assert!(result.total_pages > 1, "Should have multiple pages"); + + // Fetch page 2 + let paginate_opts_p2 = PaginateOpts { + page_num: 2, + page_size: 2, + }; + let result_p2 = workspaces::files::list(&workspace, Path::new(""), &paginate_opts_p2)?; + assert_eq!(result_p2.page_number, 2); + assert!(!result_p2.entries.is_empty(), "Page 2 should have entries"); + + // Entries on different pages should be different + assert_ne!( + result.entries[0].filename(), + result_p2.entries[0].filename(), + "Different pages should have different entries" + ); + + Ok(()) + }) + .await + } } diff --git a/oxen-rust/src/server/src/controllers/workspaces.rs b/oxen-rust/src/server/src/controllers/workspaces.rs index fe9c9b215..4471ce133 100644 --- a/oxen-rust/src/server/src/controllers/workspaces.rs +++ b/oxen-rust/src/server/src/controllers/workspaces.rs @@ -18,6 +18,7 @@ use utoipa; pub mod changes; pub mod data_frames; pub mod files; +pub mod ls; /// Get or create workspace #[utoipa::path( diff --git a/oxen-rust/src/server/src/controllers/workspaces/ls.rs b/oxen-rust/src/server/src/controllers/workspaces/ls.rs new file mode 100644 index 000000000..1ffbe2ce4 --- /dev/null +++ b/oxen-rust/src/server/src/controllers/workspaces/ls.rs @@ -0,0 +1,68 @@ +use crate::errors::OxenHttpError; +use crate::helpers::get_repo; +use crate::params::{app_data, path_param, PageNumQuery}; + +use liboxen::constants; +use liboxen::opts::PaginateOpts; +use liboxen::repositories; +use liboxen::view::entries::PaginatedDirEntriesResponse; +use liboxen::view::StatusMessageDescription; + +use actix_web::{web, HttpRequest, HttpResponse}; + +use std::path::PathBuf; + +pub async fn list_root( + req: HttpRequest, + query: web::Query, +) -> actix_web::Result { + let app_data = app_data(&req)?; + let namespace = path_param(&req, "namespace")?; + let repo_name = path_param(&req, "repo_name")?; + let workspace_id = path_param(&req, "workspace_id")?; + + let repo = get_repo(&app_data.path, namespace, repo_name)?; + + let Some(workspace) = repositories::workspaces::get(&repo, &workspace_id)? else { + return Ok(HttpResponse::NotFound() + .json(StatusMessageDescription::workspace_not_found(workspace_id))); + }; + + let paginate_opts = PaginateOpts { + page_num: query.page.unwrap_or(constants::DEFAULT_PAGE_NUM), + page_size: query.page_size.unwrap_or(constants::DEFAULT_PAGE_SIZE), + }; + + let result = + repositories::workspaces::files::list(&workspace, PathBuf::from(""), &paginate_opts)?; + + Ok(HttpResponse::Ok().json(PaginatedDirEntriesResponse::ok_from(result))) +} + +pub async fn list( + req: HttpRequest, + query: web::Query, +) -> actix_web::Result { + let app_data = app_data(&req)?; + let namespace = path_param(&req, "namespace")?; + let repo_name = path_param(&req, "repo_name")?; + let workspace_id = path_param(&req, "workspace_id")?; + + let repo = get_repo(&app_data.path, namespace, repo_name)?; + + let path = PathBuf::from(path_param(&req, "path")?); + + let Some(workspace) = repositories::workspaces::get(&repo, &workspace_id)? else { + return Ok(HttpResponse::NotFound() + .json(StatusMessageDescription::workspace_not_found(workspace_id))); + }; + + let paginate_opts = PaginateOpts { + page_num: query.page.unwrap_or(constants::DEFAULT_PAGE_NUM), + page_size: query.page_size.unwrap_or(constants::DEFAULT_PAGE_SIZE), + }; + + let result = repositories::workspaces::files::list(&workspace, path, &paginate_opts)?; + + Ok(HttpResponse::Ok().json(PaginatedDirEntriesResponse::ok_from(result))) +} diff --git a/oxen-rust/src/server/src/services/workspaces.rs b/oxen-rust/src/server/src/services/workspaces.rs index 55a1cef8d..47e8e2a61 100644 --- a/oxen-rust/src/server/src/services/workspaces.rs +++ b/oxen-rust/src/server/src/services/workspaces.rs @@ -16,6 +16,11 @@ pub fn workspace() -> Scope { web::scope("/{workspace_id}") .route("", web::get().to(controllers::workspaces::get)) .route("", web::delete().to(controllers::workspaces::delete)) + .route("/ls", web::get().to(controllers::workspaces::ls::list_root)) + .route( + "/ls/{path:.*}", + web::get().to(controllers::workspaces::ls::list), + ) .route( "/changes", web::get().to(controllers::workspaces::changes::list_root),