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 DefaultDetailsWidth = 420f; private const float MinDetailsWidth = 320f; private const float MinBoardWidth = 420f; private const float SplitterWidth = 5f; private const float MinButtonSize = 9f; private const string CustomOwnerOption = ""; private const string DetailsWidthPrefsKey = "Project.Tasks.Editor.TaskBoardWindow.DetailsWidth"; private const double DeleteConfirmTimeoutSeconds = 3.0; private TaskBoardData data; private Vector2 boardScroll; private Vector2 detailsScroll; private string searchText = string.Empty; private string sortOption = "Priority Desc"; private bool filtersExpanded = true; private readonly HashSet selectedStatuses = new HashSet(StringComparer.OrdinalIgnoreCase); private readonly HashSet selectedPriorities = new HashSet(StringComparer.OrdinalIgnoreCase); private readonly HashSet selectedAreas = new HashSet(StringComparer.OrdinalIgnoreCase); private readonly HashSet selectedOwners = new HashSet(StringComparer.OrdinalIgnoreCase); private readonly HashSet selectedFileStates = new HashSet(StringComparer.OrdinalIgnoreCase); private readonly HashSet selectedWarningStates = new HashSet(StringComparer.OrdinalIgnoreCase); private TaskRecord selectedTask; private string saveMessage = string.Empty; private MessageType saveMessageType = MessageType.Info; private string editStatus; private string editPriority; private string editOwner; private bool editOwnerIsCustom; private string editExecutionTime; private string editIndexSummary; private string editTaskSummary; private string editWhy; private string editExpectedOutcome; private string editCurrentContext; private string editAcceptanceCriteria; private string editVerification; private string editRisks; private string editHumanDecisions; private string editDecisionLog; private string editHandoffNotes; private GUIStyle readOnlyWrappedTextArea; private GUIStyle priorityBadgeStyle; private GUIStyle compactButtonStyle; private TaskRecord pressedTask; private Vector2 pressedMousePosition; private float detailsWidth = DefaultDetailsWidth; private bool isResizingDetails; private string pendingDeleteTaskId; private double pendingDeleteUntil; [MenuItem("Tools/Tasks/Kanban Board")] public static void Open() { GetWindow("Task Board"); } private void OnEnable() { detailsWidth = EditorPrefs.GetFloat(DetailsWidthPrefsKey, DefaultDetailsWidth); Reload(); } private void OnDisable() { EditorPrefs.SetFloat(DetailsWidthPrefsKey, detailsWidth); } private void OnGUI() { ResetDeleteConfirmationIfExpired(); 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); } DrawSummaryBar(); DrawFilterPanel(); float clampedDetailsWidth = Mathf.Clamp(detailsWidth, MinDetailsWidth, Mathf.Max(MinDetailsWidth, position.width - MinBoardWidth)); if (!Mathf.Approximately(clampedDetailsWidth, detailsWidth)) { detailsWidth = clampedDetailsWidth; } EditorGUILayout.BeginHorizontal(); DrawBoard(position.width - detailsWidth - SplitterWidth - 24f); DrawDetailsSplitter(); DrawDetails(detailsWidth); EditorGUILayout.EndHorizontal(); } private void DrawToolbar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); if (GUILayout.Button("Reload", EditorStyles.toolbarButton)) { Reload(selectedTask != null ? selectedTask.Id : null); } if (GUILayout.Button("Index", EditorStyles.toolbarButton)) { string indexPath = data != null ? data.IndexPath : TaskBoardService.NormalizePath(System.IO.Path.Combine(TaskBoardService.GetProjectRoot(), "docs", "tasks", "Index.md")); TaskBoardService.OpenInDefaultApp(indexPath); } if (GUILayout.Button("Owners", EditorStyles.toolbarButton)) { OpenOwnersConfig(); } GUILayout.Space(8f); GUILayout.Label("Search", GUILayout.Width(45f)); searchText = GUILayout.TextField(searchText, GUI.skin.FindStyle("ToolbarSeachTextField") ?? EditorStyles.toolbarTextField, GUILayout.MinWidth(180f)); GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); } private void DrawSummaryBar() { if (data == null) { return; } List filteredTasks = GetFilteredTasks(); int totalMinutes = TaskBoardService.SumEstimatedMinutes(filteredTasks); int warnings = filteredTasks.Count(task => task.ValidationMessages.Count > 0); int missingFiles = filteredTasks.Count(task => !task.FileExists); EditorGUILayout.BeginVertical("box"); EditorGUILayout.LabelField( "Visible Tasks: " + filteredTasks.Count + " / " + data.Tasks.Count + " | Visible Time: " + TaskBoardService.FormatMinutesForDisplay(totalMinutes) + " | Warnings: " + warnings + " | Missing Files: " + missingFiles, EditorStyles.wordWrappedMiniLabel); EditorGUILayout.BeginHorizontal(); GUILayout.Label("Sort", GUILayout.Width(32f)); sortOption = DrawPopup(sortOption, BuildSortOptions(), 160f); GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); EditorGUILayout.EndVertical(); } private void DrawFilterPanel() { EditorGUILayout.BeginVertical("box"); filtersExpanded = EditorGUILayout.Foldout(filtersExpanded, "Filters", true); if (filtersExpanded) { DrawSelectionGroup("Statuses", TaskBoardConstants.Statuses, selectedStatuses); DrawSelectionGroup("Priorities", TaskBoardConstants.Priorities, selectedPriorities); DrawSelectionGroup("Areas", BuildAreaOptions(), selectedAreas); DrawSelectionGroup("Owners", BuildOwnerFilterOptions(), selectedOwners); DrawSelectionGroup("Files", new[] { "Existing", "Missing" }, selectedFileStates); DrawSelectionGroup("Warnings", new[] { "Warnings", "Clean" }, selectedWarningStates); } EditorGUILayout.EndVertical(); } private void DrawSelectionGroup(string label, string[] options, HashSet selectedValues) { if (options == null || options.Length == 0) { return; } GUIStyle buttonStyle = GetCompactButtonStyle(); EditorGUILayout.BeginVertical("box"); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField(label, EditorStyles.boldLabel); if (GUILayout.Button("All", buttonStyle)) { SetAll(selectedValues, options, true); } if (GUILayout.Button("Nothing", buttonStyle)) { selectedValues.Clear(); } EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); for (int i = 0; i < options.Length; i++) { string option = options[i]; bool current = selectedValues.Contains(option); bool next = GUILayout.Toggle(current, option, buttonStyle); if (next != current) { if (next) { selectedValues.Add(option); } else { selectedValues.Remove(option); } } if ((i + 1) % 4 == 0) { EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); } } EditorGUILayout.EndHorizontal(); EditorGUILayout.EndVertical(); } private void DrawBoard(float width) { List filteredTasks = GetFilteredTasks(); EditorGUILayout.BeginVertical(GUILayout.Width(Mathf.Max(MinBoardWidth, width)), GUILayout.ExpandHeight(true)); boardScroll = EditorGUILayout.BeginScrollView(boardScroll, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); EditorGUILayout.BeginHorizontal(); foreach (string status in TaskBoardConstants.Statuses) { DrawColumn(status, filteredTasks.Where(task => string.Equals(task.Status, status, StringComparison.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(status, EditorStyles.boldLabel); EditorGUILayout.LabelField(tasks.Count + " task(s)", EditorStyles.miniLabel); EditorGUILayout.LabelField(TaskBoardService.FormatMinutesForDisplay(TaskBoardService.SumEstimatedMinutes(tasks)), 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.IndexSummary)) { EditorGUILayout.LabelField(task.IndexSummary, EditorStyles.wordWrappedMiniLabel); } EditorGUILayout.Space(4f); EditorGUILayout.LabelField("Area: " + SafeValue(task.Area) + " Owner: " + SafeValue(task.Owner), EditorStyles.miniLabel); EditorGUILayout.LabelField("Time: " + GetFormattedTime(task), EditorStyles.miniLabel); if (task.ValidationMessages.Count > 0) { EditorGUILayout.HelpBox(task.ValidationMessages[0], MessageType.Warning); } if (GUILayout.Button("Details", GetCompactButtonStyle())) { 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(float width) { EditorGUILayout.BeginVertical("box", GUILayout.Width(width), 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); } if (!selectedTask.FileExists) { EditorGUILayout.HelpBox("Task-файл отсутствует. Поля из task-файла недоступны до создания файла по шаблону.", MessageType.Info); } GUIStyle btn = GetCompactButtonStyle(); EditorGUILayout.BeginHorizontal(); using (new EditorGUI.DisabledScope(!selectedTask.FileExists)) { if (GUILayout.Button("File", btn, GUILayout.Width(75f), GUILayout.Height(18f))) { TaskBoardService.OpenInDefaultApp(selectedTask.AbsoluteFilePath); } } using (new EditorGUI.DisabledScope(selectedTask.FileExists)) { if (GUILayout.Button("Create", btn, GUILayout.Width(75f), GUILayout.Height(18f))) { CreateSelectedTaskFromTemplate(); } } if (GUILayout.Button("Index", btn, GUILayout.Width(75f), GUILayout.Height(18f))) { TaskBoardService.OpenInDefaultApp(data.IndexPath); } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(4f); DrawDeleteButton(); EditorGUILayout.Space(8f); DrawEditableFields(); EditorGUILayout.Space(12f); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Priority", GUILayout.Width(80f)); DrawPriorityBadge(selectedTask.Priority); EditorGUILayout.EndHorizontal(); DrawReadOnlyField("Estimate", GetFormattedTime(selectedTask)); DrawReadOnlyField("File", selectedTask.RelativeFilePath); DrawReadOnlyField("Created", selectedTask.Created); DrawReadOnlyField("Updated", selectedTask.Updated); DrawReadOnlyField("Area", selectedTask.Area); EditorGUILayout.Space(10f); EditorGUILayout.LabelField("Task File Sections", EditorStyles.boldLabel); DrawEditableSection("Why", ref editWhy, selectedTask.FileExists); DrawEditableSection("Expected Outcome", ref editExpectedOutcome, selectedTask.FileExists); DrawEditableSection("Current Context", ref editCurrentContext, selectedTask.FileExists); DrawEditableSection("Acceptance Criteria", ref editAcceptanceCriteria, selectedTask.FileExists); DrawEditableSection("Verification", ref editVerification, selectedTask.FileExists); DrawEditableSection("Risks / Open Questions", ref editRisks, selectedTask.FileExists); DrawEditableSection("Human Decisions Needed", ref editHumanDecisions, selectedTask.FileExists); DrawEditableSection("Decision Log", ref editDecisionLog, selectedTask.FileExists); DrawEditableSection("Handoff Notes", ref editHandoffNotes, selectedTask.FileExists); EditorGUILayout.EndScrollView(); EditorGUILayout.EndVertical(); } private void DrawEditableFields() { EditorGUILayout.LabelField("Index Fields", EditorStyles.boldLabel); editStatus = DrawStringPopup("Status", editStatus, TaskBoardConstants.Statuses); editPriority = DrawStringPopup("Priority", editPriority, TaskBoardConstants.Priorities); DrawOwnerField(); editExecutionTime = EditorGUILayout.TextField("Execution Time", editExecutionTime ?? string.Empty); string normalizedExecutionTime; int estimatedMinutes; if (TaskBoardService.TryNormalizeExecutionTime(editExecutionTime, out normalizedExecutionTime, out estimatedMinutes)) { editExecutionTime = normalizedExecutionTime; } EditorGUILayout.LabelField("Estimate", TaskBoardService.IsValidExecutionTime(editExecutionTime) ? TaskBoardService.FormatExecutionTimeForDisplay(editExecutionTime) : "-"); EditorGUILayout.LabelField("Index Summary"); editIndexSummary = EditorGUILayout.TextArea(editIndexSummary ?? string.Empty, GUILayout.MinHeight(60f)); EditorGUILayout.Space(8f); EditorGUILayout.LabelField("Task File Fields", EditorStyles.boldLabel); using (new EditorGUI.DisabledScope(!selectedTask.FileExists)) { EditorGUILayout.LabelField("Task Summary"); editTaskSummary = EditorGUILayout.TextArea(editTaskSummary ?? 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", GetCompactButtonStyle())) { SaveSelectedTask(); } } } private void DrawDeleteButton() { if (selectedTask == null) { return; } bool isPending = string.Equals(pendingDeleteTaskId, selectedTask.Id, StringComparison.OrdinalIgnoreCase) && EditorApplication.timeSinceStartup <= pendingDeleteUntil; Color previous = GUI.backgroundColor; GUI.backgroundColor = isPending ? new Color(0.85f, 0.3f, 0.3f) : new Color(0.7f, 0.25f, 0.25f); if (GUILayout.Button(isPending ? "Sure?" : "Delete Task", GetCompactButtonStyle(), GUILayout.Width(75f), GUILayout.Height(18f))) { if (isPending) { DeleteSelectedTask(); } else { pendingDeleteTaskId = selectedTask.Id; pendingDeleteUntil = EditorApplication.timeSinceStartup + DeleteConfirmTimeoutSeconds; } } GUI.backgroundColor = previous; } 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 string DrawPopup(string currentValue, string[] options, float width) { int index = Array.IndexOf(options, currentValue); if (index < 0) { index = 0; } int nextIndex = EditorGUILayout.Popup(index, options, EditorStyles.toolbarPopup, GUILayout.Width(width)); 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 DrawEditableSection(string title, ref string content, bool enabled) { EditorGUILayout.LabelField(title, EditorStyles.boldLabel); using (new EditorGUI.DisabledScope(!enabled)) { content = EditorGUILayout.TextArea(enabled ? (content ?? string.Empty) : string.Empty, 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 GUIStyle GetCompactButtonStyle() { if (compactButtonStyle == null) { compactButtonStyle = new GUIStyle("Button") { alignment = TextAnchor.MiddleCenter, padding = new RectOffset(4, 4, 2, 2), margin = new RectOffset(1, 1, 1, 1), stretchWidth = true, }; } return compactButtonStyle; } 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 DrawOwnerField() { string[] presetOptions = BuildOwnerPresetOptions(); int currentIndex = GetOwnerPopupIndex(presetOptions); int nextIndex = EditorGUILayout.Popup("Owner", currentIndex, presetOptions); if (nextIndex >= 0 && nextIndex < presetOptions.Length) { string selectedValue = presetOptions[nextIndex]; if (string.Equals(selectedValue, CustomOwnerOption, StringComparison.Ordinal)) { editOwnerIsCustom = true; } else { editOwnerIsCustom = false; editOwner = selectedValue; } } if (editOwnerIsCustom) { editOwner = EditorGUILayout.TextField("Custom Owner", editOwner ?? string.Empty); } } 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-файл не найден, поэтому поля task-файла не обновлялись."; saveMessageType = MessageType.Info; Reload(selectedId); } private void SelectTask(TaskRecord task) { ResetDeleteConfirmation(); selectedTask = task; TaskBoardService.LoadTaskDetails(selectedTask); editStatus = selectedTask.Status; editPriority = selectedTask.Priority; editOwner = selectedTask.Owner; editOwnerIsCustom = data != null && !data.OwnerPresets.Any(owner => string.Equals(owner, selectedTask.Owner, StringComparison.OrdinalIgnoreCase)); editExecutionTime = TaskBoardService.NormalizeExecutionTime(selectedTask.ExecutionTime); editIndexSummary = selectedTask.IndexSummary; editTaskSummary = selectedTask.TaskSummary; editWhy = selectedTask.Why; editExpectedOutcome = selectedTask.ExpectedOutcome; editCurrentContext = selectedTask.CurrentContext; editAcceptanceCriteria = selectedTask.AcceptanceCriteria; editVerification = selectedTask.Verification; editRisks = selectedTask.Risks; editHumanDecisions = selectedTask.HumanDecisions; editDecisionLog = selectedTask.DecisionLog; editHandoffNotes = selectedTask.HandoffNotes; } private void SaveSelectedTask() { if (selectedTask == null) { return; } string selectedId = selectedTask.Id; selectedTask.Status = TaskBoardService.NormalizeStatus(editStatus); selectedTask.Priority = editPriority; selectedTask.Owner = string.IsNullOrWhiteSpace(editOwner) ? "unassigned" : editOwner.Trim(); selectedTask.ExecutionTime = TaskBoardService.NormalizeExecutionTime(editExecutionTime); selectedTask.IndexSummary = (editIndexSummary ?? string.Empty).Trim(); if (selectedTask.FileExists) { selectedTask.TaskSummary = (editTaskSummary ?? string.Empty).Trim(); selectedTask.Why = (editWhy ?? string.Empty).Trim(); selectedTask.ExpectedOutcome = (editExpectedOutcome ?? string.Empty).Trim(); selectedTask.CurrentContext = (editCurrentContext ?? string.Empty).Trim(); selectedTask.AcceptanceCriteria = (editAcceptanceCriteria ?? string.Empty).Trim(); selectedTask.Verification = (editVerification ?? string.Empty).Trim(); selectedTask.Risks = (editRisks ?? string.Empty).Trim(); selectedTask.HumanDecisions = (editHumanDecisions ?? string.Empty).Trim(); selectedTask.DecisionLog = (editDecisionLog ?? string.Empty).Trim(); selectedTask.HandoffNotes = (editHandoffNotes ?? 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-файл не найден, поэтому поля 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 void DeleteSelectedTask() { if (selectedTask == null) { return; } string deletedId = selectedTask.Id; string error; if (!TaskBoardService.DeleteTask(data, selectedTask, out error)) { saveMessage = "Не удалось удалить задачу: " + error; saveMessageType = MessageType.Error; return; } ResetDeleteConfirmation(); saveMessage = "Задача " + deletedId + " удалена."; saveMessageType = MessageType.Info; selectedTask = null; Reload(); } private bool HasChanges() { if (selectedTask == null) { return false; } bool taskSummaryChanged = selectedTask.FileExists && !string.Equals(selectedTask.TaskSummary ?? string.Empty, editTaskSummary ?? string.Empty, StringComparison.Ordinal); bool taskBodyChanged = selectedTask.FileExists && (!string.Equals(selectedTask.Why ?? string.Empty, editWhy ?? string.Empty, StringComparison.Ordinal) || !string.Equals(selectedTask.ExpectedOutcome ?? string.Empty, editExpectedOutcome ?? string.Empty, StringComparison.Ordinal) || !string.Equals(selectedTask.CurrentContext ?? string.Empty, editCurrentContext ?? string.Empty, StringComparison.Ordinal) || !string.Equals(selectedTask.AcceptanceCriteria ?? string.Empty, editAcceptanceCriteria ?? string.Empty, StringComparison.Ordinal) || !string.Equals(selectedTask.Verification ?? string.Empty, editVerification ?? string.Empty, StringComparison.Ordinal) || !string.Equals(selectedTask.Risks ?? string.Empty, editRisks ?? string.Empty, StringComparison.Ordinal) || !string.Equals(selectedTask.HumanDecisions ?? string.Empty, editHumanDecisions ?? string.Empty, StringComparison.Ordinal) || !string.Equals(selectedTask.DecisionLog ?? string.Empty, editDecisionLog ?? string.Empty, StringComparison.Ordinal) || !string.Equals(selectedTask.HandoffNotes ?? string.Empty, editHandoffNotes ?? string.Empty, StringComparison.Ordinal)); 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, TaskBoardService.NormalizeExecutionTime(editExecutionTime), StringComparison.Ordinal) || !string.Equals(selectedTask.IndexSummary ?? string.Empty, editIndexSummary ?? string.Empty, StringComparison.Ordinal) || taskSummaryChanged || taskBodyChanged; } private void Reload(string selectedId = null) { data = TaskBoardService.Load(); SyncSelections(selectedStatuses, TaskBoardConstants.Statuses); SyncSelections(selectedPriorities, TaskBoardConstants.Priorities); SyncSelections(selectedAreas, BuildAreaOptions()); SyncSelections(selectedOwners, BuildOwnerFilterOptions()); SyncSelections(selectedFileStates, new[] { "Existing", "Missing" }); SyncSelections(selectedWarningStates, new[] { "Warnings", "Clean" }); if (data == null || data.Tasks.Count == 0) { ResetDeleteConfirmation(); 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) { selectedTask = null; editStatus = string.Empty; editPriority = string.Empty; editOwner = string.Empty; editExecutionTime = string.Empty; editIndexSummary = string.Empty; editTaskSummary = string.Empty; editWhy = string.Empty; editExpectedOutcome = string.Empty; editCurrentContext = string.Empty; editAcceptanceCriteria = string.Empty; editVerification = string.Empty; editRisks = string.Empty; editHumanDecisions = string.Empty; editDecisionLog = string.Empty; editHandoffNotes = string.Empty; return; } SelectTask(nextSelection); } private List GetFilteredTasks() { IEnumerable query = data != null ? data.Tasks : Enumerable.Empty(); if (!string.IsNullOrWhiteSpace(searchText)) { string search = searchText.Trim(); query = query.Where(task => ContainsIgnoreCase(task.Id, search) || ContainsIgnoreCase(task.Title, search) || ContainsIgnoreCase(task.IndexSummary, search) || ContainsIgnoreCase(task.Area, search) || ContainsIgnoreCase(task.Owner, search)); } query = query.Where(task => selectedStatuses.Contains(task.Status)); query = query.Where(task => selectedPriorities.Contains(task.Priority)); query = query.Where(task => selectedAreas.Contains(string.IsNullOrWhiteSpace(task.Area) ? "-" : task.Area)); query = query.Where(task => selectedOwners.Contains(string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner)); query = query.Where(task => selectedFileStates.Contains(task.FileExists ? "Existing" : "Missing")); query = query.Where(task => selectedWarningStates.Contains(task.ValidationMessages.Count > 0 ? "Warnings" : "Clean")); return ApplySort(query).ToList(); } private string[] BuildAreaOptions() { if (data == null) { return Array.Empty(); } return data.Tasks .Select(task => string.IsNullOrWhiteSpace(task.Area) ? "-" : task.Area) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(area => area, StringComparer.OrdinalIgnoreCase) .ToArray(); } private string[] BuildOwnerFilterOptions() { return TaskBoardService.BuildOwnerOptions(data, data != null ? data.Tasks : null); } private string[] BuildOwnerPresetOptions() { var options = new List(); if (data != null) { options.AddRange(data.OwnerPresets.Where(owner => !string.IsNullOrWhiteSpace(owner))); } if (!string.IsNullOrWhiteSpace(editOwner) && !options.Any(owner => string.Equals(owner, editOwner, StringComparison.OrdinalIgnoreCase)) && !editOwnerIsCustom) { options.Insert(0, editOwner); } options.Add(CustomOwnerOption); return options.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } private string[] BuildSortOptions() { return new[] { "Priority Desc", "Priority Asc", "Execution Time Desc", "Execution Time Asc", "ID Asc", "ID Desc", "Owner Asc", "Title Asc", "Area Asc", }; } private IEnumerable ApplySort(IEnumerable tasks) { switch (sortOption) { case "Priority Asc": return tasks.OrderByDescending(task => TaskBoardService.GetPrioritySortOrder(task.Priority)).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); case "Execution Time Desc": return tasks.OrderByDescending(task => task.EstimatedMinutes).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); case "Execution Time Asc": return tasks.OrderBy(task => task.EstimatedMinutes < 0 ? int.MaxValue : task.EstimatedMinutes).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); case "ID Desc": return tasks.OrderByDescending(task => task.Id, StringComparer.OrdinalIgnoreCase); case "Owner Asc": return tasks.OrderBy(task => task.Owner, StringComparer.OrdinalIgnoreCase).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); case "Title Asc": return tasks.OrderBy(task => task.Title, StringComparer.OrdinalIgnoreCase).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); case "Area Asc": return tasks.OrderBy(task => task.Area, StringComparer.OrdinalIgnoreCase).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); case "ID Asc": return tasks.OrderBy(task => task.Id, StringComparer.OrdinalIgnoreCase); case "Priority Desc": default: return tasks.OrderBy(task => TaskBoardService.GetPrioritySortOrder(task.Priority)).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); } } private void DrawDetailsSplitter() { Rect splitterRect = GUILayoutUtility.GetRect(SplitterWidth, 1f, GUILayout.Width(SplitterWidth), GUILayout.ExpandHeight(true)); EditorGUIUtility.AddCursorRect(splitterRect, MouseCursor.ResizeHorizontal); EditorGUI.DrawRect(splitterRect, new Color(0.2f, 0.2f, 0.2f, 0.75f)); Event current = Event.current; switch (current.type) { case EventType.MouseDown: if (splitterRect.Contains(current.mousePosition)) { isResizingDetails = true; current.Use(); } break; case EventType.MouseDrag: if (isResizingDetails) { detailsWidth = Mathf.Clamp(position.width - current.mousePosition.x, MinDetailsWidth, Mathf.Max(MinDetailsWidth, position.width - MinBoardWidth)); Repaint(); current.Use(); } break; case EventType.MouseUp: if (isResizingDetails) { isResizingDetails = false; EditorPrefs.SetFloat(DetailsWidthPrefsKey, detailsWidth); current.Use(); } break; } } private void OpenOwnersConfig() { if (data == null || string.IsNullOrWhiteSpace(data.OwnersConfigPath)) { return; } UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(data.OwnersConfigPath); if (asset != null) { Selection.activeObject = asset; EditorGUIUtility.PingObject(asset); } } private int GetOwnerPopupIndex(string[] presetOptions) { if (editOwnerIsCustom) { return Array.IndexOf(presetOptions, CustomOwnerOption); } int index = Array.FindIndex(presetOptions, owner => string.Equals(owner, editOwner, StringComparison.OrdinalIgnoreCase)); return index >= 0 ? index : Array.IndexOf(presetOptions, CustomOwnerOption); } private string GetFormattedTime(TaskRecord task) { return task == null ? "-" : TaskBoardService.FormatExecutionTimeForDisplay(task.ExecutionTime); } private void SyncSelections(HashSet selectedValues, string[] availableOptions) { var availableSet = new HashSet(availableOptions ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); selectedValues.RemoveWhere(value => !availableSet.Contains(value)); if (selectedValues.Count == 0) { SetAll(selectedValues, availableOptions, true); } } private void SetAll(HashSet selectedValues, string[] values, bool enabled) { selectedValues.Clear(); if (!enabled || values == null) { return; } foreach (string value in values) { selectedValues.Add(value); } } private static bool ContainsIgnoreCase(string source, string value) { return !string.IsNullOrEmpty(source) && source.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0; } private void ResetDeleteConfirmationIfExpired() { if (!string.IsNullOrEmpty(pendingDeleteTaskId) && EditorApplication.timeSinceStartup > pendingDeleteUntil) { ResetDeleteConfirmation(); } } private void ResetDeleteConfirmation() { pendingDeleteTaskId = null; pendingDeleteUntil = 0d; } private static string SafeValue(string value) { return string.IsNullOrEmpty(value) ? "-" : value; } private static Color GetStatusColor(string status) { switch (TaskBoardService.NormalizeStatus(status)) { case "BackLog": return new Color(0.94f, 0.94f, 0.94f); case "ToDo": return new Color(0.85f, 0.96f, 0.85f); case "InProgress": return new Color(0.84f, 0.91f, 1f); case "Review": 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); } } } }