github-issue-tower-defence-management 1.92.1 → 1.93.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.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.test.tsx +49 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.tsx +36 -20
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.stories.tsx +19 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.test.tsx +24 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.tsx +30 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +30 -24
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.stories.tsx +47 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +54 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +36 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +117 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +105 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.test.ts +115 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.ts +115 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.test.ts +153 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.ts +103 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/diff.test.ts +81 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/diff.ts +61 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/navigation.test.ts +53 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/navigation.ts +33 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/swipe.test.ts +34 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/swipe.ts +28 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +9 -1
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +51 -6
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +263 -17
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +85 -3
- package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +19 -3
- package/src/adapter/entry-points/console/ui/src/index.css +205 -1
- package/src/adapter/entry-points/console/ui-dist/assets/index-C5nxEEOu.css +1 -0
- package/src/adapter/entry-points/console/ui-dist/assets/index-DoJ05EuW.js +101 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/src/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +0 -1
- package/src/adapter/entry-points/console/ui-dist/assets/index-BSNMvjcB.js +0 -100
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { useConsoleSwipeNavigation } from './useConsoleSwipeNavigation';
|
|
3
|
+
|
|
4
|
+
const touchEvent = (
|
|
5
|
+
type: string,
|
|
6
|
+
point: { clientX: number; clientY: number },
|
|
7
|
+
property: 'touches' | 'changedTouches',
|
|
8
|
+
): TouchEvent => {
|
|
9
|
+
const event = new Event(type, { bubbles: true }) as TouchEvent;
|
|
10
|
+
Object.defineProperty(event, property, {
|
|
11
|
+
value: [point],
|
|
12
|
+
configurable: true,
|
|
13
|
+
});
|
|
14
|
+
return event;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const swipe = (
|
|
18
|
+
element: HTMLElement,
|
|
19
|
+
from: { clientX: number; clientY: number },
|
|
20
|
+
to: { clientX: number; clientY: number },
|
|
21
|
+
): void => {
|
|
22
|
+
element.dispatchEvent(touchEvent('touchstart', from, 'touches'));
|
|
23
|
+
element.dispatchEvent(touchEvent('touchmove', to, 'touches'));
|
|
24
|
+
element.dispatchEvent(touchEvent('touchend', to, 'changedTouches'));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('useConsoleSwipeNavigation', () => {
|
|
28
|
+
it('reports next for a dominant left swipe', () => {
|
|
29
|
+
const element = document.createElement('div');
|
|
30
|
+
document.body.appendChild(element);
|
|
31
|
+
const onSwipe = jest.fn();
|
|
32
|
+
const { result } = renderHook(() => useConsoleSwipeNavigation(onSwipe));
|
|
33
|
+
result.current(element);
|
|
34
|
+
swipe(
|
|
35
|
+
element,
|
|
36
|
+
{ clientX: 200, clientY: 100 },
|
|
37
|
+
{ clientX: 60, clientY: 110 },
|
|
38
|
+
);
|
|
39
|
+
expect(onSwipe).toHaveBeenCalledWith('next');
|
|
40
|
+
document.body.removeChild(element);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reports previous for a dominant right swipe', () => {
|
|
44
|
+
const element = document.createElement('div');
|
|
45
|
+
document.body.appendChild(element);
|
|
46
|
+
const onSwipe = jest.fn();
|
|
47
|
+
const { result } = renderHook(() => useConsoleSwipeNavigation(onSwipe));
|
|
48
|
+
result.current(element);
|
|
49
|
+
swipe(
|
|
50
|
+
element,
|
|
51
|
+
{ clientX: 60, clientY: 100 },
|
|
52
|
+
{ clientX: 200, clientY: 110 },
|
|
53
|
+
);
|
|
54
|
+
expect(onSwipe).toHaveBeenCalledWith('previous');
|
|
55
|
+
document.body.removeChild(element);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('ignores a mostly vertical drag', () => {
|
|
59
|
+
const element = document.createElement('div');
|
|
60
|
+
document.body.appendChild(element);
|
|
61
|
+
const onSwipe = jest.fn();
|
|
62
|
+
const { result } = renderHook(() => useConsoleSwipeNavigation(onSwipe));
|
|
63
|
+
result.current(element);
|
|
64
|
+
swipe(
|
|
65
|
+
element,
|
|
66
|
+
{ clientX: 100, clientY: 50 },
|
|
67
|
+
{ clientX: 110, clientY: 200 },
|
|
68
|
+
);
|
|
69
|
+
expect(onSwipe).not.toHaveBeenCalled();
|
|
70
|
+
document.body.removeChild(element);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not navigate when the gesture starts in a horizontally scrollable element', () => {
|
|
74
|
+
const element = document.createElement('div');
|
|
75
|
+
const scroller = document.createElement('div');
|
|
76
|
+
Object.defineProperty(scroller, 'scrollWidth', {
|
|
77
|
+
value: 500,
|
|
78
|
+
configurable: true,
|
|
79
|
+
});
|
|
80
|
+
Object.defineProperty(scroller, 'clientWidth', {
|
|
81
|
+
value: 100,
|
|
82
|
+
configurable: true,
|
|
83
|
+
});
|
|
84
|
+
scroller.style.overflowX = 'auto';
|
|
85
|
+
element.appendChild(scroller);
|
|
86
|
+
document.body.appendChild(element);
|
|
87
|
+
const onSwipe = jest.fn();
|
|
88
|
+
const { result } = renderHook(() => useConsoleSwipeNavigation(onSwipe));
|
|
89
|
+
result.current(element);
|
|
90
|
+
scroller.dispatchEvent(
|
|
91
|
+
touchEvent('touchstart', { clientX: 200, clientY: 100 }, 'touches'),
|
|
92
|
+
);
|
|
93
|
+
scroller.dispatchEvent(
|
|
94
|
+
touchEvent('touchend', { clientX: 60, clientY: 100 }, 'changedTouches'),
|
|
95
|
+
);
|
|
96
|
+
expect(onSwipe).not.toHaveBeenCalled();
|
|
97
|
+
document.body.removeChild(element);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('detaches listeners when the element is unmounted', () => {
|
|
101
|
+
const element = document.createElement('div');
|
|
102
|
+
document.body.appendChild(element);
|
|
103
|
+
const onSwipe = jest.fn();
|
|
104
|
+
const { result } = renderHook(() => useConsoleSwipeNavigation(onSwipe));
|
|
105
|
+
result.current(element);
|
|
106
|
+
result.current(null);
|
|
107
|
+
swipe(
|
|
108
|
+
element,
|
|
109
|
+
{ clientX: 200, clientY: 100 },
|
|
110
|
+
{ clientX: 60, clientY: 110 },
|
|
111
|
+
);
|
|
112
|
+
expect(onSwipe).not.toHaveBeenCalled();
|
|
113
|
+
document.body.removeChild(element);
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
type ConsoleSwipeDirection,
|
|
4
|
+
isVerticallyDominant,
|
|
5
|
+
resolveSwipeDirection,
|
|
6
|
+
} from '../logic/swipe';
|
|
7
|
+
|
|
8
|
+
const isHorizontallyScrollable = (start: EventTarget | null): boolean => {
|
|
9
|
+
let node = start instanceof Element ? start : null;
|
|
10
|
+
while (node !== null && node !== document.body) {
|
|
11
|
+
const style = window.getComputedStyle(node);
|
|
12
|
+
const overflowX = style.overflowX;
|
|
13
|
+
if (
|
|
14
|
+
(overflowX === 'auto' || overflowX === 'scroll') &&
|
|
15
|
+
node.scrollWidth > node.clientWidth
|
|
16
|
+
) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
node = node.parentElement;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const useConsoleSwipeNavigation = (
|
|
25
|
+
onSwipe: (direction: ConsoleSwipeDirection) => void,
|
|
26
|
+
): ((element: HTMLElement | null) => void) => {
|
|
27
|
+
const onSwipeRef = useRef(onSwipe);
|
|
28
|
+
onSwipeRef.current = onSwipe;
|
|
29
|
+
|
|
30
|
+
const detachRef = useRef<(() => void) | null>(null);
|
|
31
|
+
|
|
32
|
+
const attach = useCallback((element: HTMLElement): (() => void) => {
|
|
33
|
+
let startX = 0;
|
|
34
|
+
let startY = 0;
|
|
35
|
+
let tracking = false;
|
|
36
|
+
|
|
37
|
+
const onTouchStart = (event: TouchEvent): void => {
|
|
38
|
+
if (event.touches.length !== 1) {
|
|
39
|
+
tracking = false;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (isHorizontallyScrollable(event.target)) {
|
|
43
|
+
tracking = false;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const touch = event.touches[0];
|
|
47
|
+
startX = touch.clientX;
|
|
48
|
+
startY = touch.clientY;
|
|
49
|
+
tracking = true;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const onTouchMove = (event: TouchEvent): void => {
|
|
53
|
+
if (!tracking) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const touch = event.touches[0];
|
|
57
|
+
if (
|
|
58
|
+
isVerticallyDominant(touch.clientX - startX, touch.clientY - startY)
|
|
59
|
+
) {
|
|
60
|
+
tracking = false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const onTouchEnd = (event: TouchEvent): void => {
|
|
65
|
+
if (!tracking) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
tracking = false;
|
|
69
|
+
if (event.changedTouches.length !== 1) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const touch = event.changedTouches[0];
|
|
73
|
+
const direction = resolveSwipeDirection(
|
|
74
|
+
touch.clientX - startX,
|
|
75
|
+
touch.clientY - startY,
|
|
76
|
+
);
|
|
77
|
+
if (direction !== null) {
|
|
78
|
+
onSwipeRef.current(direction);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
element.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
83
|
+
element.addEventListener('touchmove', onTouchMove, { passive: true });
|
|
84
|
+
element.addEventListener('touchend', onTouchEnd, { passive: true });
|
|
85
|
+
return () => {
|
|
86
|
+
element.removeEventListener('touchstart', onTouchStart);
|
|
87
|
+
element.removeEventListener('touchmove', onTouchMove);
|
|
88
|
+
element.removeEventListener('touchend', onTouchEnd);
|
|
89
|
+
};
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const swipeRef = useCallback(
|
|
93
|
+
(element: HTMLElement | null): void => {
|
|
94
|
+
if (detachRef.current !== null) {
|
|
95
|
+
detachRef.current();
|
|
96
|
+
detachRef.current = null;
|
|
97
|
+
}
|
|
98
|
+
if (element !== null) {
|
|
99
|
+
detachRef.current = attach(element);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[attach],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
return () => {
|
|
107
|
+
if (detachRef.current !== null) {
|
|
108
|
+
detachRef.current();
|
|
109
|
+
detachRef.current = null;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
return swipeRef;
|
|
115
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { consoleListItemsFixture } from '../testing/fixtures';
|
|
2
|
+
import {
|
|
3
|
+
ACTION_TOAST_DELAY_MS,
|
|
4
|
+
actionAdvances,
|
|
5
|
+
actionToastColor,
|
|
6
|
+
actionToastMessage,
|
|
7
|
+
type ConsoleActionKind,
|
|
8
|
+
formatActionToast,
|
|
9
|
+
itemToastLabel,
|
|
10
|
+
} from './actionToast';
|
|
11
|
+
|
|
12
|
+
const prItem = consoleListItemsFixture[0];
|
|
13
|
+
const issueItem = consoleListItemsFixture[2];
|
|
14
|
+
|
|
15
|
+
describe('ACTION_TOAST_DELAY_MS', () => {
|
|
16
|
+
it('is the five second cancellable window', () => {
|
|
17
|
+
expect(ACTION_TOAST_DELAY_MS).toBe(5000);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('itemToastLabel', () => {
|
|
22
|
+
it('prefixes pull requests with PR #', () => {
|
|
23
|
+
expect(itemToastLabel(prItem)).toBe(`PR #${prItem.number}`);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('prefixes issues with #', () => {
|
|
27
|
+
expect(itemToastLabel(issueItem)).toBe(`#${issueItem.number}`);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('actionToastMessage', () => {
|
|
32
|
+
it('labels review actions', () => {
|
|
33
|
+
expect(
|
|
34
|
+
actionToastMessage({ type: 'review', action: 'approve' }, 'prs'),
|
|
35
|
+
).toBe('Approved');
|
|
36
|
+
expect(
|
|
37
|
+
actionToastMessage({ type: 'review', action: 'request_changes' }, 'prs'),
|
|
38
|
+
).toBe('Rejected');
|
|
39
|
+
expect(
|
|
40
|
+
actionToastMessage({ type: 'review', action: 'totally_wrong' }, 'prs'),
|
|
41
|
+
).toBe('Marked totally wrong');
|
|
42
|
+
expect(
|
|
43
|
+
actionToastMessage({ type: 'review', action: 'unnecessary' }, 'prs'),
|
|
44
|
+
).toBe('Marked unnecessary');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('labels the +1 week snooze differently on the todo-by-human tab', () => {
|
|
48
|
+
const kind: ConsoleActionKind = {
|
|
49
|
+
type: 'next_action_date',
|
|
50
|
+
action: 'snooze_1week',
|
|
51
|
+
};
|
|
52
|
+
expect(actionToastMessage(kind, 'prs')).toBe('Next Action Date +1 week');
|
|
53
|
+
expect(actionToastMessage(kind, 'todo-by-human')).toBe(
|
|
54
|
+
'Next Action Date +1 week and skip',
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('labels the +1 day snooze the same on every tab', () => {
|
|
59
|
+
const kind: ConsoleActionKind = {
|
|
60
|
+
type: 'next_action_date',
|
|
61
|
+
action: 'snooze_1day',
|
|
62
|
+
};
|
|
63
|
+
expect(actionToastMessage(kind, 'prs')).toBe('Next Action Date +1 day');
|
|
64
|
+
expect(actionToastMessage(kind, 'todo-by-human')).toBe(
|
|
65
|
+
'Next Action Date +1 day',
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('labels close actions', () => {
|
|
70
|
+
expect(actionToastMessage({ type: 'close', action: 'close' }, 'prs')).toBe(
|
|
71
|
+
'Closed',
|
|
72
|
+
);
|
|
73
|
+
expect(
|
|
74
|
+
actionToastMessage({ type: 'close', action: 'close_not_planned' }, 'prs'),
|
|
75
|
+
).toBe('Closed as not planned');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('labels the in-tmux-by-human action', () => {
|
|
79
|
+
expect(
|
|
80
|
+
actionToastMessage(
|
|
81
|
+
{ type: 'set_in_tmux_by_human', optionName: 'In Tmux by human' },
|
|
82
|
+
'prs',
|
|
83
|
+
),
|
|
84
|
+
).toBe('Added to In Tmux by human');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('labels field updates with the chosen option name', () => {
|
|
88
|
+
expect(
|
|
89
|
+
actionToastMessage(
|
|
90
|
+
{ type: 'set_status', optionName: 'Awaiting Workspace' },
|
|
91
|
+
'prs',
|
|
92
|
+
),
|
|
93
|
+
).toBe('Status → Awaiting Workspace');
|
|
94
|
+
expect(
|
|
95
|
+
actionToastMessage(
|
|
96
|
+
{ type: 'set_story', optionName: 'Move to Okinawa' },
|
|
97
|
+
'triage',
|
|
98
|
+
),
|
|
99
|
+
).toBe('Story → Move to Okinawa');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('actionToastColor', () => {
|
|
104
|
+
it('colors each action group like the reference', () => {
|
|
105
|
+
expect(actionToastColor({ type: 'review', action: 'approve' })).toBe(
|
|
106
|
+
'green',
|
|
107
|
+
);
|
|
108
|
+
expect(
|
|
109
|
+
actionToastColor({ type: 'review', action: 'request_changes' }),
|
|
110
|
+
).toBe('amber');
|
|
111
|
+
expect(actionToastColor({ type: 'review', action: 'totally_wrong' })).toBe(
|
|
112
|
+
'red',
|
|
113
|
+
);
|
|
114
|
+
expect(actionToastColor({ type: 'review', action: 'unnecessary' })).toBe(
|
|
115
|
+
'gray',
|
|
116
|
+
);
|
|
117
|
+
expect(
|
|
118
|
+
actionToastColor({ type: 'set_status', optionName: 'Todo by human' }),
|
|
119
|
+
).toBe('blue');
|
|
120
|
+
expect(
|
|
121
|
+
actionToastColor({ type: 'next_action_date', action: 'snooze_1day' }),
|
|
122
|
+
).toBe('amber');
|
|
123
|
+
expect(actionToastColor({ type: 'close', action: 'close' })).toBe('red');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('actionAdvances', () => {
|
|
128
|
+
it('advances every non-snooze action in every tab', () => {
|
|
129
|
+
expect(actionAdvances({ type: 'review', action: 'approve' }, 'prs')).toBe(
|
|
130
|
+
true,
|
|
131
|
+
);
|
|
132
|
+
expect(actionAdvances({ type: 'set_status', optionName: 'x' }, 'prs')).toBe(
|
|
133
|
+
true,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('does not advance a snooze except on the todo-by-human tab', () => {
|
|
138
|
+
const kind: ConsoleActionKind = {
|
|
139
|
+
type: 'next_action_date',
|
|
140
|
+
action: 'snooze_1week',
|
|
141
|
+
};
|
|
142
|
+
expect(actionAdvances(kind, 'prs')).toBe(false);
|
|
143
|
+
expect(actionAdvances(kind, 'todo-by-human')).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('formatActionToast', () => {
|
|
148
|
+
it('combines the message and the item label', () => {
|
|
149
|
+
expect(
|
|
150
|
+
formatActionToast({ type: 'review', action: 'approve' }, prItem, 'prs'),
|
|
151
|
+
).toBe(`Approved — PR #${prItem.number}`);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConsoleCloseAction,
|
|
3
|
+
ConsoleNextActionDateAction,
|
|
4
|
+
ConsoleReviewAction,
|
|
5
|
+
} from './operations';
|
|
6
|
+
import type { ConsoleListItem, ConsoleTabName } from './types';
|
|
7
|
+
|
|
8
|
+
export type ConsoleToastColor =
|
|
9
|
+
| 'green'
|
|
10
|
+
| 'amber'
|
|
11
|
+
| 'red'
|
|
12
|
+
| 'gray'
|
|
13
|
+
| 'blue'
|
|
14
|
+
| 'error';
|
|
15
|
+
|
|
16
|
+
export type ConsoleActionKind =
|
|
17
|
+
| { type: 'review'; action: ConsoleReviewAction }
|
|
18
|
+
| { type: 'next_action_date'; action: ConsoleNextActionDateAction }
|
|
19
|
+
| { type: 'set_story'; optionName: string }
|
|
20
|
+
| { type: 'set_status'; optionName: string }
|
|
21
|
+
| { type: 'set_in_tmux_by_human'; optionName: string }
|
|
22
|
+
| { type: 'close'; action: ConsoleCloseAction };
|
|
23
|
+
|
|
24
|
+
export const ACTION_TOAST_DELAY_MS = 5000;
|
|
25
|
+
|
|
26
|
+
export const itemToastLabel = (item: ConsoleListItem): string =>
|
|
27
|
+
`${item.isPr ? 'PR #' : '#'}${item.number}`;
|
|
28
|
+
|
|
29
|
+
export const actionToastMessage = (
|
|
30
|
+
kind: ConsoleActionKind,
|
|
31
|
+
tab: ConsoleTabName,
|
|
32
|
+
): string => {
|
|
33
|
+
switch (kind.type) {
|
|
34
|
+
case 'review':
|
|
35
|
+
if (kind.action === 'approve') {
|
|
36
|
+
return 'Approved';
|
|
37
|
+
}
|
|
38
|
+
if (kind.action === 'request_changes') {
|
|
39
|
+
return 'Rejected';
|
|
40
|
+
}
|
|
41
|
+
if (kind.action === 'totally_wrong') {
|
|
42
|
+
return 'Marked totally wrong';
|
|
43
|
+
}
|
|
44
|
+
return 'Marked unnecessary';
|
|
45
|
+
case 'next_action_date':
|
|
46
|
+
if (kind.action === 'snooze_1day') {
|
|
47
|
+
return 'Next Action Date +1 day';
|
|
48
|
+
}
|
|
49
|
+
return tab === 'todo-by-human'
|
|
50
|
+
? 'Next Action Date +1 week and skip'
|
|
51
|
+
: 'Next Action Date +1 week';
|
|
52
|
+
case 'set_story':
|
|
53
|
+
return `Story → ${kind.optionName}`;
|
|
54
|
+
case 'set_status':
|
|
55
|
+
return `Status → ${kind.optionName}`;
|
|
56
|
+
case 'set_in_tmux_by_human':
|
|
57
|
+
return 'Added to In Tmux by human';
|
|
58
|
+
case 'close':
|
|
59
|
+
return kind.action === 'close' ? 'Closed' : 'Closed as not planned';
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const actionToastColor = (
|
|
64
|
+
kind: ConsoleActionKind,
|
|
65
|
+
): ConsoleToastColor => {
|
|
66
|
+
switch (kind.type) {
|
|
67
|
+
case 'review':
|
|
68
|
+
if (kind.action === 'approve') {
|
|
69
|
+
return 'green';
|
|
70
|
+
}
|
|
71
|
+
if (kind.action === 'request_changes') {
|
|
72
|
+
return 'amber';
|
|
73
|
+
}
|
|
74
|
+
if (kind.action === 'totally_wrong') {
|
|
75
|
+
return 'red';
|
|
76
|
+
}
|
|
77
|
+
return 'gray';
|
|
78
|
+
case 'next_action_date':
|
|
79
|
+
return 'amber';
|
|
80
|
+
case 'set_story':
|
|
81
|
+
case 'set_status':
|
|
82
|
+
case 'set_in_tmux_by_human':
|
|
83
|
+
return 'blue';
|
|
84
|
+
case 'close':
|
|
85
|
+
return 'red';
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const actionAdvances = (
|
|
90
|
+
kind: ConsoleActionKind,
|
|
91
|
+
tab: ConsoleTabName,
|
|
92
|
+
): boolean => {
|
|
93
|
+
if (kind.type === 'next_action_date') {
|
|
94
|
+
return tab === 'todo-by-human';
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const formatActionToast = (
|
|
100
|
+
kind: ConsoleActionKind,
|
|
101
|
+
item: ConsoleListItem,
|
|
102
|
+
tab: ConsoleTabName,
|
|
103
|
+
): string => `${actionToastMessage(kind, tab)} — ${itemToastLabel(item)}`;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { parseUnifiedDiff } from './diff';
|
|
2
|
+
|
|
3
|
+
describe('parseUnifiedDiff', () => {
|
|
4
|
+
it('marks a hunk header with no line numbers', () => {
|
|
5
|
+
const rows = parseUnifiedDiff('@@ -54,7 +54,12 @@ jobs:');
|
|
6
|
+
expect(rows).toEqual([
|
|
7
|
+
{
|
|
8
|
+
kind: 'hunk',
|
|
9
|
+
oldLineNumber: null,
|
|
10
|
+
newLineNumber: null,
|
|
11
|
+
content: '@@ -54,7 +54,12 @@ jobs:',
|
|
12
|
+
},
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('numbers context lines on both sides starting from the hunk header', () => {
|
|
17
|
+
const rows = parseUnifiedDiff(
|
|
18
|
+
['@@ -10,2 +20,2 @@', ' context one', ' context two'].join('\n'),
|
|
19
|
+
);
|
|
20
|
+
expect(rows[1]).toEqual({
|
|
21
|
+
kind: 'ctx',
|
|
22
|
+
oldLineNumber: 10,
|
|
23
|
+
newLineNumber: 20,
|
|
24
|
+
content: ' context one',
|
|
25
|
+
});
|
|
26
|
+
expect(rows[2]).toEqual({
|
|
27
|
+
kind: 'ctx',
|
|
28
|
+
oldLineNumber: 11,
|
|
29
|
+
newLineNumber: 21,
|
|
30
|
+
content: ' context two',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('numbers added lines only on the new side', () => {
|
|
35
|
+
const rows = parseUnifiedDiff(
|
|
36
|
+
['@@ -1,1 +1,2 @@', ' kept', '+added line'].join('\n'),
|
|
37
|
+
);
|
|
38
|
+
expect(rows[2]).toEqual({
|
|
39
|
+
kind: 'add',
|
|
40
|
+
oldLineNumber: null,
|
|
41
|
+
newLineNumber: 2,
|
|
42
|
+
content: '+added line',
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('numbers removed lines only on the old side', () => {
|
|
47
|
+
const rows = parseUnifiedDiff(
|
|
48
|
+
['@@ -1,2 +1,1 @@', ' kept', '-removed line'].join('\n'),
|
|
49
|
+
);
|
|
50
|
+
expect(rows[2]).toEqual({
|
|
51
|
+
kind: 'del',
|
|
52
|
+
oldLineNumber: 2,
|
|
53
|
+
newLineNumber: null,
|
|
54
|
+
content: '-removed line',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps the old and new counters independent across a mixed hunk', () => {
|
|
59
|
+
const rows = parseUnifiedDiff(
|
|
60
|
+
[
|
|
61
|
+
'@@ -54,7 +54,12 @@ jobs:',
|
|
62
|
+
' loose-matching: true',
|
|
63
|
+
'- npm install',
|
|
64
|
+
'+ npm ci',
|
|
65
|
+
'+ npm run build',
|
|
66
|
+
' after',
|
|
67
|
+
].join('\n'),
|
|
68
|
+
);
|
|
69
|
+
expect(rows.map((row) => row.kind)).toEqual([
|
|
70
|
+
'hunk',
|
|
71
|
+
'ctx',
|
|
72
|
+
'del',
|
|
73
|
+
'add',
|
|
74
|
+
'add',
|
|
75
|
+
'ctx',
|
|
76
|
+
]);
|
|
77
|
+
const lastContext = rows[5];
|
|
78
|
+
expect(lastContext.oldLineNumber).toBe(56);
|
|
79
|
+
expect(lastContext.newLineNumber).toBe(57);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type ConsoleDiffLineKind = 'hunk' | 'add' | 'del' | 'ctx';
|
|
2
|
+
|
|
3
|
+
export type ConsoleDiffLine = {
|
|
4
|
+
kind: ConsoleDiffLineKind;
|
|
5
|
+
oldLineNumber: number | null;
|
|
6
|
+
newLineNumber: number | null;
|
|
7
|
+
content: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
11
|
+
|
|
12
|
+
export const parseUnifiedDiff = (patch: string): ConsoleDiffLine[] => {
|
|
13
|
+
const rows: ConsoleDiffLine[] = [];
|
|
14
|
+
let oldLineNumber = 0;
|
|
15
|
+
let newLineNumber = 0;
|
|
16
|
+
for (const line of patch.split('\n')) {
|
|
17
|
+
if (line.startsWith('@@')) {
|
|
18
|
+
const match = line.match(HUNK_HEADER);
|
|
19
|
+
if (match !== null) {
|
|
20
|
+
oldLineNumber = Number(match[1]);
|
|
21
|
+
newLineNumber = Number(match[2]);
|
|
22
|
+
}
|
|
23
|
+
rows.push({
|
|
24
|
+
kind: 'hunk',
|
|
25
|
+
oldLineNumber: null,
|
|
26
|
+
newLineNumber: null,
|
|
27
|
+
content: line,
|
|
28
|
+
});
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (line.startsWith('+')) {
|
|
32
|
+
rows.push({
|
|
33
|
+
kind: 'add',
|
|
34
|
+
oldLineNumber: null,
|
|
35
|
+
newLineNumber: newLineNumber,
|
|
36
|
+
content: line,
|
|
37
|
+
});
|
|
38
|
+
newLineNumber += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (line.startsWith('-')) {
|
|
42
|
+
rows.push({
|
|
43
|
+
kind: 'del',
|
|
44
|
+
oldLineNumber: oldLineNumber,
|
|
45
|
+
newLineNumber: null,
|
|
46
|
+
content: line,
|
|
47
|
+
});
|
|
48
|
+
oldLineNumber += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
rows.push({
|
|
52
|
+
kind: 'ctx',
|
|
53
|
+
oldLineNumber: oldLineNumber,
|
|
54
|
+
newLineNumber: newLineNumber,
|
|
55
|
+
content: line,
|
|
56
|
+
});
|
|
57
|
+
oldLineNumber += 1;
|
|
58
|
+
newLineNumber += 1;
|
|
59
|
+
}
|
|
60
|
+
return rows;
|
|
61
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
nextPendingKeyAfter,
|
|
3
|
+
nextPendingKeyBrowse,
|
|
4
|
+
previousPendingKeyBefore,
|
|
5
|
+
} from './navigation';
|
|
6
|
+
|
|
7
|
+
const keys = ['a', 'b', 'c', 'd'];
|
|
8
|
+
|
|
9
|
+
describe('nextPendingKeyAfter', () => {
|
|
10
|
+
it('returns the key immediately after the acted key', () => {
|
|
11
|
+
expect(nextPendingKeyAfter(keys, 'b')).toBe('c');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns null when the acted key is last so the caller returns to the list', () => {
|
|
15
|
+
expect(nextPendingKeyAfter(keys, 'd')).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('falls back to the first key when the acted key is absent', () => {
|
|
19
|
+
expect(nextPendingKeyAfter(keys, 'missing')).toBe('a');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns null for an empty list', () => {
|
|
23
|
+
expect(nextPendingKeyAfter([], 'a')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('nextPendingKeyBrowse', () => {
|
|
28
|
+
it('returns the next key in display order', () => {
|
|
29
|
+
expect(nextPendingKeyBrowse(keys, 'a')).toBe('b');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns null at the end of the list', () => {
|
|
33
|
+
expect(nextPendingKeyBrowse(keys, 'd')).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns null when the current key is absent', () => {
|
|
37
|
+
expect(nextPendingKeyBrowse(keys, 'missing')).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('previousPendingKeyBefore', () => {
|
|
42
|
+
it('returns the previous key in display order', () => {
|
|
43
|
+
expect(previousPendingKeyBefore(keys, 'c')).toBe('b');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns null at the start of the list', () => {
|
|
47
|
+
expect(previousPendingKeyBefore(keys, 'a')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns null when the current key is absent', () => {
|
|
51
|
+
expect(previousPendingKeyBefore(keys, 'missing')).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|