Files
TheDeclineOfWarriors/Assets/Editor/Tasks/TaskBoardWindow.cs
T

678 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace Project.Tasks.Editor
{
internal sealed class TaskBoardWindow : EditorWindow
{
private const float ColumnWidth = 290f;
private const float DetailsWidth = 420f;
private TaskBoardData data;
private Vector2 boardScroll;
private Vector2 detailsScroll;
private string searchText = string.Empty;
private string areaFilter = "All";
private string priorityFilter = "All";
private bool showDone = true;
private TaskRecord selectedTask;
private string saveMessage = string.Empty;
private MessageType saveMessageType = MessageType.Info;
private string editStatus;
private string editPriority;
private string editOwner;
private string editExecutionTime;
private string editSummary;
private GUIStyle readOnlyWrappedTextArea;
private GUIStyle priorityBadgeStyle;
private TaskRecord pressedTask;
private Vector2 pressedMousePosition;
[MenuItem("Tools/Tasks/Kanban Board")]
public static void Open()
{
GetWindow<TaskBoardWindow>("Task Board");
}
private void OnEnable()
{
Reload();
}
private void OnGUI()
{
DrawToolbar();
if (data == null)
{
EditorGUILayout.HelpBox("Данные задач еще не загружены.", MessageType.Info);
return;
}
if (data.Warnings.Count > 0)
{
EditorGUILayout.HelpBox(string.Join("\n", data.Warnings), MessageType.Warning);
}
if (!string.IsNullOrEmpty(saveMessage))
{
EditorGUILayout.HelpBox(saveMessage, saveMessageType);
}
EditorGUILayout.BeginHorizontal();
DrawBoard();
DrawDetails();
EditorGUILayout.EndHorizontal();
}
private void DrawToolbar()
{
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(60f)))
{
Reload();
}
if (GUILayout.Button("Open Index", EditorStyles.toolbarButton, GUILayout.Width(80f)))
{
string indexPath = data != null ? data.IndexPath : TaskBoardService.NormalizePath(System.IO.Path.Combine(TaskBoardService.GetProjectRoot(), "docs", "tasks", "Index.md"));
TaskBoardService.OpenInDefaultApp(indexPath);
}
GUILayout.Space(8f);
GUILayout.Label("Search", GUILayout.Width(45f));
searchText = GUILayout.TextField(searchText, GUI.skin.FindStyle("ToolbarSeachTextField") ?? EditorStyles.toolbarTextField, GUILayout.MinWidth(150f));
GUILayout.Space(8f);
areaFilter = DrawFilterPopup("Area", areaFilter, BuildAreaOptions(), 120f);
priorityFilter = DrawFilterPopup("Priority", priorityFilter, BuildPriorityOptions(), 110f);
showDone = GUILayout.Toggle(showDone, "Show Done", EditorStyles.toolbarButton, GUILayout.Width(85f));
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
}
private string DrawFilterPopup(string label, string currentValue, string[] options, float width)
{
GUILayout.Label(label, GUILayout.Width(36f));
int currentIndex = Array.IndexOf(options, currentValue);
if (currentIndex < 0)
{
currentIndex = 0;
}
int nextIndex = EditorGUILayout.Popup(currentIndex, options, EditorStyles.toolbarPopup, GUILayout.Width(width));
return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)];
}
private void DrawBoard()
{
List<TaskRecord> filteredTasks = GetFilteredTasks();
EditorGUILayout.BeginVertical(GUILayout.ExpandWidth(true));
boardScroll = EditorGUILayout.BeginScrollView(boardScroll, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
EditorGUILayout.BeginHorizontal();
foreach (string status in TaskBoardConstants.Statuses)
{
if (!showDone && string.Equals(status, "done", StringComparison.Ordinal))
{
continue;
}
DrawColumn(status, filteredTasks
.Where(task => string.Equals(task.Status, status, StringComparison.Ordinal))
.OrderBy(task => TaskBoardService.GetPrioritySortOrder(task.Priority))
.ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase)
.ToList());
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndScrollView();
EditorGUILayout.EndVertical();
}
private void DrawColumn(string status, List<TaskRecord> tasks)
{
EditorGUILayout.BeginVertical("box", GUILayout.Width(ColumnWidth), GUILayout.ExpandHeight(true));
EditorGUILayout.LabelField(ObjectNames.NicifyVariableName(status), EditorStyles.boldLabel);
EditorGUILayout.LabelField(tasks.Count + " task(s)", EditorStyles.miniLabel);
EditorGUILayout.Space(6f);
Rect headerRect = GUILayoutUtility.GetLastRect();
Rect lastRect = headerRect;
if (tasks.Count == 0)
{
EditorGUILayout.HelpBox("Нет задач по текущему фильтру.", MessageType.None);
lastRect = GUILayoutUtility.GetLastRect();
}
foreach (TaskRecord task in tasks)
{
DrawTaskCard(task);
lastRect = GUILayoutUtility.GetLastRect();
EditorGUILayout.Space(6f);
}
GUILayout.FlexibleSpace();
EditorGUILayout.EndVertical();
Rect columnRect = new Rect(headerRect.xMin - 6f, headerRect.yMin - 28f, ColumnWidth, Mathf.Max(110f, lastRect.yMax - headerRect.yMin + 36f));
HandleColumnDrop(columnRect, status);
}
private void DrawTaskCard(TaskRecord task)
{
Color previousBackground = GUI.backgroundColor;
GUI.backgroundColor = selectedTask == task ? new Color(0.72f, 0.82f, 1f) : GetStatusColor(task.Status);
EditorGUILayout.BeginVertical("box");
GUI.backgroundColor = previousBackground;
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(task.Id, EditorStyles.boldLabel);
GUILayout.FlexibleSpace();
DrawPriorityBadge(task.Priority);
EditorGUILayout.EndHorizontal();
Rect firstRect = GUILayoutUtility.GetLastRect();
EditorGUILayout.LabelField(string.IsNullOrEmpty(task.Title) ? task.Id : task.Title, EditorStyles.wordWrappedLabel);
if (!string.IsNullOrEmpty(task.Summary))
{
EditorGUILayout.LabelField(task.Summary, EditorStyles.wordWrappedMiniLabel);
}
EditorGUILayout.Space(4f);
EditorGUILayout.LabelField("Area: " + SafeValue(task.Area) + " Owner: " + SafeValue(task.Owner), EditorStyles.miniLabel);
EditorGUILayout.LabelField("Time: " + SafeValue(task.ExecutionTime), EditorStyles.miniLabel);
if (task.ValidationMessages.Count > 0)
{
EditorGUILayout.HelpBox(task.ValidationMessages[0], MessageType.Warning);
}
if (GUILayout.Button("Open Details"))
{
SelectTask(task);
}
EditorGUILayout.EndVertical();
Rect lastRect = GUILayoutUtility.GetLastRect();
Rect cardRect = new Rect(firstRect.xMin - 6f, firstRect.yMin - 6f, firstRect.width + 12f, Mathf.Max(44f, lastRect.yMax - firstRect.yMin + 12f));
HandleCardDrag(task, cardRect);
}
private void DrawDetails()
{
EditorGUILayout.BeginVertical("box", GUILayout.Width(DetailsWidth), GUILayout.ExpandHeight(true));
EditorGUILayout.LabelField("Task Details", EditorStyles.boldLabel);
if (selectedTask == null)
{
EditorGUILayout.HelpBox("Выберите карточку, чтобы посмотреть детали задачи и изменить статус.", MessageType.Info);
EditorGUILayout.EndVertical();
return;
}
detailsScroll = EditorGUILayout.BeginScrollView(detailsScroll, GUILayout.ExpandHeight(true));
EditorGUILayout.LabelField(selectedTask.Id, EditorStyles.boldLabel);
EditorGUILayout.LabelField(string.IsNullOrEmpty(selectedTask.Title) ? selectedTask.Id : selectedTask.Title, EditorStyles.wordWrappedLabel);
EditorGUILayout.Space(8f);
if (selectedTask.ValidationMessages.Count > 0)
{
EditorGUILayout.HelpBox(string.Join("\n", selectedTask.ValidationMessages), MessageType.Warning);
}
EditorGUILayout.BeginHorizontal();
using (new EditorGUI.DisabledScope(!selectedTask.FileExists))
{
if (GUILayout.Button("Open Task File"))
{
TaskBoardService.OpenInDefaultApp(selectedTask.AbsoluteFilePath);
}
}
using (new EditorGUI.DisabledScope(selectedTask.FileExists))
{
if (GUILayout.Button("Create From Template"))
{
CreateSelectedTaskFromTemplate();
}
}
if (GUILayout.Button("Open Index"))
{
TaskBoardService.OpenInDefaultApp(data.IndexPath);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(8f);
DrawEditableFields();
EditorGUILayout.Space(12f);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Priority", GUILayout.Width(80f));
DrawPriorityBadge(selectedTask.Priority);
EditorGUILayout.EndHorizontal();
DrawReadOnlyField("File", selectedTask.RelativeFilePath);
DrawReadOnlyField("Created", selectedTask.Created);
DrawReadOnlyField("Updated", selectedTask.Updated);
DrawReadOnlyField("Area", selectedTask.Area);
EditorGUILayout.Space(10f);
DrawSection("Why", selectedTask.Why);
DrawSection("Expected Outcome", selectedTask.ExpectedOutcome);
DrawSection("Current Context", selectedTask.CurrentContext);
DrawSection("Acceptance Criteria", selectedTask.AcceptanceCriteria);
DrawSection("Verification", selectedTask.Verification);
DrawSection("Risks / Open Questions", selectedTask.Risks);
DrawSection("Human Decisions Needed", selectedTask.HumanDecisions);
DrawSection("Decision Log", selectedTask.DecisionLog);
DrawSection("Handoff Notes", selectedTask.HandoffNotes);
EditorGUILayout.EndScrollView();
EditorGUILayout.EndVertical();
}
private void DrawEditableFields()
{
EditorGUILayout.LabelField("Quick Edit", EditorStyles.boldLabel);
editStatus = DrawStringPopup("Status", editStatus, TaskBoardConstants.Statuses);
editPriority = DrawStringPopup("Priority", editPriority, TaskBoardConstants.Priorities);
editOwner = EditorGUILayout.TextField("Owner", editOwner ?? string.Empty);
editExecutionTime = EditorGUILayout.TextField("Execution Time", editExecutionTime ?? string.Empty);
EditorGUILayout.LabelField("Summary");
editSummary = EditorGUILayout.TextArea(editSummary ?? string.Empty, GUILayout.MinHeight(60f));
bool hasChanges = HasChanges();
bool executionTimeValid = TaskBoardService.IsValidExecutionTime(editExecutionTime);
if (!executionTimeValid)
{
EditorGUILayout.HelpBox("execution_time должен быть в формате Jira, например 1d6h30m, и быть кратным 30 минутам.", MessageType.Warning);
}
using (new EditorGUI.DisabledScope(!hasChanges || !executionTimeValid))
{
if (GUILayout.Button("Save Changes"))
{
SaveSelectedTask();
}
}
}
private string DrawStringPopup(string label, string currentValue, string[] options)
{
int index = Array.IndexOf(options, currentValue);
if (index < 0)
{
index = 0;
}
int nextIndex = EditorGUILayout.Popup(label, index, options);
return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)];
}
private void DrawReadOnlyField(string label, string value)
{
EditorGUILayout.LabelField(label, string.IsNullOrEmpty(value) ? "-" : value);
}
private void DrawSection(string title, string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return;
}
EditorGUILayout.LabelField(title, EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.TextArea(content, GetReadOnlyWrappedTextArea(), GUILayout.MinHeight(52f));
}
EditorGUILayout.Space(6f);
}
private GUIStyle GetReadOnlyWrappedTextArea()
{
if (readOnlyWrappedTextArea == null)
{
readOnlyWrappedTextArea = new GUIStyle(EditorStyles.textArea)
{
wordWrap = true,
};
}
return readOnlyWrappedTextArea;
}
private GUIStyle GetPriorityBadgeStyle()
{
if (priorityBadgeStyle == null)
{
priorityBadgeStyle = new GUIStyle(EditorStyles.miniBoldLabel)
{
alignment = TextAnchor.MiddleCenter,
padding = new RectOffset(8, 8, 3, 3),
normal = { textColor = Color.white },
};
}
return priorityBadgeStyle;
}
private void DrawPriorityBadge(string priority)
{
GUIStyle style = GetPriorityBadgeStyle();
Vector2 contentSize = style.CalcSize(new GUIContent(priority));
Rect rect = GUILayoutUtility.GetRect(contentSize.x + 18f, 22f, GUILayout.Width(contentSize.x + 18f), GUILayout.Height(22f));
EditorGUI.DrawRect(rect, GetPriorityColor(priority));
GUI.Label(rect, priority, style);
}
private void HandleCardDrag(TaskRecord task, Rect cardRect)
{
Event current = Event.current;
if (current.type == EventType.MouseDown && current.button == 0 && cardRect.Contains(current.mousePosition))
{
pressedTask = task;
pressedMousePosition = current.mousePosition;
}
if (pressedTask != task)
{
return;
}
if (current.type == EventType.MouseDrag && (current.mousePosition - pressedMousePosition).sqrMagnitude > 16f)
{
DragAndDrop.PrepareStartDrag();
DragAndDrop.objectReferences = Array.Empty<UnityEngine.Object>();
DragAndDrop.SetGenericData("TaskBoard.TaskId", task.Id);
DragAndDrop.StartDrag(task.Id);
current.Use();
}
if (current.type == EventType.MouseUp)
{
pressedTask = null;
}
}
private void HandleColumnDrop(Rect columnRect, string status)
{
Event current = Event.current;
if (!columnRect.Contains(current.mousePosition))
{
return;
}
if (current.type == EventType.DragUpdated)
{
if (GetDraggedTask() != null)
{
DragAndDrop.visualMode = DragAndDropVisualMode.Move;
current.Use();
Repaint();
}
return;
}
if (current.type == EventType.DragPerform)
{
TaskRecord task = GetDraggedTask();
if (task == null)
{
return;
}
DragAndDrop.AcceptDrag();
current.Use();
pressedTask = null;
if (!string.Equals(task.Status, status, StringComparison.Ordinal))
{
MoveTaskToStatus(task, status);
}
}
}
private TaskRecord GetDraggedTask()
{
string draggedTaskId = DragAndDrop.GetGenericData("TaskBoard.TaskId") as string;
if (string.IsNullOrEmpty(draggedTaskId) || data == null)
{
return null;
}
return data.Tasks.FirstOrDefault(task => string.Equals(task.Id, draggedTaskId, StringComparison.OrdinalIgnoreCase));
}
private void MoveTaskToStatus(TaskRecord task, string status)
{
string selectedId = task.Id;
task.Status = status;
string error;
if (!TaskBoardService.Save(data, task, out error))
{
saveMessage = "Не удалось изменить статус перетаскиванием: " + error;
saveMessageType = MessageType.Error;
return;
}
saveMessage = task.FileExists
? "Статус задачи обновлен перетаскиванием."
: "Статус задачи обновлен в Index.md. Task-файл не найден, поэтому его метаданные не обновлялись.";
saveMessageType = MessageType.Info;
Reload(selectedId);
}
private void SelectTask(TaskRecord task)
{
selectedTask = task;
editStatus = task.Status;
editPriority = task.Priority;
editOwner = task.Owner;
editExecutionTime = task.ExecutionTime;
editSummary = task.Summary;
}
private void SaveSelectedTask()
{
if (selectedTask == null)
{
return;
}
string selectedId = selectedTask.Id;
selectedTask.Status = editStatus;
selectedTask.Priority = editPriority;
selectedTask.Owner = string.IsNullOrWhiteSpace(editOwner) ? "unassigned" : editOwner.Trim();
selectedTask.ExecutionTime = editExecutionTime.Trim();
selectedTask.Summary = (editSummary ?? string.Empty).Trim();
string error;
if (!TaskBoardService.Save(data, selectedTask, out error))
{
saveMessage = "Не удалось сохранить задачу: " + error;
saveMessageType = MessageType.Error;
return;
}
saveMessage = selectedTask.FileExists
? "Изменения сохранены в Index.md и task-файл."
: "Изменения сохранены в Index.md. Task-файл не найден, поэтому его метаданные не обновлялись.";
saveMessageType = MessageType.Info;
Reload(selectedId);
}
private void CreateSelectedTaskFromTemplate()
{
if (selectedTask == null)
{
return;
}
string selectedId = selectedTask.Id;
string error;
if (!TaskBoardService.CreateTaskFileFromTemplate(data, selectedTask, out error))
{
saveMessage = "Не удалось создать task-файл из шаблона: " + error;
saveMessageType = MessageType.Error;
return;
}
saveMessage = "Task-файл создан по шаблону.";
saveMessageType = MessageType.Info;
Reload(selectedId);
}
private bool HasChanges()
{
if (selectedTask == null)
{
return false;
}
return !string.Equals(selectedTask.Status, editStatus, StringComparison.Ordinal)
|| !string.Equals(selectedTask.Priority, editPriority, StringComparison.Ordinal)
|| !string.Equals(selectedTask.Owner ?? string.Empty, editOwner ?? string.Empty, StringComparison.Ordinal)
|| !string.Equals(selectedTask.ExecutionTime ?? string.Empty, editExecutionTime ?? string.Empty, StringComparison.Ordinal)
|| !string.Equals(selectedTask.Summary ?? string.Empty, editSummary ?? string.Empty, StringComparison.Ordinal);
}
private void Reload(string selectedId = null)
{
data = TaskBoardService.Load();
if (data == null || data.Tasks.Count == 0)
{
selectedTask = null;
return;
}
TaskRecord nextSelection = null;
if (!string.IsNullOrEmpty(selectedId))
{
nextSelection = data.Tasks.FirstOrDefault(task => string.Equals(task.Id, selectedId, StringComparison.OrdinalIgnoreCase));
}
if (nextSelection == null && selectedTask != null)
{
nextSelection = data.Tasks.FirstOrDefault(task => string.Equals(task.Id, selectedTask.Id, StringComparison.OrdinalIgnoreCase));
}
if (nextSelection == null)
{
nextSelection = data.Tasks[0];
}
SelectTask(nextSelection);
}
private List<TaskRecord> GetFilteredTasks()
{
IEnumerable<TaskRecord> query = data.Tasks;
if (!string.IsNullOrWhiteSpace(searchText))
{
string search = searchText.Trim();
query = query.Where(task =>
ContainsIgnoreCase(task.Id, search)
|| ContainsIgnoreCase(task.Title, search)
|| ContainsIgnoreCase(task.Summary, search)
|| ContainsIgnoreCase(task.Area, search)
|| ContainsIgnoreCase(task.Owner, search));
}
if (!string.Equals(areaFilter, "All", StringComparison.Ordinal))
{
query = query.Where(task => string.Equals(task.Area, areaFilter, StringComparison.OrdinalIgnoreCase));
}
if (!string.Equals(priorityFilter, "All", StringComparison.Ordinal))
{
query = query.Where(task => string.Equals(task.Priority, priorityFilter, StringComparison.OrdinalIgnoreCase));
}
return query.ToList();
}
private string[] BuildAreaOptions()
{
if (data == null || data.Tasks.Count == 0)
{
return new[] { "All" };
}
List<string> options = new List<string> { "All" };
options.AddRange(data.Tasks.Select(task => task.Area).Where(area => !string.IsNullOrWhiteSpace(area)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(area => area, StringComparer.OrdinalIgnoreCase));
return options.ToArray();
}
private string[] BuildPriorityOptions()
{
return new[] { "All", "Lowest", "Low", "Medium", "High", "Highest" };
}
private static bool ContainsIgnoreCase(string source, string value)
{
return !string.IsNullOrEmpty(source) && source.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0;
}
private static string SafeValue(string value)
{
return string.IsNullOrEmpty(value) ? "-" : value;
}
private static Color GetStatusColor(string status)
{
switch (status)
{
case "proposal":
return new Color(0.94f, 0.94f, 0.94f);
case "ready":
return new Color(0.85f, 0.96f, 0.85f);
case "in_progress":
return new Color(0.84f, 0.91f, 1f);
case "blocked":
return new Color(1f, 0.9f, 0.82f);
case "done":
return new Color(0.88f, 0.88f, 0.88f);
default:
return Color.white;
}
}
private static Color GetPriorityColor(string priority)
{
switch (TaskBoardService.NormalizePriority(priority))
{
case "Lowest":
return new Color(0.42f, 0.52f, 0.61f);
case "Low":
return new Color(0.45f, 0.45f, 0.45f);
case "Medium":
return new Color(0.19f, 0.48f, 0.84f);
case "High":
return new Color(0.88f, 0.54f, 0.16f);
case "Highest":
return new Color(0.78f, 0.2f, 0.2f);
default:
return new Color(0.35f, 0.35f, 0.35f);
}
}
}
}