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/App.axaml.cs b/src/App.axaml.cs index 818a6b26d..38d448161 100644 --- a/src/App.axaml.cs +++ b/src/App.axaml.cs @@ -25,6 +25,10 @@ public static void Main(string[] args) { Native.OS.SetupBasicDirectories(); + var scanLogFromEnv = Environment.GetEnvironmentVariable("SOURCEGIT_SCAN_LOG"); + if (!string.IsNullOrEmpty(scanLogFromEnv)) + Models.ScanLog.Open(scanLogFromEnv); + AppDomain.CurrentDomain.UnhandledException += (_, e) => { Native.OS.LogException(e.ExceptionObject as Exception); @@ -37,7 +41,9 @@ public static void Main(string[] args) try { - if (TryLaunchAsRebaseTodoEditor(args, out int exitTodo)) + if (TryRunScanCommand(args, out int exitScan)) + Environment.Exit(exitScan); + else if (TryLaunchAsRebaseTodoEditor(args, out int exitTodo)) Environment.Exit(exitTodo); else if (TryLaunchAsRebaseMessageEditor(args, out int exitMessage)) Environment.Exit(exitMessage); @@ -278,6 +284,52 @@ public override void OnFrameworkInitializationCompleted() } #endregion + #region Scan Diagnostics + /// + /// Headless diagnostic entry: `SourceGit --scan-log [--log ]` scans the + /// directory with the exact scanner used by the project tree, writes a trace and + /// exits. Used to tell a "slow scan" apart from a "scan finds nothing" failure. + /// + private static bool TryRunScanCommand(string[] args, out int exitCode) + { + exitCode = 0; + + if (args == null || args.Length < 2 || !args[0].Equals("--scan-log", StringComparison.Ordinal)) + return false; + + var target = args[1]; + var logPath = Path.Combine(Path.GetTempPath(), "sourcegit_scan.log"); + if (args.Length >= 4 && args[2].Equals("--log", StringComparison.Ordinal)) + logPath = args[3]; + + Models.ScanLog.Open(logPath); + Models.ScanLog.Write($"headless scan: target='{target}' log='{logPath}'"); + + // In GUI mode this is assigned while loading preferences. Headless runs must do + // it explicitly, otherwise every git process fails to start and is dropped. + Native.OS.GitExecutable = Native.OS.FindGitExecutable(); + Models.ScanLog.Write($"headless scan: git executable='{Native.OS.GitExecutable}'"); + + try + { + var repos = Models.RepositoryScanner.ScanAsync(target, new Models.ScanLogProgress(), new Models.ScanLogProgress()) + .GetAwaiter() + .GetResult(); + + Models.ScanLog.Write($"headless scan: RESULT {repos.Count} repositories"); + foreach (var repo in repos) + Models.ScanLog.Write($"headless scan: {repo}"); + } + catch (Exception e) + { + Models.ScanLog.Write($"headless scan: EXCEPTION {e}"); + exitCode = 2; + } + + return true; + } + #endregion + #region Launch Ways private static bool TryLaunchAsRebaseTodoEditor(string[] args, out int exitCode) { diff --git a/src/Models/Project.cs b/src/Models/Project.cs new file mode 100644 index 000000000..e09efa8bb --- /dev/null +++ b/src/Models/Project.cs @@ -0,0 +1,26 @@ +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. + /// Nodes are expanded by default, so only the paths the user collapsed are stored. + /// + public class ProjectsSaveModel + { + public List Projects { get; set; } = new List(); + public List Collapsed { get; set; } = new List(); + } +} diff --git a/src/Models/RepositoryScanner.cs b/src/Models/RepositoryScanner.cs new file mode 100644 index 000000000..25711dd46 --- /dev/null +++ b/src/Models/RepositoryScanner.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +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. It is safe to call from a background thread. + /// + /// Unlike the upstream implementation, it does NOT spawn one `git` process per + /// subdirectory to detect bare repositories. That made a scan of a few thousand + /// folders take minutes. A bare repository is recognised by its layout instead + /// (HEAD + objects/ + refs/), and only real candidates are verified by git. + /// + public static class RepositoryScanner + { + public const int MAX_DEPTH = 5; + public const int VERIFY_CONCURRENCY = 4; + + /// + /// Returns absolute paths (forward-slash normalized) of the repositories found + /// under , including rootDir itself when it is one. + /// reports the directory currently being inspected; + /// is called as soon as a repository is + /// confirmed, so the caller can render it incrementally. + /// + public static async Task> ScanAsync(string rootDir, + IProgress progress = null, + IProgress repositoryFound = null, + CancellationToken token = default) + { + var found = new List(); + if (string.IsNullOrEmpty(rootDir) || !Directory.Exists(rootDir)) + { + ScanLog.Write($"scan: root not found '{rootDir}'"); + return found; + } + + ScanLog.Write($"scan: begin root='{rootDir}'"); + + var root = new DirectoryInfo(rootDir); + var opts = new EnumerationOptions + { + AttributesToSkip = FileAttributes.Hidden | FileAttributes.System, + IgnoreInaccessible = true, + }; + + var seen = new HashSet(); + var gate = new SemaphoreSlim(VERIFY_CONCURRENCY); + progress?.Report(root.FullName); + + if (await IsRepositoryAsync(root.FullName)) + { + var normalized = Normalize(root.FullName); + seen.Add(Key(normalized)); + found.Add(normalized); + repositoryFound?.Report(normalized); + ScanLog.Write($"scan: root itself is a repository '{normalized}'"); + return found; + } + + await ScanChildrenAsync(root, found, seen, opts, gate, progress, repositoryFound, 0, token); + ScanLog.Write($"scan: done, {found.Count} repositories"); + return found; + } + + public static string Normalize(string path) + { + return path.Replace('\\', '/').TrimEnd('/'); + } + + public static string Key(string path) + { + return OperatingSystem.IsLinux() ? Normalize(path) : Normalize(path).ToLower(CultureInfo.CurrentCulture); + } + + private static bool HasGitEntry(DirectoryInfo dir) + { + var gitDir = Path.Combine(dir.FullName, ".git"); + return Directory.Exists(gitDir) || File.Exists(gitDir); + } + + /// + /// Cheap layout check for a bare repository: `HEAD` file plus `objects/` and + /// `refs/` directories. This replaces a `git rev-parse` call per directory; + /// only directories passing this check are verified by git afterwards. + /// + private static bool LooksLikeBareRepository(DirectoryInfo dir) + { + return File.Exists(Path.Combine(dir.FullName, "HEAD")) && + Directory.Exists(Path.Combine(dir.FullName, "objects")) && + Directory.Exists(Path.Combine(dir.FullName, "refs")); + } + + 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().ConfigureAwait(false); + if (!test.IsSuccess || string.IsNullOrEmpty(test.StdOut)) + { + ScanLog.Write($"verify: NOT a repository '{dir}' (exitOk={test.IsSuccess}) stderr='{test.StdErr?.Trim()}'"); + return null; + } + + return Normalize(test.StdOut.Trim()); + } + + private static async Task ScanChildrenAsync(DirectoryInfo dir, List outs, HashSet seen, EnumerationOptions opts, + SemaphoreSlim gate, IProgress progress, IProgress repositoryFound, int depth, CancellationToken token) + { + if (token.IsCancellationRequested) + return; + + var candidates = new List(); + var bareCandidates = new List(); + var pending = new List(); + + 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; + + progress?.Report(subdir.FullName); + + if (HasGitEntry(subdir)) + { + ScanLog.Write($"visit: '{subdir.FullName}' has .git entry"); + candidates.Add(subdir); + } + else if (LooksLikeBareRepository(subdir)) + { + ScanLog.Write($"visit: '{subdir.FullName}' looks like a bare repository"); + bareCandidates.Add(subdir); + } + else + { + pending.Add(subdir); + } + } + + if (candidates.Count > 0) + { + var tasks = new Task[candidates.Count]; + for (var i = 0; i < candidates.Count; i++) + { + var path = candidates[i].FullName; + tasks[i] = Task.Run(async () => + { + await gate.WaitAsync(token).ConfigureAwait(false); + try + { + return await TryGetRepositoryRootAsync(path).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + }, token); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + + // Read results in index order so the output stays deterministic. + for (var i = 0; i < tasks.Length; i++) + { + var repo = tasks[i].Result; + if (repo != null && seen.Add(Key(repo))) + { + ScanLog.Write($"found: '{repo}' (root was '{candidates[i].FullName}')"); + outs.Add(repo); + repositoryFound?.Report(repo); + } + else if (repo != null) + { + ScanLog.Write($"found: duplicate skipped '{repo}'"); + } + } + } + + foreach (var bare in bareCandidates) + { + if (token.IsCancellationRequested) + return; + + if (!await new Commands.IsBareRepository(bare.FullName).GetResultAsync().ConfigureAwait(false)) + continue; + + var normalized = Normalize(bare.FullName); + if (seen.Add(Key(normalized))) + { + outs.Add(normalized); + repositoryFound?.Report(normalized); + } + } + + if (depth >= MAX_DEPTH) + return; + + foreach (var subdir in pending) + { + if (token.IsCancellationRequested) + return; + + // Do not descend into a repository: nested repositories are a separate + // concern and would produce a confusing tree. + await ScanChildrenAsync(subdir, outs, seen, opts, gate, progress, repositoryFound, depth + 1, token).ConfigureAwait(false); + } + } + } +} diff --git a/src/Models/ScanLog.cs b/src/Models/ScanLog.cs new file mode 100644 index 000000000..6ca60365d --- /dev/null +++ b/src/Models/ScanLog.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; + +namespace SourceGit.Models +{ + /// + /// Minimal append-only diagnostic log for repository scanning. Only enabled when a + /// log file has been opened (either by the `--scan-log` command line switch or the + /// SOURCEGIT_SCAN_LOG environment variable), so it costs nothing in normal use. + /// + public static class ScanLog + { + private static readonly object _lock = new object(); + private static StreamWriter _writer = null; + + public static bool Enabled + { + get + { + lock (_lock) + return _writer != null; + } + } + + public static void Open(string path) + { + lock (_lock) + { + try + { + _writer = new StreamWriter(path, false); + _writer.AutoFlush = true; + _writer.WriteLine($"# scan log opened at {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}"); + } + catch + { + _writer = null; + } + } + } + + public static void Write(string message) + { + lock (_lock) + { + if (_writer == null) + return; + + try + { + _writer.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {message}"); + } + catch + { + // Never let diagnostics break the scan. + } + } + } + } + + /// + /// IProgress implementation that writes straight into instead + /// of marshalling to a UI thread. Used by the headless `--scan-log` mode. + /// + public class ScanLogProgress : IProgress + { + public void Report(string value) + { + ScanLog.Write($"progress: {value}"); + } + } +} diff --git a/src/ViewModels/ProjectTreeNode.cs b/src/ViewModels/ProjectTreeNode.cs new file mode 100644 index 000000000..08602f160 --- /dev/null +++ b/src/ViewModels/ProjectTreeNode.cs @@ -0,0 +1,97 @@ +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 + { + if (SetProperty(ref _isRepository, value)) + OnPropertyChanged(nameof(IsDirectory)); + } + } + + /// + /// An intermediate directory: neither the project container nor a repository. + /// Used by the view to pick the folder icon instead of the repository icon. + /// + public bool IsDirectory + { + get => !IsProject && !_isRepository; + } + + 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..789c842d2 --- /dev/null +++ b/src/ViewModels/Projects.cs @@ -0,0 +1,522 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +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 bool IsScanning + { + get => _isScanning; + private set => SetProperty(ref _isScanning, 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; + + // Reuse a project that covers exactly the same directories: pressing "+ 工程" twice + // on the same folder used to produce "docs", "docs 2", "docs 3". + var existing = Roots.FirstOrDefault(r => r.Project != null && + r.Project.Directories.Count == dirs.Count && + r.Project.Directories.All(d => dirs.Contains(d, StringComparer.OrdinalIgnoreCase))); + if (existing != null) + { + Models.ScanLog.Write($"projects: reuse existing project '{existing.Name}'"); + return existing; + } + + var baseName = Path.GetFileName(dirs[0]); + if (string.IsNullOrEmpty(baseName)) + baseName = dirs[0]; + + var project = new Models.Project { Name = MakeUniqueName(baseName, null) }; + project.Directories.AddRange(dirs); + _projects.Add(project); + + var node = new ProjectTreeNode + { + Name = project.Name, + FullPath = string.Empty, + IsProject = true, + IsExpanded = true, + Project = project, + }; + node.PropertyChanged += OnNodePropertyChanged; + + 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 RenameProject(ProjectTreeNode projectNode, string newName) + { + if (projectNode?.Project == null) + return; + + var name = (newName ?? string.Empty).Trim(); + if (name.Length == 0 || string.Equals(name, projectNode.Project.Name, StringComparison.Ordinal)) + return; + + projectNode.Project.Name = MakeUniqueName(name, projectNode); + projectNode.Name = projectNode.Project.Name; + Save(); + } + + public void RemoveProject(ProjectTreeNode projectNode) + { + if (projectNode?.Project == null) + return; + + projectNode.PropertyChanged -= OnNodePropertyChanged; + _projects.Remove(projectNode.Project); + Roots.Remove(projectNode); + Save(); + } + + /// + /// Removes one configured directory from a project. + /// must be the tree node created for that directory (a direct child of the + /// project node), not an intermediate directory node. + /// + public void RemoveDirectory(ProjectTreeNode projectNode, ProjectTreeNode rootNode) + { + if (projectNode?.Project == null || rootNode?.Parent != projectNode) + return; + + var index = projectNode.Project.Directories.FindIndex(d => string.Equals(Models.RepositoryScanner.Key(d), Models.RepositoryScanner.Key(rootNode.FullPath), StringComparison.Ordinal)); + if (index < 0) + return; + + projectNode.Project.Directories.RemoveAt(index); + projectNode.Children.Remove(rootNode); + Save(); + } + + public void CancelScan() + { + _scanCts?.Cancel(); + } + + /// + /// Rebuilds one project's subtree. Scanning runs on the thread pool while the tree + /// is updated as repositories are found, so this must be called from the UI thread. + /// A running scan of the previous request is cancelled. + /// + public async Task RebuildAsync(ProjectTreeNode projectNode) + { + if (projectNode?.Project == null) + return; + + var previous = _scanCts; + var cts = new CancellationTokenSource(); + _scanCts = cts; + previous?.Cancel(); + previous?.Dispose(); + + var token = cts.Token; + var dirs = projectNode.Project.Directories.ToArray(); + var entries = new List(); + IsScanning = true; + Models.ScanLog.Write($"projects: rebuild '{projectNode.Name}' dirs={dirs.Length}"); + + try + { + projectNode.Children.Clear(); + + for (var i = 0; i < dirs.Length; i++) + { + if (token.IsCancellationRequested) + return; + + var index = i + 1; + var total = dirs.Length; + var dir = dirs[i]; + StatusText = $"{projectNode.Name}: 扫描 ({index}/{total}) {dir}"; + + if (!Directory.Exists(dir)) + { + var missing = new ProjectTreeNode + { + Name = $"{dir} (不存在)", + FullPath = dir, + Parent = projectNode, + }; + projectNode.Children.Add(missing); + continue; + } + + // The directory node is added before scanning starts, so the user sees the + // tree structure immediately and repositories appear under it as they are + // confirmed instead of all at once at the end. + var entry = new ScanEntry(dir, new ProjectTreeNode + { + // The first level under a project is the configured directory itself, + // named by its full path: using only the last segment made it collide + // with the project name ("docs → docs"). + Name = Models.RepositoryScanner.Normalize(dir), + FullPath = dir, + Parent = projectNode, + IsExpanded = !_collapsed.Contains(Models.RepositoryScanner.Key(dir)), + }); + entry.Node.PropertyChanged += OnNodePropertyChanged; + projectNode.Children.Add(entry.Node); + entries.Add(entry); + + var found = new Progress(repo => + { + if (!entry.Reconciled && Roots.Contains(projectNode)) + InsertRepository(entry.Node, dir, repo); + }); + + var progress = new Progress(current => + StatusText = $"{projectNode.Name}: 扫描 ({index}/{total}) {Shorten(current)}"); + + entry.Repositories = await Task.Run(() => Models.RepositoryScanner.ScanAsync(dir, progress, found, token), token); + Models.ScanLog.Write($"projects: dir '{dir}' returned {entry.Repositories.Count} repositories"); + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception e) + { + Native.OS.LogException(e); + StatusText = $"扫描失败: {e.Message}"; + return; + } + finally + { + if (ReferenceEquals(_scanCts, cts)) + { + _scanCts = null; + IsScanning = false; + } + } + + // Drop the result when the project was removed while the scan was running. + if (token.IsCancellationRequested || !Roots.Contains(projectNode)) + return; + + FinalizeTree(projectNode, entries, dirs.Length); + } + + /// + /// Rebuilds every project once, so that the tree appears without the user + /// having to press 扫描 on every project after a restart. + /// + public void StartRestore() + { + if (_restored) + return; + + _restored = true; + _ = RestoreAllAsync(); + } + + private async Task RestoreAllAsync() + { + // Sequentially, not concurrently: RebuildAsync cancels the previous scan of the + // same instance, so firing them all at once would cancel every one but the last. + foreach (var root in Roots.ToList()) + { + await RebuildAsync(root); + await Task.Yield(); + } + } + + private Projects() + { + Load(); + } + + /// + /// Makes sure the incremental insertions are complete (progress callbacks can + /// still be queued when the scan task returns), applies the "directory itself is + /// a repository" case, sorts children and refreshes the status line. + /// + private void FinalizeTree(ProjectTreeNode projectNode, List entries, int totalDirs) + { + _suppressCollapseSave = true; + try + { + var total = 0; + foreach (var entry in entries) + { + entry.Reconciled = true; + total += entry.Repositories.Count; + + foreach (var repo in entry.Repositories) + InsertRepository(entry.Node, entry.Dir, repo); + + if (entry.Repositories.Count == 1 && + string.Equals(entry.Repositories[0], Models.RepositoryScanner.Normalize(entry.Dir), StringComparison.OrdinalIgnoreCase)) + { + entry.Node.IsRepository = true; + } + + SortChildren(entry.Node); + } + + StatusText = $"{projectNode.Name}: {entries.Count}/{totalDirs} 个目录 / {total} 个仓库"; + Models.ScanLog.Write($"projects: finalize '{projectNode.Name}' dirs={entries.Count}/{totalDirs} repos={total} topChildren={projectNode.Children.Count}"); + } + finally + { + _suppressCollapseSave = false; + } + } + + private static string Shorten(string path) + { + var normalized = Models.RepositoryScanner.Normalize(path); + var index = normalized.LastIndexOf('/'); + return index < 0 || index + 1 >= normalized.Length ? normalized : normalized.Substring(index + 1); + } + + private string MakeUniqueName(string baseName, ProjectTreeNode except) + { + var name = baseName; + var i = 2; + while (_projects.Any(p => !ReferenceEquals(p, except?.Project) && string.Equals(p.Name, name, StringComparison.CurrentCultureIgnoreCase))) + name = $"{baseName} {i++}"; + return name; + } + + private void InsertRepository(ProjectTreeNode rootNode, string rootDir, string repoPath) + { + Models.ScanLog.Write($"projects: insert '{repoPath}' under '{rootDir}'"); + + 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(Path.Combine(new[] { rootDir }.Concat(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 = !_collapsed.Contains(Models.RepositoryScanner.Key(full)), + }; + child.PropertyChanged += OnNodePropertyChanged; + 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 void OnNodePropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(ProjectTreeNode.IsExpanded) || _suppressCollapseSave) + return; + + if (sender is not ProjectTreeNode node || string.IsNullOrEmpty(node.FullPath)) + return; + + var key = Models.RepositoryScanner.Key(node.FullPath); + if (node.IsExpanded) + _collapsed.Remove(key); + else + _collapsed.Add(key); + + Save(); + } + + 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 key in model.Collapsed ?? new List()) + { + if (!string.IsNullOrEmpty(key)) + _collapsed.Add(key); + } + + 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, + }; + node.PropertyChanged += OnNodePropertyChanged; + Roots.Add(node); + } + } + catch (Exception e) + { + Native.OS.LogException(e); + } + } + + public void Save() + { + try + { + var model = new Models.ProjectsSaveModel + { + Projects = _projects, + Collapsed = _collapsed.ToList(), + }; + + 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 sealed class ScanEntry + { + public ScanEntry(string dir, ProjectTreeNode node) + { + Dir = dir; + Node = node; + Repositories = new List(); + } + + public string Dir { get; } + public ProjectTreeNode Node { get; } + public List Repositories { get; set; } + public bool Reconciled { get; set; } + } + + private readonly List _projects = new List(); + private readonly HashSet _collapsed = new HashSet(); + private CancellationTokenSource _scanCts = null; + private bool _suppressCollapseSave = false; + private bool _restored = false; + private string _statusText = string.Empty; + private bool _isScanning = false; + } +} 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 @@ - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +