From f8c1a6ffc8f036a450b39e2e2e7c5e3d69fe512a Mon Sep 17 00:00:00 2001 From: ncnnnnn Date: Thu, 12 Nov 2026 08:38:29 +0800 Subject: [PATCH 1/2] feat: add left-side project tree Projects are user-defined containers of directories. The tree scans each directory for git repositories (same pruning rules as ScanRepositories but without the side effect of registering nodes into Preferences), keeps only the directory chains that actually lead to a repository, and opens a repository in a regular tab on double click - opening an already opened repository activates its tab. The panel is mounted as a new column of the existing page area in Launcher.axaml; Preferences and the repository pages are untouched. Data is persisted to a separate projects.json so the upstream Preferences class keeps its diff-free state. --- src/App.JsonCodeGen.cs | 1 + src/Models/Project.cs | 24 +++ src/Models/RepositoryScanner.cs | 124 ++++++++++++ src/ViewModels/ProjectTreeNode.cs | 84 +++++++++ src/ViewModels/Projects.cs | 301 ++++++++++++++++++++++++++++++ src/Views/Launcher.axaml | 18 +- src/Views/ProjectTree.axaml | 49 +++++ src/Views/ProjectTree.axaml.cs | 109 +++++++++++ 8 files changed, 703 insertions(+), 7 deletions(-) create mode 100644 src/Models/Project.cs create mode 100644 src/Models/RepositoryScanner.cs create mode 100644 src/ViewModels/ProjectTreeNode.cs create mode 100644 src/ViewModels/Projects.cs create mode 100644 src/Views/ProjectTree.axaml create mode 100644 src/Views/ProjectTree.axaml.cs diff --git a/src/App.JsonCodeGen.cs b/src/App.JsonCodeGen.cs index c63fccc29..ed2614ed5 100644 --- a/src/App.JsonCodeGen.cs +++ b/src/App.JsonCodeGen.cs @@ -73,6 +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.ProjectsSaveModel))] [JsonSerializable(typeof(List))] internal partial class JsonCodeGen : JsonSerializerContext { } } diff --git a/src/Models/Project.cs b/src/Models/Project.cs new file mode 100644 index 000000000..41b8d219c --- /dev/null +++ b/src/Models/Project.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + /// + /// A "project" is a user-defined container that owns one or more directories. + /// Repositories are discovered by scanning those directories and are NOT stored + /// here - they are rebuilt on every scan so that the file system stays the source + /// of truth. + /// + public class Project + { + public string Name { get; set; } = string.Empty; + public List Directories { get; set; } = new List(); + } + + /// + /// Root object persisted to projects.json. + /// + public class ProjectsSaveModel + { + public List Projects { get; set; } = new List(); + } +} diff --git a/src/Models/RepositoryScanner.cs b/src/Models/RepositoryScanner.cs new file mode 100644 index 000000000..9c8bbb789 --- /dev/null +++ b/src/Models/RepositoryScanner.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SourceGit.Models +{ + /// + /// Filesystem scanner that discovers git repositories under a root directory. + /// It is intentionally free of side effects: it does not touch Preferences and + /// does not register anything, unlike ViewModels.ScanRepositories which both + /// scans and imports. + /// + public static class RepositoryScanner + { + public const int MAX_DEPTH = 5; + + /// + /// Returns absolute paths (forward-slash normalized) of the repositories found + /// under , including rootDir itself when it is one. + /// + public static async Task> ScanAsync(string rootDir, CancellationToken token = default) + { + var found = new List(); + if (string.IsNullOrEmpty(rootDir) || !Directory.Exists(rootDir)) + return found; + + var root = new DirectoryInfo(rootDir); + var opts = new EnumerationOptions + { + AttributesToSkip = FileAttributes.Hidden | FileAttributes.System, + IgnoreInaccessible = true, + }; + + var seen = new HashSet(); + if (await IsRepositoryAsync(root.FullName)) + { + var normalized = Normalize(root.FullName); + seen.Add(Key(normalized)); + found.Add(normalized); + return found; + } + + await ScanChildrenAsync(root, found, seen, opts, 0, token); + return found; + } + + public static string Normalize(string path) + { + return path.Replace('\\', '/').TrimEnd('/'); + } + + private static string Key(string normalizedPath) + { + return OperatingSystem.IsLinux() ? normalizedPath : normalizedPath.ToLower(CultureInfo.CurrentCulture); + } + + private static async Task IsRepositoryAsync(string dir) + { + var gitDir = Path.Combine(dir, ".git"); + if (!Directory.Exists(gitDir) && !File.Exists(gitDir)) + return false; + return await TryGetRepositoryRootAsync(dir) != null; + } + + /// + /// Runs `git rev-parse --show-toplevel` to make sure the directory is really a + /// repository root (a plain .git folder is not enough, e.g. worktrees and + /// submodules report a different top level). + /// + private static async Task TryGetRepositoryRootAsync(string dir) + { + var test = await new Commands.QueryRepositoryRootPath(dir).GetResultAsync(); + if (!test.IsSuccess || string.IsNullOrEmpty(test.StdOut)) + return null; + + return Normalize(test.StdOut.Trim()); + } + + private static async Task ScanChildrenAsync(DirectoryInfo dir, List outs, HashSet seen, EnumerationOptions opts, int depth, CancellationToken token) + { + if (token.IsCancellationRequested) + return; + + foreach (var subdir in dir.GetDirectories("*", opts)) + { + if (token.IsCancellationRequested) + return; + + // Same pruning rules as ViewModels.ScanRepositories: hidden folders and + // node_modules never contain repositories worth listing. + if (subdir.Name.StartsWith(".", StringComparison.Ordinal) || + subdir.Name.Equals("node_modules", StringComparison.Ordinal)) + continue; + + var normalized = Normalize(subdir.FullName); + + var gitDir = Path.Combine(subdir.FullName, ".git"); + if (Directory.Exists(gitDir) || File.Exists(gitDir)) + { + var repo = await TryGetRepositoryRootAsync(subdir.FullName); + if (repo != null && seen.Add(Key(repo))) + outs.Add(repo); + + // Do not descend into a repository: nested repositories are a + // separate concern and would produce a confusing tree. + continue; + } + + if (await new Commands.IsBareRepository(subdir.FullName).GetResultAsync()) + { + if (seen.Add(Key(normalized))) + outs.Add(normalized); + continue; + } + + if (depth < MAX_DEPTH) + await ScanChildrenAsync(subdir, outs, seen, opts, depth + 1, token); + } + } + } +} diff --git a/src/ViewModels/ProjectTreeNode.cs b/src/ViewModels/ProjectTreeNode.cs new file mode 100644 index 000000000..a4b171564 --- /dev/null +++ b/src/ViewModels/ProjectTreeNode.cs @@ -0,0 +1,84 @@ +using System.IO; + +using Avalonia.Collections; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + /// + /// Node of the left-side project tree. Three kinds of nodes exist: + /// project (container), directory (only kept when it leads to repositories) and + /// repository (double-click opens a regular repository tab). + /// + public class ProjectTreeNode : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public string FullPath + { + get => _fullPath; + set => SetProperty(ref _fullPath, value); + } + + public bool IsProject + { + get; + set; + } = false; + + public bool IsRepository + { + get => _isRepository; + set => SetProperty(ref _isRepository, value); + } + + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + public ProjectTreeNode Parent + { + get; + set; + } = null; + + public Models.Project Project + { + get; + set; + } = null; + + public AvaloniaList Children + { + get; + } = new AvaloniaList(); + + public bool IsMissing + { + get => _isRepository && !Directory.Exists(_fullPath); + } + + /// + /// Opens the repository in a regular tab. The upstream implementation already + /// deduplicates by path, so double-clicking an already opened repository just + /// activates its tab instead of creating a second one. + /// + public void Open() + { + if (_isRepository && Directory.Exists(_fullPath)) + App.GetLauncher().OpenRepositoryInTab(_fullPath, null); + } + + private string _name = string.Empty; + private string _fullPath = string.Empty; + private bool _isRepository = false; + private bool _isExpanded = false; + } +} diff --git a/src/ViewModels/Projects.cs b/src/ViewModels/Projects.cs new file mode 100644 index 000000000..2134b47bc --- /dev/null +++ b/src/ViewModels/Projects.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + +using Avalonia.Collections; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + /// + /// Owns the user's projects and the left-side tree built from them. + /// Persisted to its own projects.json so that the big Preferences class (and + /// therefore the most conflict-prone file with upstream) is not touched. + /// + public class Projects : ObservableObject + { + public static Projects Instance + { + get; + } = new Projects(); + + public AvaloniaList Roots + { + get; + } = new AvaloniaList(); + + public string StatusText + { + get => _statusText; + set => SetProperty(ref _statusText, value); + } + + public ProjectTreeNode CreateProject(IEnumerable directories) + { + var dirs = directories.Select(Models.RepositoryScanner.Normalize).Where(d => !string.IsNullOrEmpty(d)).ToList(); + if (dirs.Count == 0) + return null; + + var baseName = Path.GetFileName(dirs[0]); + if (string.IsNullOrEmpty(baseName)) + baseName = dirs[0]; + + var project = new Models.Project { Name = MakeUniqueName(baseName) }; + project.Directories.AddRange(dirs); + _projects.Add(project); + + var node = new ProjectTreeNode + { + Name = project.Name, + FullPath = string.Empty, + IsProject = true, + IsExpanded = true, + Project = project, + }; + + Roots.Add(node); + Save(); + return node; + } + + public bool AddDirectories(ProjectTreeNode projectNode, IEnumerable directories) + { + if (projectNode?.Project == null) + return false; + + var added = false; + foreach (var dir in directories.Select(Models.RepositoryScanner.Normalize)) + { + if (string.IsNullOrEmpty(dir)) + continue; + + var existing = projectNode.Project.Directories.FirstOrDefault(d => string.Equals(d, dir, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + continue; + + projectNode.Project.Directories.Add(dir); + added = true; + } + + if (added) + Save(); + + return added; + } + + public void RemoveProject(ProjectTreeNode projectNode) + { + if (projectNode?.Project == null) + return; + + _projects.Remove(projectNode.Project); + Roots.Remove(projectNode); + Save(); + } + + public async Task RebuildAsync(ProjectTreeNode projectNode) + { + if (projectNode?.Project == null) + return; + + projectNode.Children.Clear(); + + var total = 0; + foreach (var dir in projectNode.Project.Directories.ToArray()) + { + if (!Directory.Exists(dir)) + { + var missing = new ProjectTreeNode + { + Name = $"{dir} (不存在)", + FullPath = dir, + Parent = projectNode, + }; + projectNode.Children.Add(missing); + continue; + } + + StatusText = $"{projectNode.Name}: 正在扫描 {dir} ..."; + var repos = await Models.RepositoryScanner.ScanAsync(dir); + total += repos.Count; + + var rootNode = new ProjectTreeNode + { + Name = DisplayNameOf(dir), + FullPath = dir, + Parent = projectNode, + IsExpanded = true, + }; + + if (repos.Count == 1 && string.Equals(repos[0], dir, StringComparison.OrdinalIgnoreCase)) + { + rootNode.IsRepository = true; + } + else + { + foreach (var repo in repos) + InsertRepository(rootNode, dir, repo); + } + + projectNode.Children.Add(rootNode); + SortChildren(rootNode); + } + + StatusText = $"{projectNode.Name}: {projectNode.Project.Directories.Count} 个目录 / {total} 个仓库"; + } + + public void ForgetAllCachedTree() + { + Roots.Clear(); + } + + private Projects() + { + Load(); + } + + private static string DisplayNameOf(string dir) + { + var name = Path.GetFileName(dir); + return string.IsNullOrEmpty(name) ? dir : name; + } + + private string MakeUniqueName(string baseName) + { + var name = baseName; + var i = 2; + while (_projects.Any(p => string.Equals(p.Name, name, StringComparison.CurrentCultureIgnoreCase))) + name = $"{baseName} {i++}"; + return name; + } + + private static void InsertRepository(ProjectTreeNode rootNode, string rootDir, string repoPath) + { + var normalizedRoot = Models.RepositoryScanner.Normalize(rootDir); + if (string.Equals(repoPath, normalizedRoot, StringComparison.OrdinalIgnoreCase)) + { + rootNode.IsRepository = true; + return; + } + + var relative = Path.GetRelativePath(rootDir, repoPath.Replace('/', Path.DirectorySeparatorChar)); + var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (segments.Length == 0) + return; + + var cursor = rootNode; + for (var i = 0; i < segments.Length; i++) + { + if (string.IsNullOrEmpty(segments[i])) + continue; + + var isLeaf = i == segments.Length - 1; + var full = Models.RepositoryScanner.Normalize(Path.Combine(rootDir, Path.Combine(segments.Take(i + 1).ToArray()))); + + var child = cursor.Children.FirstOrDefault(c => string.Equals(c.Name, segments[i], StringComparison.CurrentCultureIgnoreCase)); + if (child == null) + { + child = new ProjectTreeNode + { + Name = segments[i], + FullPath = full, + Parent = cursor, + IsRepository = isLeaf, + IsExpanded = true, + }; + cursor.Children.Add(child); + } + else if (isLeaf) + { + child.IsRepository = true; + } + + cursor = child; + } + } + + private static void SortChildren(ProjectTreeNode node) + { + var sorted = node.Children.OrderBy(c => c.IsRepository ? 1 : 0) + .ThenBy(c => c.Name, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + node.Children.Clear(); + foreach (var child in sorted) + node.Children.Add(child); + + foreach (var child in node.Children) + SortChildren(child); + } + + private string StorePath + { + get => Path.Combine(Native.OS.BasicDirectories.ConfigDir, "projects.json"); + } + + private void Load() + { + try + { + var path = StorePath; + if (!File.Exists(path)) + return; + + var model = JsonSerializer.Deserialize(File.ReadAllText(path), JsonCodeGen.Default.ProjectsSaveModel); + if (model?.Projects == null) + return; + + foreach (var project in model.Projects) + { + if (string.IsNullOrEmpty(project.Name)) + continue; + + _projects.Add(project); + + var node = new ProjectTreeNode + { + Name = project.Name, + FullPath = string.Empty, + IsProject = true, + IsExpanded = true, + Project = project, + }; + Roots.Add(node); + } + } + catch (Exception e) + { + Native.OS.LogException(e); + } + } + + public void Save() + { + try + { + var model = new Models.ProjectsSaveModel { Projects = _projects }; + var text = JsonSerializer.Serialize(model, JsonCodeGen.Default.ProjectsSaveModel); + + var finalFile = StorePath; + var tmpFile = Path.Combine(Native.OS.BasicDirectories.ConfigDir, "projects_tmp.json"); + File.WriteAllText(tmpFile, text); + + if (File.Exists(finalFile)) + File.Delete(finalFile); + + File.Move(tmpFile, finalFile); + } + catch (Exception e) + { + Native.OS.LogException(e); + } + } + + private readonly List _projects = new List(); + private string _statusText = string.Empty; + } +} diff --git a/src/Views/Launcher.axaml b/src/Views/Launcher.axaml index ced304b54..a8b803718 100644 --- a/src/Views/Launcher.axaml +++ b/src/Views/Launcher.axaml @@ -150,13 +150,17 @@ - - - - - - - + + + + + + + + + + + + + + + +