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("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 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 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(); 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 GetFilteredTasks() { IEnumerable 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 options = new List { "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); } } } }