From b59f39894d8c96b60cfeb2c4a857435b5488d959 Mon Sep 17 00:00:00 2001 From: Malcolm Greaves <75upbeats_hybrids@icloud.com> Date: Mon, 16 Feb 2026 14:56:35 -0800 Subject: [PATCH 1/5] wip --- oxen-rust/src/cli/src/cmd/workspace.rs | 4 + oxen-rust/src/cli/src/cmd/workspace/ls.rs | 137 ++++++++ .../src/lib/src/api/client/workspaces.rs | 1 + .../src/lib/src/api/client/workspaces/ls.rs | 152 +++++++++ .../src/lib/src/core/v_latest/entries.rs | 10 + .../lib/src/core/v_latest/workspaces/files.rs | 176 +++++++++- .../lib/src/repositories/workspaces/files.rs | 314 ++++++++++++++++++ .../src/server/src/controllers/workspaces.rs | 1 + .../server/src/controllers/workspaces/ls.rs | 68 ++++ .../src/server/src/services/workspaces.rs | 5 + 10 files changed, 864 insertions(+), 4 deletions(-) create mode 100644 oxen-rust/src/cli/src/cmd/workspace/ls.rs create mode 100644 oxen-rust/src/lib/src/api/client/workspaces/ls.rs create mode 100644 oxen-rust/src/server/src/controllers/workspaces/ls.rs 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..60a812e91 100644 --- a/oxen-rust/src/lib/src/core/v_latest/entries.rs +++ b/oxen-rust/src/lib/src/core/v_latest/entries.rs @@ -285,6 +285,16 @@ pub fn dir_entries_with_depth( Ok(entries) } +/// Public wrapper for getting a directory's own metadata entry (without appending resource). +pub fn dir_node_to_metadata_entry_public( + repo: &LocalRepository, + node: &MerkleTreeNode, + parsed_resource: &ParsedResource, + found_commits: &mut HashMap, +) -> Result, OxenError> { + dir_node_to_metadata_entry(repo, node, parsed_resource, found_commits, false) +} + fn dir_node_to_metadata_entry( repo: &LocalRepository, node: &MerkleTreeNode, 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..381ec1c61 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,184 @@ 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, 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 { + 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 mut additions_map: HashMap = HashMap::new(); + let mut other_changes_map: HashMap = HashMap::new(); + + for (file_path, entry) in workspace_changes.staged_files.iter() { + let status = entry.status.clone(); + if status == StagedEntryStatus::Added { + // For added files, use the full path as key (relative to repo root) + let key = file_path.to_str().unwrap().to_string(); + additions_map.insert(key, status); + } else { + // For modified or removed files, use the filename as key + let key = file_path.file_name().unwrap().to_string_lossy().to_string(); + other_changes_map.insert(key, status); + } + } + + // Step 4: Apply workspace changes to commit entries + let mut merged_entries: Vec = Vec::new(); + + for entry in commit_entries { + let filename = &entry.filename; + if let Some(status) = other_changes_map.get(filename) { + match status { + StagedEntryStatus::Removed => { + // Filter out removed files + continue; + } + _ => { + // Modified — annotate with workspace changes + let mut ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); + ws_entry.changes = Some(WorkspaceChanges { + status: status.clone(), + }); + merged_entries.push(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)); + } + } + } else { + // Unmodified commit entry + let ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); + merged_entries.push(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)); + } + } + + // Step 5: Add workspace additions + for (file_path_str, status) in additions_map.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_public( + base_repo, + dir, + &parsed_resource, + &mut found_commits, + )?; + 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), From 8fbe6befb2c7b0e64a70da394850fa481f8734f0 Mon Sep 17 00:00:00 2001 From: Malcolm Greaves Date: Thu, 26 Feb 2026 18:16:20 -0800 Subject: [PATCH 2/5] wip --- .../src/lib/src/core/v_latest/entries.rs | 41 ++++++------ .../lib/src/core/v_latest/workspaces/files.rs | 63 ++++++++++++++----- 2 files changed, 66 insertions(+), 38 deletions(-) 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 60a812e91..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,17 +285,8 @@ pub fn dir_entries_with_depth( Ok(entries) } -/// Public wrapper for getting a directory's own metadata entry (without appending resource). -pub fn dir_node_to_metadata_entry_public( - repo: &LocalRepository, - node: &MerkleTreeNode, - parsed_resource: &ParsedResource, - found_commits: &mut HashMap, -) -> Result, OxenError> { - dir_node_to_metadata_entry(repo, node, parsed_resource, found_commits, false) -} - -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, @@ -310,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 381ec1c61..32658d819 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 @@ -76,25 +76,57 @@ pub fn list( repositories::workspaces::status::status_from_dir(workspace, directory)?; // Step 3: Build status maps from staged files - let mut additions_map: HashMap = HashMap::new(); - let mut other_changes_map: HashMap = HashMap::new(); - - for (file_path, entry) in workspace_changes.staged_files.iter() { - let status = entry.status.clone(); - if status == StagedEntryStatus::Added { - // For added files, use the full path as key (relative to repo root) - let key = file_path.to_str().unwrap().to_string(); - additions_map.insert(key, status); - } else { - // For modified or removed files, use the filename as key - let key = file_path.file_name().unwrap().to_string_lossy().to_string(); - other_changes_map.insert(key, status); + let (additions, removed, modified) = { + workspace_changes.staged_files.iter().fold( + (HashMap::new(), HashMap::new(), HashMap::new()), + |(mut additions, mut removed, mut modified), (file_path, entry)| { + + if let Some(maybe_map_to_be_modified) = match entry.status { + StagedEntryStatus::Added => Some(&mut additions), + StagedEntryStatus::Modified => Some(&mut modified), + StagedEntryStatus::Removed => Some(&mut removed), + StagedEntryStatus::Unmodified => None, + } { + let Some(key) = file_path.file_name() { + map_to_be_modified.insert(key, status); + } else { + log::warn!("[skip] Could not retrieve file name for: '{file_path:?}' in workspace: {}", workspace.id) + } + } + + (additions, removed, modified } - } + ) + }; // Step 4: Apply workspace changes to commit entries let mut merged_entries: Vec = Vec::new(); + let merged_entries = commit_entries.into_iter() + .filter_map(|entry| { + + let filename = &entry.filename; + if let Some(status) = other_changes_map.get(filename) { + match status { + // Don't show removed files + StagedEntryStatus::Removed => None, + _ => { + // Still present => must be modified (either modified or unmodified) + let mut ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); + ws_entry.changes = Some(WorkspaceChanges { + status: status.clone(), + }); + Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) + } + } + } else { + // Unmodified commit entry + let ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); + merged_entries.push(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)); + } + + }); + for entry in commit_entries { let filename = &entry.filename; if let Some(status) = other_changes_map.get(filename) { @@ -171,11 +203,12 @@ pub fn list( // 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_public( + 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)) From ff5f48d21e082de07490ba915018864a34e57cfd Mon Sep 17 00:00:00 2001 From: Malcolm Greaves Date: Thu, 26 Feb 2026 18:59:06 -0800 Subject: [PATCH 3/5] wip --- .../lib/src/core/v_latest/workspaces/files.rs | 85 +++++++++---------- 1 file changed, 40 insertions(+), 45 deletions(-) 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 32658d819..c066bb45f 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 @@ -23,7 +23,7 @@ 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, MerkleHash, ParsedResource, StagedEntryStatus}; +use crate::model::{Branch, Commit, MerkleHash, ParsedResource, StagedEntry, StagedEntryStatus}; use crate::model::{LocalRepository, MetadataEntry, NewCommitBody}; use crate::opts::PaginateOpts; use crate::repositories; @@ -59,6 +59,7 @@ pub fn list( 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, @@ -76,36 +77,54 @@ pub fn list( repositories::workspaces::status::status_from_dir(workspace, directory)?; // Step 3: Build status maps from staged files - let (additions, removed, modified) = { + + let status_map = + let (additions, removed, modified, unmodified) = { workspace_changes.staged_files.iter().fold( - (HashMap::new(), HashMap::new(), HashMap::new()), - |(mut additions, mut removed, mut modified), (file_path, entry)| { - - if let Some(maybe_map_to_be_modified) = match entry.status { - StagedEntryStatus::Added => Some(&mut additions), - StagedEntryStatus::Modified => Some(&mut modified), - StagedEntryStatus::Removed => Some(&mut removed), - StagedEntryStatus::Unmodified => None, - } { - let Some(key) = file_path.file_name() { - map_to_be_modified.insert(key, status); - } else { - log::warn!("[skip] Could not retrieve file name for: '{file_path:?}' in workspace: {}", workspace.id) - } + (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new()), + |(mut additions, mut removed, mut modified, mut unmodified), (file_path, entry)| { + + let map_to_be_modified = match entry.status { + StagedEntryStatus::Added => &mut additions, + StagedEntryStatus::Modified => &mut modified, + StagedEntryStatus::Removed => &mut removed, + StagedEntryStatus::Unmodified => &mut unmodified, + }; + + if let Some(key) = file_path.file_name() { + map_to_be_modified.insert(key, entry.status); + } else { + log::warn!("[skip] Could not retrieve file name for: '{file_path:?}' in workspace: {}", workspace.id) } - (additions, removed, modified + (additions, removed, modified, unmodified) } ) }; // Step 4: Apply workspace changes to commit entries - let mut merged_entries: Vec = Vec::new(); - let merged_entries = commit_entries.into_iter() .filter_map(|entry| { let filename = &entry.filename; + + if removed.contains_key(filename) { + // Don't show removed files + None + + } else if unmodified.contains_key(filename) { + let ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); + Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) + + } else if modified.contains_key(filename) { + let mut ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); + ws_entry.changes = Some(WorkspaceChanges { + status: status.clone(), + }); + Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) + } + + if let Some(status) = other_changes_map.get(filename) { match status { // Don't show removed files @@ -122,37 +141,13 @@ pub fn list( } else { // Unmodified commit entry let ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); - merged_entries.push(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)); + Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) } - }); - for entry in commit_entries { - let filename = &entry.filename; - if let Some(status) = other_changes_map.get(filename) { - match status { - StagedEntryStatus::Removed => { - // Filter out removed files - continue; - } - _ => { - // Modified — annotate with workspace changes - let mut ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); - ws_entry.changes = Some(WorkspaceChanges { - status: status.clone(), - }); - merged_entries.push(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)); - } - } - } else { - // Unmodified commit entry - let ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); - merged_entries.push(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)); - } - } // Step 5: Add workspace additions - for (file_path_str, status) in additions_map.iter() { + for (file_path_str, status) in additions.iter() { if *status != StagedEntryStatus::Added { continue; } From 53e77c3d555252f9fc08e114aa3b039a5959220e Mon Sep 17 00:00:00 2001 From: Malcolm Greaves Date: Fri, 27 Feb 2026 10:37:33 -0800 Subject: [PATCH 4/5] wip --- .../lib/src/core/v_latest/workspaces/files.rs | 79 ++++++------------- 1 file changed, 22 insertions(+), 57 deletions(-) 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 c066bb45f..851108eda 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 @@ -77,71 +77,36 @@ pub fn list( repositories::workspaces::status::status_from_dir(workspace, directory)?; // Step 3: Build status maps from staged files - - let status_map = - let (additions, removed, modified, unmodified) = { - workspace_changes.staged_files.iter().fold( - (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new()), - |(mut additions, mut removed, mut modified, mut unmodified), (file_path, entry)| { - - let map_to_be_modified = match entry.status { - StagedEntryStatus::Added => &mut additions, - StagedEntryStatus::Modified => &mut modified, - StagedEntryStatus::Removed => &mut removed, - StagedEntryStatus::Unmodified => &mut unmodified, - }; - - if let Some(key) = file_path.file_name() { - map_to_be_modified.insert(key, entry.status); - } else { - log::warn!("[skip] Could not retrieve file name for: '{file_path:?}' in workspace: {}", workspace.id) - } - - (additions, removed, modified, unmodified) + let status_map = workspace_changes.staged_files.into_iter().fold( + HashMap::new(), + |mut status_map, (file_path, entry)| { + if let Some(key) = file_path.file_name() { + status_map.insert(key.to_string_lossy().to_string(), entry.status); + } else { + log::warn!("[skip] Could not retrieve file name for: '{file_path:?}' in workspace: {}", workspace.id) } - ) - }; + status_map + }); // Step 4: Apply workspace changes to commit entries let merged_entries = commit_entries.into_iter() .filter_map(|entry| { - let filename = &entry.filename; - - if removed.contains_key(filename) { - // Don't show removed files - None - - } else if unmodified.contains_key(filename) { - let ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); - Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) - - } else if modified.contains_key(filename) { - let mut ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); - ws_entry.changes = Some(WorkspaceChanges { - status: status.clone(), - }); - Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) - } - - - if let Some(status) = other_changes_map.get(filename) { - match status { - // Don't show removed files - StagedEntryStatus::Removed => None, - _ => { - // Still present => must be modified (either modified or unmodified) - let mut ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); - ws_entry.changes = Some(WorkspaceChanges { - status: status.clone(), - }); - Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) - } - } - } else { - // Unmodified commit entry + match status_map.get(filename) { + 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 an unmodified file let ws_entry = WorkspaceMetadataEntry::from_metadata_entry(entry); Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) + }, + // Don't show removed files + Some(StagedEntryStatus::Removed) => None, } }); From df460bd8ded7083380a6a793e6359e9c24608088 Mon Sep 17 00:00:00 2001 From: Malcolm Greaves Date: Fri, 27 Feb 2026 10:42:50 -0800 Subject: [PATCH 5/5] wip --- .../lib/src/core/v_latest/workspaces/files.rs | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) 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 851108eda..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 @@ -77,22 +77,24 @@ pub fn list( 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().fold( - HashMap::new(), - |mut status_map, (file_path, entry)| { - if let Some(key) = file_path.file_name() { - status_map.insert(key.to_string_lossy().to_string(), entry.status); - } else { + 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) } - status_map - }); + 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) { + 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 { @@ -100,11 +102,13 @@ pub fn list( }); Some(EMetadataEntry::WorkspaceMetadataEntry(ws_entry)) }, + Some(StagedEntryStatus::Unmodified) | None => { - // treat no status (shouldn't happen!) as an unmodified file + // 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, }