@sascha384/tic 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/app.d.ts +2 -34
  3. package/dist/app.js +8 -68
  4. package/dist/app.js.map +1 -1
  5. package/dist/components/Breadcrumbs.d.ts +5 -0
  6. package/dist/components/Breadcrumbs.js +16 -0
  7. package/dist/components/Breadcrumbs.js.map +1 -0
  8. package/dist/components/HelpScreen.d.ts +1 -1
  9. package/dist/components/HelpScreen.js +31 -3
  10. package/dist/components/HelpScreen.js.map +1 -1
  11. package/dist/components/IterationPicker.js +6 -2
  12. package/dist/components/IterationPicker.js.map +1 -1
  13. package/dist/components/Settings.js +38 -4
  14. package/dist/components/Settings.js.map +1 -1
  15. package/dist/components/StatusScreen.js +32 -4
  16. package/dist/components/StatusScreen.js.map +1 -1
  17. package/dist/components/WorkItemForm.js +230 -118
  18. package/dist/components/WorkItemForm.js.map +1 -1
  19. package/dist/components/WorkItemList.js +53 -50
  20. package/dist/components/WorkItemList.js.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/stores/backendDataStore.d.ts +2 -0
  24. package/dist/stores/backendDataStore.js +5 -1
  25. package/dist/stores/backendDataStore.js.map +1 -1
  26. package/dist/stores/formStackStore.d.ts +35 -0
  27. package/dist/stores/formStackStore.js +65 -0
  28. package/dist/stores/formStackStore.js.map +1 -0
  29. package/dist/stores/listViewStore.d.ts +17 -0
  30. package/dist/stores/listViewStore.js +54 -0
  31. package/dist/stores/listViewStore.js.map +1 -0
  32. package/dist/stores/navigationStore.d.ts +29 -0
  33. package/dist/stores/navigationStore.js +85 -0
  34. package/dist/stores/navigationStore.js.map +1 -0
  35. package/package.json +2 -1
@@ -5,17 +5,31 @@ import TextInput from 'ink-text-input';
5
5
  import SelectInput from 'ink-select-input';
6
6
  import { AutocompleteInput } from './AutocompleteInput.js';
7
7
  import { MultiAutocompleteInput } from './MultiAutocompleteInput.js';
8
- import { useAppState } from '../app.js';
8
+ import { useNavigationStore } from '../stores/navigationStore.js';
9
+ import { formStackStore, useFormStackStore, } from '../stores/formStackStore.js';
9
10
  import { SyncQueueStore } from '../sync/queue.js';
10
11
  import { useScrollViewport } from '../hooks/useScrollViewport.js';
11
12
  import { useBackendDataStore } from '../stores/backendDataStore.js';
12
13
  import { openInEditor } from '../editor.js';
13
14
  import { slugifyTemplateName } from '../backends/local/templates.js';
14
- import { createSnapshot, isSnapshotEqual, } from './formSnapshot.js';
15
+ import { Breadcrumbs } from './Breadcrumbs.js';
15
16
  const SELECT_FIELDS = ['type', 'status', 'iteration', 'priority'];
16
17
  const PRIORITIES = ['low', 'medium', 'high', 'critical'];
