project_panel: Add sort mode (#40160)

Closes #4533 (partly at least)

Release Notes:

- Added `project_panel.sort_mode` option to control explorer file sort
(directories first, mixed, files first)

 ## Summary

Adds three sorting modes for the project panel to give users more
control over how files and directories are displayed:

- **`directories_first`** (default): Current behaviour - directories
grouped before files
- **`mixed`**: Files and directories sorted together alphabetically
- **`files_first`**: filed grouped before directories

 ## Motivation

Users coming from different editors and file managers have different
expectations for file sorting. Some prefer directories grouped at the
top (traditional), while others prefer the macOS Finder-style mixed
sorting where "Apple1/", "apple2.tsx" and "Apple3/" appear
alphabetically mixed together.


 ### Screenshots

New sort options in settings:
<img width="515" height="160" alt="image"
src="https://github.com/user-attachments/assets/8f4e6668-6989-4881-a9bd-ed1f4f0beb40"
/>


Directories first | Mixed | Files first
-------------|-----|-----
<img width="328" height="888" alt="image"
src="https://github.com/user-attachments/assets/308e5c7a-6e6a-46ba-813d-6e268222925c"
/> | <img width="327" height="891" alt="image"
src="https://github.com/user-attachments/assets/8274d8ca-b60f-456e-be36-e35a3259483c"
/> | <img width="328" height="890" alt="image"
src="https://github.com/user-attachments/assets/3c3b1332-cf08-4eaf-9bed-527c00b41529"
/>


### Agent usage

Copilot-cli/claude-code/codex-cli helped out a lot. I'm not from a rust
background, but really wanted this solved, and it gave me a chance to
play with some of the coding agents I'm not permitted to use for work
stuff

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
This commit is contained in:
Lucas Parry
2025-11-17 17:52:46 +05:30
committed by GitHub
co-authored by Smit Barmase
parent 175162af4f
commit a2d3e3baf9
12 changed files with 849 additions and 44 deletions
+42 -13
View File
@@ -703,6 +703,9 @@ impl ProjectPanel {
if project_panel_settings.hide_hidden != new_settings.hide_hidden {
this.update_visible_entries(None, false, false, window, cx);
}
if project_panel_settings.sort_mode != new_settings.sort_mode {
this.update_visible_entries(None, false, false, window, cx);
}
if project_panel_settings.sticky_scroll && !new_settings.sticky_scroll {
this.sticky_items_count = 0;
}
@@ -2102,7 +2105,8 @@ impl ProjectPanel {
.map(|entry| entry.to_owned())
.collect();
sort_worktree_entries(&mut siblings);
let mode = ProjectPanelSettings::get_global(cx).sort_mode;
sort_worktree_entries_with_mode(&mut siblings, mode);
let sibling_entry_index = siblings
.iter()
.position(|sibling| sibling.id == latest_entry.id)?;
@@ -3229,6 +3233,7 @@ impl ProjectPanel {
let settings = ProjectPanelSettings::get_global(cx);
let auto_collapse_dirs = settings.auto_fold_dirs;
let hide_gitignore = settings.hide_gitignore;
let sort_mode = settings.sort_mode;
let project = self.project.read(cx);
let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx);
@@ -3440,7 +3445,10 @@ impl ProjectPanel {
entry_iter.advance();
}
par_sort_worktree_entries(&mut visible_worktree_entries);
par_sort_worktree_entries_with_mode(
&mut visible_worktree_entries,
sort_mode,
);
new_state.visible_entries.push(VisibleEntriesForWorktree {
worktree_id,
entries: visible_worktree_entries,
@@ -6101,21 +6109,42 @@ impl ClipboardEntry {
}
}
fn cmp<T: AsRef<Entry>>(lhs: T, rhs: T) -> cmp::Ordering {
let entry_a = lhs.as_ref();
let entry_b = rhs.as_ref();
util::paths::compare_rel_paths(
(&entry_a.path, entry_a.is_file()),
(&entry_b.path, entry_b.is_file()),
)
#[inline]
fn cmp_directories_first(a: &Entry, b: &Entry) -> cmp::Ordering {
util::paths::compare_rel_paths((&a.path, a.is_file()), (&b.path, b.is_file()))
}
pub fn sort_worktree_entries(entries: &mut [impl AsRef<Entry>]) {
entries.sort_by(|lhs, rhs| cmp(lhs, rhs));
#[inline]
fn cmp_mixed(a: &Entry, b: &Entry) -> cmp::Ordering {
util::paths::compare_rel_paths_mixed((&a.path, a.is_file()), (&b.path, b.is_file()))
}
pub fn par_sort_worktree_entries(entries: &mut Vec<GitEntry>) {
entries.par_sort_by(|lhs, rhs| cmp(lhs, rhs));
#[inline]
fn cmp_files_first(a: &Entry, b: &Entry) -> cmp::Ordering {
util::paths::compare_rel_paths_files_first((&a.path, a.is_file()), (&b.path, b.is_file()))
}
#[inline]
fn cmp_with_mode(a: &Entry, b: &Entry, mode: &settings::ProjectPanelSortMode) -> cmp::Ordering {
match mode {
settings::ProjectPanelSortMode::DirectoriesFirst => cmp_directories_first(a, b),
settings::ProjectPanelSortMode::Mixed => cmp_mixed(a, b),
settings::ProjectPanelSortMode::FilesFirst => cmp_files_first(a, b),
}
}
pub fn sort_worktree_entries_with_mode(
entries: &mut [impl AsRef<Entry>],
mode: settings::ProjectPanelSortMode,
) {
entries.sort_by(|lhs, rhs| cmp_with_mode(lhs.as_ref(), rhs.as_ref(), &mode));
}
pub fn par_sort_worktree_entries_with_mode(
entries: &mut Vec<GitEntry>,
mode: settings::ProjectPanelSortMode,
) {
entries.par_sort_by(|lhs, rhs| cmp_with_mode(lhs, rhs, &mode));
}
#[cfg(test)]
@@ -3,8 +3,8 @@ use gpui::Pixels;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{
DockSide, ProjectPanelEntrySpacing, RegisterSetting, Settings, ShowDiagnostics,
ShowIndentGuides,
DockSide, ProjectPanelEntrySpacing, ProjectPanelSortMode, RegisterSetting, Settings,
ShowDiagnostics, ShowIndentGuides,
};
use ui::{
px,
@@ -33,6 +33,7 @@ pub struct ProjectPanelSettings {
pub hide_hidden: bool,
pub drag_and_drop: bool,
pub auto_open: AutoOpenSettings,
pub sort_mode: ProjectPanelSortMode,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -115,6 +116,9 @@ impl Settings for ProjectPanelSettings {
on_drop: auto_open.on_drop.unwrap(),
}
},
sort_mode: project_panel
.sort_mode
.unwrap_or(ProjectPanelSortMode::DirectoriesFirst),
}
}
}
@@ -326,6 +326,7 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
ProjectPanelSettings::override_global(
ProjectPanelSettings {
auto_fold_dirs: true,
sort_mode: settings::ProjectPanelSortMode::DirectoriesFirst,
..settings
},
cx,
@@ -7704,6 +7705,215 @@ fn visible_entries_as_strings(
result
}
/// Test that missing sort_mode field defaults to DirectoriesFirst
#[gpui::test]
async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) {
init_test(cx);
// Verify that when sort_mode is not specified, it defaults to DirectoriesFirst
let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx));
assert_eq!(
default_settings.sort_mode,
settings::ProjectPanelSortMode::DirectoriesFirst,
"sort_mode should default to DirectoriesFirst"
);
}
/// Test sort modes: DirectoriesFirst (default) vs Mixed
#[gpui::test]
async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"zebra.txt": "",
"Apple": {},
"banana.rs": "",
"Carrot": {},
"aardvark.txt": "",
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
cx.run_until_parked();
// Default sort mode should be DirectoriesFirst
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
"v root",
" > Apple",
" > Carrot",
" aardvark.txt",
" banana.rs",
" zebra.txt",
]
);
}
#[gpui::test]
async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"Zebra.txt": "",
"apple": {},
"Banana.rs": "",
"carrot": {},
"Aardvark.txt": "",
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
// Switch to Mixed mode
cx.update(|_, cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.project_panel.get_or_insert_default().sort_mode =
Some(settings::ProjectPanelSortMode::Mixed);
});
});
});
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
cx.run_until_parked();
// Mixed mode: case-insensitive sorting
// Aardvark < apple < Banana < carrot < Zebra (all case-insensitive)
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
"v root",
" Aardvark.txt",
" > apple",
" Banana.rs",
" > carrot",
" Zebra.txt",
]
);
}
#[gpui::test]
async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"Zebra.txt": "",
"apple": {},
"Banana.rs": "",
"carrot": {},
"Aardvark.txt": "",
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
// Switch to FilesFirst mode
cx.update(|_, cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.project_panel.get_or_insert_default().sort_mode =
Some(settings::ProjectPanelSortMode::FilesFirst);
});
});
});
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
cx.run_until_parked();
// FilesFirst mode: files first, then directories (both case-insensitive)
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
"v root",
" Aardvark.txt",
" Banana.rs",
" Zebra.txt",
" > apple",
" > carrot",
]
);
}
#[gpui::test]
async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"file2.txt": "",
"dir1": {},
"file1.txt": "",
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
cx.run_until_parked();
// Initially DirectoriesFirst
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&["v root", " > dir1", " file1.txt", " file2.txt",]
);
// Toggle to Mixed
cx.update(|_, cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.project_panel.get_or_insert_default().sort_mode =
Some(settings::ProjectPanelSortMode::Mixed);
});
});
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&["v root", " > dir1", " file1.txt", " file2.txt",]
);
// Toggle back to DirectoriesFirst
cx.update(|_, cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.project_panel.get_or_insert_default().sort_mode =
Some(settings::ProjectPanelSortMode::DirectoriesFirst);
});
});
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&["v root", " > dir1", " file1.txt", " file2.txt",]
);
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);