feat: add Files tab for browsing workspace files (#154)

* feat: add Files tab for browsing workspace files

- Add Files tab between Diff and Terminal in header
- Implement hierarchical file tree with expand/collapse
- Add fuzzy search with debouncing and relevance ranking
- Support gitignore filtering via `git check-ignore` (web + desktop)
- Add syntax highlighting for 150+ file types
- Add image preview (SVG, PNG, JPG, etc.)
- Add line numbers, wrap toggle, and copy button
- Desktop: split-pane layout matching DiffView
- Mobile: drill-in navigation with full-width sidebar
- Update help dialog with Cmd+3 shortcut
- Increase header breakpoint to 940px for new tab

* feat: enhance context and session stores to track agent/model/variant choices for historical sessions

* feat: implement line selection and commenting functionality in FilesView
This commit is contained in:
Bohdan Triapitsyn
2026-01-15 20:19:06 +02:00
committed by GitHub
parent ecf81c901d
commit 1be5dfda05
21 changed files with 2070 additions and 82 deletions
@@ -135,6 +135,7 @@ impl From<std::io::Error> for FsCommandError {
#[tauri::command]
pub async fn list_directory(
path: Option<String>,
respect_gitignore: Option<bool>,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<DirectoryListResult, String> {
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
@@ -164,18 +165,62 @@ pub async fn list_directory(
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?;
// Collect all entry names first for gitignore check
let mut all_entries: Vec<(tokio::fs::DirEntry, String)> = Vec::new();
while let Some(entry) = dir_entries
.next_entry()
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?
{
let name = entry.file_name().to_string_lossy().to_string();
all_entries.push((entry, name));
}
// Get gitignored paths if requested
let ignored_names: HashSet<String> = if respect_gitignore.unwrap_or(false) {
let names: Vec<String> = all_entries.iter().map(|(_, name)| name.clone()).collect();
if names.is_empty() {
HashSet::new()
} else {
let cwd = resolved_path.clone();
tokio::task::spawn_blocking(move || {
let output = Command::new("git")
.arg("check-ignore")
.arg("--")
.args(&names)
.current_dir(&cwd)
.output();
match output {
Ok(out) => {
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
Err(_) => HashSet::new(),
}
})
.await
.unwrap_or_default()
}
} else {
HashSet::new()
};
for (entry, name) in all_entries {
// Skip gitignored entries
if !ignored_names.is_empty() && ignored_names.contains(&name) {
continue;
}
let file_type = entry
.file_type()
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?;
let entry_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let mut is_directory = file_type.is_dir();
let is_symlink = file_type.is_symlink();
@@ -631,6 +676,43 @@ pub struct ReadFileResponse {
path: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadFileBinaryResponse {
data_url: String,
path: String,
}
fn get_image_mime_type(file_path: &str) -> &'static str {
let lower = file_path.to_lowercase();
if lower.ends_with(".png") {
return "image/png";
}
if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
return "image/jpeg";
}
if lower.ends_with(".gif") {
return "image/gif";
}
if lower.ends_with(".svg") {
return "image/svg+xml";
}
if lower.ends_with(".webp") {
return "image/webp";
}
if lower.ends_with(".ico") {
return "image/x-icon";
}
if lower.ends_with(".bmp") {
return "image/bmp";
}
if lower.ends_with(".avif") {
return "image/avif";
}
"application/octet-stream"
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteFileResponse {
@@ -689,6 +771,50 @@ pub async fn read_file(
})
}
#[tauri::command]
pub async fn read_file_binary(
path: String,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<ReadFileBinaryResponse, String> {
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
const MAX_BYTES: u64 = 10 * 1024 * 1024;
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(|_| "File not found or access denied".to_string())?;
let metadata = fs::metadata(&resolved_path)
.await
.map_err(|_| "File not found".to_string())?;
if !metadata.is_file() {
return Err("Specified path is not a file".to_string());
}
if metadata.len() > MAX_BYTES {
return Err("File too large".to_string());
}
let bytes = fs::read(&resolved_path)
.await
.map_err(|err| format!("Failed to read file: {}", err))?;
let mime_type = get_image_mime_type(trimmed);
let data_url = format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes));
Ok(ReadFileBinaryResponse {
data_url,
path: normalize_path(&resolved_path),
})
}
#[tauri::command]
pub async fn write_file(
path: String,
+2 -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, search_files, write_file};
use commands::files::{create_directory, exec_commands, list_directory, read_file, read_file_binary, 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,
@@ -836,6 +836,7 @@ fn main() {
search_files,
create_directory,
read_file,
read_file_binary,
write_file,
exec_commands,
request_directory_access,