summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-01-05 15:09:57 +0800
committer魏曹先生 <1992414357@qq.com>2026-01-05 15:09:57 +0800
commit4c54c3282b5980551179da5c7f7416359ad2ded9 (patch)
tree8fe05b7ffcd09fa66782d2908829f31eeb044bf7 /src
parent5c4e1b0f94fcf61d44b4d0ba86f54dbde98c31fc (diff)
Add share command with subcommands and completion supportMVP
The share command now supports `list`, `see`, and merging operations with conflict resolution modes (--safe, --skip, --overwrite, --reject). Updated help documentation in both English and Chinese locales, and added Bash and PowerShell completion scripts.
Diffstat (limited to 'src')
-rw-r--r--src/bin/jv.rs477
-rw-r--r--src/utils/display.rs121
2 files changed, 575 insertions, 23 deletions
diff --git a/src/bin/jv.rs b/src/bin/jv.rs
index b4c7c91..8b73366 100644
--- a/src/bin/jv.rs
+++ b/src/bin/jv.rs
@@ -21,8 +21,11 @@ use just_enough_vcs::{
},
sheet_actions::{
DropSheetActionResult, EditMappingActionArguments, EditMappingActionResult,
- EditMappingOperations, InvalidMoveReason, MakeSheetActionResult, OperationArgument,
- proc_drop_sheet_action, proc_edit_mapping_action, proc_make_sheet_action,
+ EditMappingOperations, InvalidMoveReason, MakeSheetActionResult,
+ MergeShareMappingActionResult, MergeShareMappingArguments, OperationArgument,
+ ShareMappingActionResult, ShareMappingArguments, proc_drop_sheet_action,
+ proc_edit_mapping_action, proc_make_sheet_action, proc_merge_share_mapping_action,
+ proc_share_mapping_action,
},
track_action::{
CreateTaskResult, NextVersion, SyncTaskResult, TrackFileActionArguments,
@@ -36,7 +39,7 @@ use just_enough_vcs::{
},
constants::{
CLIENT_FILE_TODOLIST, CLIENT_FILE_WORKSPACE, CLIENT_FOLDER_WORKSPACE_ROOT_NAME,
- CLIENT_PATH_WORKSPACE_ROOT, PORT,
+ CLIENT_PATH_WORKSPACE_ROOT, PORT, VAULT_HOST_NAME,
},
current::{correct_current_dir, current_cfg_dir, current_local_path},
data::{
@@ -53,7 +56,10 @@ use just_enough_vcs::{
member::{Member, MemberId},
sheet::{SheetData, SheetMappingMetadata},
user::UserDirectory,
- vault::virtual_file::{VirtualFileId, VirtualFileVersion},
+ vault::{
+ sheet_share::{Share, ShareMergeMode},
+ virtual_file::{VirtualFileId, VirtualFileVersion},
+ },
},
docs::{ASCII_YIZI, document, documents},
},
@@ -81,7 +87,7 @@ use just_enough_vcs_cli::{
ipaddress_history::{get_recent_ip_address, insert_recent_ip_address},
},
utils::{
- display::{SimpleTable, display_width, md, size_str},
+ display::{SimpleTable, display_width, md, render_share_path_tree, size_str},
env::{auto_update_outdate, current_locales, enable_auto_update},
fs::move_across_partitions,
globber::{GlobItem, Globber},
@@ -171,7 +177,7 @@ enum JustEnoughVcsWorkspaceCommand {
Move(MoveMappingArgs),
/// Share file visibility to other sheets
- Share(ShareFileArgs),
+ Share(ShareMappingArgs),
/// Sync information from upstream vault
#[command(alias = "u")]
@@ -639,7 +645,7 @@ struct MoveMappingArgs {
}
#[derive(Parser, Debug)]
-struct ShareFileArgs {
+struct ShareMappingArgs {
/// Show help information
#[arg(short, long)]
help: bool,
@@ -652,6 +658,26 @@ struct ShareFileArgs {
/// Arguments 3
args3: Option<String>,
+
+ /// Safe merge
+ #[arg(short = 's', long)]
+ safe: bool,
+
+ /// Skip all conflicting mappings
+ #[arg(short = 'S', long)]
+ skip: bool,
+
+ /// Overwrite all conflicting mappings
+ #[arg(short = 'o', long)]
+ overwrite: bool,
+
+ /// Reject this share
+ #[arg(short = 'R', long)]
+ reject: bool,
+
+ /// Show raw output
+ #[arg(short = 'r', long)]
+ raw: bool,
}
#[derive(Parser, Debug)]
@@ -4267,17 +4293,28 @@ async fn proc_mapping_edit(
}
}
-async fn jv_share(args: ShareFileArgs) {
+async fn jv_share(args: ShareMappingArgs) {
// Import share mode
- if let (Some(import_id), None, None) = (&args.args1, &args.args2, &args.args3) {
- share_accept(import_id).await;
+ if let (Some(args1), None, None) = (&args.args1, &args.args2, &args.args3) {
+ // List mode
+ if args1.trim() == "list" || args1.trim() == "ls" {
+ share_list(args).await;
+ return;
+ }
+
+ share_accept(args1.to_string(), args).await;
return;
}
// Pull mode
- if let (Some(from_sheet), Some(import_pattern), None) = (&args.args1, &args.args2, &args.args3)
- {
- share_in(from_sheet, import_pattern).await;
+ if let (Some(args1), Some(args2), None) = (&args.args1, &args.args2, &args.args3) {
+ // See mode
+ if args1.trim() == "see" {
+ share_see(args2.to_string()).await;
+ return;
+ }
+
+ share_in(args1.to_string(), args2.to_string(), args).await;
return;
}
@@ -4285,26 +4322,422 @@ async fn jv_share(args: ShareFileArgs) {
if let (Some(share_pattern), Some(to_sheet), Some(description)) =
(&args.args1, &args.args2, &args.args3)
{
- share_out(share_pattern, to_sheet, description).await;
+ share_out(
+ share_pattern.to_string(),
+ to_sheet.to_string(),
+ description.to_string(),
+ args,
+ )
+ .await;
return;
}
println!("{}", md(t!("jv.share")));
}
-async fn share_accept(_import_id: &str) {
- // TODO: Implement import share logic
- eprintln!("share_accept not implemented yet");
+async fn share_list(args: ShareMappingArgs) {
+ let _ = correct_current_dir();
+
+ let Some(local_dir) = current_local_path() else {
+ eprintln!("{}", t!("jv.fail.workspace_not_found").trim());
+ return;
+ };
+
+ let local_config = match precheck().await {
+ Some(config) => config,
+ None => return,
+ };
+
+ let sheet_name = local_config.sheet_in_use().clone().unwrap_or_default();
+
+ let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path(
+ &local_dir,
+ &local_config.current_account(),
+ ))
+ .await
+ else {
+ eprintln!(
+ "{}",
+ md(t!(
+ "jv.fail.cfg_not_found.latest_info",
+ account = &local_config.current_account()
+ ))
+ );
+ return;
+ };
+
+ if let Some(shares) = latest_info.shares_in_my_sheets.get(&sheet_name) {
+ // Sort
+ let mut sorted_shares: BTreeMap<String, &Share> = BTreeMap::new();
+ for (id, share) in shares {
+ sorted_shares.insert(id.clone(), share);
+ }
+
+ if !args.raw {
+ // Create table and insert information
+ let mut table = SimpleTable::new(vec![
+ t!("jv.success.share.list.headers.id"),
+ t!("jv.success.share.list.headers.sharer"),
+ t!("jv.success.share.list.headers.description"),
+ t!("jv.success.share.list.headers.file_count"),
+ ]);
+ for (id, share) in sorted_shares {
+ table.insert_item(
+ 0,
+ vec![
+ id.to_string(),
+ share.sharer.to_string(),
+ truncate_first_line(share.description.to_string()),
+ share.mappings.len().to_string(),
+ ],
+ );
+ }
+
+ // Render
+ println!("{}", table);
+ println!("{}", md(t!("jv.success.share.list.footer")));
+ } else {
+ sorted_shares
+ .iter()
+ .for_each(|share| println!("{}", share.0));
+ }
+ }
+}
+
+async fn share_see(share_id: String) {
+ let _ = correct_current_dir();
+
+ let Some(local_dir) = current_local_path() else {
+ eprintln!("{}", t!("jv.fail.workspace_not_found").trim());
+ return;
+ };
+
+ let local_config = match precheck().await {
+ Some(config) => config,
+ None => return,
+ };
+
+ let sheet_name = local_config.sheet_in_use().clone().unwrap_or_default();
+
+ let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path(
+ &local_dir,
+ &local_config.current_account(),
+ ))
+ .await
+ else {
+ eprintln!(
+ "{}",
+ md(t!(
+ "jv.fail.cfg_not_found.latest_info",
+ account = &local_config.current_account()
+ ))
+ );
+ return;
+ };
+
+ if let Some(shares) = latest_info.shares_in_my_sheets.get(&sheet_name) {
+ if let Some(share) = shares.get(&share_id) {
+ println!(
+ "{}",
+ md(t!(
+ "jv.success.share.content",
+ share_id = share_id,
+ sharer = share.sharer,
+ description = share.description,
+ mappings = render_share_path_tree(&share.mappings)
+ ))
+ );
+ }
+ }
+}
+
+async fn share_accept(import_id: String, args: ShareMappingArgs) {
+ let _ = correct_current_dir();
+
+ let Some(local_dir) = current_local_path() else {
+ eprintln!("{}", t!("jv.fail.workspace_not_found").trim());
+ return;
+ };
+
+ let local_config = match precheck().await {
+ Some(config) => config,
+ None => return,
+ };
+
+ let sheet_name = local_config.sheet_in_use().clone().unwrap_or_default();
+
+ let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path(
+ &local_dir,
+ &local_config.current_account(),
+ ))
+ .await
+ else {
+ eprintln!(
+ "{}",
+ md(t!(
+ "jv.fail.cfg_not_found.latest_info",
+ account = &local_config.current_account()
+ ))
+ );
+ return;
+ };
+
+ let contains_share = if let Some(share_ids) = latest_info.shares_in_my_sheets.get(&sheet_name) {
+ share_ids.contains_key(&import_id)
+ } else {
+ false
+ };
+
+ if !contains_share {
+ eprintln!(
+ "{}",
+ md(t!("jv.fail.share.share_id_not_exist", id = &import_id))
+ );
+ return;
+ }
+
+ let (pool, ctx, _output) = match build_pool_and_ctx(&local_config).await {
+ Some(result) => result,
+ None => return,
+ };
+
+ let share_merge_mode = {
+ if args.safe {
+ ShareMergeMode::Safe
+ } else if args.skip {
+ ShareMergeMode::Skip
+ } else if args.overwrite {
+ ShareMergeMode::Overwrite
+ } else if args.reject {
+ ShareMergeMode::RejectAll
+ } else {
+ ShareMergeMode::Safe
+ }
+ };
+
+ match proc_merge_share_mapping_action(
+ &pool,
+ ctx,
+ MergeShareMappingArguments {
+ share_id: import_id.clone(),
+ share_merge_mode,
+ },
+ )
+ .await
+ {
+ Ok(r) => match r {
+ MergeShareMappingActionResult::Success => {
+ if args.reject {
+ println!(
+ "{}",
+ md(t!(
+ "jv.result.share.merge_shares.success_reject",
+ share_id = &import_id
+ ))
+ );
+ } else {
+ println!(
+ "{}",
+ md(t!(
+ "jv.result.share.merge_shares.success",
+ share_id = &import_id,
+ sheet = &sheet_name
+ ))
+ );
+ }
+ }
+ MergeShareMappingActionResult::HasConflicts => {
+ eprintln!(
+ "{}",
+ md(t!(
+ "jv.result.share.merge_shares.has_conflicts",
+ share_id = &import_id
+ ))
+ );
+ }
+ MergeShareMappingActionResult::AuthorizeFailed(e) => {
+ eprintln!("{}", md(t!("jv.result.common.authroize_failed", err = e)));
+ }
+ MergeShareMappingActionResult::EditNotAllowed => {
+ eprintln!(
+ "{}",
+ md(t!("jv.result.share.merge_shares.edit_not_allowed"))
+ );
+ }
+ MergeShareMappingActionResult::ShareIdNotFound(share_id) => {
+ eprintln!(
+ "{}",
+ md(t!(
+ "jv.result.share.merge_shares.share_id_not_found",
+ share_id = share_id
+ ))
+ );
+ }
+ MergeShareMappingActionResult::MergeFails(error) => {
+ eprintln!(
+ "{}",
+ md(t!(
+ "jv.result.share.merge_shares.merge_failed",
+ error = error
+ ))
+ );
+ }
+ MergeShareMappingActionResult::Unknown => {
+ eprintln!("{}", md(t!("jv.result.share.merge_shares.unknown")));
+ }
+ },
+ Err(e) => handle_err(e),
+ }
}
-async fn share_in(_from_sheet: &str, _import_pattern: &str) {
+async fn share_in(_from_sheet: String, _import_pattern: String, _args: ShareMappingArgs) {
// TODO: Implement pull mode logic
- eprintln!("share_in not implemented yet");
}
-async fn share_out(_share_pattern: &str, _to_sheet: &str, _description: &str) {
- // TODO: Implement share mode logic
- eprintln!("share_out not implemented yet");
+async fn share_out(
+ share_pattern: String,
+ to_sheet: String,
+ description: String,
+ _args: ShareMappingArgs,
+) {
+ let shared_files = {
+ let local_dir = match current_local_path() {
+ Some(dir) => dir,
+ None => {
+ eprintln!("{}", t!("jv.fail.workspace_not_found").trim());
+ return;
+ }
+ };
+ let files = glob(share_pattern, &local_dir).await;
+ files
+ .iter()
+ .filter_map(|f| PathBuf::from_str(f.0).ok())
+ .collect::<Vec<_>>()
+ };
+
+ let _ = correct_current_dir();
+
+ let Some(local_dir) = current_local_path() else {
+ eprintln!("{}", t!("jv.fail.workspace_not_found").trim());
+ return;
+ };
+
+ let local_config = match precheck().await {
+ Some(config) => config,
+ None => return,
+ };
+
+ let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path(
+ &local_dir,
+ &local_config.current_account(),
+ ))
+ .await
+ else {
+ eprintln!(
+ "{}",
+ md(t!(
+ "jv.fail.cfg_not_found.latest_info",
+ account = &local_config.current_account()
+ ))
+ );
+ return;
+ };
+
+ // Pre-check if the sheet exists
+ let contains_in_my_sheet = latest_info.visible_sheets.contains(&to_sheet);
+ let contains_in_other_sheet = latest_info
+ .invisible_sheets
+ .iter()
+ .find(|info| info.sheet_name == to_sheet)
+ .is_some();
+ if !contains_in_my_sheet && !contains_in_other_sheet {
+ eprintln!(
+ "{}",
+ md(t!("jv.fail.share.invalid_target_sheet", sheet = &to_sheet))
+ );
+ return;
+ }
+
+ let (pool, ctx, _output) = match build_pool_and_ctx(&local_config).await {
+ Some(result) => result,
+ None => return,
+ };
+
+ let to_sheet_holder = {
+ if latest_info.reference_sheets.contains(&to_sheet) {
+ VAULT_HOST_NAME.to_string()
+ } else if latest_info.visible_sheets.contains(&to_sheet) {
+ local_config.current_account()
+ } else {
+ let mut holder = String::new();
+ for info in &latest_info.invisible_sheets {
+ if info.sheet_name == to_sheet {
+ holder = info.holder_name.as_ref().cloned().unwrap_or_default();
+ break;
+ }
+ }
+ holder
+ }
+ };
+
+ match proc_share_mapping_action(
+ &pool,
+ ctx,
+ ShareMappingArguments {
+ mappings: shared_files.clone(),
+ description,
+
+ // Since the Action internally checks the current sheet,
+ // there's no need to fill in from_sheet here.
+ // This is prepared for pull operations.
+ from_sheet: None,
+ to_sheet: to_sheet.clone(),
+ },
+ )
+ .await
+ {
+ Ok(r) => match r {
+ ShareMappingActionResult::Success => {
+ println!(
+ "{}",
+ md(t!(
+ "jv.result.share.share_mapping.success",
+ file_nums = shared_files.len(),
+ to_sheet = to_sheet,
+ to_sheet_holder = to_sheet_holder
+ ))
+ );
+ }
+ ShareMappingActionResult::AuthorizeFailed(e) => {
+ eprintln!("{}", md(t!("jv.result.common.authroize_failed", err = e)));
+ }
+ ShareMappingActionResult::TargetSheetNotFound(sheet) => {
+ eprintln!(
+ "{}",
+ md(t!(
+ "jv.result.share.share_mapping.target_sheet_not_found",
+ to_sheet = sheet
+ ))
+ );
+ }
+ ShareMappingActionResult::TargetIsSelf => {
+ eprintln!("{}", md(t!("jv.result.share.share_mapping.target_is_self")));
+ }
+ ShareMappingActionResult::MappingNotFound(path_buf) => {
+ eprintln!(
+ "{}",
+ md(t!(
+ "jv.result.share.share_mapping.mapping_not_found",
+ mapping = path_buf.display()
+ ))
+ );
+ }
+ ShareMappingActionResult::Unknown => {
+ eprintln!("{}", md(t!("jv.result.share.share_mapping.unknown")));
+ }
+ },
+ Err(e) => handle_err(e),
+ }
}
async fn jv_account_add(user_dir: UserDirectory, args: AccountAddArgs) {
diff --git a/src/utils/display.rs b/src/utils/display.rs
index 4610f4f..f0532f3 100644
--- a/src/utils/display.rs
+++ b/src/utils/display.rs
@@ -1,5 +1,9 @@
use colored::*;
-use std::collections::VecDeque;
+use just_enough_vcs::vcs::data::sheet::SheetMappingMetadata;
+use std::{
+ collections::{BTreeMap, HashMap, VecDeque},
+ path::PathBuf,
+};
pub struct SimpleTable {
items: Vec<String>,
@@ -365,3 +369,118 @@ fn apply_color(text: &str, color_name: &str) -> String {
_ => text.to_string(),
}
}
+
+/// Render a HashMap of PathBuf to SheetMappingMetadata as a tree string.
+pub fn render_share_path_tree(paths: &HashMap<PathBuf, SheetMappingMetadata>) -> String {
+ if paths.is_empty() {
+ return String::new();
+ }
+
+ // Collect all path components into a tree structure
+ let mut root = TreeNode::new("".to_string());
+
+ for (path, metadata) in paths {
+ let mut current = &mut root;
+ let components: Vec<String> = path
+ .components()
+ .filter_map(|comp| match comp {
+ std::path::Component::Normal(s) => s.to_str().map(|s| s.to_string()),
+ _ => None,
+ })
+ .collect();
+
+ for (i, comp) in components.iter().enumerate() {
+ let is_leaf = i == components.len() - 1;
+ let child = current
+ .children
+ .entry(comp.clone())
+ .or_insert_with(|| TreeNode::new(comp.clone()));
+
+ // If this is the leaf node, store the metadata
+ if is_leaf {
+ child.metadata = Some((metadata.id.clone(), metadata.version.clone()));
+ }
+
+ current = child;
+ }
+ }
+
+ // Convert tree to string representation
+ let mut result = String::new();
+ let is_root = true;
+ let prefix = String::new();
+ let last_stack = vec![true]; // Root is always "last"
+
+ add_tree_node_to_string(&root, &mut result, is_root, &prefix, &last_stack);
+
+ result
+}
+
+/// Internal tree node structure for building the path tree
+#[derive(Debug)]
+struct TreeNode {
+ name: String,
+ children: BTreeMap<String, TreeNode>, // Use BTreeMap for sorted output
+ metadata: Option<(String, String)>, // Store (id, version) for leaf nodes
+}
+
+impl TreeNode {
+ fn new(name: String) -> Self {
+ Self {
+ name,
+ children: BTreeMap::new(),
+ metadata: None,
+ }
+ }
+}
+
+/// Recursively add tree node to string representation
+fn add_tree_node_to_string(
+ node: &TreeNode,
+ result: &mut String,
+ is_root: bool,
+ prefix: &str,
+ last_stack: &[bool],
+) {
+ if !is_root {
+ // Add the tree prefix for this node
+ for &is_last in &last_stack[1..] {
+ if is_last {
+ result.push_str(" ");
+ } else {
+ result.push_str("│ ");
+ }
+ }
+
+ // Add the connector for this node
+ if let Some(&is_last) = last_stack.last() {
+ if is_last {
+ result.push_str("└── ");
+ } else {
+ result.push_str("├── ");
+ }
+ }
+
+ // Add node name
+ result.push_str(&node.name);
+
+ // Add metadata for leaf nodes
+ if let Some((id, version)) = &node.metadata {
+ // Truncate id to first 11 characters
+ let truncated_id = if id.len() > 11 { &id[..11] } else { id };
+ result.push_str(&format!(" [{}|{}]", truncated_id, version));
+ }
+
+ result.push('\n');
+ }
+
+ // Process children
+ let child_count = node.children.len();
+ for (i, (_, child)) in node.children.iter().enumerate() {
+ let is_last_child = i == child_count - 1;
+ let mut new_last_stack = last_stack.to_vec();
+ new_last_stack.push(is_last_child);
+
+ add_tree_node_to_string(child, result, false, prefix, &new_last_stack);
+ }
+}