diff --git a/src/App.JsonCodeGen.cs b/src/App.JsonCodeGen.cs index 8eef67ae7..6ce6f3cdc 100644 --- a/src/App.JsonCodeGen.cs +++ b/src/App.JsonCodeGen.cs @@ -73,5 +73,7 @@ public override void Write(Utf8JsonWriter writer, GridLength value, JsonSerializ [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ViewModels.Preferences))] [JsonSerializable(typeof(ViewModels.RepositoryNodeMinimalInfo))] + [JsonSerializable(typeof(Models.DirectoryTreeCacheData))] + [JsonSerializable(typeof(Models.DirectoryTreeCacheNode))] internal partial class JsonCodeGen : JsonSerializerContext { } } diff --git a/src/Converters/PathConverters.cs b/src/Converters/PathConverters.cs index 23dae2ab3..52294f69b 100644 --- a/src/Converters/PathConverters.cs +++ b/src/Converters/PathConverters.cs @@ -1,5 +1,7 @@ +using System; using System.IO; using Avalonia.Data.Converters; +using Avalonia.Media.Imaging; namespace SourceGit.Converters { @@ -13,5 +15,13 @@ public static class PathConverters public static readonly FuncValueConverter RelativeToHome = new(Native.OS.GetRelativePathToHome); + + public static readonly FuncValueConverter FileExtensionToSystemIcon = + new(v => + { + if (!OperatingSystem.IsWindows() || string.IsNullOrEmpty(v)) + return null; + return Native.SystemFileIcon.GetIcon(v); + }); } } diff --git a/src/Models/DirectoryTreeCacheData.cs b/src/Models/DirectoryTreeCacheData.cs new file mode 100644 index 000000000..150ca552e --- /dev/null +++ b/src/Models/DirectoryTreeCacheData.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class DirectoryTreeCacheData + { + public List SourceDirectories { get; set; } = []; + public int ScanDepth { get; set; } = 5; + public List ExpandedPaths { get; set; } = []; + public List Nodes { get; set; } = []; + } + + public class DirectoryTreeCacheNode + { + public string Path { get; set; } + public string Name { get; set; } + public bool IsRepository { get; set; } + public List Children { get; set; } = []; + } +} diff --git a/src/Native/SystemFileIcon.cs b/src/Native/SystemFileIcon.cs new file mode 100644 index 000000000..0afa261af --- /dev/null +++ b/src/Native/SystemFileIcon.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +using Avalonia.Media.Imaging; + +namespace SourceGit.Native +{ + [SupportedOSPlatform("windows")] + public static class SystemFileIcon + { + private const int MAX_PATH = 260; + private const uint SHGFI_ICON = 0x000000100; + private const uint SHGFI_USEFILEATTRIBUTES = 0x000000010; + private const uint SHGFI_SMALLICON = 0x000000001; + private const uint FILE_ATTRIBUTE_NORMAL = 0x00000080; + + private const int DI_NORMAL = 0x0003; + + private static readonly Dictionary _cache = new(StringComparer.OrdinalIgnoreCase); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct SHFILEINFOW + { + public IntPtr hIcon; + public int iIcon; + public uint dwAttributes; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = MAX_PATH)] + public string szDisplayName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] + public string szTypeName; + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr SHGetFileInfoW( + string pszPath, + uint dwFileAttributes, + ref SHFILEINFOW psfi, + uint cbSizeFileInfo, + uint uFlags); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyIcon(IntPtr hIcon); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DrawIconEx( + IntPtr hdc, + int xLeft, + int yTop, + IntPtr hIcon, + int cxWidth, + int cyHeight, + int istepIfAniCur, + IntPtr hbrFlickerFreeDraw, + int diFlags); + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern IntPtr CreateCompatibleDC(IntPtr hdc); + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern IntPtr CreateCompatibleBitmap(IntPtr hdc, int width, int height); + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern IntPtr SelectObject(IntPtr hdc, IntPtr hgdiobj); + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern bool DeleteDC(IntPtr hdc); + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern bool DeleteObject(IntPtr hObject); + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern int GetDIBits( + IntPtr hdc, + IntPtr hbmp, + uint uStartScan, + uint cScanLines, + byte[] lpvBits, + ref BITMAPINFO lpbi, + uint uUsage); + + [StructLayout(LayoutKind.Sequential)] + private struct BITMAPINFOHEADER + { + public uint biSize; + public int biWidth; + public int biHeight; + public ushort biPlanes; + public ushort biBitCount; + public uint biCompression; + public uint biSizeImage; + public int biXPelsPerMeter; + public int biYPelsPerMeter; + public uint biClrUsed; + public uint biClrImportant; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RGBQUAD + { + public byte rgbBlue; + public byte rgbGreen; + public byte rgbRed; + public byte rgbReserved; + } + + [StructLayout(LayoutKind.Sequential)] + private struct BITMAPINFO + { + public BITMAPINFOHEADER bmiHeader; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)] + public RGBQUAD[] bmiColors; + } + + private const uint BI_RGB = 0; + private const uint DIB_RGB_COLORS = 0; + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern IntPtr GetStockObject(int fnObject); + + private const int WHITE_BRUSH = 0; + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("user32.dll", SetLastError = true)] + private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); + + /// + /// Gets the system file icon for a given file path, cached by extension. + /// Returns null if not on Windows or icon cannot be retrieved. + /// + public static Bitmap GetIcon(string filePath) + { + if (!OperatingSystem.IsWindows()) + return null; + + if (string.IsNullOrEmpty(filePath)) + return null; + + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + if (string.IsNullOrEmpty(ext)) + ext = ".*"; // fallback for files without extension + + lock (_cache) + { + if (_cache.TryGetValue(ext, out var cached)) + return cached; + } + + var icon = LoadIconForExtension(ext); + + lock (_cache) + { + _cache[ext] = icon; + } + + return icon; + } + + /// + /// Clears the icon cache. Useful when system theme changes. + /// + public static void ClearCache() + { + lock (_cache) + { + foreach (var bmp in _cache.Values) + bmp?.Dispose(); + _cache.Clear(); + } + } + + private static Bitmap LoadIconForExtension(string ext) + { + var shInfo = new SHFILEINFOW(); + var shInfoSize = (uint)Marshal.SizeOf(shInfo); + + // Use a dummy path with the target extension - SHGFI_USEFILEATTRIBUTES means + // the file doesn't need to actually exist + var dummyPath = "dummy" + ext; + + var result = SHGetFileInfoW( + dummyPath, + FILE_ATTRIBUTE_NORMAL, + ref shInfo, + shInfoSize, + SHGFI_ICON | SHGFI_USEFILEATTRIBUTES | SHGFI_SMALLICON); + + if (result == IntPtr.Zero || shInfo.hIcon == IntPtr.Zero) + return null; + + try + { + return ConvertHIconToBitmap(shInfo.hIcon, 16, 16); + } + finally + { + DestroyIcon(shInfo.hIcon); + } + } + + private static Bitmap ConvertHIconToBitmap(IntPtr hIcon, int width, int height) + { + // Create a compatible DC and bitmap for drawing + IntPtr screenDC = GetDC(IntPtr.Zero); + if (screenDC == IntPtr.Zero) + return null; + + IntPtr memDC = CreateCompatibleDC(screenDC); + if (memDC == IntPtr.Zero) + { + ReleaseDC(IntPtr.Zero, screenDC); + return null; + } + + IntPtr hBitmap = CreateCompatibleBitmap(screenDC, width, height); + if (hBitmap == IntPtr.Zero) + { + DeleteDC(memDC); + ReleaseDC(IntPtr.Zero, screenDC); + return null; + } + + IntPtr oldBmp = SelectObject(memDC, hBitmap); + + // Fill background with white (for transparency handling) + IntPtr hBrush = GetStockObject(WHITE_BRUSH); + // We skip filling - DrawIconEx handles transparency + + // Draw the icon onto our bitmap + bool drawn = DrawIconEx(memDC, 0, 0, hIcon, width, height, 0, IntPtr.Zero, DI_NORMAL); + + SelectObject(memDC, oldBmp); + DeleteDC(memDC); + ReleaseDC(IntPtr.Zero, screenDC); + + if (!drawn) + { + DeleteObject(hBitmap); + return null; + } + + // Extract pixel data using GetDIBits + try + { + return CreateBitmapFromHBitmap(hBitmap, width, height); + } + finally + { + DeleteObject(hBitmap); + } + } + + private static Bitmap CreateBitmapFromHBitmap(IntPtr hBitmap, int width, int height) + { + // Set up BITMAPINFO for 32-bit RGBA + var bmi = new BITMAPINFO + { + bmiHeader = new BITMAPINFOHEADER + { + biSize = (uint)Marshal.SizeOf(), + biWidth = width, + biHeight = -height, // Negative = top-down DIB + biPlanes = 1, + biBitCount = 32, + biCompression = BI_RGB, + biSizeImage = (uint)(width * height * 4), + }, + bmiColors = new RGBQUAD[256], + }; + + // GetDIBits needs a DC + IntPtr screenDC = GetDC(IntPtr.Zero); + if (screenDC == IntPtr.Zero) + return null; + + var pixels = new byte[width * height * 4]; + + int lines = GetDIBits(screenDC, hBitmap, 0, (uint)height, pixels, ref bmi, DIB_RGB_COLORS); + ReleaseDC(IntPtr.Zero, screenDC); + + if (lines <= 0) + return null; + + // GetDIBits returns BGRA format, convert to RGBA for Avalonia + var rgbaPixels = new byte[width * height * 4]; + for (int i = 0; i < width * height; i++) + { + int srcIdx = i * 4; + int dstIdx = i * 4; + // BGRA -> RGBA + rgbaPixels[dstIdx + 0] = pixels[srcIdx + 2]; // R + rgbaPixels[dstIdx + 1] = pixels[srcIdx + 1]; // G + rgbaPixels[dstIdx + 2] = pixels[srcIdx + 0]; // B + rgbaPixels[dstIdx + 3] = pixels[srcIdx + 3]; // A + } + + var handle = GCHandle.Alloc(rgbaPixels, GCHandleType.Pinned); + try + { + return new Bitmap( + Avalonia.Platform.PixelFormat.Rgba8888, + Avalonia.Platform.AlphaFormat.Premul, + handle.AddrOfPinnedObject(), + new Avalonia.PixelSize(width, height), + new Avalonia.Vector(96, 96), + width * 4); + } + finally + { + handle.Free(); + } + } + } +} diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 269de476b..a2e1406bd 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -1018,6 +1018,15 @@ Open Terminal Rescan Repositories in Default Clone Dir Search Repositories... + Repository Manager + Clone Directory + Refresh + Right-click to scan clone directory + Error scanning directory "{0}": {1} Continue scanning? + Continue + Stop + Open Path + Recent Local Changes Git Ignore Ignore all *{0} files diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index 8cecd0e57..8cc2c4f3f 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -1022,6 +1022,15 @@ 打开终端 重新扫描默认克隆路径下的仓库 快速查找仓库... + 仓库管理 + 克隆目录 + 刷新 + 右键点击扫描克隆目录 + 扫描目录 "{0}" 时出错: {1} 是否继续扫描? + 继续 + 停止 + 打开路径 + 最近 本地更改 添加至 .gitignore 忽略列表 忽略所有 *{0} 文件 diff --git a/src/ViewModels/DirectoryTree.cs b/src/ViewModels/DirectoryTree.cs new file mode 100644 index 000000000..522d9dedd --- /dev/null +++ b/src/ViewModels/DirectoryTree.cs @@ -0,0 +1,396 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +using Avalonia.Collections; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class DirectoryTree : ObservableObject + { + private static readonly Regex NaturalSortRegex = new(@"\d+|\D+", RegexOptions.Compiled); + + private static int NaturalCompare(string a, string b) + { + var partsA = NaturalSortRegex.Matches(a); + var partsB = NaturalSortRegex.Matches(b); + var len = Math.Min(partsA.Count, partsB.Count); + for (int i = 0; i < len; i++) + { + var sa = partsA[i].Value; + var sb = partsB[i].Value; + if (char.IsDigit(sa[0]) && char.IsDigit(sb[0])) + { + if (sa.Length != sb.Length) + return sa.Length.CompareTo(sb.Length); + var cmp = string.Compare(sa, sb, StringComparison.Ordinal); + if (cmp != 0) return cmp; + } + else + { + var cmp = string.Compare(sa, sb, StringComparison.OrdinalIgnoreCase); + if (cmp != 0) return cmp; + } + } + return partsA.Count.CompareTo(partsB.Count); + } + + private static DirectoryTree _instance; + public static DirectoryTree Instance => _instance ??= new DirectoryTree(); + + public AvaloniaList Rows { get; } = []; + + public AvaloniaList RecentRepos { get; } = []; + + private bool _hasRecentRepos; + public bool HasRecentRepos + { + get => _hasRecentRepos; + private set => SetProperty(ref _hasRecentRepos, value); + } + + private bool _isEmpty = true; + public bool IsEmpty + { + get => _isEmpty; + private set => SetProperty(ref _isEmpty, value); + } + + private bool _isScanning; + public bool IsScanning + { + get => _isScanning; + private set => SetProperty(ref _isScanning, value); + } + + private bool _recentLoaded; + private bool _stopRequested; + + private DirectoryTree() { } + + private void EnsureRecentLoaded() + { + if (_recentLoaded) + return; + _recentLoaded = true; + LoadRecentRepos(); + } + + public void RecordRecentRepo(string path) + { + EnsureRecentLoaded(); + + if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) + return; + + var name = Path.GetFileName(path); + + Dispatcher.UIThread.Post(() => + { + for (int i = RecentRepos.Count - 1; i >= 0; i--) + { + if (RecentRepos[i].Path.Equals(path, StringComparison.Ordinal)) + { + RecentRepos.RemoveAt(i); + break; + } + } + + RecentRepos.Insert(0, new RecentRepo(path, name)); + + while (RecentRepos.Count > 20) + RecentRepos.RemoveAt(RecentRepos.Count - 1); + + UpdateEmptyState(); + SaveRecentRepos(); + }); + } + + public async Task ScanAsync() + { + EnsureRecentLoaded(); + + if (_isScanning) + return; + + var cloneDir = Preferences.Instance.GitDefaultCloneDir; + if (string.IsNullOrEmpty(cloneDir) || !Directory.Exists(cloneDir)) + { + Dispatcher.UIThread.Post(() => + { + Rows.Clear(); + UpdateEmptyState(); + }); + return; + } + + _isScanning = true; + _stopRequested = false; + + try + { + var rootDir = new DirectoryInfo(cloneDir); + var rootNode = new DirectoryTreeNode(rootDir.FullName, rootDir.Name, false); + await BuildTreeAsync(rootNode, rootDir, 0); + PruneEmptyFolders(rootNode); + + var rows = new List(); + foreach (var child in rootNode.Children) + MakeTreeRows(rows, child, 0); + + Dispatcher.UIThread.Post(() => + { + Rows.Clear(); + Rows.AddRange(rows); + UpdateEmptyState(); + + if (rows.Count > 0) + SaveCache(); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[DirectoryTree] ScanAsync failed: {ex}"); + } + finally + { + _isScanning = false; + } + } + + public void ToggleNodeIsExpanded(DirectoryTreeNode node) + { + node.IsExpanded = !node.IsExpanded; + + var depth = node.Depth; + var idx = Rows.IndexOf(node); + if (idx == -1) + return; + + if (node.IsExpanded) + { + var subrows = new List(); + foreach (var child in node.Children) + MakeTreeRows(subrows, child, depth + 1); + Rows.InsertRange(idx + 1, subrows); + } + else + { + var removeCount = 0; + for (int i = idx + 1; i < Rows.Count; i++) + { + if (Rows[i].Depth <= depth) + break; + removeCount++; + } + Rows.RemoveRange(idx + 1, removeCount); + } + } + + public List GetAllRepositories() + { + var repos = new List(); + foreach (var row in Rows) + { + if (row.IsRepository) + repos.Add(row); + } + return repos; + } + + private void UpdateEmptyState() + { + HasRecentRepos = RecentRepos.Count > 0; + IsEmpty = Rows.Count == 0 && !HasRecentRepos; + } + + private void LoadRecentRepos() + { + foreach (var path in Preferences.Instance.RecentRepositories) + { + if (Directory.Exists(path)) + RecentRepos.Add(new RecentRepo(path, Path.GetFileName(path))); + } + UpdateEmptyState(); + } + + private void SaveRecentRepos() + { + Preferences.Instance.RecentRepositories.Clear(); + foreach (var repo in RecentRepos) + Preferences.Instance.RecentRepositories.Add(repo.Path); + Preferences.Instance.Save(); + } + + public void LoadFromCache() + { + EnsureRecentLoaded(); + + var cache = Preferences.Instance.DirectoryTreeCache; + if (cache == null || cache.Nodes.Count == 0) + return; + + var rows = new List(); + foreach (var cacheNode in cache.Nodes) + { + var node = ConvertCacheToNode(cacheNode); + if (cache.ExpandedPaths.Contains(node.Path)) + node.IsExpanded = true; + MakeTreeRows(rows, node, 0); + } + + Dispatcher.UIThread.Post(() => + { + Rows.Clear(); + Rows.AddRange(rows); + UpdateEmptyState(); + }); + } + + private void SaveCache() + { + var cache = new Models.DirectoryTreeCacheData + { + SourceDirectories = [Preferences.Instance.GitDefaultCloneDir], + ScanDepth = 5, + }; + + foreach (var row in Rows) + { + if (row.Depth == 0) + cache.Nodes.Add(ConvertNodeToCache(row)); + if (row.IsExpanded) + cache.ExpandedPaths.Add(row.Path); + } + + Preferences.Instance.DirectoryTreeCache = cache; + Preferences.Instance.Save(); + } + + private DirectoryTreeNode ConvertCacheToNode(Models.DirectoryTreeCacheNode cacheNode) + { + var node = new DirectoryTreeNode(cacheNode.Path, cacheNode.Name, cacheNode.IsRepository); + foreach (var child in cacheNode.Children) + node.Children.Add(ConvertCacheToNode(child)); + return node; + } + + private Models.DirectoryTreeCacheNode ConvertNodeToCache(DirectoryTreeNode node) + { + var cacheNode = new Models.DirectoryTreeCacheNode + { + Path = node.Path, + Name = node.Name, + IsRepository = node.IsRepository, + }; + foreach (var child in node.Children) + cacheNode.Children.Add(ConvertNodeToCache(child)); + return cacheNode; + } + + private void MakeTreeRows(List rows, DirectoryTreeNode node, int depth) + { + node.Depth = depth; + rows.Add(node); + + if (node.IsRepository || !node.IsExpanded) + return; + + foreach (var child in node.Children) + MakeTreeRows(rows, child, depth + 1); + } + + private void PruneEmptyFolders(DirectoryTreeNode node) + { + for (int i = node.Children.Count - 1; i >= 0; i--) + { + var child = node.Children[i]; + if (!child.IsRepository) + { + PruneEmptyFolders(child); + if (child.Children.Count == 0) + node.Children.RemoveAt(i); + } + } + } + + private async Task BuildTreeAsync(DirectoryTreeNode node, DirectoryInfo dir, int depth) + { + if (depth > 5 || _stopRequested) + return; + + string[] subdirs; + try + { + subdirs = dir.GetDirectories("*", new EnumerationOptions() + { + AttributesToSkip = FileAttributes.Hidden | FileAttributes.System, + IgnoreInaccessible = true, + }).Select(d => d.FullName).ToArray(); + Array.Sort(subdirs, (a, b) => NaturalCompare(Path.GetFileName(a), Path.GetFileName(b))); + } + catch (Exception ex) + { + var result = await AskScanErrorAsync(dir.FullName, ex.Message); + if (result == Views.ScanErrorResult.Stop) + _stopRequested = true; + return; + } + + foreach (var subdirPath in subdirs) + { + if (_stopRequested) + break; + + var subdirName = Path.GetFileName(subdirPath); + if (subdirName.StartsWith(".", StringComparison.Ordinal) || + subdirName.Equals("node_modules", StringComparison.Ordinal)) + continue; + + try + { + var gitDir = Path.Combine(subdirPath, ".git"); + var isRepo = Directory.Exists(gitDir) || File.Exists(gitDir); + + if (!isRepo) + { + isRepo = await new Commands.IsBareRepository(subdirPath).GetResultAsync(); + } + + var childNode = new DirectoryTreeNode(subdirPath, subdirName, isRepo); + node.Children.Add(childNode); + + if (!isRepo) + { + await BuildTreeAsync(childNode, new DirectoryInfo(subdirPath), depth + 1); + } + } + catch (Exception ex) + { + var result = await AskScanErrorAsync(subdirPath, ex.Message); + if (result == Views.ScanErrorResult.Stop) + { + _stopRequested = true; + break; + } + } + } + } + + private async Task AskScanErrorAsync(string path, string errorMessage) + { + return await Dispatcher.UIThread.InvokeAsync(() => + { + var dialog = new Views.ScanError(); + dialog.SetData(path, errorMessage); + return dialog.ShowDialog( + (App.Current?.ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow); + }); + } + } +} diff --git a/src/ViewModels/DirectoryTreeNode.cs b/src/ViewModels/DirectoryTreeNode.cs new file mode 100644 index 000000000..4e114f73a --- /dev/null +++ b/src/ViewModels/DirectoryTreeNode.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class DirectoryTreeNode : ObservableObject + { + public string Path { get; set; } + public string Name { get; set; } + public bool IsRepository { get; set; } + + public int Depth { get; set; } + + private bool _isExpanded; + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + public List Children { get; } = []; + + public DirectoryTreeNode(string path, string name, bool isRepository) + { + Path = path; + Name = name; + IsRepository = isRepository; + } + } +} diff --git a/src/ViewModels/LauncherPagesCommandPalette.cs b/src/ViewModels/LauncherPagesCommandPalette.cs index 265aa0704..3a9a03618 100644 --- a/src/ViewModels/LauncherPagesCommandPalette.cs +++ b/src/ViewModels/LauncherPagesCommandPalette.cs @@ -85,6 +85,7 @@ private void UpdateVisible() var repos = new List(); CollectVisibleRepository(repos, Preferences.Instance.RepositoryNodes); + CollectVisibleDirectoryTreeRepos(repos); var autoSelectPage = _selectedPage; var autoSelectRepo = _selectedRepo; @@ -185,6 +186,29 @@ private void CollectVisibleRepository(List outs, List outs) + { + var dirTreeRepos = DirectoryTree.Instance.GetAllRepositories(); + foreach (var dirNode in dirTreeRepos) + { + if (_opened.Contains(dirNode.Path)) + continue; + + if (string.IsNullOrEmpty(_searchFilter) || + dirNode.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase) || + dirNode.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + { + outs.Add(new RepositoryNode + { + Id = dirNode.Path, + Name = dirNode.Name, + IsRepository = true, + IsUnmanaged = true, + }); + } + } + } + private Launcher _launcher = null; private HashSet _opened = new HashSet(); private List _visiblePages = []; diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 58ac55503..596d3585e 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -468,6 +468,18 @@ public List RepositoryNodes set; } = []; + public List RecentRepositories + { + get; + set; + } = []; + + public Models.DirectoryTreeCacheData DirectoryTreeCache + { + get; + set; + } = new(); + public List Workspaces { get; diff --git a/src/ViewModels/RecentRepo.cs b/src/ViewModels/RecentRepo.cs new file mode 100644 index 000000000..0ee0ecd71 --- /dev/null +++ b/src/ViewModels/RecentRepo.cs @@ -0,0 +1,16 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class RecentRepo : ObservableObject + { + public string Path { get; set; } + public string Name { get; set; } + + public RecentRepo(string path, string name) + { + Path = path; + Name = name; + } + } +} diff --git a/src/ViewModels/RepositoryNode.cs b/src/ViewModels/RepositoryNode.cs index 9d7ab9deb..847896901 100644 --- a/src/ViewModels/RepositoryNode.cs +++ b/src/ViewModels/RepositoryNode.cs @@ -99,6 +99,7 @@ public void Open() } else if (Directory.Exists(_id)) { + DirectoryTree.Instance.RecordRecentRepo(_id); App.GetLauncher().OpenRepositoryInTab(this, null); } } diff --git a/src/Views/AssumeUnchangedManager.axaml b/src/Views/AssumeUnchangedManager.axaml index 9f4d007d5..7f24a08be 100644 --- a/src/Views/AssumeUnchangedManager.axaml +++ b/src/Views/AssumeUnchangedManager.axaml @@ -65,6 +65,7 @@ + diff --git a/src/Views/BlameCommandPalette.axaml b/src/Views/BlameCommandPalette.axaml index efb2000f8..31520be33 100644 --- a/src/Views/BlameCommandPalette.axaml +++ b/src/Views/BlameCommandPalette.axaml @@ -99,6 +99,10 @@ Width="12" Height="12" Data="{StaticResource Icons.File}" IsHitTestVisible="False"/> + + + + + + + + diff --git a/src/Views/FileHistoryCommandPalette.axaml b/src/Views/FileHistoryCommandPalette.axaml index 0027fcc2f..5beb08c6e 100644 --- a/src/Views/FileHistoryCommandPalette.axaml +++ b/src/Views/FileHistoryCommandPalette.axaml @@ -99,6 +99,10 @@ Width="12" Height="12" Data="{StaticResource Icons.File}" IsHitTestVisible="False"/> + + diff --git a/src/Views/Launcher.axaml b/src/Views/Launcher.axaml index 00aa09a22..bfd921a3c 100644 --- a/src/Views/Launcher.axaml +++ b/src/Views/Launcher.axaml @@ -12,7 +12,9 @@ Icon="/App.ico" Title="{Binding Title}" MinWidth="1024" MinHeight="600"> - + diff --git a/src/Views/Launcher.axaml.cs b/src/Views/Launcher.axaml.cs index e0708895c..a9ef8afb0 100644 --- a/src/Views/Launcher.axaml.cs +++ b/src/Views/Launcher.axaml.cs @@ -447,6 +447,39 @@ private async void OnShowNewVersion(object sender, RoutedEventArgs e) e.Handled = true; } + private void DragOverWindow(object sender, DragEventArgs e) + { + if (e.DataTransfer.Contains(DataFormat.File)) + { + e.DragEffects = DragDropEffects.Move; + e.Handled = true; + } + else + { + e.DragEffects = DragDropEffects.None; + e.Handled = true; + } + } + + private void DropOnWindow(object sender, DragEventArgs e) + { + if (DataContext is not ViewModels.Launcher launcher) + return; + + if (e.DataTransfer.Contains(DataFormat.File)) + { + var items = e.DataTransfer.TryGetFiles() ?? []; + foreach (var item in items) + { + var path = item.Path.LocalPath; + if (!string.IsNullOrEmpty(path)) + launcher.TryOpenRepositoryFromPath(path); + } + + e.Handled = true; + } + } + private GridLength _captionHeight = new(32); private WindowState _lastWindowState = WindowState.Normal; } diff --git a/src/Views/LauncherTabBar.axaml b/src/Views/LauncherTabBar.axaml index 76b4f7ddf..a51cec863 100644 --- a/src/Views/LauncherTabBar.axaml +++ b/src/Views/LauncherTabBar.axaml @@ -69,6 +69,7 @@ PointerReleased="OnPointerReleasedTab" ContextRequested="OnTabContextRequested" DragDrop.AllowDrop="True" + DragDrop.DragOver="DragOverTab" DragDrop.Drop="DropTab"> diff --git a/src/Views/LauncherTabBar.axaml.cs b/src/Views/LauncherTabBar.axaml.cs index 83826afce..b76cbdec3 100644 --- a/src/Views/LauncherTabBar.axaml.cs +++ b/src/Views/LauncherTabBar.axaml.cs @@ -259,6 +259,15 @@ private async void OnPointerMovedOverTab(object sender, PointerEventArgs e) e.Handled = true; } + private void DragOverTab(object sender, DragEventArgs e) + { + if (e.DataTransfer.Contains(_dndMainTabFormat)) + { + e.DragEffects = DragDropEffects.Move; + e.Handled = true; + } + } + private void DropTab(object sender, DragEventArgs e) { if (e.DataTransfer.TryGetValue(_dndMainTabFormat) is not { Length: > 0 } id) diff --git a/src/Views/OpenFileCommandPalette.axaml b/src/Views/OpenFileCommandPalette.axaml index 3c9b9f15c..a150b1f68 100644 --- a/src/Views/OpenFileCommandPalette.axaml +++ b/src/Views/OpenFileCommandPalette.axaml @@ -99,6 +99,10 @@ Width="12" Height="12" Data="{StaticResource Icons.File}" IsHitTestVisible="False"/> + + + + + + + + + + + + + + + + + + + + + +