first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+4 -1
View File
@@ -6,7 +6,10 @@
#[path = "snapshot/iterator.rs"]
mod iterator;
use std::{cmp::Ordering, ops::AddAssign, path::Path, sync::Arc};
use std::cmp::Ordering;
use std::ops::AddAssign;
use std::path::Path;
use std::sync::Arc;
use sum_tree::{Edit, KeyedItem, SeekBias, SumTree};
+2 -1
View File
@@ -1,6 +1,7 @@
//! Iterator implementations for FileTreeSnapshot.
use std::{path::Path, sync::Arc};
use std::path::Path;
use std::sync::Arc;
use sum_tree::{Cursor, SeekBias};
+2 -1
View File
@@ -1,6 +1,7 @@
//! Tests for the file tree snapshot module.
use std::{path::Path, sync::Arc};
use std::path::Path;
use std::sync::Arc;
use sum_tree::Item;
+197 -128
View File
@@ -1,3 +1,8 @@
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use editing::sort_entries_for_file_tree;
use galaxy_util::path::LineAndColumnArg;
use galaxy_util::standardized_path::StandardizedPath;
@@ -9,69 +14,58 @@ use repo_metadata::file_tree_store::{
FileTreeDirectoryEntryState, FileTreeEntryState, FileTreeFileMetadata,
};
use repo_metadata::local_model::IndexedRepoState;
use repo_metadata::FileTreeEntry;
use repo_metadata::RepoMetadataModel;
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use galaxy_core::send_telemetry_from_ctx;
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::{FileTreeEntry, RepoMetadataModel};
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
use galaxy_core::{send_telemetry_from_ctx, HostId};
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::{
AcceptedByDropTarget, Align, Clipped, ConstrainedBox, Container, Dismiss, Draggable,
DraggableState, Empty, FormattedTextElement, MainAxisAlignment, Percentage, Rect, SavePosition,
Scrollable, Shrinkable,
AcceptedByDropTarget, Align, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container,
CrossAxisAlignment, Dismiss, Draggable, DraggableState, Empty, Flex, FormattedTextElement,
Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Percentage, Rect, SavePosition, ScrollStateHandle,
Scrollable, ScrollableElement, ScrollbarWidth, Shrinkable, Stack, Text, UniformList,
UniformListState,
};
use galaxyui::fonts::Style;
use galaxyui::fonts::{Properties, Style, Weight};
use galaxyui::keymap::FixedBinding;
use galaxyui::platform::Cursor;
use galaxyui::text_layout::TextAlignment;
use galaxyui::{clipboard::ClipboardContent, id, ViewContext, WeakViewHandle};
use galaxyui::{
elements::{
ChildAnchor, ChildView, CrossAxisAlignment, Flex, Hoverable, MainAxisSize,
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
ScrollStateHandle, ScrollableElement, ScrollbarWidth, Stack, Text, UniformList,
UniformListState,
},
fonts::{Properties, Weight},
AppContext, Element, Entity, EventContext, SingletonEntity as _, TypedActionView, View,
ViewHandle,
id, AppContext, BlurContext, Element, Entity, EventContext, ModelHandle, SingletonEntity as _,
TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle,
};
use galaxyui::{BlurContext, ModelHandle};
use repo_metadata::repositories::DetectedRepositories;
use crate::appearance::Appearance;
use crate::code::active_file::{ActiveFileEvent, ActiveFileModel};
use crate::code::buffer_location::LocalOrRemotePath;
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
use crate::editor::{EditorOptions, EditorView, TextOptions};
use crate::menu::{Menu, MenuItem, MenuItemFields};
#[cfg(feature = "local_fs")]
use crate::server::telemetry::CodePanelsFileOpenEntrypoint;
use crate::server::telemetry::TelemetryEvent;
use crate::terminal::input::InputDropTargetData;
use crate::terminal::view::{TerminalDropTargetData, TerminalView};
use crate::ui_components::icons::Icon;
use crate::ui_components::item_highlight::{ImageOrIcon, ItemHighlightState};
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor::EditorSettings;
use crate::util::openable_file_type::{is_file_content_binary, EditorLayout, FileTarget};
use crate::util::openable_file_type::{
is_file_content_binary, is_markdown_file, EditorLayout, FileTarget,
};
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::{
resolve_file_target_to_open_in_warp, resolve_file_target_with_editor_choice,
};
use crate::{
appearance::Appearance,
menu::{Menu, MenuItem, MenuItemFields},
server::telemetry::TelemetryEvent,
ui_components::icons::Icon,
view_components::DismissibleToast,
workspace::ToastStack,
};
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::theme::{color::internal_colors, Fill};
use galaxy_core::HostId;
use galaxyui::ui_components::components::UiComponent;
mod editing;
mod render;
use crate::settings::{CodeSettings, CodeSettingsChangedEvent};
const REMOTE_TEXT: &str = "The Project Explorer requires access to your local workspace, which isnt supported in remote sessions.";
const DISABLED_TEXT: &str = "The Project Explorer requires access to your local workspace. Open a new session or navigate to an active session to view.";
const WSL_TEXT: &str = "The Project Explorer doesn't currently work in WSL.";
@@ -294,6 +288,8 @@ pub struct FileTreeView {
/// the target is selected by the user or when the target root stops
/// being displayed.
pending_focus_target: Option<PendingFocusTarget>,
/// Whether to show hidden files (dotfiles) in the file tree.
show_hidden_files: bool,
}
/// Directory the file tree wants to focus once its entry becomes available.
@@ -355,6 +351,8 @@ impl FileTreeView {
if is_active {
self.subscribe_to_repository_metadata(ctx);
self.subscribe_to_active_file_model(ctx);
self.subscribe_to_code_settings(ctx);
self.show_hidden_files = *CodeSettings::as_ref(ctx).show_hidden_files;
// Catch up on any repository/file changes that happened while inactive.
// Skip remote-backed roots — their data comes from server pushes,
@@ -388,6 +386,7 @@ impl FileTreeView {
} else {
ctx.unsubscribe_to_model(&self.repository_metadata_model);
self.unsubscribe_from_active_file_model(ctx);
self.unsubscribe_from_code_settings(ctx);
let repository_metadata_model = self.repository_metadata_model.clone();
let paths: Vec<_> = self.registered_lazy_loaded_paths.drain().collect();
repository_metadata_model.update(ctx, move |model: &mut RepoMetadataModel, ctx| {
@@ -502,8 +501,7 @@ impl FileTreeView {
event: &repo_metadata::RepoMetadataEvent,
ctx: &mut ViewContext<Self>,
) {
use repo_metadata::RepoMetadataEvent;
use repo_metadata::RepositoryIdentifier;
use repo_metadata::{RepoMetadataEvent, RepositoryIdentifier};
match event {
RepoMetadataEvent::RepositoryUpdated {
id: RepositoryIdentifier::Local(std_path),
@@ -535,6 +533,7 @@ impl FileTreeView {
}
RepoMetadataEvent::FileTreeEntryUpdated {
id: RepositoryIdentifier::Local(std_path),
..
} => {
// Find root directories whose backing model entry matches this path.
let root_paths: Vec<StandardizedPath> = self
@@ -549,13 +548,15 @@ impl FileTreeView {
if !root_paths.is_empty() {
let id = RepositoryIdentifier::Local(std_path.clone());
if let Some(state) = RepoMetadataModel::as_ref(ctx).get_repository(&id, ctx) {
for root_path in root_paths {
if let Some(root_dir) = self.root_directories.get_mut(&root_path) {
for root_path in &root_paths {
if let Some(root_dir) = self.root_directories.get_mut(root_path) {
root_dir.entry = state.entry.clone();
}
}
self.rebuild_flattened_items();
for root_path in &root_paths {
self.rebuild_flattened_items_for_root(root_path);
}
self.apply_pending_focus_target();
ctx.notify();
}
@@ -593,6 +594,7 @@ impl FileTreeView {
}
RepoMetadataEvent::FileTreeEntryUpdated {
id: RepositoryIdentifier::Remote(remote_id),
..
} => {
let repo_path = remote_id.path.clone();
let id = RepositoryIdentifier::Remote(remote_id.clone());
@@ -600,7 +602,11 @@ impl FileTreeView {
if let Some(root_dir) = self.root_directories.get_mut(&repo_path) {
root_dir.entry = state.entry.clone();
}
self.rebuild_flattened_items();
// Only rebuild the affected remote root instead of all roots.
// Remote servers stream frequent incremental updates; a full
// rebuild would cause unrelated local roots to re-render on
// every remote filesystem change, leading to visible flicker.
self.rebuild_flattened_items_for_root(&repo_path);
ctx.notify();
}
}
@@ -610,11 +616,15 @@ impl FileTreeView {
let repo_path = &remote_id.path;
self.displayed_directories.retain(|p| p != repo_path);
self.root_directories.remove(repo_path);
self.rebuild_flattened_items();
// The removed root is already gone from root_directories, so
// this is effectively a no-op rebuild that avoids touching
// the remaining roots' flattened items.
self.rebuild_flattened_items_for_root(repo_path);
ctx.notify();
}
RepoMetadataEvent::FileTreeUpdated { .. }
| RepoMetadataEvent::RepositoryRemoved { .. }
| RepoMetadataEvent::StandingQueryResultsUpdated { .. }
| RepoMetadataEvent::UpdatingRepositoryFailed { .. }
| RepoMetadataEvent::IncrementalUpdateReady { .. } => {}
}
@@ -640,6 +650,20 @@ impl FileTreeView {
ctx.unsubscribe_to_model(active_file_model);
}
fn subscribe_to_code_settings(&self, ctx: &mut ViewContext<Self>) {
ctx.subscribe_to_model(&CodeSettings::handle(ctx), |me, _, event, ctx| {
if let CodeSettingsChangedEvent::ShowHiddenFiles { .. } = event {
me.show_hidden_files = *CodeSettings::as_ref(ctx).show_hidden_files;
me.rebuild_flattened_items();
ctx.notify();
}
});
}
fn unsubscribe_from_code_settings(&self, ctx: &mut ViewContext<Self>) {
ctx.unsubscribe_to_model(&CodeSettings::handle(ctx));
}
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let context_menu = ctx.add_typed_action_view(|_| {
Menu::new()
@@ -701,6 +725,7 @@ impl FileTreeView {
#[cfg(feature = "local_fs")]
registered_lazy_loaded_paths: HashSet::new(),
pending_focus_target: None,
show_hidden_files: *CodeSettings::as_ref(ctx).show_hidden_files,
};
picker
@@ -730,9 +755,17 @@ impl FileTreeView {
fn handle_code_event(&mut self, event: &ActiveFileEvent, ctx: &mut ViewContext<Self>) {
// When a file is focused, scroll to show it in the file tree
match event {
ActiveFileEvent::ActiveFileChanged { file_info } => {
let Ok(file_std) = StandardizedPath::try_from_local(file_info) else {
return;
ActiveFileEvent::ActiveFileChanged { location } => {
let file_std = match location {
crate::code::buffer_location::LocalOrRemotePath::Local(path) => {
match StandardizedPath::try_from_local(path) {
Ok(std_path) => std_path,
Err(_) => return,
}
}
crate::code::buffer_location::LocalOrRemotePath::Remote(remote) => {
remote.path.clone()
}
};
// Prefer the currently-selected item's root if the file lives under it;
// otherwise fall back to the deepest matching root directory.
@@ -1273,8 +1306,9 @@ impl FileTreeView {
remote_host_id: None,
});
let root_local = root_path.to_local_path_lossy();
if let Some(repo_root) =
DetectedRepositories::as_ref(ctx).get_root_for_path(&root_local)
if let Some(repo_root) = DetectedRepositories::as_ref(ctx)
.get_root_for_path(&LocalOrRemotePath::Local(root_local))
.and_then(|r| PathBuf::try_from(r).ok())
{
let repo_entry = {
let repo_metadata = RepoMetadataModel::as_ref(ctx);
@@ -1288,6 +1322,12 @@ impl FileTreeView {
{
Some(state.entry.clone())
}
Some(IndexedRepoState::Pending(_)) => {
// Repo is being (re-)indexed. Keep whatever entry
// we already have so the tree doesn't flash to a
// loading state during the transition.
continue;
}
_ => None,
}
};
@@ -1397,14 +1437,6 @@ impl FileTreeView {
.update(ctx, |model: &mut RepoMetadataModel, ctx| {
model.load_directory(&backing_root, &dir_path, ctx)
});
if matches!(
load_result,
Err(repo_metadata::RepoMetadataError::BuildTree(
repo_metadata::BuildTreeError::ExceededMaxFileLimit,
))
) {
Self::show_exceeded_file_limit_toast(ctx);
}
if let Err(error) = load_result {
log::warn!("Failed to load directory {dir_path}: {error}");
}
@@ -1538,14 +1570,6 @@ impl FileTreeView {
.update(ctx, |model: &mut RepoMetadataModel, ctx| {
model.index_lazy_loaded_path(path, ctx)
});
if matches!(
index_result,
Err(repo_metadata::RepoMetadataError::BuildTree(
repo_metadata::BuildTreeError::ExceededMaxFileLimit,
))
) {
Self::show_exceeded_file_limit_toast(ctx);
}
if let Err(error) = &index_result {
log::warn!("Failed to index lazy-loaded path {path}: {error}");
}
@@ -1555,14 +1579,21 @@ impl FileTreeView {
}
let id = repo_metadata::RepositoryIdentifier::local(path.clone());
let entry = RepoMetadataModel::as_ref(ctx)
.get_repository(&id, ctx)
.map(|state| state.entry.clone());
let repo_state = RepoMetadataModel::as_ref(ctx).repository_state(&id, ctx);
if let Some(root_dir) = self.root_directories.get_mut(path) {
root_dir.entry = match entry {
Some(entry) => entry,
None => Self::create_empty_entry(path),
};
match repo_state {
Some(IndexedRepoState::Indexed(state)) => {
root_dir.entry = state.entry.clone();
}
Some(IndexedRepoState::Pending(_)) => {
// Repo is being (re-)indexed. Keep whatever entry we already
// have so the tree doesn't flash back to a loading state
// during the Pending → Indexed transition.
}
Some(IndexedRepoState::Failed(_)) | None => {
root_dir.entry = Self::create_empty_entry(path);
}
}
}
}
@@ -1570,43 +1601,53 @@ impl FileTreeView {
FileTreeEntry::new_for_directory(Arc::new(path.clone()))
}
fn show_exceeded_file_limit_toast(ctx: &mut ViewContext<Self>) {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error(String::from(
"Folder has too many files to display in the file explorer.",
))
.with_object_id("file_tree_exceeded_file_limit".to_string());
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
/// Rebuilds the flattened items list for a single root directory only,
/// leaving all other roots untouched. Use this when only one root's
/// backing data has changed (e.g. a metadata update) to avoid
/// unnecessarily re-flattening — and re-rendering — unrelated roots.
fn rebuild_flattened_items_for_root(&mut self, target_root: &StandardizedPath) {
self.rebuild_flatten_items_impl(None, None, Some(target_root));
}
/// Rebuilds the flattened items list from the current entry tree, optionally removing an item.
fn rebuild_flattened_items(&mut self) {
self.rebuild_flatten_items_and_select_path(None, None);
self.rebuild_flatten_items_impl(None, None, None);
}
fn rebuild_flattened_items_without(&mut self, path_to_remove: &StandardizedPath) -> bool {
self.rebuild_flatten_items_and_select_path(None, Some(path_to_remove))
self.rebuild_flatten_items_impl(None, Some(path_to_remove), None)
}
/// Rebuilds the flattened items list from the current entry tree
/// If `id_to_select` is `Some`, the item identified by that FileTreeIdentifier will be selected.
/// If `path_to_remove` is `Some`, the item identified by `path_to_remove` will be removed
/// upon rebuilding.
/// Core implementation for rebuilding the flattened items list.
///
/// When `target_root` is `Some`, only that root is re-flattened; all
/// other roots keep their existing items. When `None`, every displayed
/// root is rebuilt.
///
/// If `id_to_select` is `Some`, the item identified by that
/// `FileTreeIdentifier` will be selected. If `path_to_remove` is
/// `Some`, the item at that path will be excluded from the result.
///
/// Returns `true` if an item was removed.
fn rebuild_flatten_items_and_select_path(
fn rebuild_flatten_items_impl(
&mut self,
id_to_select: Option<&FileTreeIdentifier>,
path_to_remove: Option<&StandardizedPath>,
target_root: Option<&StandardizedPath>,
) -> bool {
let mut any_item_removed = false;
// Clone the ID to preserve so we don't hold a borrow on self.selected_item
let id_to_preserve = id_to_select.cloned().or_else(|| self.selected_item.clone());
// Process all displayed directories
// Process displayed directories, optionally filtering to a single root.
for root_path in self.displayed_directories.clone() {
if let Some(target) = target_root {
if root_path != *target {
continue;
}
}
let Some(root_dir) = self.root_directories.get(&root_path) else {
continue;
};
@@ -1634,13 +1675,19 @@ impl FileTreeView {
root_dir.items = items;
}
// If we found the selection in this root, update selected_item
if let (Some(index), Some(id)) = (new_index, id_to_preserve.as_ref()) {
// If we found the selection in this root, update selected_item.
// If the selection was expected but not found (e.g. filtered out as hidden),
// clear selected_item to avoid stale references.
if let Some(id) = id_to_preserve.as_ref() {
if id.root == root_path {
self.selected_item = Some(FileTreeIdentifier {
root: root_path,
index,
});
if let Some(index) = new_index {
self.selected_item = Some(FileTreeIdentifier {
root: root_path,
index,
});
} else if selected_item_path.is_some() {
self.selected_item = None;
}
}
}
@@ -1669,6 +1716,17 @@ impl FileTreeView {
return (None, true);
}
// Filter hidden files/directories when show_hidden_files is disabled.
// Only filter descendants (depth > 0), not the root entry itself,
// so that hidden workspace directories (e.g. ~/.config) are still shown.
if !self.show_hidden_files && depth > 0 {
if let Some(name) = current_path.file_name() {
if name.starts_with('.') {
return (selected_item_index, removed_item);
}
}
}
if path_of_selected_item == Some(current_path) {
selected_item_index = Some(items.len());
}
@@ -1935,7 +1993,6 @@ impl FileTreeView {
let is_selected = self.selected_item.as_ref() == Some(id);
let is_expanded = self.is_item_expanded(&id.root, item);
let render_state = item.to_render_state(is_expanded, appearance);
let is_remote_file = root_dir.is_remote() && matches!(item, FileTreeItem::File { .. });
let item_display_name = render_state.display_name.clone();
let item_position_id = format!("file_tree_item:{item_display_name}");
@@ -1954,34 +2011,14 @@ impl FileTreeView {
let id_for_context = id.clone();
let id_for_drop = id.clone();
let id_for_drag = id.clone();
let ui_builder = appearance.ui_builder();
let hoverable = Hoverable::new(render_state.mouse_state.clone(), move |mouse_state| {
let item_highlight_state = ItemHighlightState::new(is_selected, mouse_state);
let element = Self::render_item_with_hover(
Self::render_item_with_hover(
render_state,
appearance,
item_highlight_state,
editor_view,
);
if is_remote_file && mouse_state.is_hovered() {
let tooltip = ui_builder
.tool_tip("Opening files is unavailable for remote sessions".to_string())
.build()
.finish();
let offset = OffsetPositioning::offset_from_parent(
Vector2F::new(0., 4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomLeft,
ChildAnchor::TopLeft,
);
Stack::new()
.with_child(element)
.with_positioned_overlay_child(tooltip, offset)
.finish()
} else {
element
}
)
})
.on_click(
move |event_ctx: &mut EventContext, _app_ctx: &AppContext, _position| {
@@ -2004,12 +2041,7 @@ impl FileTreeView {
});
},
)
// Remote files can't be opened in the editor, so use the default cursor.
.with_cursor(if is_remote_file {
Cursor::Arrow
} else {
Cursor::PointingHand
})
.with_cursor(Cursor::PointingHand)
.finish();
let draggable = Draggable::new(draggable_state, hoverable)
@@ -2208,7 +2240,7 @@ impl FileTreeView {
);
ctx.emit(FileTreeEvent::OpenFile {
path: path.to_path_buf(),
path: LocalOrRemotePath::Local(path.to_path_buf()),
target,
line_col: None,
});
@@ -2230,8 +2262,38 @@ impl FileTreeView {
match item {
FileTreeItem::File { metadata, .. } => {
// Remote file trees don't support opening files in the editor.
if !is_remote {
if is_remote {
// Emit a remote open event if we have a host ID.
if let Some(host_id) = &root_dir.remote_host_id {
let remote_path = warp_util::remote_path::RemotePath::new(
host_id.clone(),
(*metadata.path).clone(),
);
let path_str = metadata.path.as_str();
let target = if is_markdown_file(Path::new(path_str)) {
#[cfg(feature = "local_fs")]
{
let prefer_md = *EditorSettings::as_ref(ctx).prefer_markdown_viewer;
if prefer_md {
FileTarget::MarkdownViewer(EditorLayout::SplitPane)
} else {
FileTarget::CodeEditor(EditorLayout::SplitPane)
}
}
#[cfg(not(feature = "local_fs"))]
{
FileTarget::CodeEditor(EditorLayout::SplitPane)
}
} else {
FileTarget::CodeEditor(EditorLayout::SplitPane)
};
ctx.emit(FileTreeEvent::OpenFile {
path: LocalOrRemotePath::Remote(remote_path),
target,
line_col: None,
});
}
} else {
let path = metadata.path.to_local_path_lossy();
self.open_file(&path, None, ctx);
}
@@ -2855,7 +2917,7 @@ pub enum FileTreeEvent {
AttachAsContext { path: PathBuf },
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
OpenFile {
path: PathBuf,
path: LocalOrRemotePath,
target: FileTarget,
line_col: Option<LineAndColumnArg>,
},
@@ -2892,6 +2954,13 @@ impl View for FileTreeView {
return self.render_error_state(DISABLED_TEXT.to_string(), app);
}
if matches!(
self.enablement,
CodingPanelEnablementState::PendingRemoteSession
) {
return self.render_loading_state(app);
}
if self.displayed_directories.is_empty() {
if let CodingPanelEnablementState::RemoteSession { has_remote_server } = self.enablement
{
+15 -18
View File
@@ -1,37 +1,34 @@
//! Module for utlities related to editing items in the file tree.
//! Module for utilities related to editing items in the file tree.
#[cfg(test)]
#[path = "editing_tests.rs"]
mod tests;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::{elements::MouseStateHandle, ViewContext};
use repo_metadata::file_tree_store::FileTreeEntryState;
use repo_metadata::{FileMetadata, FileTreeEntry};
use std::cmp::Ordering;
use std::sync::Arc;
use repo_metadata::file_tree_store::FileTreeEntryState;
use repo_metadata::{FileMetadata, FileTreeEntry};
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::elements::MouseStateHandle;
use galaxyui::ViewContext;
use super::{FileTreeIdentifier, FileTreeItem, FileTreeView};
use crate::{
code::file_tree::{
view::{PendingEdit, PendingEditKind},
FileTreeEvent,
},
send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
};
use crate::code::file_tree::view::{PendingEdit, PendingEditKind};
use crate::code::file_tree::FileTreeEvent;
use crate::send_telemetry_from_ctx;
use crate::server::telemetry::TelemetryEvent;
/// Custom ordering function for items in the file tree.
///
/// Directories are ordered first, sorted alphabetically.
/// Files are ordered second, sorted alphabetically.
/// Directories are ordered first, sorted by natural (numeric-aware) order.
/// Files are ordered second, sorted by natural (numeric-aware) order.
/// Within each group, dotfiles (entries starting with a dot) are ordered first.
pub(super) fn sort_entries_for_file_tree(
entry_1: &StandardizedPath,
entry_2: &StandardizedPath,
entry_map: &FileTreeEntry,
) -> Ordering {
use std::cmp::Ordering;
// Entries missing from the map sort before present entries, and compare
// equal to each other. Using the same `Ordering` on both sides would
@@ -70,7 +67,7 @@ pub(super) fn sort_entries_for_file_tree(
match (starts_with_dot_1, starts_with_dot_2) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => name_1.cmp(name_2),
_ => alphanumeric_sort::compare_str(name_1, name_2),
}
}
@@ -240,7 +237,7 @@ impl FileTreeView {
});
// Rebuild and select the renamed item using its FileTreeIdentifier
self.rebuild_flatten_items_and_select_path(Some(&file_tree_id), None);
self.rebuild_flatten_items_impl(Some(&file_tree_id), None, None);
ctx.notify();
}
}
@@ -72,3 +72,35 @@ fn sort_entries_for_file_tree_sorts_without_panicking_on_missing_children() {
paths.sort_by(|a, b| sort_entries_for_file_tree(a, b, &entry));
}
#[test]
fn sort_entries_for_file_tree_uses_natural_order_for_numbered_files() {
let root = std_path("/repo");
let mut entry = FileTreeEntry::new_for_directory(Arc::new(root.clone()));
for name in ["L1", "L2", "L3", "L10", "L11", "L12"] {
entry.insert_child_state(&root, file_state(&format!("/repo/{name}.tsx")));
}
let mut paths = [
std_path("/repo/L10.tsx"),
std_path("/repo/L2.tsx"),
std_path("/repo/L1.tsx"),
std_path("/repo/L12.tsx"),
std_path("/repo/L3.tsx"),
std_path("/repo/L11.tsx"),
];
paths.sort_by(|a, b| sort_entries_for_file_tree(a, b, &entry));
let sorted: Vec<&str> = paths.iter().map(|p| p.as_str()).collect();
assert_eq!(
sorted,
[
"/repo/L1.tsx",
"/repo/L2.tsx",
"/repo/L3.tsx",
"/repo/L10.tsx",
"/repo/L11.tsx",
"/repo/L12.tsx",
]
);
}
+2 -1
View File
@@ -1,9 +1,10 @@
use galaxyui::elements::{DraggableState, MouseStateHandle};
use super::FileTreeItem;
use crate::appearance::Appearance;
use crate::code::icon_from_file_path;
use crate::ui_components::icons::Icon;
use crate::ui_components::item_highlight::ImageOrIcon;
use crate::{appearance::Appearance, ui_components::icons::Icon};
impl FileTreeItem {
pub(super) fn to_render_state(
+150 -5
View File
@@ -8,10 +8,16 @@ use repo_metadata::local_model::IndexedRepoState;
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::watcher::DirectoryWatcher;
use repo_metadata::RepoMetadataModel;
use settings::Setting;
use virtual_fs::{Stub, VirtualFS};
use galaxyui::platform::WindowStyle;
use galaxyui::{App, ModelHandle, SingletonEntity};
use super::FileTreeView;
use crate::auth::AuthStateProvider;
use crate::server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient};
use crate::server::server_api::team::MockTeamClient;
use crate::server::server_api::workspace::MockWorkspaceClient;
use crate::settings::CodeSettings;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::vim_registers::VimRegisters;
@@ -19,8 +25,6 @@ use crate::workspace::sync_inputs::SyncedInputState;
use crate::workspace::ToastStack;
use crate::workspaces::user_workspaces::UserWorkspaces;
use super::FileTreeView;
fn std_path(path: &std::path::Path) -> galaxy_util::standardized_path::StandardizedPath {
galaxy_util::standardized_path::StandardizedPath::try_from_local(path).unwrap()
}
@@ -112,6 +116,147 @@ fn build_repo_state_with_unloaded_directory(repo_root: &std::path::Path) -> File
FileTreeState::new(root, vec![], None)
}
fn flattened_paths(
view: &FileTreeView,
root: &std::path::Path,
) -> Vec<warp_util::standardized_path::StandardizedPath> {
view.root_directories
.get(&std_path(root))
.expect("root directory is tracked")
.items
.iter()
.map(|item| item.path().clone())
.collect()
}
fn set_show_hidden_files(app: &mut App, show_hidden_files: bool) {
CodeSettings::handle(app).update(app, |settings, ctx| {
Setting::set_value(&mut settings.show_hidden_files, show_hidden_files, ctx)
.expect("show hidden files setting updates");
});
}
#[test]
fn hidden_files_are_filtered_until_setting_is_enabled() {
VirtualFS::test("file_tree_hidden_files_setting", |dirs, mut vfs| {
vfs.mkdir("tree/.config").with_files(vec![
Stub::FileWithContent("tree/.env", "SECRET=value\n"),
Stub::FileWithContent("tree/.config/settings.toml", ""),
Stub::FileWithContent("tree/visible.txt", "content\n"),
]);
let tree = dirs.tests().join("tree");
let hidden_file = tree.join(".env");
let hidden_dir = tree.join(".config");
let visible_file = tree.join("visible.txt");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
set_show_hidden_files(&mut app, false);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![tree.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
let paths = flattened_paths(view, &tree);
assert!(paths.contains(&std_path(&tree)));
assert!(paths.contains(&std_path(&visible_file)));
assert!(!paths.contains(&std_path(&hidden_file)));
assert!(!paths.contains(&std_path(&hidden_dir)));
});
set_show_hidden_files(&mut app, true);
file_tree_view.read(&app, |view, _ctx| {
let paths = flattened_paths(view, &tree);
assert!(paths.contains(&std_path(&hidden_file)));
assert!(paths.contains(&std_path(&hidden_dir)));
});
});
});
}
#[test]
fn hidden_root_directory_is_not_filtered() {
VirtualFS::test("file_tree_hidden_root_directory", |dirs, mut vfs| {
vfs.mkdir(".config").with_files(vec![
Stub::FileWithContent(".config/settings.toml", ""),
Stub::FileWithContent(".config/.secret", ""),
]);
let hidden_root = dirs.tests().join(".config");
let visible_file = hidden_root.join("settings.toml");
let hidden_file = hidden_root.join(".secret");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
set_show_hidden_files(&mut app, false);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![hidden_root.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
let paths = flattened_paths(view, &hidden_root);
assert!(paths.contains(&std_path(&hidden_root)));
assert!(paths.contains(&std_path(&visible_file)));
assert!(!paths.contains(&std_path(&hidden_file)));
});
});
});
}
#[test]
fn selected_hidden_file_is_cleared_when_filtered() {
VirtualFS::test(
"file_tree_selected_hidden_file_filtered",
|dirs, mut vfs| {
vfs.mkdir("tree").with_files(vec![
Stub::FileWithContent("tree/.env", "SECRET=value\n"),
Stub::FileWithContent("tree/visible.txt", "content\n"),
]);
let tree = dirs.tests().join("tree");
let hidden_file = tree.join(".env");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
set_show_hidden_files(&mut app, true);
let (_, file_tree_view) =
app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![tree.clone()], ctx);
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
let (index, _) = root_dir
.items
.iter()
.enumerate()
.find(|(_, item)| item.path() == &std_path(&hidden_file))
.expect("hidden file is visible");
let id = super::FileTreeIdentifier {
root: std_path(&tree),
index,
};
view.select_id(&id, ctx);
});
set_show_hidden_files(&mut app, false);
file_tree_view.read(&app, |view, _ctx| {
let paths = flattened_paths(view, &tree);
assert!(!paths.contains(&std_path(&hidden_file)));
assert!(view.selected_item.is_none());
});
});
},
);
}
#[test]
fn repo_transition_unregisters_lazy_loaded_path() {
VirtualFS::test("file_tree_repo_transition", |dirs, mut vfs| {
@@ -389,7 +534,7 @@ fn pending_repository_root_does_not_register_lazy_loaded_path() {
);
assert!(matches!(
model.repository_state(&id, ctx),
Some(IndexedRepoState::Pending)
Some(IndexedRepoState::Pending(_))
));
});
@@ -416,7 +561,7 @@ fn pending_repository_root_does_not_register_lazy_loaded_path() {
);
assert!(matches!(
model.repository_state(&id, ctx),
Some(IndexedRepoState::Pending)
Some(IndexedRepoState::Pending(_))
));
});
});