diff --git a/docs/Documentation.md b/docs/Documentation.md
index 70be125..b85df02 100644
--- a/docs/Documentation.md
+++ b/docs/Documentation.md
@@ -118,7 +118,7 @@ Open it from **Tools → Recycle Bin**. Workbench talks to the real Windows Recy
The open folder is always the live filesystem when the location is online. Index data fills in folder sizes, search, and analysis.
-Large folders appear as soon as names are known — the previous folder stays on screen until the first new name arrives. Size, date, and type from the directory listing show with the row. Cloud status, indexed folder totals, and Git badges fill in shortly after — blank cells mean that extra metadata has not arrived yet, not that the file is empty. Opening another folder cancels leftover work from the previous one. The status bar shows how many items are in the active folder and their known size; with a selection it switches to how many are selected and the size of that selection. Folder sizes appear in the total once the index has them.
+Large folders appear as soon as names are known — the previous folder stays on screen until the first new name arrives. Size, date, and type from the directory listing show with the row. **Details** also has Date created, Git, and Cloud. Git is blank for clean tracked files; otherwise Modified, Staged, Untracked, Unmerged, or a nested-repo badge. Cloud is blank unless the overlay has a state (Online-only, Local, Pinned, Syncing, Error). Free space is shown only on This PC (and other listings that actually have volume free space). Cloud status, indexed folder totals, and Git fill in shortly after — blank cells mean that extra metadata has not arrived yet, not that the file is empty. Opening another folder cancels leftover work from the previous one. The status bar shows how many items are in the active folder and their known size; with a selection it switches to how many are selected and the size of that selection. Folder sizes appear in the total once the index has them.
Right-click a file or folder to get Workbench commands plus extra Windows items that fit in the compact menu (PDF24, 7-Zip, and similar). **Open in Notepad++** and **Open in Cursor** are Workbench entries next to **Open terminal here**. Open, Cut, Copy, Delete, and Rename stay Workbench’s own entries so they are not listed twice. Listing those items does not download online-only cloud files; running one of them is an explicit open and may hydrate.
@@ -343,7 +343,7 @@ Never auto-reorganizes. No MIME/content sniffing (that would hydrate cloud files
## Git
-Workbench detects repositories and shows a badge (branch, modified, untracked, ahead/behind, merging/rebasing). **Tools → Development** (and the folder context menu) offers **View changes…**, **Commit…**, **Fetch**, **Pull (fast-forward)**, **Pull (merge)**, **Push**, **Open terminal here**, **Open in Cursor**, and **Open in Notepad++** when a folder is in a repository.
+Workbench detects repositories and shows a badge (branch, modified, untracked, ahead/behind, merging/rebasing). In **Details**, the **Git** column is per file: blank when the file matches HEAD, otherwise Modified, Staged, Untracked, Unmerged, or Staged · Modified. Folders with dirty children show Modified or Untracked. A nested repository folder shows a compact repo badge. **Tools → Development** (and the folder context menu) offers **View changes…**, **Commit…**, **Fetch**, **Pull (fast-forward)**, **Pull (merge)**, **Push**, **Open terminal here**, **Open in Cursor**, and **Open in Notepad++** when a folder is in a repository.
**View changes** lists staged, unstaged, untracked, and unmerged paths from `git status`. Double-click opens a unified **diff** (`git diff` / `git diff --cached`). **Open in Cursor** opens the file. Online-only cloud files are not opened or diffed (that would download them). Stage, unstage, and discard call the matching `git` commands. Discard asks first.
@@ -361,7 +361,7 @@ Missing `git.exe` means no badge and no Git actions. Path can be set in Settings
When a cloud folder is added:
-- Status text on items (available / online-only / syncing)
+- Status on items (Details **Cloud** column, and next to the name in List): Online-only, Local, Pinned, Syncing, Error
- **Always keep on this device** / **Free up space** when the provider supports pin/dehydrate
- Quota in capacity/free space where the provider reports it
diff --git a/src/Explorer.App/App.xaml b/src/Explorer.App/App.xaml
index 902922b..40696b3 100644
--- a/src/Explorer.App/App.xaml
+++ b/src/Explorer.App/App.xaml
@@ -57,7 +57,33 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
element.SetValue(ShowFreeSpaceProperty, value);
+
+ public static bool GetShowFreeSpace(ListView element)
+ => (bool)element.GetValue(ShowFreeSpaceProperty);
+
+ private static void OnShowFreeSpaceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is ListView list)
+ {
+ Apply(list);
+ list.Loaded -= OnLoaded;
+ list.Loaded += OnLoaded;
+ }
+ }
+
+ private static void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is ListView list)
+ {
+ Apply(list);
+ }
+ }
+
+ private static void Apply(ListView list)
+ {
+ if (list.View is not GridView view)
+ {
+ return;
+ }
+
+ var show = GetShowFreeSpace(list);
+ foreach (var column in view.Columns)
+ {
+ var title = column.Header?.ToString() ?? "";
+ title = title.Replace(" ▲", "", StringComparison.Ordinal).Replace(" ▼", "", StringComparison.Ordinal).Trim();
+ if (title == "Free space")
+ {
+ column.Width = show ? 110 : 0;
+ }
+ }
+
+ ListViewLayout.Stretch(list);
+ }
+}
diff --git a/src/Explorer.App/MainWindow.xaml b/src/Explorer.App/MainWindow.xaml
index 33ce9b8..191ca0a 100644
--- a/src/Explorer.App/MainWindow.xaml
+++ b/src/Explorer.App/MainWindow.xaml
@@ -525,14 +525,18 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
+ local:DetailsColumnLayout.ShowFreeSpace="{Binding ShowFreeSpaceColumn}"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
+
+
+
@@ -562,7 +566,7 @@
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
-
+
@@ -645,14 +649,18 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
+ local:DetailsColumnLayout.ShowFreeSpace="{Binding ShowFreeSpaceColumn}"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
+
+
+
@@ -682,7 +690,7 @@
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
-
+
@@ -782,7 +790,7 @@
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
-
+
diff --git a/src/Explorer.App/MainWindow.xaml.cs b/src/Explorer.App/MainWindow.xaml.cs
index 50b32eb..4cfbf4b 100644
--- a/src/Explorer.App/MainWindow.xaml.cs
+++ b/src/Explorer.App/MainWindow.xaml.cs
@@ -567,8 +567,11 @@ public partial class MainWindow : Window
{
"Name" => "Name",
"Date modified" => "Modified",
+ "Date created" => "Created",
"Type" => "Type",
"Size" => "Size",
+ "Git" => "Git",
+ "Cloud" => "Cloud",
"Free space" => "Free",
_ => null
};
diff --git a/src/Explorer.Application/FolderListingSort.cs b/src/Explorer.Application/FolderListingSort.cs
index 1fb1f70..8db6000 100644
--- a/src/Explorer.Application/FolderListingSort.cs
+++ b/src/Explorer.Application/FolderListingSort.cs
@@ -39,6 +39,9 @@ public static class FolderListingSort
"Modified" => descending
? items.OrderByDescending(i => i.ModifiedUtc).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.ModifiedUtc).ThenBy(i => i.Name, names),
+ "Created" => descending
+ ? items.OrderByDescending(i => i.CreatedUtc).ThenBy(i => i.Name, names)
+ : items.OrderBy(i => i.CreatedUtc).ThenBy(i => i.Name, names),
"Type" => descending
? items.OrderByDescending(TypeKey).ThenByDescending(i => i.Name, names)
: items.OrderBy(TypeKey).ThenBy(i => i.Name, names),
diff --git a/src/Explorer.Application/GitListingOverlay.cs b/src/Explorer.Application/GitListingOverlay.cs
new file mode 100644
index 0000000..23b504b
--- /dev/null
+++ b/src/Explorer.Application/GitListingOverlay.cs
@@ -0,0 +1,148 @@
+using Explorer.Domain;
+
+namespace Explorer.Application;
+
+public static class GitListingOverlay
+{
+ public static string ForNestedRepo(GitStatus status)
+ {
+ var parts = new List { status.Branch };
+ if (!string.IsNullOrEmpty(status.OperationLabel))
+ {
+ parts.Add(status.OperationLabel);
+ }
+
+ if (status.ModifiedCount > 0)
+ {
+ parts.Add($"{status.ModifiedCount} modified");
+ }
+
+ if (status.UntrackedCount > 0)
+ {
+ parts.Add($"{status.UntrackedCount} untracked");
+ }
+
+ return string.Join(" · ", parts);
+ }
+
+ public static string ForItem(string itemFullPath, bool isDirectory, string repoRoot, GitStatus status)
+ {
+ var rel = ToGitRelative(repoRoot, itemFullPath);
+ if (rel is null)
+ {
+ return "";
+ }
+
+ return isDirectory ? FolderLabel(status.Changes, rel) : FileLabel(status.Changes, rel);
+ }
+
+ internal static string? ToGitRelative(string repoRoot, string fullPath)
+ {
+ var root = PathRules.FromExtended(repoRoot).TrimEnd('\\');
+ var full = PathRules.FromExtended(fullPath).TrimEnd('\\');
+ if (full.Equals(root, StringComparison.OrdinalIgnoreCase))
+ {
+ return "";
+ }
+
+ if (!full.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return full[(root.Length + 1)..].Replace('\\', '/');
+ }
+
+ private static string FileLabel(IReadOnlyList changes, string rel)
+ {
+ if (rel.Length == 0)
+ {
+ return "";
+ }
+
+ var hits = changes.Where(c => Matches(c, rel)).ToList();
+ if (hits.Count == 0)
+ {
+ return "";
+ }
+
+ if (hits.Exists(c => c.State == GitChangeState.Unmerged))
+ {
+ return "Unmerged";
+ }
+
+ if (hits.Exists(c => c.State == GitChangeState.Untracked))
+ {
+ return "Untracked";
+ }
+
+ var staged = hits.Find(c => c.State == GitChangeState.Staged);
+ var unstaged = hits.Find(c => c.State == GitChangeState.Unstaged);
+ if (staged is not null && unstaged is not null)
+ {
+ return "Staged · Modified";
+ }
+
+ if (staged is not null)
+ {
+ return staged.Index switch
+ {
+ 'R' => "Renamed",
+ 'A' => "Added",
+ 'D' => "Deleted",
+ _ => "Staged"
+ };
+ }
+
+ return unstaged is null ? "" : Title(unstaged.ChangeLabel);
+ }
+
+ private static string FolderLabel(IReadOnlyList changes, string rel)
+ {
+ var hits = changes.Where(c => Under(c, rel)).ToList();
+ if (hits.Count == 0)
+ {
+ return "";
+ }
+
+ if (hits.Exists(c => c.State == GitChangeState.Unmerged))
+ {
+ return "Unmerged";
+ }
+
+ if (hits.TrueForAll(c => c.State == GitChangeState.Untracked))
+ {
+ return "Untracked";
+ }
+
+ return "Modified";
+ }
+
+ private static bool Matches(GitChange change, string rel)
+ => Same(change.Path, rel)
+ || (change.OriginalPath is { Length: > 0 } original && Same(original, rel));
+
+ private static bool Under(GitChange change, string rel)
+ {
+ if (Matches(change, rel))
+ {
+ return true;
+ }
+
+ if (rel.Length == 0)
+ {
+ return true;
+ }
+
+ var prefix = rel + "/";
+ return change.Path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
+ || (change.OriginalPath is { Length: > 0 } original
+ && original.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static bool Same(string left, string right)
+ => left.Equals(right, StringComparison.OrdinalIgnoreCase);
+
+ private static string Title(string value)
+ => string.IsNullOrEmpty(value) ? "" : char.ToUpperInvariant(value[0]) + value[1..];
+}
diff --git a/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs b/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
index 1a88726..a07091e 100644
--- a/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
@@ -46,6 +46,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
[ObservableProperty] private bool _isEditingPath;
[ObservableProperty] private string _pathEditText = "This PC";
public bool HasGitBadge => !string.IsNullOrEmpty(GitBadge);
+ public bool ShowFreeSpaceColumn
+ => CurrentPath == LocationRoots.ThisPc
+ || Items.Any(i => i.Item.FreeSpaceBytes is not null);
+
+ partial void OnCurrentPathChanged(string value) => NotifyDetailsColumns();
+
+ private void NotifyDetailsColumns() => OnPropertyChanged(nameof(ShowFreeSpaceColumn));
public ExplorerPaneViewModel(
BrowseService browse,
@@ -158,6 +165,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
Items.ReplaceAll(rows);
replaceListing = false;
+ NotifyDetailsColumns();
}
else if (_rows is not null)
{
@@ -172,6 +180,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
published = true;
IsBusy = false;
RefreshListingStatus();
+ NotifyDetailsColumns();
}
else if (delta.EnumerationComplete && replaceListing)
{
@@ -181,6 +190,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
published = true;
IsBusy = false;
RefreshListingStatus();
+ NotifyDetailsColumns();
}
if (delta.Updated.Count > 0)
@@ -421,6 +431,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
Items.AddRange(rows);
ApplyCurrentSort();
RefreshListingStatus();
+ NotifyDetailsColumns();
}
private void ApplyCurrentSource(Task sourceTask)
@@ -488,11 +499,24 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
GitBadge = folder?.Badge ?? "";
HasGitRepo = folder is not null || root is not null;
- foreach (var (full, status) in child)
+ var nested = child.ToDictionary(x => x.Path, x => x.Status, StringComparer.OrdinalIgnoreCase);
+ var repoRoot = folder?.RepoRoot ?? root;
+ foreach (var item in Items)
{
- var vm = Items.FirstOrDefault(i =>
- string.Equals(i.FullPath, full, StringComparison.OrdinalIgnoreCase));
- vm?.SetGit(status);
+ if (nested.TryGetValue(item.FullPath, out var nestedStatus) && nestedStatus is not null)
+ {
+ item.SetGit(GitListingOverlay.ForNestedRepo(nestedStatus));
+ continue;
+ }
+
+ item.SetGit(folder is null || repoRoot is null
+ ? null
+ : GitListingOverlay.ForItem(item.FullPath, item.IsDirectory, repoRoot, folder));
+ }
+
+ if (string.Equals(SortProperty, "Git", StringComparison.OrdinalIgnoreCase))
+ {
+ ApplyCurrentSort();
}
}
catch (OperationCanceledException)
@@ -631,7 +655,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
else
{
SortProperty = property;
- SortDescending = property is "Size" or "Free" or "Modified";
+ SortDescending = property is "Size" or "Free" or "Modified" or "Created" or "Git" or "Cloud";
}
_userChoseSort = true;
@@ -651,11 +675,29 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
bool descending)
{
var map = items as IList ?? items.ToList();
+ if (property is "Git" or "Cloud")
+ {
+ return OrderByLabel(map, property == "Git" ? i => i.GitLabel : i => i.CloudLabel, descending);
+ }
+
var ordered = FolderListingSort.Order(map.Select(i => i.Item), property, descending);
var byPath = map.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
return ordered.Select(item => byPath[item.FullPath]);
}
+ private static IEnumerable OrderByLabel(
+ IList items,
+ Func label,
+ bool descending)
+ {
+ var names = StringComparer.CurrentCultureIgnoreCase;
+ var keyed = items.Select(i => (Item: i, Empty: string.IsNullOrEmpty(label(i)) ? 1 : 0, Label: label(i)));
+ var ordered = descending
+ ? keyed.OrderBy(x => x.Empty).ThenByDescending(x => x.Label, names).ThenBy(x => x.Item.Name, names)
+ : keyed.OrderBy(x => x.Empty).ThenBy(x => x.Label, names).ThenBy(x => x.Item.Name, names);
+ return ordered.Select(x => x.Item);
+ }
+
private IReadOnlyList BuildBreadcrumb(string path)
{
if (path is LocationRoots.ThisPc or LocationRoots.Home or LocationRoots.Favorites)
diff --git a/src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs b/src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs
index 6afa7d6..55fdaed 100644
--- a/src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs
@@ -74,7 +74,16 @@ public sealed partial class FolderItemViewModel : ObservableObject
public bool MayHydrateOnRead => Item.Cloud?.MayHydrateOnRead == true
|| AttributeFlags.MayHydrateOnRead(Item.Attributes);
public string CloudStatus => Item.Cloud?.StatusText ?? "";
- public bool HasCloudStatus => !string.IsNullOrEmpty(CloudStatus);
+ public string CloudLabel => Item.Cloud?.Availability switch
+ {
+ CloudAvailability.OnlineOnly => "Online-only",
+ CloudAvailability.LocallyAvailable => "Local",
+ CloudAvailability.Pinned => "Pinned",
+ CloudAvailability.Syncing => "Syncing",
+ CloudAvailability.Error => "Error",
+ _ => ""
+ };
+ public bool HasCloudStatus => !string.IsNullOrEmpty(CloudLabel);
[ObservableProperty] private string _gitLabel = "";
public bool HasGitLabel => !string.IsNullOrEmpty(GitLabel);
@@ -82,10 +91,7 @@ public sealed partial class FolderItemViewModel : ObservableObject
[ObservableProperty] private object? _thumbnail;
public bool HasThumbnail => Thumbnail is not null;
- public void SetGit(GitStatus? status)
- {
- GitLabel = status?.Badge ?? "";
- }
+ public void SetGit(string? label) => GitLabel = label ?? "";
public void SetThumbnail(object? image) => Thumbnail = image;
diff --git a/tests/Explorer.Application.Tests/GitListingOverlayTests.cs b/tests/Explorer.Application.Tests/GitListingOverlayTests.cs
new file mode 100644
index 0000000..0f0d1e0
--- /dev/null
+++ b/tests/Explorer.Application.Tests/GitListingOverlayTests.cs
@@ -0,0 +1,59 @@
+using Explorer.Application;
+using Explorer.Domain;
+
+namespace Explorer.Application.Tests;
+
+public class GitListingOverlayTests
+{
+ [Fact]
+ public void Clean_tracked_file_is_blank()
+ {
+ var status = Status("1 .M N... 100644 100644 100644 a a src/App.cs");
+ Assert.Equal("", GitListingOverlay.ForItem(@"C:\src\README.md", false, @"C:\src", status));
+ }
+
+ [Fact]
+ public void File_shows_modified_unstaged_and_staged()
+ {
+ var status = Status("""
+ 1 .M N... 100644 100644 100644 a a src/App.cs
+ 1 M. N... 100644 100644 100644 b b README.md
+ 1 MM N... 100644 100644 100644 c c src/Both.cs
+ ? notes.md
+ """);
+ Assert.Equal("Modified", GitListingOverlay.ForItem(@"C:\src\src\App.cs", false, @"C:\src", status));
+ Assert.Equal("Staged", GitListingOverlay.ForItem(@"C:\src\README.md", false, @"C:\src", status));
+ Assert.Equal("Staged · Modified", GitListingOverlay.ForItem(@"C:\src\src\Both.cs", false, @"C:\src", status));
+ Assert.Equal("Untracked", GitListingOverlay.ForItem(@"C:\src\notes.md", false, @"C:\src", status));
+ }
+
+ [Fact]
+ public void Folder_summarizes_dirty_children()
+ {
+ var status = Status("""
+ 1 .M N... 100644 100644 100644 a a src/App.cs
+ ? bin/out.dll
+ """);
+ Assert.Equal("Modified", GitListingOverlay.ForItem(@"C:\src\src", true, @"C:\src", status));
+ Assert.Equal("Untracked", GitListingOverlay.ForItem(@"C:\src\bin", true, @"C:\src", status));
+ Assert.Equal("", GitListingOverlay.ForItem(@"C:\src\docs", true, @"C:\src", status));
+ }
+
+ [Fact]
+ public void Nested_repo_uses_compact_badge()
+ {
+ var status = Status("1 .M N... 100644 100644 100644 a a App.cs");
+ Assert.Equal("main · 1 modified", GitListingOverlay.ForNestedRepo(status));
+ }
+
+ [Fact]
+ public void Outside_the_repo_is_blank()
+ {
+ var status = Status("1 .M N... 100644 100644 100644 a a App.cs");
+ Assert.Equal("", GitListingOverlay.ForItem(@"D:\other\App.cs", false, @"C:\src", status));
+ }
+
+ private static GitStatus Status(string body)
+ => GitPorcelainParser.Parse("# branch.head main\n" + body, @"C:\src")
+ ?? throw new InvalidOperationException("parse");
+}