@sascha384/tic 1.34.0 → 1.35.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.
@@ -1,5 +1,5 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useState, useMemo, useEffect, useCallback } from 'react';
2
+ import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
3
3
  import { Box, Text, useInput, useApp } from 'ink';
4
4
  import { navigationStore, useNavigationStore, } from '../stores/navigationStore.js';
5
5
  import { listViewStore, useListViewStore } from '../stores/listViewStore.js';
@@ -20,6 +20,9 @@ import { DetailPanel } from './DetailPanel.js';
20
20
  import { undoStore } from '../stores/undoStore.js';
21
21
  import { recentCommandsStore, useRecentCommandsStore, } from '../stores/recentCommandsStore.js';
22
22
  import { isSoftDeleteBackend } from '../backends/types.js';
23
+ import { filterStore, useFilterStore } from '../stores/filterStore.js';
24
+ import { applyFilters, countActiveFilters, summarizeFilters, } from '../filters.js';
25
+ const EMPTY_VIEWS = [];
23
26
  export function getTargetIds(markedIds, cursorItem) {
24
27
  if (markedIds.size > 0) {
25
28
  return [...markedIds];
@@ -77,6 +80,8 @@ export function WorkItemList() {
77
80
  const branchCommand = useConfigStore((s) => s.config.branchCommand);
78
81
  const copyToClipboard = useConfigStore((s) => s.config.copyToClipboard);
79
82
  const showDetailPanel = useConfigStore((s) => s.config.showDetailPanel ?? true);
83
+ const savedViews = useConfigStore((s) => s.config.views ?? EMPTY_VIEWS);
84
+ const defaultView = useConfigStore((s) => s.config.defaultView);
80
85
  const { exit } = useApp();
81
86
  // Store selectors for persistent list view state
82
87
  const { cursor, markedIds, expandedIds, rangeAnchor, sortStack } = useListViewStore(useShallow((s) => ({
@@ -86,6 +91,11 @@ export function WorkItemList() {
86
91
  rangeAnchor: s.rangeAnchor,
87
92
  sortStack: s.sortStack,
88
93
  })));
94
+ const { activeFilters, activeViewName } = useFilterStore(useShallow((s) => ({
95
+ activeFilters: s.activeFilters,
96
+ activeViewName: s.activeViewName,
97
+ })));
98
+ const filterCount = useMemo(() => countActiveFilters(activeFilters), [activeFilters]);
89
99
  const { setCursor, toggleExpanded, toggleMarked, clearMarked, setMarkedIds, setRangeAnchor, clampCursor, removeDeletedItem, toggleSortColumn, clearSort, } = listViewStore.getState();
90
100
  // Local state for inputs and templates
91
101
  const [allSearchItems, setAllSearchItems] = useState([]);
@@ -121,6 +131,22 @@ export function WorkItemList() {
121
131
  void backend.listTemplates().then(setTemplates);
122
132
  }
123
133
  }, [backend, capabilities.templates]);
134
+ // Load default view on startup
135
+ const defaultViewLoadedRef = useRef(false);
136
+ useEffect(() => {
137
+ if (defaultViewLoadedRef.current)
138
+ return;
139
+ if (!defaultView)
140
+ return;
141
+ const view = savedViews.find((v) => v.name === defaultView);
142
+ if (view) {
143
+ defaultViewLoadedRef.current = true;
144
+ filterStore.getState().loadView(view);
145
+ if (view.sort) {
146
+ listViewStore.getState().setSortStack(view.sort);
147
+ }
148
+ }
149
+ }, [savedViews, defaultView]);
124
150
  const queueStore = useMemo(() => {
125
151
  if (!syncManager)
126
152
  return null;
@@ -155,13 +181,29 @@ export function WorkItemList() {
155
181
  setActiveType(defaultType && types.includes(defaultType) ? defaultType : types[0]);
156
182
  }
157
183
  }, [activeType, types, setActiveType, defaultType]);
158
- const items = useMemo(() => allItems.filter((item) => item.type === activeType), [allItems, activeType]);
184
+ // Apply view filters to all items (used for children in tree view)
185
+ const viewFilteredItems = useMemo(() => applyFilters(allItems, activeFilters), [allItems, activeFilters]);
186
+ const unfilteredCount = useMemo(() => allItems.filter((item) => item.type === activeType).length, [allItems, activeType]);
187
+ const items = useMemo(() => {
188
+ const hasTypeFilter = (activeFilters.types?.length ?? 0) > 0;
189
+ let filtered = hasTypeFilter
190
+ ? allItems
191
+ : allItems.filter((item) => item.type === activeType);
192
+ filtered = applyFilters(filtered, activeFilters);
193
+ return filtered;
194
+ }, [allItems, activeType, activeFilters]);
159
195
  const fullTree = useMemo(() => {
160
196
  const tree = capabilities.relationships
161
- ? buildTree(items, allItems, activeType ?? '')
197
+ ? buildTree(items, viewFilteredItems, activeType ?? '')
162
198
  : buildTree(items, items, activeType ?? '');
163
199
  return sortTree(tree, sortStack);
164
- }, [items, allItems, activeType, capabilities.relationships, sortStack]);
200
+ }, [
201
+ items,
202
+ viewFilteredItems,
203
+ activeType,
204
+ capabilities.relationships,
205
+ sortStack,
206
+ ]);
165
207
  const parentSuggestions = useMemo(() => allItems.map((item) => `${item.id} - ${item.title}`), [allItems]);
