1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
"""Task editor page for the pgt TUI."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
from pygit2 import Oid
from pygittools.tags_sync import delete_task_remote, push_board_tags
from pygittools.tasks import (
BOARD_REF_PREFIX,
EMPTY_TREE_OID_HEX,
TASK_REF_PREFIX,
Task,
delete_task_local,
get_board,
get_task,
)
from pygittools.tasks_query import BOARD_STATUS_ORDER, get_task_view
from pygittools.tui.draw import draw_line
from pygittools.tui.git_colors import current_theme, focus_attr
from pygittools.tui.types import PageAction, PageContext, PageResult
from pygittools.tui.ucurses import ( # type: ignore[import-untyped]
A_BOLD,
KEY_BACKSPACE,
KEY_DOWN,
KEY_ENTER,
KEY_RESIZE,
KEY_UP,
)
_FieldName = Literal["title", "description", "status", "priority", "assignee", "due_date"]
_FIELDS: tuple[_FieldName, ...] = ("title", "description", "status", "priority", "assignee", "due_date")
_PRIORITY_ORDER: tuple[str, ...] = ("LOW", "MEDIUM", "HIGH", "CRITICAL")
@dataclass(frozen=True, slots=True)
class _EditorState:
title: str
description: str
status: str | None
priority: str | None
assignee: str
due_date: str
class TaskEditorPage:
title = "task"
def __init__(self, *, board_name: str, task_ref: str | None, default_status: str | None) -> None:
self._ctx: PageContext | None = None
self._board_name = board_name
self._task_ref = task_ref
self._default_status = default_status
self._state = _EditorState(
title="",
description="",
status=default_status,
priority=None,
assignee="",
due_date="",
)
self._field_index = 0
self._input_mode: Literal["nav", "input"] = "nav"
self._error: str | None = None
def on_enter(self, ctx: PageContext) -> None:
self._ctx = ctx
if self._task_ref is None:
return
view = get_task_view(ctx.repo, self._task_ref)
self._state = _EditorState(
title=view.get("title", ""),
description=view.get("description", ""),
status=view.get("status"),
priority=view.get("priority"),
assignee=view.get("assignee") or "",
due_date=view.get("due_date") or "",
)
def status_text(self) -> str:
if self._error:
return self._error
if self._task_ref is not None:
if self._field_index == len(_FIELDS):
return "Enter remove task (local) — j/k move — q or Esc back"
if self._field_index == len(_FIELDS) + 1:
return "Enter remove task (remote & local) — j/k move — q or Esc back"
if self._input_mode == "input":
field = _FIELDS[self._field_index]
if field in ("status", "priority"):
return "j/k change value — Enter save — Esc stop editing — q back"
return "Type to edit — Enter save — Esc stop editing — q back"
return "Enter edit field — j/k move field — q or Esc back"
def draw(self, stdscr: int, height: int, width: int) -> None:
del stdscr
if height <= 0:
return
theme = current_theme()
header = f"Task in board {self._board_name}"
draw_line(0, 0, header[:width], width, theme.header)
if height <= 1:
return
rows = [
f"Title: {self._state.title}",
f"Description: {self._state.description}",
f"Status: {self._state.status or '(none)'}",
f"Priority: {self._state.priority or '(none)'}",
f"Assignee: {self._state.assignee}",
f"Due date (ISO): {self._state.due_date}",
]
if self._task_ref is not None:
rows.append("Remove task")
rows.append("Remove task (REMOTE & LOCAL)")
for index, text in enumerate(rows):
if index + 1 >= height:
break
attr = theme.unstaged if self._task_ref is not None and index >= len(_FIELDS) else theme.normal
if index == self._field_index:
attr = focus_attr(attr)
if self._input_mode == "input" and index < len(_FIELDS):
attr |= A_BOLD
draw_line(index + 1, 0, text[:width], width, attr)
def handle_key(self, key: int) -> PageResult:
if key in (ord("q"), ord("Q")):
return PageResult(action=PageAction.POP)
if key == 27:
if self._input_mode == "input":
self._input_mode = "nav"
return PageResult()
return PageResult(action=PageAction.POP)
if key == KEY_RESIZE:
return PageResult()
if self._error is not None:
self._error = None
field = _FIELDS[self._field_index] if self._field_index < len(_FIELDS) else "title"
if self._input_mode == "input":
if field in ("title", "description", "assignee", "due_date"):
if key in (KEY_UP, KEY_DOWN):
if key == KEY_UP:
self._field_index = max(0, self._field_index - 1)
else:
self._field_index = min(len(_FIELDS) - 1, self._field_index + 1)
return PageResult()
if key in (KEY_ENTER, 10, 13):
return self._save()
return self._edit_text_field(field, key)
if field == "status":
if key in (KEY_ENTER, 10, 13):
return self._save()
self._cycle_status(key)
return PageResult()
if field == "priority":
if key in (KEY_ENTER, 10, 13):
return self._save()
self._cycle_priority(key)
return PageResult()
return PageResult()
if key in (KEY_UP, ord("k"), ord("K")):
self._field_index = max(0, self._field_index - 1)
return PageResult()
if key in (KEY_DOWN, ord("j"), ord("J")):
max_index = (len(_FIELDS) + 1) if self._task_ref is not None else len(_FIELDS) - 1
self._field_index = min(max_index, self._field_index + 1)
return PageResult()
if key in (KEY_ENTER, 10, 13):
if self._task_ref is not None:
if self._field_index == len(_FIELDS):
return self._delete_local()
if self._field_index == len(_FIELDS) + 1:
return self._delete_remote_local()
self._input_mode = "input"
return PageResult()
if key in (KEY_BACKSPACE, 127, 8) and field in ("title", "description", "assignee", "due_date"):
self._input_mode = "input"
return self._edit_text_field(field, key)
return PageResult()
def _edit_text_field(self, field: _FieldName, key: int) -> PageResult:
if key in (KEY_BACKSPACE, 127, 8):
self._state = _replace_state_field(self._state, field, _text_for_field(self._state, field)[:-1])
return PageResult()
if 32 <= key <= 126:
text = _text_for_field(self._state, field) + chr(key)
self._state = _replace_state_field(self._state, field, text)
return PageResult()
return PageResult()
def _cycle_status(self, key: int) -> None:
if key not in (KEY_UP, KEY_DOWN, ord("j"), ord("J"), ord("k"), ord("K")):
return
current = self._state.status or self._default_status or BOARD_STATUS_ORDER[0]
try:
index = BOARD_STATUS_ORDER.index(current)
except ValueError:
index = 0
if key in (KEY_UP, ord("k"), ord("K")):
index = (index - 1) % len(BOARD_STATUS_ORDER)
else:
index = (index + 1) % len(BOARD_STATUS_ORDER)
self._state = _EditorState(
title=self._state.title,
description=self._state.description,
status=BOARD_STATUS_ORDER[index],
priority=self._state.priority,
assignee=self._state.assignee,
due_date=self._state.due_date,
)
def _cycle_priority(self, key: int) -> None:
if key not in (KEY_UP, KEY_DOWN, ord("j"), ord("J"), ord("k"), ord("K")):
return
if not _PRIORITY_ORDER:
return
current = self._state.priority or _PRIORITY_ORDER[0]
try:
index = _PRIORITY_ORDER.index(current)
except ValueError:
index = 0
if key in (KEY_UP, ord("k"), ord("K")):
index = (index - 1) % len(_PRIORITY_ORDER)
else:
index = (index + 1) % len(_PRIORITY_ORDER)
self._state = _EditorState(
title=self._state.title,
description=self._state.description,
status=self._state.status,
priority=_PRIORITY_ORDER[index],
assignee=self._state.assignee,
due_date=self._state.due_date,
)
def _save(self) -> PageResult:
ctx = self._ctx
if ctx is None:
return PageResult(action=PageAction.POP)
try:
due_dt = _parse_due_date(self._state.due_date)
except ValueError as exc:
self._error = str(exc)
return PageResult()
if self._task_ref is None:
try:
_create_task(ctx, self._board_name, self._state, due_dt)
except Exception as exc: # noqa: BLE001
self._error = str(exc)
return PageResult()
return PageResult(action=PageAction.POP)
task = get_task(ctx.repo, self._task_ref)
if task is None:
self._error = f"Task not found: {self._task_ref}"
return PageResult()
task.title = self._state.title
task.description = self._state.description
task.status = Task.Status(self._state.status) if self._state.status else None
task.priority = Task.Priority(self._state.priority) if self._state.priority else None
task.assignee = self._state.assignee or None
task.due_date = due_dt
task.update_message()
task.write(ctx.repo)
return PageResult(action=PageAction.POP)
def _delete_local(self) -> PageResult:
ctx = self._ctx
if ctx is None or self._task_ref is None:
return PageResult(action=PageAction.POP)
delete_task_local(ctx.repo, self._task_ref)
return PageResult(action=PageAction.POP)
def _delete_remote_local(self) -> PageResult:
ctx = self._ctx
if ctx is None or self._task_ref is None:
return PageResult(action=PageAction.POP)
remote_name = _default_remote_name(ctx)
if remote_name is not None:
delete_task_remote(ctx.repo, remote_name, self._task_ref)
delete_task_local(ctx.repo, self._task_ref)
if remote_name is not None:
push_board_tags(ctx.repo, remote_name, self._board_name)
return PageResult(action=PageAction.POP)
def _replace_state_field(state: _EditorState, field: _FieldName, value: str) -> _EditorState:
if field == "title":
return _EditorState(
title=value,
description=state.description,
status=state.status,
priority=state.priority,
assignee=state.assignee,
due_date=state.due_date,
)
if field == "description":
return _EditorState(
title=state.title,
description=value,
status=state.status,
priority=state.priority,
assignee=state.assignee,
due_date=state.due_date,
)
if field == "assignee":
return _EditorState(
title=state.title,
description=state.description,
status=state.status,
priority=state.priority,
assignee=value,
due_date=state.due_date,
)
return _EditorState(
title=state.title,
description=state.description,
status=state.status,
priority=state.priority,
assignee=state.assignee,
due_date=value,
)
def _text_for_field(state: _EditorState, field: _FieldName) -> str:
if field == "title":
return state.title
if field == "description":
return state.description
if field == "assignee":
return state.assignee
return state.due_date
def _parse_due_date(text: str) -> datetime | None:
if not text.strip():
return None
try:
return datetime.fromisoformat(text)
except ValueError as exc:
raise ValueError(f"Invalid due date (expected ISO format): {text}") from exc
def _create_task(ctx: PageContext, board_name: str, state: _EditorState, due_dt: datetime | None) -> None:
board_ref = f"{BOARD_REF_PREFIX}{board_name}"
board = get_board(ctx.repo, board_ref)
if board is None:
raise ValueError(f"Board not found: {board_name}")
title = state.title or "Untitled"
slug = title.lower().replace(" ", "_") or "task"
full_ref = f"{TASK_REF_PREFIX}{slug}"
target = Oid(hex=EMPTY_TREE_OID_HEX)
sig = ctx.repo.default_signature
tagger = f"{sig.name} <{sig.email}>"
task = Task(
target,
full_ref,
tagger,
title=title,
description=state.description,
status=Task.Status(state.status) if state.status else None,
priority=Task.Priority(state.priority) if state.priority else None,
assignee=state.assignee or None,
due_date=due_dt,
)
task_oid = task.write(ctx.repo)
board.tasks = [*(getattr(board, "tasks", []) or []), str(task_oid)]
board.update_message()
board.write(ctx.repo)
def _default_remote_name(ctx: PageContext) -> str | None:
names = list(ctx.repo.remotes.names())
if not names:
return None
if "origin" in names:
return "origin"
return names[0]