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);
}