using Sdt.Config; using Spectre.Console; namespace Sdt.Tui; public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceRoot, string currentProjectRoot) { private readonly WorkspaceConfig _workspace = workspace; private readonly string _workspaceRoot = workspaceRoot; private readonly string _currentProjectRoot = currentProjectRoot; /// /// Shows the project switcher. Returns the absolute path to the selected project root, /// or null if the user cancelled. /// public string? SelectProject() { while (true) { AnsiConsole.Clear(); AnsiConsole.Write(Theme.SectionRule("WORKSPACE — " + _workspace.Name)); AnsiConsole.WriteLine(); var projects = _workspace.Projects; if (projects.Count == 0) { AnsiConsole.MarkupLine(Theme.Warn("No projects defined in sdt-workspace.json.")); AnsiConsole.MarkupLine(Theme.Faint("Add entries to the \"projects\" array.")); if (AnsiConsole.Confirm($"[{Theme.Amber}]Add an external project now?[/]", defaultValue: true)) { AddExternalProject(); continue; } AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back...")); Console.ReadKey(intercept: true); return null; } // Build choice list with current project marked var choices = new List(); foreach (var proj in projects) { var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj); var devtoolPath = Path.Combine(absPath, "devtool.json"); var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase); var exists = File.Exists(devtoolPath); var label = isCurrent ? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/] [{Theme.GreenDim}](current)[/]" : $"[{Theme.Green}] {Markup.Escape(proj.Name)}[/]"; if (proj.Disabled) label += $" [{Theme.Amber}](disabled)[/]"; var desc = !exists ? $" [{Theme.Red}]devtool.json not found[/]" : string.IsNullOrWhiteSpace(proj.Description) ? $" [{Theme.GreenDim}]{Markup.Escape(absPath)}[/]" : $" [{Theme.GreenDim}]{Markup.Escape(proj.Description)}[/]"; if (proj.Tags.Count > 0) desc += $" [{Theme.GreenDim}]tags: {Markup.Escape(string.Join(",", proj.Tags))}[/]"; choices.Add(new WorkspaceMenuItem(label + "\n" + desc, absPath, exists && !isCurrent && !proj.Disabled)); } choices.Add(new WorkspaceMenuItem($"[{Theme.Green}]+ Add external project[/]", "__add__", true)); choices.Add(new WorkspaceMenuItem($"[{Theme.GreenDim}]← Cancel[/]", null, true)); // Show project table for overview var table = new Table() .Border(TableBorder.Rounded) .BorderStyle(Theme.DimStyle) .AddColumn(new TableColumn($"[{Theme.Amber}]Project[/]")) .AddColumn(new TableColumn($"[{Theme.Amber}]Path[/]")) .AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(12)); foreach (var proj in projects) { var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj); var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase); var hasConfig = File.Exists(Path.Combine(absPath, "devtool.json")); var status = proj.Disabled ? Theme.Warn("disabled") : hasConfig ? Theme.Ok("ready") : Theme.Fail("no config"); table.AddRow( isCurrent ? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/]" : Theme.G(proj.Name), Theme.Faint(proj.Path), status); } AnsiConsole.Write(table); AnsiConsole.WriteLine(); var switchable = choices.Where(c => c.Selectable).ToList(); if (switchable.Count == 1) // only Cancel { AnsiConsole.MarkupLine(Theme.Warn("No other projects available to switch to.")); AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back...")); Console.ReadKey(intercept: true); return null; } var selected = AnsiConsole.Prompt( new SelectionPrompt() .Title($"[{Theme.Green}]Switch to project:[/]") .PageSize(15) .UseConverter(m => m.Display) .AddChoices(switchable)); if (selected.AbsPath == "__add__") { AddExternalProject(); continue; } return selected.AbsPath; // null = cancelled } } private void AddExternalProject() { var raw = AnsiConsole.Ask($"[{Theme.Amber}]Project root path[/]"); if (string.IsNullOrWhiteSpace(raw)) return; var absolutePath = Path.GetFullPath(raw.Trim()); if (!Directory.Exists(absolutePath)) { AnsiConsole.MarkupLine(Theme.Fail("Directory does not exist.")); Thread.Sleep(700); return; } var configPath = Path.Combine(absolutePath, "devtool.json"); if (!File.Exists(configPath)) { var create = AnsiConsole.Confirm( $"[{Theme.Amber}]No devtool.json found. Create a minimal template?[/]", defaultValue: true); if (!create) return; File.WriteAllText(configPath, "{\n \"name\": \"SDT Project\",\n \"version\": \"0.1.0\",\n \"workflows\": []\n}\n"); } if (_workspace.Projects.Any(p => string.Equals(WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, p), absolutePath, StringComparison.OrdinalIgnoreCase))) { AnsiConsole.MarkupLine(Theme.Warn("Project already exists in workspace.")); Thread.Sleep(700); return; } var relativePath = Path.GetRelativePath(_workspaceRoot, absolutePath); var useRelative = !relativePath.StartsWith("..", StringComparison.OrdinalIgnoreCase) && !Path.IsPathRooted(relativePath); var projectEntry = new WorkspaceProject { Name = new DirectoryInfo(absolutePath).Name, Description = $"External project at {absolutePath}", Path = useRelative ? relativePath : absolutePath, Disabled = false, }; _workspace.Projects.Add(projectEntry); WorkspaceLoader.Save(_workspaceRoot, _workspace); AnsiConsole.MarkupLine(Theme.Ok("Project added to workspace.")); Thread.Sleep(700); } private sealed record WorkspaceMenuItem(string Display, string? AbsPath, bool Selectable); }