From 4c54c3282b5980551179da5c7f7416359ad2ded9 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Mon, 5 Jan 2026 15:09:57 +0800 Subject: Add share command with subcommands and completion support 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. --- locales/help_docs/en.yml | 82 +++- locales/help_docs/zh-CN.yml | 83 +++- scripts/completions/bash/completion_jv.sh | 36 +- scripts/completions/powershell/completion_jv.ps1 | 35 +- src/bin/jv.rs | 477 +++++++++++++++++++++-- src/utils/display.rs | 121 +++++- 6 files changed, 799 insertions(+), 35 deletions(-) diff --git a/locales/help_docs/en.yml b/locales/help_docs/en.yml index 1f55143..be88c60 100644 --- a/locales/help_docs/en.yml +++ b/locales/help_docs/en.yml @@ -450,15 +450,15 @@ jv: **Usage**: jv share - Share mapping to other sheets jv share - Import share to current sheet - jv share - Import mapping from other reference sheet - jv shares - View incoming shares + jv share list - View incoming shares + jv share see - View share details **Tip**: The import command can use the following parameters - --only-remote - Only import mapping into the sheet, do not modify local structure - --strict - Strict import mode, reject all conflicts, this is the default scheme - --skip - Skip conflicting items + --safe - Safe import, reject all conflicts, this is the default scheme + --skip - Skip all conflicting items --overwrite - Force overwrite conflicting mappings, dangerous operation + --reject - Reject this share **Sharing** is the simplest way to give file visibility to others @@ -577,6 +577,14 @@ jv: from_core: | **Error**: `%{err}` (This error is from core call) + share: + share_id_not_exist: | + The share `%{id}` does not exist. + + invalid_target_sheet: | + The sheet `%{sheet}` you specified does not exist in your context. + If you are sure it exists, please use `jv update` to update the workspace. + sheet: align: no_direction: | @@ -911,6 +919,22 @@ jv: description: DESCRIPTION description_current: Editing ... + share: + list: + headers: + id: ID + sharer: SHARER + description: DESCRIPTION + file_count: COUNT + footer: Use `jv share see ` to view the specific content of the share + + content: | + %{share_id} + FROM: %{sharer} + %{description} + MAPPINGS: + %{mappings} + status: struct_changes_display: | Viewing sheet %{sheet_name} (%{h}h %{m}min %{s}secs ago). @@ -1032,6 +1056,54 @@ jv: Error syncing upstream information to local: Local path %{path} already exists, but a move operation needs to move an item here. Please try moving the item to a different path, then run `jv update` again + share: + share_mapping: + success: | + Successfully shared visibility of %{file_nums} files to `%{to_sheet}` + The holder of that sheet, `%{to_sheet_holder}`, will see your share after performing an update + + target_sheet_not_found: | + The sheet `%{to_sheet}` you specified does not exist. + You can use `jv sheet list --all` to list all sheets + + target_is_self: | + You cannot share your own mapping to yourself + + mapping_not_found: | + In your share, a mapping was found that is not recognized by the upstream! + Mapping: %{mapping} + + Please confirm your local mapping is aligned with the upstream. You can use `jv align` to check the status + + unknown: | + Unknown result! + + merge_shares: + success: | + Successfully merged share `%{share_id}` into your sheet `%{sheet}` + Upstream information has changed, please use `jv update` to sync to the latest information + + success_reject: + Rejected share `%{share_id}` + + has_conflicts: | + Conflicts occurred when merging structure from share `%{share_id}` into your sheet! + Because the share contains mappings that overlap with your sheet! + You can use `jv share %{share_id} --skip` + or use `jv share %{share_id} --overwrite` + to select the merge mode + + edit_not_allowed: | + Upstream prevented you from modifying this sheet! + Because you do not have edit rights for this sheet + + share_id_not_found: | + Cannot find the share `%{share_id}` you provided in the upstream + You can use `jv share list` to list all shares after `jv update` + + merge_failed: | + Merge failed: %{error} + sheet: make: success: | diff --git a/locales/help_docs/zh-CN.yml b/locales/help_docs/zh-CN.yml index 3f5f438..50441ff 100644 --- a/locales/help_docs/zh-CN.yml +++ b/locales/help_docs/zh-CN.yml @@ -441,15 +441,15 @@ jv: **用法**: jv share <文件> <表> <描述> - 分享映射到其他表 jv share <分享ID> - 将分享导入到当前表 - jv share <文件> - 从其他参考表中导入映射 - jv shares - 查看传入的分享 + jv share list - 查看传入的分享 + jv share see - 查看分享的详情 **提示**:import 命令可使用如下参数 - --only-remote - 只在表中导入映射,不修改本地结构 - --strict - 严格的导入模式,拒绝所有冲突,这是默认的方案 - --skip - 跳过冲突项 + --safe - 安全导入,拒绝所有冲突,这是默认的方案 + --skip - 跳过所有冲突项 --overwrite - 强制覆盖冲突的映射,危险的操作 + --reject - 拒绝该分享 **分享** 是将文件可见性交由其他人的最简途径 @@ -567,6 +567,14 @@ jv: from_core: | **错误**:`%{err}`(该错误来自核心调用) + share: + share_id_not_exist: | + 您给出的分享 `%{id}` 不存在 + + invalid_target_sheet: | + 您所给出的表 `%{sheet}` 在您的上下文中并不存在 + 若您确定它存在,请使用 `jv update` 更新工作区 + sheet: align: no_direction: | @@ -904,6 +912,22 @@ jv: description: 描述 description_current: 正在编辑中 ... + share: + list: + headers: + id: 分享ID + sharer: 分享者 + description: 描述 + file_count: 文件数 + footer: 使用 `jv share see ` 查看分享的具体内容 + + content: | + %{share_id} + 来自: %{sharer} + %{description} + 映射: + %{mappings} + status: struct_changes_display: | 表 %{sheet_name} 的状态基于 %{h} 小时 %{m} 分钟 %{s} 秒前 @@ -1023,6 +1047,55 @@ jv: 在同步上游信息至本地时发生了错误:本地已存在 %{path},但是某个移动项需要移动到此处。 请尝试移动该项至其他路径,再重新输入 `jv update` + share: + share_mapping: + success: | + 成功将 %{file_nums} 个文件的可见性分享至 `%{to_sheet}` + 该表的持有者 `%{to_sheet_holder}` 在执行更新后即可看到您的分享 + + target_sheet_not_found: | + 您所指定的 `%{to_sheet}` 不存在, + 您可以使用 `jv sheet list --all` 列出所有的表 + + target_is_self: | + 您不能将自己的映射分享给自己 + + mapping_not_found: | + 在您的分享中,找到并未被上游所承认的映射! + 映射:%{mapping} + + 请确认您的本地映射和上游是否对齐,您可以使用 `jv align` 查看状态 + + unknown: | + 未知的结果! + + merge_shares: + success: | + 成功将分享 `%{share_id}` 合入您的表 `%{sheet}` + 上游信息已变更,请使用 `jv update` 同步至最新信息 + + success_reject: + 已拒绝接受分享 `%{share_id}` + + has_conflicts: | + 从分享 `%{share_id}` 合并结构到您的表时发生冲突! + 因为分享中存在和您表中重合的映射! + 您可以使用 `jv share %{share_id} --skip` + 或使用 `jv share %{share_id} --overwrite` + 来选择合并模式 + + edit_not_allowed: | + 上游阻止了您修改此表! + 因为您没有该表的编辑权 + + share_id_not_found: | + 在上游中无法找到您给出的分享 `%{share_id}` + 您可以在 `jv update` 后使用 + `jv share list` 来列出所有的分享 + + merge_failed: | + 合并失败:%{error} + sheet: make: success: | diff --git a/scripts/completions/bash/completion_jv.sh b/scripts/completions/bash/completion_jv.sh index 4023e42..364df9d 100644 --- a/scripts/completions/bash/completion_jv.sh +++ b/scripts/completions/bash/completion_jv.sh @@ -18,7 +18,7 @@ _jv_completion() { local base_commands="create init direct unstain account update \ sheet status here move mv docs exit use sheets accounts \ as make drop track hold throw login \ - jump align info" + jump align info share" # Subcommands - Account local account_commands="list as add remove movekey mvkey mvk genpub help" @@ -157,6 +157,40 @@ _jv_completion() { return 0 fi + # Completion share + if [[ "$subcmd" == "share" ]]; then + if [[ $cword -eq 2 ]]; then + # First parameter: list, see, jv share list --raw results, or files + local share_list + share_list=$($cmd share list --raw 2>/dev/null) + local first_param_options="list see $share_list" + COMPREPLY=($(compgen -W "$first_param_options" -f -- "$cur")) + elif [[ $cword -eq 3 ]]; then + # Second parameter: depends on first parameter + local first_param="${words[2]}" + + if [[ "$first_param" == "list" ]]; then + # list -> nothing + COMPREPLY=() + elif [[ "$first_param" == "see" ]]; then + # see -> jv share list --raw results + local share_list + share_list=$($cmd share list --raw 2>/dev/null) + COMPREPLY=($(compgen -W "$share_list" -- "$cur")) + elif [[ "$first_param" == *"@"* ]]; then + # Contains "@" (shareid) -> show options + COMPREPLY=($(compgen -W "--safe --overwrite --skip --reject" -- "$cur")) + else + # File input -> show jv sheet list --all --raw results + local all_sheets + all_sheets=$($cmd sheet list --all --raw 2>/dev/null) + COMPREPLY=($(compgen -W "$all_sheets" -- "$cur")) + fi + fi + # Third parameter: no completion + return 0 + fi + # Completion login if [[ "$subcmd" == "login" ]]; then if [[ $cword -eq 2 ]]; then diff --git a/scripts/completions/powershell/completion_jv.ps1 b/scripts/completions/powershell/completion_jv.ps1 index 0c854e3..84ba01a 100644 --- a/scripts/completions/powershell/completion_jv.ps1 +++ b/scripts/completions/powershell/completion_jv.ps1 @@ -16,7 +16,7 @@ Register-ArgumentCompleter -Native -CommandName jv -ScriptBlock { "create", "init", "direct", "unstain", "account", "update", "sheet", "status", "here", "move", "mv", "docs", "exit", "use", "sheets", "accounts", "as", "make", "drop", "track", "hold", "throw", "login", - "jump", "align", "info" + "jump", "align", "info", "share" ) # Account subcommands @@ -175,6 +175,39 @@ Register-ArgumentCompleter -Native -CommandName jv -ScriptBlock { return @() } + # Completion for share command + if ($subcmd -eq "share") { + if ($currentIndex -eq 2) { + # First parameter: list, see, jv share list --raw results, or files in current directory + $staticOptions = @("list", "see") + $shareList = & $cmd share list --raw 2>$null + $files = Get-ChildItem -Name -File -Path "." 2>$null + $completions = $staticOptions + $shareList + $files + return $completions | Where-Object { $_ -like "$wordToComplete*" } + } elseif ($currentIndex -eq 3) { + # Second parameter: depends on the first parameter + $firstParam = $words[2] + if ($firstParam -eq "list") { + # list -> nothing + return @() + } elseif ($firstParam -eq "see") { + # see -> jv share list --raw results + $shareList = & $cmd share list --raw 2>$null + return $shareList | Where-Object { $_ -like "$wordToComplete*" } + } elseif ($firstParam -like "*@*") { + # Contains "@" (shareid) -> show options + $options = @("--safe", "--overwrite", "--skip", "--reject") + return $options | Where-Object { $_ -like "$wordToComplete*" } + } else { + # Otherwise, assume it's a file -> show jv sheet list --all --raw results + $allSheets = & $cmd sheet list --all --raw 2>$null + return $allSheets | Where-Object { $_ -like "$wordToComplete*" } + } + } + # Third parameter: no completion + return @() + } + # Aliases completion switch ($subcmd) { "as" { 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, + + /// 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 = 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::>() + }; + + 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, @@ -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) -> 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 = 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, // 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); + } +} -- cgit