first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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};
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
@@ -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 isn’t 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
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user