diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 9b8d0631..4ef49912 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2977,7 +2977,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.5.2" +version = "1.5.3" dependencies = [ "anyhow", "axum", diff --git a/packages/desktop/src-tauri/src/commands/files.rs b/packages/desktop/src-tauri/src/commands/files.rs index d8f7a04f..0c23d171 100644 --- a/packages/desktop/src-tauri/src/commands/files.rs +++ b/packages/desktop/src-tauri/src/commands/files.rs @@ -53,6 +53,19 @@ pub struct CreateDirectoryResponse { path: String, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeletePathResponse { + success: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RenamePathResponse { + success: bool, + path: String, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct FileSearchHit { @@ -121,6 +134,34 @@ impl FsCommandError { FsCommandError::NotFound => "Parent directory not found".to_string(), } } + + fn to_delete_message(&self) -> String { + match self { + FsCommandError::NotFound => "File or directory not found".to_string(), + FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => { + "Access to path denied".to_string() + } + FsCommandError::NotDirectory => "Specified path is not a directory".to_string(), + FsCommandError::Other(message) => { + let _ = message; + "Failed to delete path".to_string() + } + } + } + + fn to_rename_message(&self) -> String { + match self { + FsCommandError::NotFound => "Source path not found".to_string(), + FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => { + "Access to path denied".to_string() + } + FsCommandError::NotDirectory => "Parent path must be a directory".to_string(), + FsCommandError::Other(message) => { + let _ = message; + "Failed to rename path".to_string() + } + } + } } impl From for FsCommandError { @@ -455,6 +496,71 @@ pub async fn create_directory( }) } +#[tauri::command] +pub async fn delete_path( + path: String, + state: tauri::State<'_, DesktopRuntime>, +) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("Path is required".to_string()); + } + + let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; + let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref()) + .await + .map_err(|err| err.to_delete_message())?; + + let metadata = fs::metadata(&resolved_path) + .await + .map_err(|err| FsCommandError::from(err).to_delete_message())?; + + if metadata.is_dir() { + fs::remove_dir_all(&resolved_path) + .await + .map_err(|err| FsCommandError::from(err).to_delete_message())?; + } else { + fs::remove_file(&resolved_path) + .await + .map_err(|err| FsCommandError::from(err).to_delete_message())?; + } + + Ok(DeletePathResponse { success: true }) +} + +#[tauri::command] +pub async fn rename_path( + old_path: String, + new_path: String, + state: tauri::State<'_, DesktopRuntime>, +) -> Result { + let trimmed_old = old_path.trim(); + if trimmed_old.is_empty() { + return Err("oldPath is required".to_string()); + } + let trimmed_new = new_path.trim(); + if trimmed_new.is_empty() { + return Err("newPath is required".to_string()); + } + + let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; + let resolved_old = resolve_sandboxed_path(Some(trimmed_old.to_string()), &workspace_roots, default_root.as_ref()) + .await + .map_err(|err| err.to_rename_message())?; + let resolved_new = resolve_creatable_path(trimmed_new, &workspace_roots, default_root.as_ref()) + .await + .map_err(|err| err.to_rename_message())?; + + fs::rename(&resolved_old, &resolved_new) + .await + .map_err(|err| FsCommandError::from(err).to_rename_message())?; + + Ok(RenamePathResponse { + success: true, + path: normalize_path(&resolved_new), + }) +} + async fn resolve_sandboxed_path( path: Option, workspace_roots: &[PathBuf], diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index ac32cdea..dc163a98 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -28,7 +28,7 @@ use axum::{ routing::{any, get, post}, Json, Router, }; -use commands::files::{create_directory, exec_commands, list_directory, read_file, read_file_binary, search_files, write_file}; +use commands::files::{create_directory, delete_path, exec_commands, list_directory, read_file, read_file_binary, rename_path, search_files, write_file}; use commands::git::{ add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch, create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch, @@ -835,6 +835,8 @@ fn main() { list_directory, search_files, create_directory, + delete_path, + rename_path, read_file, read_file_binary, write_file, diff --git a/packages/desktop/src/api/files.ts b/packages/desktop/src/api/files.ts index aadec574..4e9ae497 100644 --- a/packages/desktop/src/api/files.ts +++ b/packages/desktop/src/api/files.ts @@ -192,6 +192,49 @@ export const createDesktopFilesAPI = (): FilesAPI => ({ } }, + async delete(path: string): Promise<{ success: boolean }> { + try { + const normalizedPath = normalizePath(path); + const result = await safeInvoke<{ success: boolean }>('delete_path', { + path: normalizedPath, + }, { + timeout: 10000, + onCancel: () => { + console.warn('[FilesAPI] Delete operation timed out'); + } + }); + + return { + success: Boolean(result?.success), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(message || 'Failed to delete path'); + } + }, + + async rename(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }> { + try { + const result = await safeInvoke<{ success: boolean; path: string }>('rename_path', { + oldPath: normalizePath(oldPath), + newPath: normalizePath(newPath), + }, { + timeout: 10000, + onCancel: () => { + console.warn('[FilesAPI] Rename operation timed out'); + } + }); + + return { + success: Boolean(result?.success), + path: result?.path ? normalizePath(result.path) : normalizePath(newPath), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(message || 'Failed to rename path'); + } + }, + async execCommands(commands: string[], cwd: string): Promise<{ success: boolean; results: Array<{ diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 5bc617cd..bd56d504 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -221,7 +221,7 @@ export const ChatContainer: React.FC = () => { style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined} >
-
+
{[1, 2, 3].map((i) => (
diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index f3c347ee..33c4ec2b 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -827,17 +827,17 @@ const ChatMessage: React.FC = ({
-
+
{isUser ? (
-
+
= ({
) : ( -
+
{shouldShowHeader && ( { )}
{getFileIcon()} - - {displayName} - +
+ + {displayName} + +
({formatFileSize(file.size)}) @@ -289,9 +291,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta" > {getFileIcon(file.mime)} - - {extractFilename(file.filename)} - +
+ + {extractFilename(file.filename)} + +
))}
diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index bbdfc9eb..f0485ea7 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -2049,13 +2049,15 @@ export const ModelControls: React.FC = ({ className }) => { - {getCurrentModelDisplayName()} + + {getCurrentModelDisplayName()} +
@@ -2169,11 +2171,13 @@ export const ModelControls: React.FC = ({ className }) => { )} - {getCurrentModelDisplayName()} + + {getCurrentModelDisplayName()} + )} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 36974809..a70b842c 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -155,7 +155,7 @@ const UserMessageBody: React.FC<{ style={{ contain: 'layout', transform: 'translateZ(0)' }} onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined} > -
+
{textParts.map((part, index) => { let mentionForPart: AgentMentionInfo | undefined; if (agentMention && mentionToken && !mentionInjected) { @@ -180,8 +180,7 @@ const UserMessageBody: React.FC<{ {(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
{onRevert && ( @@ -284,7 +283,6 @@ const AssistantMessageBody: React.FC> = ({ errorMessage, }) => { - void _streamPhase; void _allowAnimation; const [copyHintVisible, setCopyHintVisible] = React.useState(false); const copyHintTimeoutRef = React.useRef(null); @@ -899,11 +897,9 @@ const AssistantMessageBody: React.FC> = ({ }} onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined} > -
+
{renderedParts} {showErrorMessage && ( diff --git a/packages/ui/src/components/chat/message/MessageHeader.tsx b/packages/ui/src/components/chat/message/MessageHeader.tsx index 10cb2a70..16731214 100644 --- a/packages/ui/src/components/chat/message/MessageHeader.tsx +++ b/packages/ui/src/components/chat/message/MessageHeader.tsx @@ -19,7 +19,7 @@ const MessageHeader: React.FC = ({ isUser, providerID, agent return ( -
+
diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 2b6f2c76..a4ceb5ae 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -59,7 +59,7 @@ export const FixedSessionsButton: React.FC = () => { } }, [isMobile, setSessionSwitcherOpen, toggleSidebar]); - const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground'; + const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-md gap-2 p-2 typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-secondary/50 transition-colors'; if (isMobile || !isDesktopApp || !isMacPlatform) { return null; @@ -89,7 +89,6 @@ export const Header: React.FC = () => { const toggleCommandPalette = useUIStore((state) => state.toggleCommandPalette); const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog); const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); - const sidebarWidth = useUIStore((state) => state.sidebarWidth); const activeMainTab = useUIStore((state) => state.activeMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); @@ -168,12 +167,11 @@ export const Header: React.FC = () => { setSettingsDialogOpen(true); }, [blurActiveElement, isMobile, setSessionSwitcherOpen, setSettingsDialogOpen]); - const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground'; + const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 rounded-md typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-secondary/50 transition-colors'; const desktopPaddingClass = React.useMemo(() => { if (isDesktopApp && isMacPlatform) { - - return isSidebarOpen ? 'pl-0' : 'pl-[8.0rem]'; + return isSidebarOpen ? 'pl-2' : 'pl-[8.0rem]'; } return 'pl-3'; }, [isDesktopApp, isMacPlatform, isSidebarOpen]); @@ -221,15 +219,12 @@ export const Header: React.FC = () => { }, [updateHeaderHeight, isMobile]); const handleDragStart = React.useCallback(async (e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) { return; } - if (e.button !== 0) { return; } - if (isDesktopApp) { try { const { getCurrentWindow } = await import('@tauri-apps/api/window'); @@ -242,11 +237,9 @@ export const Header: React.FC = () => { }, [isDesktopApp]); const handleActiveTabDragStart = React.useCallback(async (e: React.MouseEvent) => { - if (e.button !== 0) { return; } - if (isDesktopApp) { try { const { getCurrentWindow } = await import('@tauri-apps/api/window'); @@ -278,7 +271,6 @@ export const Header: React.FC = () => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (hasModifier(e) && !e.shiftKey && !e.altKey) { const num = parseInt(e.key, 10); if (num >= 1 && num <= tabs.length) { @@ -287,82 +279,58 @@ export const Header: React.FC = () => { } } }; - window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [tabs, setActiveMainTab]); - const renderTab = (tab: TabConfig, isLast: boolean) => { + const renderTab = (tab: TabConfig) => { const isActive = activeMainTab === tab.id; const Icon = tab.icon; const isChatTab = tab.id === 'chat'; - const isGitTab = tab.id === 'git'; return ( - - - {} - {!isLast && ( -