Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 93 additions & 7 deletions src/apps/desktop/src/api/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>/<name>/...`.
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,
);

Expand All @@ -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,
);

Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down Expand Up @@ -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; \
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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))?;
}
Expand Down
5 changes: 5 additions & 0 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1463,17 +1463,22 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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 });
Expand Down
23 changes: 23 additions & 0 deletions src/web-ui/src/flow_chat/store/sessionComposerStore.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -60,6 +61,7 @@ describe('sessionComposerStore', () => {
'session-a',
'session-b',
[context('context-a')],
null,
);

expect(sessionComposerStore.getState().getDraft('session-a').contexts).toMatchObject([
Expand All @@ -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');
Expand Down
18 changes: 17 additions & 1 deletion src/web-ui/src/flow_chat/store/sessionComposerStore.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

export interface SessionComposerDraft {
value: string;
contexts: ContextItem[];
presentation: ComposerPresentation | null;
pendingLargePastes: PendingLargePasteMap;
updatedAt: number;
}
Expand All @@ -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<string>) => void;
Expand All @@ -33,6 +37,7 @@ function createEmptyDraft(): SessionComposerDraft {
return {
value: '',
contexts: EMPTY_CONTEXTS,
presentation: null,
pendingLargePastes: EMPTY_PENDING_LARGE_PASTES,
updatedAt: 0,
};
Expand Down Expand Up @@ -61,9 +66,15 @@ export const useSessionComposerStore = create<SessionComposerState>((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();
},
Expand All @@ -76,6 +87,10 @@ export const useSessionComposerStore = create<SessionComposerState>((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 },
Expand All @@ -91,6 +106,7 @@ export const useSessionComposerStore = create<SessionComposerState>((set, get) =
return updateDraft(state, sessionId, {
value: '',
contexts: [],
presentation: null,
pendingLargePastes: {},
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,11 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
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));
Expand All @@ -233,9 +237,6 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
if (onItemClick) {
onItemClick(item, context);
}


onClose();
}, [context, onItemClick, onClose]);


Expand Down
Loading