17
18
  export function WorkItemForm() {
18
- const { backend, syncManager, navigate, navigateToHelp, selectedWorkItemId, activeType, activeTemplate, setActiveTemplate, formMode, setFormMode, editingTemplateSlug, setEditingTemplateSlug, pushWorkItem, popWorkItem, } = useAppState();
19
+ const backend = useBackendDataStore((s) => s.backend);
20
+ const syncManager = useBackendDataStore((s) => s.syncManager);
21
+ const navigate = useNavigationStore((s) => s.navigate);
22
+ const navigateToHelp = useNavigationStore((s) => s.navigateToHelp);
23
+ const selectedWorkItemId = useNavigationStore((s) => s.selectedWorkItemId);
24
+ const activeType = useNavigationStore((s) => s.activeType);
25
+ const activeTemplate = useNavigationStore((s) => s.activeTemplate);
26
+ const setActiveTemplate = useNavigationStore((s) => s.setActiveTemplate);
27
+ const formMode = useNavigationStore((s) => s.formMode);
28
+ const setFormMode = useNavigationStore((s) => s.setFormMode);
29
+ const editingTemplateSlug = useNavigationStore((s) => s.editingTemplateSlug);
30
+ const setEditingTemplateSlug = useNavigationStore((s) => s.setEditingTemplateSlug);
31
+ const pushWorkItem = useNavigationStore((s) => s.pushWorkItem);
32
+ const popWorkItem = useNavigationStore((s) => s.popWorkItem);
19
33
  const queueStore = useMemo(() => {
20
34
  if (!syncManager)
21
35
  return null;
@@ -42,6 +56,11 @@ export function WorkItemForm() {
42
56
  const currentIteration = useBackendDataStore((s) => s.currentIteration);
43
57
  const allItems = useBackendDataStore((s) => s.items);
44
58
  const configLoading = useBackendDataStore((s) => s.loading);
59
+ // Form stack store for draft persistence
60
+ const currentDraft = useFormStackStore((s) => s.currentDraft());
61
+ const showDirtyPrompt = useFormStackStore((s) => s.showDiscardPrompt);
62
+ const storeIsDirty = useFormStackStore((s) => s.isDirty());
63
+ const { push: pushDraft, updateFields, setFocusedField: setStoreFocusedField, setShowDiscardPrompt: setStoreShowDiscardPrompt, } = formStackStore.getState();
45
64
  const [existingItem, setExistingItem] = useState(null);
46
65
  const [children, setChildren] = useState([]);
47
66
  const [dependents, setDependents] = useState([]);
@@ -56,6 +75,10 @@ export function WorkItemForm() {
56
75
  setItemLoading(false);
57
76
  return;
58
77
  }
78
+ if (!backend) {
79
+ setItemLoading(false);
80
+ return;
81
+ }
59
82
  let cancelled = false;
60
83
  setItemLoading(true);
61
84
  void (async () => {
@@ -155,62 +178,91 @@ export function WorkItemForm() {
155
178
  children,
156
179
  dependents,
157
180
  ]);
158
- const [title, setTitle] = useState('');
159
- const [type, setType] = useState(activeType ?? types[0] ?? '');
160
- const [status, setStatus] = useState(statuses[0] ?? '');
161
- const [iteration, setIteration] = useState(currentIteration);
162
- const [priority, setPriority] = useState('medium');
163
- const [assignee, setAssignee] = useState('');
164
- const [labels, setLabels] = useState('');
165
- const [description, setDescription] = useState('');
166
- const [parentId, setParentId] = useState('');
167
- const [dependsOn, setDependsOn] = useState('');
168
- const [newComment, setNewComment] = useState('');
169
181
  const [comments, setComments] = useState([]);
170
- const [initialSnapshot, setInitialSnapshot] = useState(null);
171
- const currentValues = createSnapshot({
172
- title,
173
- type,
174
- status,
175
- iteration,
176
- priority,
177
- assignee,
178
- labels,
179
- description,
180
- parentId,
181
- dependsOn,
182
- newComment,
183
- });
184
- const isDirty = initialSnapshot !== null &&
185
- !isSnapshotEqual(initialSnapshot, currentValues);
182
+ // Derive field values from current draft
183
+ const title = currentDraft?.fields.title ?? '';
184
+ const type = currentDraft?.fields.type ?? activeType ?? types[0] ?? '';
185
+ const status = currentDraft?.fields.status ?? statuses[0] ?? '';
186
+ const iteration = currentDraft?.fields.iteration ?? currentIteration;
187
+ const priority = (currentDraft?.fields.priority ?? 'medium');
188
+ const assignee = currentDraft?.fields.assignee ?? '';
189
+ const labels = currentDraft?.fields.labels ?? '';
190
+ const description = currentDraft?.fields.description ?? '';
191
+ const parentId = currentDraft?.fields.parentId ?? '';
192
+ const dependsOn = currentDraft?.fields.dependsOn ?? '';
193
+ const newComment = currentDraft?.fields.newComment ?? '';
194
+ const focusedField = currentDraft?.focusedField ?? 0;
195
+ // Use store's dirty detection
196
+ const isDirty = storeIsDirty;
197
+ // Field setter wrappers that update the store
198
+ const setTitle = (v) => updateFields({ title: v });
199
+ const setType = (v) => updateFields({ type: v });
200
+ const setStatus = (v) => updateFields({ status: v });
201
+ const setIteration = (v) => updateFields({ iteration: v });
202
+ const setPriority = (v) => updateFields({ priority: v });
203
+ const setAssignee = (v) => updateFields({ assignee: v });
204
+ const setLabels = (v) => updateFields({ labels: v });
205
+ const setDescription = (v) => updateFields({ description: v });
206
+ const setParentId = (v) => updateFields({ parentId: v });
207
+ const setDependsOn = (v) => updateFields({ dependsOn: v });
208
+ const setNewComment = (v) => updateFields({ newComment: v });
209
+ const setFocusedField = (v) => {
210
+ if (typeof v === 'function') {
211
+ const newVal = v(focusedField);
212
+ setStoreFocusedField(newVal);
213
+ }
214
+ else {
215
+ setStoreFocusedField(v);
216
+ }
217
+ };
218
+ const setShowDirtyPrompt = setStoreShowDiscardPrompt;
219
+ // Initialize form draft when entering form (only if stack is empty)
220
+ useEffect(() => {
221
+ if (formStackStore.getState().stack.length > 0)
222
+ return; // Already has a draft
223
+ const initialFields = {
224
+ title: '',
225
+ type: activeType ?? types[0] ?? '',
226
+ status: statuses[0] ?? '',
227
+ iteration: currentIteration,
228
+ priority: 'medium',
229
+ assignee: '',
230
+ labels: '',
231
+ description: '',
232
+ parentId: '',
233
+ dependsOn: '',
234
+ newComment: '',
235
+ };
236
+ const draft = {
237
+ itemId: selectedWorkItemId,
238
+ itemTitle: selectedWorkItemId ? `#${selectedWorkItemId}` : '(new)',
239
+ fields: initialFields,
240
+ initialSnapshot: { ...initialFields },
241
+ focusedField: 0,
242
+ };
243
+ pushDraft(draft);
244
+ }, []); // Only on mount
186
245
  // Sync form fields when the existing item finishes loading
187
246
  useEffect(() => {
188
247
  if (!existingItem)
189
248
  return;
190
- setTitle(existingItem.title);
191
- setType(existingItem.type);
192
- setStatus(existingItem.status);
193
- setIteration(existingItem.iteration);
194
- setPriority(existingItem.priority ?? 'medium');
195
- setAssignee(existingItem.assignee ?? '');
196
- setLabels(existingItem.labels.join(', '));
197
- setDescription(existingItem.description ?? '');
198
- setParentId(existingItem.parent !== null && existingItem.parent !== undefined
249
+ setComments(existingItem.comments ?? []);
250
+ // Build field values
251
+ const parentIdValue = existingItem.parent !== null && existingItem.parent !== undefined
199
252
  ? (() => {
200
253
  const pi = allItems.find((i) => i.id === existingItem.parent);
201
254
  return pi
202
255
  ? `#${existingItem.parent} - ${pi.title}`
203
256
  : String(existingItem.parent);
204
257
  })()
205
- : '');
206
- setDependsOn(existingItem.dependsOn
258
+ : '';
259
+ const dependsOnValue = existingItem.dependsOn
207
260
  ?.map((depId) => {
208
261
  const depItem = allItems.find((i) => i.id === depId);
209
262
  return depItem ? `#${depId} - ${depItem.title}` : depId;
210
263
  })
211
- .join(', ') ?? '');
212
- setComments(existingItem.comments ?? []);
213
- setInitialSnapshot(createSnapshot({
264
+ .join(', ') ?? '';
265
+ const newFields = {
214
266
  title: existingItem.title,
215
267
  type: existingItem.type,
216
268
  status: existingItem.status,
@@ -219,23 +271,25 @@ export function WorkItemForm() {
219
271
  assignee: existingItem.assignee ?? '',
220
272
  labels: existingItem.labels.join(', '),
221
273
  description: existingItem.description ?? '',
222
- parentId: existingItem.parent !== null && existingItem.parent !== undefined
223
- ? (() => {
224
- const pi = allItems.find((i) => i.id === existingItem.parent);
225
- return pi
226
- ? `#${existingItem.parent} - ${pi.title}`
227
- : String(existingItem.parent);
228
- })()
229
- : '',
230
- dependsOn: existingItem.dependsOn
231
- ?.map((depId) => {
232
- const depItem = allItems.find((i) => i.id === depId);
233
- return depItem ? `#${depId} - ${depItem.title}` : depId;
234
- })
235
- .join(', ') ?? '',
274
+ parentId: parentIdValue,
275
+ dependsOn: dependsOnValue,
236
276
  newComment: '',
237
- }));
238
- }, [existingItem]);
277
+ };
278
+ // Update both fields and initialSnapshot in the store
279
+ formStackStore.setState((state) => {
280
+ if (state.stack.length === 0)
281
+ return state;
282
+ const updated = [...state.stack];
283
+ const current = updated[updated.length - 1];
284
+ updated[updated.length - 1] = {
285
+ ...current,
286
+ itemTitle: existingItem.title,
287
+ fields: newFields,
288
+ initialSnapshot: { ...newFields },
289
+ };
290
+ return { stack: updated };
291
+ });
292
+ }, [existingItem, allItems]);
239
293
  // Prefill from template (create mode only)
240
294
  useEffect(() => {
241
295
  if (selectedWorkItemId !== null || !activeTemplate)
@@ -259,54 +313,15 @@ export function WorkItemForm() {
259
313
  if (activeTemplate.dependsOn != null)
260
314
  setDependsOn(activeTemplate.dependsOn.join(', '));
261
315
  }, [activeTemplate, selectedWorkItemId]);
262
- // Capture initial snapshot for new items once config finishes loading
263
- useEffect(() => {
264
- if (selectedWorkItemId !== null ||
265
- configLoading ||
266
- initialSnapshot !== null)
267
- return;
268
- setInitialSnapshot(createSnapshot({
269
- title,
270
- type,
271
- status,
272
- iteration,
273
- priority,
274
- assignee,
275
- labels,
276
- description,
277
- parentId,
278
- dependsOn,
279
- newComment,
280
- }));
281
- }, [selectedWorkItemId, configLoading]);
282
316
  // Load existing template for editing
283
317
  useEffect(() => {
284
- if (formMode !== 'template' || !editingTemplateSlug)
318
+ if (formMode !== 'template' || !editingTemplateSlug || !backend)
285
319
  return;
286
320
  let cancelled = false;
287
321
  void backend.getTemplate(editingTemplateSlug).then((t) => {
288
322
  if (cancelled)
289
323
  return;
290
- setTitle(t.name);
291
- if (t.type != null)
292
- setType(t.type);
293
- if (t.status != null)
294
- setStatus(t.status);
295
- if (t.priority != null)
296
- setPriority(t.priority);
297
- if (t.assignee != null)
298
- setAssignee(t.assignee);
299
- if (t.labels != null)
300
- setLabels(t.labels.join(', '));
301
- if (t.iteration != null)
302
- setIteration(t.iteration);
303
- if (t.description != null)
304
- setDescription(t.description);
305
- if (t.parent != null)
306
- setParentId(String(t.parent));
307
- if (t.dependsOn != null)
308
- setDependsOn(t.dependsOn.join(', '));
309
- setInitialSnapshot(createSnapshot({
324
+ const newFields = {
310
325
  title: t.name,
311
326
  type: t.type ?? type,
312
327
  status: t.status ?? status,
@@ -318,7 +333,21 @@ export function WorkItemForm() {
318
333
  parentId: t.parent != null ? String(t.parent) : parentId,
319
334
  dependsOn: t.dependsOn != null ? t.dependsOn.join(', ') : dependsOn,
320
335
  newComment: '',
321
- }));
336
+ };
337
+ // Update both fields and initialSnapshot in the store
338
+ formStackStore.setState((state) => {
339
+ if (state.stack.length === 0)
340
+ return state;
341
+ const updated = [...state.stack];
342
+ const current = updated[updated.length - 1];
343
+ updated[updated.length - 1] = {
344
+ ...current,
345
+ itemTitle: t.name,
346
+ fields: newFields,
347
+ initialSnapshot: { ...newFields },
348
+ };
349
+ return { stack: updated };
350
+ });
322
351
  });
323
352
  return () => {
324
353
  cancelled = true;
@@ -329,10 +358,8 @@ export function WorkItemForm() {
329
358
  .filter((item) => item.id !== selectedWorkItemId)
330
359
  .map((item) => `#${item.id} - ${item.title}`);
331
360
  }, [allItems, selectedWorkItemId]);
332
- const [focusedField, setFocusedField] = useState(0);
333
361
  const [editing, setEditing] = useState(false);
334
362
  const [preEditValue, setPreEditValue] = useState('');
335
- const [showDirtyPrompt, setShowDirtyPrompt] = useState(false);
336
363
  const [pendingRelNav, setPendingRelNav] = useState(null);
337
364
  const [saving, setSaving] = useState(false);
338
365
  useEffect(() => {
@@ -345,6 +372,8 @@ export function WorkItemForm() {
345
372
  currentField?.startsWith('rel-child-') ||
346
373
  currentField?.startsWith('rel-dependent-');
347
374
  async function save() {
375
+ if (!backend)
376
+ return;
348
377
  const parsedLabels = labels
349
378
  .split(',')
350
379
  .map((l) => l.trim())
@@ -456,19 +485,25 @@ export function WorkItemForm() {
456
485
  }
457
486
  setActiveTemplate(null);
458
487
  }
459
- setInitialSnapshot(createSnapshot({
460
- title,
461
- type,
462
- status,
463
- iteration,
464
- priority,
465
- assignee,
466
- labels,
467
- description,
468
- parentId,
469
- dependsOn,
470
- newComment: '',
471
- }));
488
+ // Update the initialSnapshot after saving so isDirty becomes false
489
+ formStackStore.setState((state) => {
490
+ if (state.stack.length === 0)
491
+ return state;
492
+ const updated = [...state.stack];
493
+ const current = updated[updated.length - 1];
494
+ updated[updated.length - 1] = {
495
+ ...current,
496
+ initialSnapshot: {
497
+ ...current.fields,
498
+ newComment: '', // Comment was saved, reset
499
+ },
500
+ fields: {
501
+ ...current.fields,
502
+ newComment: '', // Clear after save
503
+ },
504
+ };
505
+ return { stack: updated };
506
+ });
472
507
  }
473
508
  useInput((_input, key) => {
474
509
  // Dirty prompt overlay — capture s/d/esc only
@@ -477,15 +512,40 @@ export function WorkItemForm() {
477
512
  void (async () => {
478
513
  await save();
479
514
  if (pendingRelNav) {
515
+ // Push a new draft for the target item
516
+ const targetItem = allItems.find((i) => i.id === pendingRelNav);
517
+ const defaultFields = {
518
+ title: '',
519
+ type: activeType ?? types[0] ?? '',
520
+ status: statuses[0] ?? '',
521
+ iteration: currentIteration,
522
+ priority: 'medium',
523
+ assignee: '',
524
+ labels: '',
525
+ description: '',
526
+ parentId: '',
527
+ dependsOn: '',
528
+ newComment: '',
529
+ };
530
+ const newDraft = {
531
+ itemId: pendingRelNav,
532
+ itemTitle: targetItem?.title ?? `#${pendingRelNav}`,
533
+ fields: defaultFields,
534
+ initialSnapshot: { ...defaultFields },
535
+ focusedField: 0,
536
+ };
537
+ pushDraft(newDraft);
480
538
  pushWorkItem(pendingRelNav);
481
539
  setPendingRelNav(null);
482
540
  }
483
541
  else if (formMode === 'template') {
542
+ formStackStore.getState().pop();
484
543
  setFormMode('item');
485
544
  setEditingTemplateSlug(null);
486
545
  navigate('settings');
487
546
  }
488
547
  else {
548
+ formStackStore.getState().pop();
489
549
  const prev = popWorkItem();
490
550
  if (prev === null)
491
551
  navigate('list');
@@ -497,15 +557,41 @@ export function WorkItemForm() {
497
557
  if (_input === 'd') {
498
558
  // Discard: navigate back without saving
499
559
  if (pendingRelNav) {
560
+ // Push a new draft for the target item (discarding current)
561
+ formStackStore.getState().pop();
562
+ const targetItem = allItems.find((i) => i.id === pendingRelNav);
563
+ const defaultFields = {
564
+ title: '',
565
+ type: activeType ?? types[0] ?? '',
566
+ status: statuses[0] ?? '',
567
+ iteration: currentIteration,
568
+ priority: 'medium',
569
+ assignee: '',
570
+ labels: '',
571
+ description: '',
572
+ parentId: '',
573
+ dependsOn: '',
574
+ newComment: '',
575
+ };
576
+ const newDraft = {
577
+ itemId: pendingRelNav,
578
+ itemTitle: targetItem?.title ?? `#${pendingRelNav}`,
579
+ fields: defaultFields,
580
+ initialSnapshot: { ...defaultFields },
581
+ focusedField: 0,
582
+ };
583
+ pushDraft(newDraft);
500
584
  pushWorkItem(pendingRelNav);
501
585
  setPendingRelNav(null);
502
586
  }
503
587
  else if (formMode === 'template') {
588
+ formStackStore.getState().pop();
504
589
  setFormMode('item');
505
590
  setEditingTemplateSlug(null);
506
591
  navigate('settings');
507
592
  }
508
593
  else {
594
+ formStackStore.getState().pop();
509
595
  const prev = popWorkItem();
510
596
  if (prev === null)
511
597
  navigate('list');
@@ -525,6 +611,7 @@ export function WorkItemForm() {
525
611
  if (key.escape && !editing) {
526
612
  if (configLoading || itemLoading || saving) {
527
613
  // Allow escape even while loading (no save)
614
+ formStackStore.getState().pop();
528
615
  if (formMode === 'template') {
529
616
  setFormMode('item');
530
617
  setEditingTemplateSlug(null);
@@ -542,6 +629,7 @@ export function WorkItemForm() {
542
629
  return;
543
630
  }
544
631
  // Clean — just go back
632
+ formStackStore.getState().pop();
545
633
  if (formMode === 'template') {
546
634
  setFormMode('item');
547
635
  setEditingTemplateSlug(null);
@@ -566,6 +654,7 @@ export function WorkItemForm() {
566
654
  setSaving(true);
567
655
  void (async () => {
568
656
  await save();
657
+ formStackStore.getState().pop();
569
658
  if (formMode === 'template') {
570
659
  setFormMode('item');
571
660
  setEditingTemplateSlug(null);
@@ -603,6 +692,29 @@ export function WorkItemForm() {
603
692
  setShowDirtyPrompt(true);
604
693
  }
605
694
  else {
695
+ // Create new draft for target item before navigating
696
+ const targetItem = allItems.find((i) => i.id === targetId);
697
+ const defaultFields = {
698
+ title: '',
699
+ type: activeType ?? types[0] ?? '',
700
+ status: statuses[0] ?? '',
701
+ iteration: currentIteration,
702
+ priority: 'medium',
703
+ assignee: '',
704
+ labels: '',
705
+ description: '',
706
+ parentId: '',
707
+ dependsOn: '',
708
+ newComment: '',
709
+ };
710
+ const newDraft = {
711
+ itemId: targetId,
712
+ itemTitle: targetItem?.title ?? `#${targetId}`,
713
+ fields: defaultFields,
714
+ initialSnapshot: { ...defaultFields },
715
+ focusedField: 0,
716
+ };
717
+ pushDraft(newDraft);
606
718
  pushWorkItem(targetId);
607
719
  }
608
720
  }
@@ -866,7 +978,7 @@ export function WorkItemForm() {
866
978
  const isFieldVisible = (index) => index >= viewport.start && index < viewport.end;
867
979
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: [mode, typeLabel ? ` ${typeLabel}` : '', formMode !== 'template' && selectedWorkItemId !== null
868
980
  ? ` #${selectedWorkItemId}`
869
- : ''] }) }), fields.map((field, index) => {
981
+ : ''] }) }), _jsx(Breadcrumbs, {}), fields.map((field, index) => {
870
982
  if (field === 'rel-parent' ||
871
983
  field.startsWith('rel-child-') ||
872
984
  field.startsWith('rel-dependent-')) {