Show Git, Cloud, and created date in Details, and hide Free space except on drives.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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.
|
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.
|
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
|
## 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.
|
**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:
|
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
|
- **Always keep on this device** / **Free up space** when the provider supports pin/dehydrate
|
||||||
- Quota in capacity/free space where the provider reports it
|
- Quota in capacity/free space where the provider reports it
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,33 @@
|
|||||||
<TextBlock Text="{Binding StatusGlyph}" Margin="6,0,0,0" VerticalAlignment="Center"
|
<TextBlock Text="{Binding StatusGlyph}" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource FgMuted}"
|
Foreground="{DynamicResource FgMuted}"
|
||||||
Visibility="{Binding HasStatusGlyph, Converter={StaticResource BoolVis}}"/>
|
Visibility="{Binding HasStatusGlyph, Converter={StaticResource BoolVis}}"/>
|
||||||
<TextBlock Text="{Binding CloudStatus}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
<DataTemplate x:Key="NameWithOverlays">
|
||||||
|
<StackPanel Orientation="Horizontal" ToolTip="{Binding SizeTooltip}">
|
||||||
|
<Image Width="16" Height="16" Margin="0,0,8,0" RenderOptions.BitmapScalingMode="HighQuality"
|
||||||
|
Source="{Binding Converter={StaticResource ShellIcon}}"/>
|
||||||
|
<TextBlock Tag="ItemName" Text="{Binding Name}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsRenaming}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBox Style="{StaticResource InlineRenameBox}"
|
||||||
|
Tag="InlineRename"
|
||||||
|
Text="{Binding EditName, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
local:InlineRenameBehavior.Enable="True"
|
||||||
|
Visibility="{Binding IsRenaming, Converter={StaticResource BoolVis}}"/>
|
||||||
|
<TextBlock Text="{Binding StatusGlyph}" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource FgMuted}"
|
||||||
|
Visibility="{Binding HasStatusGlyph, Converter={StaticResource BoolVis}}"/>
|
||||||
|
<TextBlock Text="{Binding CloudLabel}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
|
||||||
Foreground="{DynamicResource FgMuted}"
|
Foreground="{DynamicResource FgMuted}"
|
||||||
Visibility="{Binding HasCloudStatus, Converter={StaticResource BoolVis}}"/>
|
Visibility="{Binding HasCloudStatus, Converter={StaticResource BoolVis}}"/>
|
||||||
<TextBlock Text="{Binding GitLabel}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
|
<TextBlock Text="{Binding GitLabel}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
|
||||||
|
|||||||
59
src/Explorer.App/DetailsColumnLayout.cs
Normal file
59
src/Explorer.App/DetailsColumnLayout.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace Explorer.App;
|
||||||
|
|
||||||
|
public static class DetailsColumnLayout
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty ShowFreeSpaceProperty =
|
||||||
|
DependencyProperty.RegisterAttached(
|
||||||
|
"ShowFreeSpace",
|
||||||
|
typeof(bool),
|
||||||
|
typeof(DetailsColumnLayout),
|
||||||
|
new PropertyMetadata(true, OnShowFreeSpaceChanged));
|
||||||
|
|
||||||
|
public static void SetShowFreeSpace(ListView element, bool value)
|
||||||
|
=> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -525,14 +525,18 @@
|
|||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch"
|
VerticalAlignment="Stretch"
|
||||||
local:ListViewLayout.StretchFirstColumn="True"
|
local:ListViewLayout.StretchFirstColumn="True"
|
||||||
|
local:DetailsColumnLayout.ShowFreeSpace="{Binding ShowFreeSpaceColumn}"
|
||||||
local:FolderViewport.IsTracked="True"
|
local:FolderViewport.IsTracked="True"
|
||||||
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
|
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
|
||||||
<ListView.View>
|
<ListView.View>
|
||||||
<GridView>
|
<GridView>
|
||||||
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
|
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
|
||||||
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
|
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
|
||||||
|
<GridViewColumn Header="Date created" Width="148" DisplayMemberBinding="{Binding CreatedLabel}"/>
|
||||||
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
|
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
|
||||||
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
|
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
|
||||||
|
<GridViewColumn Header="Git" Width="110" DisplayMemberBinding="{Binding GitLabel}"/>
|
||||||
|
<GridViewColumn Header="Cloud" Width="100" DisplayMemberBinding="{Binding CloudLabel}"/>
|
||||||
<GridViewColumn Header="Free space" Width="110" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
|
<GridViewColumn Header="Free space" Width="110" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
|
||||||
</GridView>
|
</GridView>
|
||||||
</ListView.View>
|
</ListView.View>
|
||||||
@@ -562,7 +566,7 @@
|
|||||||
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
|
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
|
||||||
<ListView.View>
|
<ListView.View>
|
||||||
<GridView>
|
<GridView>
|
||||||
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
|
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithOverlays}"/>
|
||||||
</GridView>
|
</GridView>
|
||||||
</ListView.View>
|
</ListView.View>
|
||||||
</ListView>
|
</ListView>
|
||||||
@@ -645,14 +649,18 @@
|
|||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch"
|
VerticalAlignment="Stretch"
|
||||||
local:ListViewLayout.StretchFirstColumn="True"
|
local:ListViewLayout.StretchFirstColumn="True"
|
||||||
|
local:DetailsColumnLayout.ShowFreeSpace="{Binding ShowFreeSpaceColumn}"
|
||||||
local:FolderViewport.IsTracked="True"
|
local:FolderViewport.IsTracked="True"
|
||||||
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
|
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
|
||||||
<ListView.View>
|
<ListView.View>
|
||||||
<GridView>
|
<GridView>
|
||||||
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
|
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
|
||||||
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
|
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
|
||||||
|
<GridViewColumn Header="Date created" Width="148" DisplayMemberBinding="{Binding CreatedLabel}"/>
|
||||||
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
|
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
|
||||||
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
|
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
|
||||||
|
<GridViewColumn Header="Git" Width="110" DisplayMemberBinding="{Binding GitLabel}"/>
|
||||||
|
<GridViewColumn Header="Cloud" Width="100" DisplayMemberBinding="{Binding CloudLabel}"/>
|
||||||
<GridViewColumn Header="Free space" Width="110" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
|
<GridViewColumn Header="Free space" Width="110" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
|
||||||
</GridView>
|
</GridView>
|
||||||
</ListView.View>
|
</ListView.View>
|
||||||
@@ -682,7 +690,7 @@
|
|||||||
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
|
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
|
||||||
<ListView.View>
|
<ListView.View>
|
||||||
<GridView>
|
<GridView>
|
||||||
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
|
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithOverlays}"/>
|
||||||
</GridView>
|
</GridView>
|
||||||
</ListView.View>
|
</ListView.View>
|
||||||
</ListView>
|
</ListView>
|
||||||
@@ -782,7 +790,7 @@
|
|||||||
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
|
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
|
||||||
<ListView.View>
|
<ListView.View>
|
||||||
<GridView>
|
<GridView>
|
||||||
<GridViewColumn Header="Name" Width="220" CellTemplate="{StaticResource NameWithIcon}"/>
|
<GridViewColumn Header="Name" Width="220" CellTemplate="{StaticResource NameWithOverlays}"/>
|
||||||
<GridViewColumn Header="Path" Width="420" DisplayMemberBinding="{Binding FullPath}"/>
|
<GridViewColumn Header="Path" Width="420" DisplayMemberBinding="{Binding FullPath}"/>
|
||||||
<GridViewColumn Header="Size" Width="100" DisplayMemberBinding="{Binding SizeLabel}"/>
|
<GridViewColumn Header="Size" Width="100" DisplayMemberBinding="{Binding SizeLabel}"/>
|
||||||
<GridViewColumn Header="Free space" Width="100" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
|
<GridViewColumn Header="Free space" Width="100" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
|
||||||
|
|||||||
@@ -567,8 +567,11 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
"Name" => "Name",
|
"Name" => "Name",
|
||||||
"Date modified" => "Modified",
|
"Date modified" => "Modified",
|
||||||
|
"Date created" => "Created",
|
||||||
"Type" => "Type",
|
"Type" => "Type",
|
||||||
"Size" => "Size",
|
"Size" => "Size",
|
||||||
|
"Git" => "Git",
|
||||||
|
"Cloud" => "Cloud",
|
||||||
"Free space" => "Free",
|
"Free space" => "Free",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public static class FolderListingSort
|
|||||||
"Modified" => descending
|
"Modified" => descending
|
||||||
? items.OrderByDescending(i => i.ModifiedUtc).ThenBy(i => i.Name, names)
|
? items.OrderByDescending(i => i.ModifiedUtc).ThenBy(i => i.Name, names)
|
||||||
: items.OrderBy(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
|
"Type" => descending
|
||||||
? items.OrderByDescending(TypeKey).ThenByDescending(i => i.Name, names)
|
? items.OrderByDescending(TypeKey).ThenByDescending(i => i.Name, names)
|
||||||
: items.OrderBy(TypeKey).ThenBy(i => i.Name, names),
|
: items.OrderBy(TypeKey).ThenBy(i => i.Name, names),
|
||||||
|
|||||||
148
src/Explorer.Application/GitListingOverlay.cs
Normal file
148
src/Explorer.Application/GitListingOverlay.cs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
|
||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public static class GitListingOverlay
|
||||||
|
{
|
||||||
|
public static string ForNestedRepo(GitStatus status)
|
||||||
|
{
|
||||||
|
var parts = new List<string> { 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<GitChange> 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<GitChange> 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..];
|
||||||
|
}
|
||||||
@@ -46,6 +46,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _isEditingPath;
|
[ObservableProperty] private bool _isEditingPath;
|
||||||
[ObservableProperty] private string _pathEditText = "This PC";
|
[ObservableProperty] private string _pathEditText = "This PC";
|
||||||
public bool HasGitBadge => !string.IsNullOrEmpty(GitBadge);
|
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(
|
public ExplorerPaneViewModel(
|
||||||
BrowseService browse,
|
BrowseService browse,
|
||||||
@@ -158,6 +165,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
|
|
||||||
Items.ReplaceAll(rows);
|
Items.ReplaceAll(rows);
|
||||||
replaceListing = false;
|
replaceListing = false;
|
||||||
|
NotifyDetailsColumns();
|
||||||
}
|
}
|
||||||
else if (_rows is not null)
|
else if (_rows is not null)
|
||||||
{
|
{
|
||||||
@@ -172,6 +180,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
published = true;
|
published = true;
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
RefreshListingStatus();
|
RefreshListingStatus();
|
||||||
|
NotifyDetailsColumns();
|
||||||
}
|
}
|
||||||
else if (delta.EnumerationComplete && replaceListing)
|
else if (delta.EnumerationComplete && replaceListing)
|
||||||
{
|
{
|
||||||
@@ -181,6 +190,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
published = true;
|
published = true;
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
RefreshListingStatus();
|
RefreshListingStatus();
|
||||||
|
NotifyDetailsColumns();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delta.Updated.Count > 0)
|
if (delta.Updated.Count > 0)
|
||||||
@@ -421,6 +431,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
Items.AddRange(rows);
|
Items.AddRange(rows);
|
||||||
ApplyCurrentSort();
|
ApplyCurrentSort();
|
||||||
RefreshListingStatus();
|
RefreshListingStatus();
|
||||||
|
NotifyDetailsColumns();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyCurrentSource(Task<Source?> sourceTask)
|
private void ApplyCurrentSource(Task<Source?> sourceTask)
|
||||||
@@ -488,11 +499,24 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
|
|
||||||
GitBadge = folder?.Badge ?? "";
|
GitBadge = folder?.Badge ?? "";
|
||||||
HasGitRepo = folder is not null || root is not null;
|
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 =>
|
if (nested.TryGetValue(item.FullPath, out var nestedStatus) && nestedStatus is not null)
|
||||||
string.Equals(i.FullPath, full, StringComparison.OrdinalIgnoreCase));
|
{
|
||||||
vm?.SetGit(status);
|
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)
|
catch (OperationCanceledException)
|
||||||
@@ -631,7 +655,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
SortProperty = property;
|
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;
|
_userChoseSort = true;
|
||||||
@@ -651,11 +675,29 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
bool descending)
|
bool descending)
|
||||||
{
|
{
|
||||||
var map = items as IList<FolderItemViewModel> ?? items.ToList();
|
var map = items as IList<FolderItemViewModel> ?? 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 ordered = FolderListingSort.Order(map.Select(i => i.Item), property, descending);
|
||||||
var byPath = map.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
|
var byPath = map.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
|
||||||
return ordered.Select(item => byPath[item.FullPath]);
|
return ordered.Select(item => byPath[item.FullPath]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<FolderItemViewModel> OrderByLabel(
|
||||||
|
IList<FolderItemViewModel> items,
|
||||||
|
Func<FolderItemViewModel, string> 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<BreadcrumbSegment> BuildBreadcrumb(string path)
|
private IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
|
||||||
{
|
{
|
||||||
if (path is LocationRoots.ThisPc or LocationRoots.Home or LocationRoots.Favorites)
|
if (path is LocationRoots.ThisPc or LocationRoots.Home or LocationRoots.Favorites)
|
||||||
|
|||||||
@@ -74,7 +74,16 @@ public sealed partial class FolderItemViewModel : ObservableObject
|
|||||||
public bool MayHydrateOnRead => Item.Cloud?.MayHydrateOnRead == true
|
public bool MayHydrateOnRead => Item.Cloud?.MayHydrateOnRead == true
|
||||||
|| AttributeFlags.MayHydrateOnRead(Item.Attributes);
|
|| AttributeFlags.MayHydrateOnRead(Item.Attributes);
|
||||||
public string CloudStatus => Item.Cloud?.StatusText ?? "";
|
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 = "";
|
[ObservableProperty] private string _gitLabel = "";
|
||||||
public bool HasGitLabel => !string.IsNullOrEmpty(GitLabel);
|
public bool HasGitLabel => !string.IsNullOrEmpty(GitLabel);
|
||||||
@@ -82,10 +91,7 @@ public sealed partial class FolderItemViewModel : ObservableObject
|
|||||||
[ObservableProperty] private object? _thumbnail;
|
[ObservableProperty] private object? _thumbnail;
|
||||||
public bool HasThumbnail => Thumbnail is not null;
|
public bool HasThumbnail => Thumbnail is not null;
|
||||||
|
|
||||||
public void SetGit(GitStatus? status)
|
public void SetGit(string? label) => GitLabel = label ?? "";
|
||||||
{
|
|
||||||
GitLabel = status?.Badge ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetThumbnail(object? image) => Thumbnail = image;
|
public void SetThumbnail(object? image) => Thumbnail = image;
|
||||||
|
|
||||||
|
|||||||
59
tests/Explorer.Application.Tests/GitListingOverlayTests.cs
Normal file
59
tests/Explorer.Application.Tests/GitListingOverlayTests.cs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user