feat(UI): Introduce new UI components for file attachments and related views (#191)

Add file management API and UI components
Implement directory listing, search and CRUD operations in desktop backend
Expose new Files API on frontend to list, search, and modify files
This commit is contained in:
Bohdan Triapitsyn
2026-01-22 12:21:19 +02:00
committed by GitHub
parent 1f23b63c0b
commit d97181bcd2
22 changed files with 1611 additions and 1223 deletions
+1 -1
View File
@@ -2977,7 +2977,7 @@ dependencies = [
[[package]]
name = "openchamber-desktop"
version = "1.5.2"
version = "1.5.3"
dependencies = [
"anyhow",
"axum",
@@ -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<std::io::Error> 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<DeletePathResponse, String> {
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<RenamePathResponse, String> {
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<String>,
workspace_roots: &[PathBuf],
+3 -1
View File
@@ -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,
+43
View File
@@ -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<{