From 24370e40b7bd140d94e3a599a5ca89bb6ab2e3b7 Mon Sep 17 00:00:00 2001 From: zhangzheng <826193720@qq.com> Date: Tue, 4 Aug 2026 14:55:43 +0800 Subject: [PATCH] fix file context menu actions --- src/apps/desktop/src/api/commands.rs | 100 ++++++++++++++++-- .../src/flow_chat/components/ChatInput.tsx | 5 + .../store/sessionComposerStore.test.ts | 23 ++++ .../flow_chat/store/sessionComposerStore.ts | 18 +++- .../components/ui/ContextMenu.tsx | 9 +- 5 files changed, 143 insertions(+), 12 deletions(-) diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 5dca1c486b..a93b6dc868 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -3229,11 +3229,24 @@ pub async fn compress_path( // never serialized as archive member directories. Directory archives // contain the selected directory's contents at their root; extraction // therefore does not create `//...`. - let zip_out = join_remote_path(&parent, &format!("{}.zip", base_name)); + let mut zip_suffix = 0; + let zip_archive_name = loop { + let candidate = archive_name_with_suffix(&base_name, ".zip", zip_suffix); + let check_command = build_remote_path_exists_command(&parent, &candidate); + let (_, _, code) = manager + .execute_command(cid, &check_command) + .await + .map_err(|e| e.to_string())?; + if code != 0 { + break candidate; + } + zip_suffix += 1; + }; + let zip_out = join_remote_path(&parent, &zip_archive_name); let zip_cmd = build_remote_compress_command( &parent, &base_name, - &format!("{}.zip", base_name), + &zip_archive_name, RemoteArchiveFormat::Zip, ); @@ -3247,11 +3260,24 @@ pub async fn compress_path( } // zip not available or failed — try tar. - let tar_out = join_remote_path(&parent, &format!("{}.tar.gz", base_name)); + let mut tar_suffix = 0; + let tar_archive_name = loop { + let candidate = archive_name_with_suffix(&base_name, ".tar.gz", tar_suffix); + let check_command = build_remote_path_exists_command(&parent, &candidate); + let (_, _, code) = manager + .execute_command(cid, &check_command) + .await + .map_err(|e| e.to_string())?; + if code != 0 { + break candidate; + } + tar_suffix += 1; + }; + let tar_out = join_remote_path(&parent, &tar_archive_name); let tar_cmd = build_remote_compress_command( &parent, &base_name, - &format!("{}.tar.gz", base_name), + &tar_archive_name, RemoteArchiveFormat::TarGz, ); @@ -3295,7 +3321,7 @@ pub async fn compress_path( .and_then(|n| n.to_str()) .ok_or_else(|| format!("Cannot determine file name of '{}'", src))? .to_string(); - let zip_path = parent.join(format!("{}.zip", file_name)); + let zip_path = next_available_local_archive_path(parent, &file_name, ".zip"); let zip_path_clone = zip_path.clone(); let src_path_clone = src_path.clone(); @@ -3309,6 +3335,39 @@ pub async fn compress_path( Ok(zip_path.to_string_lossy().to_string()) } +fn archive_name_with_suffix(base_name: &str, extension: &str, suffix: usize) -> String { + if suffix == 0 { + format!("{}{}", base_name, extension) + } else { + format!("{} ({}){}", base_name, suffix, extension) + } +} + +fn build_remote_path_exists_command(parent: &str, candidate: &str) -> String { + format!( + "cd -- {} || exit 2; test -e {} -o -L {}", + shell_quote_posix(parent), + shell_quote_posix(&format!("./{}", candidate)), + shell_quote_posix(&format!("./{}", candidate)), + ) +} + +fn next_available_local_archive_path(parent: &Path, base_name: &str, extension: &str) -> PathBuf { + let initial = parent.join(archive_name_with_suffix(base_name, extension, 0)); + if !initial.exists() { + return initial; + } + + for suffix in 1.. { + let candidate = parent.join(archive_name_with_suffix(base_name, extension, suffix)); + if !candidate.exists() { + return candidate; + } + } + + unreachable!("archive suffix search is unbounded") +} + #[derive(Clone, Copy)] enum RemoteArchiveFormat { Zip, @@ -3406,7 +3465,7 @@ fn build_remote_compress_command( format!( "cd -- {parent} || exit 1; \ - rm -f {archive}; \ + if [ -e {archive} ] || [ -L {archive} ]; then exit 73; fi; \ {compress}; \ status=$?; \ if [ \"$status\" -ne 0 ]; then rm -f {archive}; fi; \ @@ -4051,6 +4110,30 @@ mod archive_tests { writer.finish().expect("finish test zip"); } + #[test] + fn local_archive_path_uses_incrementing_suffixes() { + let temp = tempfile::tempdir().expect("create temp dir"); + std::fs::write(temp.path().join("project.zip"), "first").expect("write first archive"); + std::fs::write(temp.path().join("project (1).zip"), "second") + .expect("write second archive"); + + assert_eq!( + next_available_local_archive_path(temp.path(), "project", ".zip"), + temp.path().join("project (2).zip") + ); + } + + #[test] + fn archive_names_with_special_characters_are_shell_quoted() { + assert_eq!( + archive_name_with_suffix("中 文@!#", ".zip", 2), + "中 文@!# (2).zip" + ); + let command = build_remote_path_exists_command("/home/work tree", "中 文@!# (2).zip"); + assert!(command.contains("cd -- '/home/work tree'")); + assert!(command.contains("test -e './中 文@!# (2).zip'")); + } + #[test] fn directory_zip_stores_children_at_archive_root() { let temp = tempfile::tempdir().expect("create temp dir"); @@ -4226,6 +4309,8 @@ mod archive_tests { assert!(command.contains("cd -- '/home/developer/work tree'")); assert!(command.contains("cd -- ./project")); assert!(command.contains("zip -r -q ../project.zip .")); + assert!(command.contains("if [ -e ./project.zip ] || [ -L ./project.zip ]")); + assert!(command.contains("if [ \"$status\" -ne 0 ]; then rm -f ./project.zip; fi")); assert!( !command.contains("/home/developer/work tree/project"), "absolute source path must not become an archive member path" @@ -4512,7 +4597,8 @@ pub(crate) fn reveal_local_path_in_explorer( } else { let normalized_path = path_str.replace("/", "\\"); bitfun_core::util::process_manager::create_command("explorer") - .arg(format!("/select,{}", normalized_path)) + .arg("/select,") + .arg(&normalized_path) .spawn() .map_err(|e| format!("Failed to open explorer: {}", e))?; } diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index b823b1ca56..8a48880835 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -1463,17 +1463,22 @@ export const ChatInput: React.FC = ({ previousSessionId, effectiveTargetSessionId, useContextStore.getState().contexts, + richTextInputRef.current?.getComposerPresentation?.() ?? null, ); previousComposerSessionIdRef.current = effectiveTargetSessionId; const nextValue = draft.value; const nextContexts = draft.contexts; + const nextPresentation = draft.presentation; const nextPendingLargePastes = draft.pendingLargePastes; dispatchLocalInput({ type: 'SET_VALUE', payload: nextValue }); inputValueRef.current = nextValue; pendingLargePastesRef.current = { ...nextPendingLargePastes }; replaceContexts(nextContexts); + if (nextPresentation) { + richTextInputRef.current?.restoreComposerPresentation?.(nextPresentation); + } setHistoryIndex(-1); setSavedDraft(''); setMentionState({ isActive: false, query: '', startOffset: 0 }); diff --git a/src/web-ui/src/flow_chat/store/sessionComposerStore.test.ts b/src/web-ui/src/flow_chat/store/sessionComposerStore.test.ts index 91ccd7f1dd..b649ca8605 100644 --- a/src/web-ui/src/flow_chat/store/sessionComposerStore.test.ts +++ b/src/web-ui/src/flow_chat/store/sessionComposerStore.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import type { ContextItem } from '@/shared/types/context'; +import type { ComposerPresentation } from '../utils/composerPresentation'; import { sessionComposerStore } from './sessionComposerStore'; function context(id: string): ContextItem { @@ -60,6 +61,7 @@ describe('sessionComposerStore', () => { 'session-a', 'session-b', [context('context-a')], + null, ); expect(sessionComposerStore.getState().getDraft('session-a').contexts).toMatchObject([ @@ -72,6 +74,27 @@ describe('sessionComposerStore', () => { }); }); + it('saves and restores the rich context presentation on activation', () => { + const store = sessionComposerStore.getState(); + const contextA = context('context-a'); + const presentation: ComposerPresentation = { + version: 1, + segments: [{ + kind: 'context', + context: contextA, + tag: '#file:context-a.ts', + label: 'context-a.ts', + title: 'context-a.ts', + }], + }; + + store.setPresentation('session-b', presentation); + const nextDraft = store.activateDraft('session-a', 'session-b', [contextA], presentation); + + expect(sessionComposerStore.getState().getDraft('session-a').presentation).toEqual(presentation); + expect(nextDraft.presentation).toEqual(presentation); + }); + it('removes drafts for deleted session ids without disturbing others', () => { const store = sessionComposerStore.getState(); store.setValue('session-a', 'draft a'); diff --git a/src/web-ui/src/flow_chat/store/sessionComposerStore.ts b/src/web-ui/src/flow_chat/store/sessionComposerStore.ts index 4d97caf4d3..f0cb62404b 100644 --- a/src/web-ui/src/flow_chat/store/sessionComposerStore.ts +++ b/src/web-ui/src/flow_chat/store/sessionComposerStore.ts @@ -1,12 +1,14 @@ import { create } from 'zustand'; import type { ContextItem } from '@/shared/types/context'; +import type { ComposerPresentation } from '../utils/composerPresentation'; export type PendingLargePasteMap = Record; export interface SessionComposerDraft { value: string; contexts: ContextItem[]; + presentation: ComposerPresentation | null; pendingLargePastes: PendingLargePasteMap; updatedAt: number; } @@ -18,9 +20,11 @@ interface SessionComposerState { previousSessionId: string | null, nextSessionId: string | null, currentContexts: ContextItem[], + currentPresentation: ComposerPresentation | null, ) => SessionComposerDraft; setValue: (sessionId: string, value: string) => void; setContexts: (sessionId: string, contexts: ContextItem[]) => void; + setPresentation: (sessionId: string, presentation: ComposerPresentation | null) => void; setPendingLargePastes: (sessionId: string, pendingLargePastes: PendingLargePasteMap) => void; clearDraft: (sessionId: string) => void; removeDrafts: (sessionIds: Iterable) => void; @@ -33,6 +37,7 @@ function createEmptyDraft(): SessionComposerDraft { return { value: '', contexts: EMPTY_CONTEXTS, + presentation: null, pendingLargePastes: EMPTY_PENDING_LARGE_PASTES, updatedAt: 0, }; @@ -61,9 +66,15 @@ export const useSessionComposerStore = create((set, get) = getDraft: (sessionId) => get().drafts[sessionId] ?? createEmptyDraft(), - activateDraft: (previousSessionId, nextSessionId, currentContexts) => { + activateDraft: ( + previousSessionId, + nextSessionId, + currentContexts, + currentPresentation, + ) => { if (previousSessionId && previousSessionId !== nextSessionId) { get().setContexts(previousSessionId, currentContexts); + get().setPresentation(previousSessionId, currentPresentation); } return nextSessionId ? get().getDraft(nextSessionId) : createEmptyDraft(); }, @@ -76,6 +87,10 @@ export const useSessionComposerStore = create((set, get) = set(state => updateDraft(state, sessionId, { contexts: [...contexts] })); }, + setPresentation: (sessionId, presentation) => { + set(state => updateDraft(state, sessionId, { presentation })); + }, + setPendingLargePastes: (sessionId, pendingLargePastes) => { set(state => updateDraft(state, sessionId, { pendingLargePastes: { ...pendingLargePastes }, @@ -91,6 +106,7 @@ export const useSessionComposerStore = create((set, get) = return updateDraft(state, sessionId, { value: '', contexts: [], + presentation: null, pendingLargePastes: {}, }); }); diff --git a/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx b/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx index 1c624bb3e4..e6597d5b31 100644 --- a/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx +++ b/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx @@ -221,7 +221,11 @@ export const ContextMenu: React.FC = ({ return; } - + // Close the menu before running the action. Some actions open a modal and + // wait for user input; keeping the menu mounted during that wait leaves it + // visible behind the modal. + onClose(); + if (item.onClick) { try { await Promise.resolve(item.onClick(context)); @@ -233,9 +237,6 @@ export const ContextMenu: React.FC = ({ if (onItemClick) { onItemClick(item, context); } - - - onClose(); }, [context, onItemClick, onClose]);