Repository navigation
Expand file tree
/
Copy pathFileTreeNode.cs
More file actions
257 lines (213 loc) · 9.23 KB
/
Copy pathFileTreeNode.cs
File metadata and controls
257 lines (213 loc) · 9.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using Avalonia.Media;
using Snipyard.Services;
namespace Snipyard;
public class FileTreeNode : INotifyPropertyChanged
{
private static readonly Geometry FolderIcon = Geometry.Parse(
"M2 6C2 4.89 2.89 4 4 4H9L11 6H18C19.1 6 20 6.89 20 8V16C20 17.1 19.1 18 18 18H4C2.89 18 2 17.1 2 16V6Z");
private static readonly Geometry FileIcon = Geometry.Parse(
"M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2ZM18 20H6V4H13V9H18V20Z");
private static readonly Dictionary<string, string> ExtColorMap = new(StringComparer.OrdinalIgnoreCase)
{
{ ".cs", "#9B6BDF" }, { ".csproj", "#9B6BDF" }, { ".sln", "#9B6BDF" },
{ ".json", "#CBCB41" }, { ".jsonc", "#CBCB41" },
{ ".xml", "#E37933" }, { ".axaml", "#E37933" }, { ".xaml", "#E37933" },
{ ".config", "#E37933" },
{ ".md", "#519ABA" }, { ".markdown", "#519ABA" },
{ ".txt", "#8C8C8C" }, { ".log", "#8C8C8C" },
{ ".js", "#CBCB41" }, { ".mjs", "#CBCB41" },
{ ".ts", "#3178C6" }, { ".tsx", "#3178C6" }, { ".jsx", "#CBCB41" },
{ ".html", "#E44D26" }, { ".htm", "#E44D26" },
{ ".css", "#563D7C" }, { ".scss", "#CD6799" }, { ".less", "#1D365D" },
{ ".py", "#3572A5" },
{ ".java", "#B07219" },
{ ".go", "#00ADD8" },
{ ".rs", "#DEA584" },
{ ".cpp", "#F34B7D" }, { ".c", "#555555" }, { ".h", "#555555" },
{ ".rb", "#CC342D" },
{ ".php", "#4F5D95" },
{ ".sh", "#89E051" }, { ".bash", "#89E051" }, { ".ps1", "#012456" },
{ ".yml", "#CB171E" }, { ".yaml", "#CB171E" },
{ ".toml", "#9C4121" },
{ ".gitignore", "#F54D27" }, { ".gitattributes", "#F54D27" },
{ ".dockerfile", "#384D54" },
{ ".sql", "#E38C00" },
{ ".svg", "#FFB13B" }, { ".png", "#A074C4" }, { ".jpg", "#A074C4" },
{ ".gif", "#A074C4" }, { ".ico", "#A074C4" },
{ ".manifest", "#8C8C8C" },
};
private static readonly IBrush DefaultFileColor = new SolidColorBrush(Color.Parse("#8C8C8C"));
private static readonly IBrush FolderColor = new SolidColorBrush(Color.Parse("#DCAD54"));
/// <summary>
/// Draw what Windows Explorer draws instead of the built-in glyphs. On by default, tracking
/// AppSettings.UseShellIcons. The glyph set below stays as the fallback for the paths the
/// shell has no icon for, and for anyone who turns the setting back off.
/// </summary>
public static bool UseShellIcons { get; set; } = true;
public string Name { get; set; } = "";
public string FullPath { get; set; } = "";
public bool IsDirectory { get; set; }
public Geometry IconData => IsDirectory ? FolderIcon : FileIcon;
public IBrush IconColor { get; set; } = DefaultFileColor;
/// <summary>The Explorer icon, when shell icons are on and the shell had one for this path.</summary>
public IImage? ShellIcon { get; private set; }
public bool HasShellIcon => ShellIcon != null;
public bool HasGlyphIcon => ShellIcon == null;
public ObservableCollection<FileTreeNode> Children { get; set; } = new();
private bool _isExpanded;
private bool _childrenLoaded;
public bool IsExpanded
{
get => _isExpanded;
set
{
if (_isExpanded == value) return;
_isExpanded = value;
OnPropertyChanged();
if (value && IsDirectory && !_childrenLoaded)
LoadChildren();
}
}
// ── Gitignore support ──
private static HashSet<string>? _gitignorePatterns;
private static string? _gitignoreRoot;
public static bool UseGitignore { get; set; } = false;
private static void LoadGitignore(string rootPath)
{
_gitignoreRoot = rootPath;
_gitignorePatterns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var gitignorePath = Path.Combine(rootPath, ".gitignore");
if (!File.Exists(gitignorePath)) return;
try
{
foreach (var rawLine in File.ReadAllLines(gitignorePath))
{
var line = rawLine.Trim();
if (string.IsNullOrEmpty(line) || line.StartsWith('#'))
continue;
_gitignorePatterns.Add(line.TrimEnd('/'));
}
}
catch { }
}
private static bool IsGitignored(string fullPath, bool isDirectory)
{
if (!UseGitignore || _gitignorePatterns == null || _gitignorePatterns.Count == 0 || _gitignoreRoot == null)
return false;
var name = Path.GetFileName(fullPath);
var relativePath = Path.GetRelativePath(_gitignoreRoot, fullPath).Replace('\\', '/');
foreach (var pattern in _gitignorePatterns)
{
// Exact name match (e.g. "node_modules", "bin", ".vs")
if (string.Equals(name, pattern, StringComparison.OrdinalIgnoreCase))
return true;
// Path-based match (e.g. "dist/", "build/output")
if (relativePath.Equals(pattern, StringComparison.OrdinalIgnoreCase))
return true;
// Simple wildcard match (e.g. "*.suo", "*.user")
if (pattern.StartsWith('*') && name.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase))
return true;
// Directory-only pattern (e.g. "bin/")
if (isDirectory && pattern.EndsWith('/') &&
string.Equals(name, pattern.TrimEnd('/'), StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
// Always-hidden directories (version control, IDE, etc.)
private static readonly HashSet<string> AlwaysHidden = new(StringComparer.OrdinalIgnoreCase)
{
".git", ".svn", ".hg", ".vs", ".idea"
};
private static bool ShouldHide(string fullPath, bool isDirectory)
{
if (!UseGitignore) return false;
var name = Path.GetFileName(fullPath);
if (isDirectory && AlwaysHidden.Contains(name))
return true;
return IsGitignored(fullPath, isDirectory);
}
// ── Node creation ──
public static FileTreeNode CreateForPath(string path, bool isDirectory)
{
var node = new FileTreeNode
{
Name = Path.GetFileName(path),
FullPath = path,
IsDirectory = isDirectory
};
if (isDirectory)
{
node.IconColor = FolderColor;
try
{
if (Directory.EnumerateFileSystemEntries(path).Any())
node.Children.Add(new FileTreeNode { Name = "Loading..." });
}
catch { /* access denied */ }
}
else
{
var ext = Path.GetExtension(path);
if (!string.IsNullOrEmpty(ext) && ExtColorMap.TryGetValue(ext, out var hex))
node.IconColor = new SolidColorBrush(Color.Parse(hex));
}
// Resolved eagerly rather than on first render: the shell lookup is cached by extension,
// so a whole folder costs a handful of calls, and a lazy property would need change
// notification to reach the already-bound template.
if (UseShellIcons)
node.ShellIcon = ShellIconProvider.GetIcon(path, isDirectory);
return node;
}
public void LoadChildren()
{
if (_childrenLoaded) return;
_childrenLoaded = true;
Children.Clear();
try
{
var dirs = Directory.GetDirectories(FullPath)
.Where(d => !ShouldHide(d, true))
.OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase);
foreach (var dir in dirs)
Children.Add(CreateForPath(dir, true));
var files = Directory.GetFiles(FullPath)
.Where(f => !ShouldHide(f, false))
.OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase);
foreach (var file in files)
Children.Add(CreateForPath(file, false));
}
catch { /* access denied */ }
}
public static ObservableCollection<FileTreeNode> CreateRootNodes(string rootPath)
{
LoadGitignore(rootPath);
var nodes = new ObservableCollection<FileTreeNode>();
try
{
var dirs = Directory.GetDirectories(rootPath)
.Where(d => !ShouldHide(d, true))
.OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase);
foreach (var dir in dirs)
nodes.Add(CreateForPath(dir, true));
var files = Directory.GetFiles(rootPath)
.Where(f => !ShouldHide(f, false))
.OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase);
foreach (var file in files)
nodes.Add(CreateForPath(file, false));
}
catch { /* access denied */ }
return nodes;
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}