166
208
  // Collapse state: set of item IDs that are collapsed (collapsed by default)
167
209
  // Track explicitly expanded items (inverse of collapsed).
@@ -434,6 +476,8 @@ export function WorkItemList() {
434
476
  try {
435
477
  const itemUrl = backend?.getItemUrl(item.id) || '';
436
478
  const result = beginImplementation(item, comments, { branchMode, branchCommand, copyToClipboard }, process.cwd(), { itemUrl });
479
+ // Restore raw mode after interactive shell changed terminal settings
480
+ process.stdin.setRawMode?.(true);
437
481
  let msg = result.resumed
438
482
  ? `Resumed work on #${item.id}`
439
483
  : `Started work on #${item.id}`;
@@ -443,6 +487,7 @@ export function WorkItemList() {
443
487
  setWarning(msg);
444
488
  }
445
489
  catch (e) {
490
+ process.stdin.setRawMode?.(true);
446
491
  setWarning(e instanceof Error ? e.message : 'Failed to start implementation');
447
492
  }
448
493
  refreshData();
@@ -453,6 +498,16 @@ export function WorkItemList() {
453
498
  if (input === 'O') {
454
499
  openOverlay({ type: 'sort-picker' });
455
500
  }
501
+ if (input === 'F') {
502
+ openOverlay({ type: 'filter-picker' });
503
+ }
504
+ if (input === 'V') {
505
+ openOverlay({ type: 'view-picker' });
506
+ }
507
+ if (input === 'X' && filterCount > 0) {
508
+ filterStore.getState().clearFilters();
509
+ setToast('Filters cleared');
510
+ }
456
511
  if (input === 's' && treeItems.length > 0) {
457
512
  const targetIds = getTargetIds(markedIds, treeItems[cursor]?.item);
458
513
  if (targetIds.length > 0) {
@@ -551,6 +606,8 @@ export function WorkItemList() {
551
606
  activeType,
552
607
  hasSyncManager: syncManager !== null,
553
608
  gitAvailable,
609
+ hasActiveFilters: filterCount > 0,
610
+ hasSavedViews: savedViews.length > 0,
554
611
  };
555
612
  const paletteCommands = useMemo(() => getVisibleCommands(commandContext), [
556
613
  commandContext.markedCount,
@@ -618,6 +675,93 @@ export function WorkItemList() {
618
675
  }
619
676
  return items;
620
677
  }, [sortStack, capabilities.fields.priority, capabilities.fields.assignee]);
678
+ const filterPickerItems = useMemo(() => {
679
+ const items = [];
680
+ if (filterCount > 0) {
681
+ items.push({
682
+ id: '__clear__',
683
+ label: 'Clear all filters',
684
+ value: '__clear__',
685
+ });
686
+ }
687
+ for (const s of statuses) {
688
+ items.push({
689
+ id: `status-${s}`,
690
+ label: s,
691
+ value: s,
692
+ category: 'Status',
693
+ selected: activeFilters.statuses?.includes(s),
694
+ });
695
+ }
696
+ for (const p of ['critical', 'high', 'medium', 'low']) {
697
+ if (!capabilities.fields.priority)
698
+ continue;
699
+ items.push({
700
+ id: `priority-${p}`,
701
+ label: p.charAt(0).toUpperCase() + p.slice(1),
702
+ value: p,
703
+ category: 'Priority',
704
+ selected: activeFilters.priorities?.includes(p),
705
+ });
706
+ }
707
+ for (const t of types) {
708
+ items.push({
709
+ id: `type-${t}`,
710
+ label: t.charAt(0).toUpperCase() + t.slice(1),
711
+ value: t,
712
+ category: 'Type',
713
+ selected: activeFilters.types?.includes(t),
714
+ });
715
+ }
716
+ for (const a of assignees) {
717
+ if (!capabilities.fields.assignee)
718
+ continue;
719
+ items.push({
720
+ id: `assignee-${a}`,
721
+ label: a,
722
+ value: a,
723
+ category: 'Assignee',
724
+ selected: activeFilters.assignees?.includes(a),
725
+ });
726
+ }
727
+ for (const l of labelSuggestions) {
728
+ if (!capabilities.fields.labels)
729
+ continue;
730
+ items.push({
731
+ id: `label-${l}`,
732
+ label: l,
733
+ value: l,
734
+ category: 'Labels',
735
+ selected: activeFilters.labels?.includes(l),
736
+ });
737
+ }
738
+ return items;
739
+ }, [
740
+ statuses,
741
+ types,
742
+ assignees,
743
+ labelSuggestions,
744
+ capabilities,
745
+ activeFilters,
746
+ filterCount,
747
+ ]);
748
+ const viewPickerItems = useMemo(() => {
749
+ const noFilterLabel = !defaultView ? 'No filters (default)' : 'No filters';
750
+ return [
751
+ {
752
+ id: '__no-filters__',
753
+ label: noFilterLabel,
754
+ value: '__no-filters__',
755
+ hint: !activeViewName && filterCount === 0 ? '●' : '',
756
+ },
757
+ ...savedViews.map((v) => ({
758
+ id: v.name,
759
+ label: v.name + (v.name === defaultView ? ' (default)' : ''),
760
+ value: v.name,
761
+ hint: summarizeFilters(v.filters) + (v.name === activeViewName ? ' ●' : ''),
762
+ })),
763
+ ];
764
+ }, [savedViews, defaultView, activeViewName, filterCount]);
621
765
  const handleCommandSelect = (command) => {
622
766
  closeOverlay();
623
767
  recentCommandsStore.getState().addRecent(command.id);
@@ -655,6 +799,7 @@ export function WorkItemList() {
655
799
  try {
656
800
  const itemUrl = backend?.getItemUrl(item.id) || '';
657
801
  const result = beginImplementation(item, comments, { branchMode, branchCommand, copyToClipboard }, process.cwd(), { itemUrl });
802
+ process.stdin.setRawMode?.(true);
658
803
  let msg = result.resumed
659
804
  ? `Resumed work on #${item.id}`
660
805
  : `Started work on #${item.id}`;
@@ -664,6 +809,7 @@ export function WorkItemList() {
664
809
  setWarning(msg);
665
810
  }
666
811
  catch (e) {
812
+ process.stdin.setRawMode?.(true);
667
813
  setWarning(e instanceof Error ? e.message : 'Failed to start implementation');
668
814
  }
669
815
  refreshData();
@@ -733,6 +879,25 @@ export function WorkItemList() {
733
879
  case 'sort':
734
880
  openOverlay({ type: 'sort-picker' });
735
881
  break;
882
+ case 'filter':
883
+ openOverlay({ type: 'filter-picker' });
884
+ break;
885
+ case 'clear-filters':
886
+ filterStore.getState().clearFilters();
887
+ setToast('Filters cleared');
888
+ break;
889
+ case 'load-view':
890
+ openOverlay({ type: 'view-picker' });
891
+ break;
892
+ case 'save-view':
893
+ openOverlay({ type: 'save-view-input' });
894
+ break;
895
+ case 'delete-view':
896
+ openOverlay({ type: 'delete-view-picker' });
897
+ break;
898
+ case 'set-default-view':
899
+ openOverlay({ type: 'set-default-view' });
900
+ break;
736
901
  case 'quit':
737
902
  exit();
738
903
  break;
@@ -785,7 +950,7 @@ export function WorkItemList() {
785
950
  const positionText = treeItems.length > viewport.maxVisible
786
951
  ? `${cursor + 1}/${treeItems.length}`
787
952
  : '';
788
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { bold: true, color: "cyan", children: [typeLabel, " \u2014 ", iteration] }), _jsx(Text, { dimColor: true, children: ` (${items.length} items)` }), markedCount > 0 && (_jsx(Text, { color: "magenta", children: ` ● ${markedCount} marked` }))] }) }), _jsx(TableLayout, { treeItems: visibleTreeItems, cursor: viewport.visibleCursor, capabilities: capabilities, collapsedIds: collapsedIds, markedIds: markedIds, terminalWidth: terminalWidth, sortStack: sortStack }), treeItems.length === 0 && !loading && initError && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Failed to connect to backend:" }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "red", children: initError }) }), _jsx(Text, { dimColor: true, children: "Press , for settings or q to quit." })] })), treeItems.length === 0 && !loading && !initError && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["No ", activeType ?? 'item', "s in this iteration. Press c to create, / to search all."] }) })), loading && treeItems.length === 0 && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Loading..." }) })), showDetailPanel && treeItems.length > 0 && treeItems[cursor] && (_jsx(DetailPanel, { item: treeItems[cursor].item, terminalWidth: terminalWidth, showFullDescription: showFullDescription, descriptionScrollOffset: descriptionScrollOffset, maxDescriptionHeight: maxDescriptionHeight })), _jsx(Box, { marginTop: 1, children: showFullDescription ? (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "\u2191\u2193 scroll space/esc close" }), positionText && _jsxs(Text, { dimColor: true, children: [" ", positionText] })] })) : activeOverlay?.type === 'search' ? ((() => {
953
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { bold: true, color: "cyan", children: [typeLabel, " \u2014 ", iteration] }), _jsx(Text, { dimColor: true, children: ` (${filterCount > 0 ? `${items.length}/${unfilteredCount}` : items.length} item${unfilteredCount === 1 ? '' : 's'})` }), markedCount > 0 && (_jsx(Text, { color: "magenta", children: ` ● ${markedCount} marked` })), filterCount > 0 && (_jsx(Text, { color: "yellow", children: ` [${filterCount} filter${filterCount === 1 ? '' : 's'}${activeViewName ? `: ${activeViewName}` : ''}]` }))] }) }), _jsx(TableLayout, { treeItems: visibleTreeItems, cursor: viewport.visibleCursor, capabilities: capabilities, collapsedIds: collapsedIds, markedIds: markedIds, terminalWidth: terminalWidth, sortStack: sortStack }), treeItems.length === 0 && !loading && initError && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Failed to connect to backend:" }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "red", children: initError }) }), _jsx(Text, { dimColor: true, children: "Press , for settings or q to quit." })] })), treeItems.length === 0 && !loading && !initError && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["No ", activeType ?? 'item', "s in this iteration. Press c to create, / to search all."] }) })), loading && treeItems.length === 0 && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Loading..." }) })), showDetailPanel && treeItems.length > 0 && treeItems[cursor] && (_jsx(Box, { marginTop: 1, children: _jsx(DetailPanel, { item: treeItems[cursor].item, terminalWidth: terminalWidth, showFullDescription: showFullDescription, descriptionScrollOffset: descriptionScrollOffset, maxDescriptionHeight: maxDescriptionHeight }) })), _jsx(Box, { marginTop: 1, children: showFullDescription ? (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "\u2191\u2193 scroll space/esc close" }), positionText && _jsxs(Text, { dimColor: true, children: [" ", positionText] })] })) : activeOverlay?.type === 'search' ? ((() => {
789
954
  const searchItems = allSearchItems.map((item) => ({
790
955
  id: item.id,
791
956
  label: `#${item.id} ${item.title}`,
@@ -1099,6 +1264,107 @@ export function WorkItemList() {
1099
1264
  else {
1100
1265
  toggleSortColumn(item.value);
1101
1266
  }
1267
+ }, onCancel: () => closeOverlay() })) : activeOverlay?.type === 'filter-picker' ? ((() => {
1268
+ const handleFilterConfirm = (selected) => {
1269
+ const newFilters = {};
1270
+ for (const item of selected) {
1271
+ const cat = item.category;
1272
+ if (cat === 'Status') {
1273
+ (newFilters.statuses ??= []).push(item.value);
1274
+ }
1275
+ else if (cat === 'Priority') {
1276
+ (newFilters.priorities ??= []).push(item.value);
1277
+ }
1278
+ else if (cat === 'Type') {
1279
+ (newFilters.types ??= []).push(item.value);
1280
+ }
1281
+ else if (cat === 'Assignee') {
1282
+ (newFilters.assignees ??= []).push(item.value);
1283
+ }
1284
+ else if (cat === 'Labels') {
1285
+ (newFilters.labels ??= []).push(item.value);
1286
+ }
1287
+ }
1288
+ filterStore.getState().setFilters(newFilters);
1289
+ closeOverlay();
1290
+ const count = countActiveFilters(newFilters);
1291
+ if (count > 0) {
1292
+ setToast(`${count} filter${count === 1 ? '' : 's'} applied`);
1293
+ }
1294
+ else {
1295
+ setToast('Filters cleared');
1296
+ }
1297
+ };
1298
+ const handleFilterSelect = (item) => {
1299
+ if (item.value === '__clear__') {
1300
+ filterStore.getState().clearFilters();
1301
+ closeOverlay();
1302
+ setToast('Filters cleared');
1303
+ }
1304
+ };
1305
+ return (_jsx(OverlayPanel, { title: filterCount > 0 ? `Filter [${filterCount} active]` : 'Filter', items: filterPickerItems, multiSelect: true, onSelect: handleFilterSelect, onConfirm: handleFilterConfirm, onCancel: () => closeOverlay(), placeholder: "Type to filter...", footer: "space toggle enter confirm esc cancel" }));
1306
+ })()) : activeOverlay?.type === 'view-picker' ? (_jsx(OverlayPanel, { title: "Load View", items: viewPickerItems, onSelect: (item) => {
1307
+ if (item.value === '__no-filters__') {
1308
+ filterStore.getState().clearFilters();
1309
+ listViewStore.getState().setSortStack([]);
1310
+ closeOverlay();
1311
+ setToast('Filters cleared');
1312
+ return;
1313
+ }
1314
+ const view = savedViews.find((v) => v.name === item.value);
1315
+ if (view) {
1316
+ filterStore.getState().loadView(view);
1317
+ if (view.sort) {
1318
+ listViewStore
1319
+ .getState()
1320
+ .setSortStack(view.sort);
1321
+ }
1322
+ closeOverlay();
1323
+ setToast(`View "${view.name}" loaded`);
1324
+ }
1325
+ }, onCancel: () => closeOverlay(), footer: savedViews.length === 0
1326
+ ? 'Tip: press F to filter, then :save-view to save | enter select esc cancel'
1327
+ : 'enter select esc cancel' })) : activeOverlay?.type === 'save-view-input' ? (_jsx(OverlayPanel, { title: "Save View", items: [], allowFreeform: true, onSelect: () => { }, onSubmitFreeform: (name) => {
1328
+ if (!name.trim()) {
1329
+ closeOverlay();
1330
+ return;
1331
+ }
1332
+ const newView = {
1333
+ name: name.trim(),
1334
+ filters: { ...activeFilters },
1335
+ ...(sortStack.length > 0 ? { sort: [...sortStack] } : {}),
1336
+ };
1337
+ const existing = savedViews.filter((v) => v.name !== name.trim());
1338
+ void configStore.getState().update({
1339
+ views: [...existing, newView],
1340
+ });
1341
+ filterStore.setState({ activeViewName: name.trim() });
1342
+ closeOverlay();
1343
+ setToast(`View "${name.trim()}" saved`);
1344
+ }, onCancel: () => closeOverlay(), placeholder: "Enter view name...", emptyMessage: "Type a name and press enter" })) : activeOverlay?.type === 'delete-view-picker' ? (_jsx(OverlayPanel, { title: "Delete View", items: viewPickerItems.filter((i) => i.id !== '__no-filters__'), onSelect: (item) => {
1345
+ const remaining = savedViews.filter((v) => v.name !== item.value);
1346
+ void configStore.getState().update({
1347
+ views: remaining,
1348
+ ...(defaultView === item.value
1349
+ ? { defaultView: undefined }
1350
+ : {}),
1351
+ });
1352
+ if (activeViewName === item.value) {
1353
+ filterStore.setState({ activeViewName: null });
1354
+ }
1355
+ closeOverlay();
1356
+ setToast(`View "${item.value}" deleted`);
1357
+ }, onCancel: () => closeOverlay() })) : activeOverlay?.type === 'set-default-view' ? (_jsx(OverlayPanel, { title: "Set Default View", items: viewPickerItems, onSelect: (item) => {
1358
+ if (item.value === '__no-filters__') {
1359
+ void configStore.getState().update({ defaultView: undefined });
1360
+ closeOverlay();
1361
+ setToast('Default view cleared');
1362
+ }
1363
+ else {
1364
+ void configStore.getState().update({ defaultView: item.value });
1365
+ closeOverlay();
1366
+ setToast(`View "${item.value}" set as default`);
1367
+ }
1102
1368
  }, onCancel: () => closeOverlay() })) : activeOverlay?.type === 'delete-confirm' ? (_jsx(OverlayPanel, { title: `Delete ${activeOverlay.targetIds.length} item${activeOverlay.targetIds.length > 1 ? 's' : ''}?`, items: [
1103
1369
  { id: 'yes', label: 'Yes, delete', value: 'yes' },
1104
1370
  { id: 'no', label: 'Cancel', value: 'no' },