github-issue-tower-defence-management 1.118.1 → 1.119.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/bin/adapter/entry-points/console/ui-dist/assets/{index-BMe1bEF4.css → index-D0RwhyOM.css} +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-syKbd_lW.js +103 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +1 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.tsx +8 -2
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.test.tsx +21 -8
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.tsx +63 -6
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentList.tsx +4 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +4 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.test.ts +37 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.ts +69 -5
- package/src/adapter/entry-points/console/ui/src/features/console/logic/fileTree.test.ts +95 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/fileTree.ts +93 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/references.test.ts +23 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/references.ts +20 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +6 -2
- package/src/adapter/entry-points/console/ui/src/index.css +25 -0
- package/src/adapter/entry-points/console/ui-dist/assets/{index-BMe1bEF4.css → index-D0RwhyOM.css} +1 -1
- package/src/adapter/entry-points/console/ui-dist/assets/index-syKbd_lW.js +103 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/bin/adapter/entry-points/console/ui-dist/assets/index-DWiG7btO.js +0 -103
- package/src/adapter/entry-points/console/ui-dist/assets/index-DWiG7btO.js +0 -103
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>TDPM Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-syKbd_lW.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-D0RwhyOM.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -188,7 +188,7 @@ test('collects an inline comment on a related pull request diff without hover on
|
|
|
188
188
|
|
|
189
189
|
const changedFile = page
|
|
190
190
|
.locator('.console-file-row', {
|
|
191
|
-
hasText: '
|
|
191
|
+
hasText: 'index.css',
|
|
192
192
|
})
|
|
193
193
|
.first();
|
|
194
194
|
await expect(changedFile).toBeVisible();
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
rewriteGitHubImageSources,
|
|
6
6
|
} from '../../lib/imageProxy';
|
|
7
7
|
import {
|
|
8
|
+
type ConsoleRepoContext,
|
|
8
9
|
renderMarkdownToSafeHtml,
|
|
9
10
|
splitMarkdownSegments,
|
|
10
11
|
} from '../../lib/markdown';
|
|
@@ -20,12 +21,14 @@ export type ConsoleMarkdownViewProps = {
|
|
|
20
21
|
body: string;
|
|
21
22
|
buildImageProxyUrl?: ImageProxyUrlBuilder;
|
|
22
23
|
renderReferenceLink?: ConsoleReferenceLinkRenderer;
|
|
24
|
+
repoContext?: ConsoleRepoContext;
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
type ConsoleMarkdownHtmlBlockProps = {
|
|
26
28
|
source: string;
|
|
27
29
|
buildImageProxyUrl?: ImageProxyUrlBuilder;
|
|
28
30
|
renderReferenceLink?: ConsoleReferenceLinkRenderer;
|
|
31
|
+
repoContext?: ConsoleRepoContext;
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
type ReferenceMount = {
|
|
@@ -56,14 +59,15 @@ const ConsoleMarkdownHtmlBlock = ({
|
|
|
56
59
|
source,
|
|
57
60
|
buildImageProxyUrl,
|
|
58
61
|
renderReferenceLink,
|
|
62
|
+
repoContext,
|
|
59
63
|
}: ConsoleMarkdownHtmlBlockProps) => {
|
|
60
64
|
const html = useMemo(() => {
|
|
61
|
-
const safeHtml = renderMarkdownToSafeHtml(source);
|
|
65
|
+
const safeHtml = renderMarkdownToSafeHtml(source, repoContext);
|
|
62
66
|
if (buildImageProxyUrl === undefined) {
|
|
63
67
|
return safeHtml;
|
|
64
68
|
}
|
|
65
69
|
return rewriteGitHubImageSources(safeHtml, buildImageProxyUrl);
|
|
66
|
-
}, [source, buildImageProxyUrl]);
|
|
70
|
+
}, [source, buildImageProxyUrl, repoContext]);
|
|
67
71
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
68
72
|
const [referenceMounts, setReferenceMounts] = useState<ReferenceMount[]>([]);
|
|
69
73
|
|
|
@@ -98,6 +102,7 @@ export const ConsoleMarkdownContent = ({
|
|
|
98
102
|
body,
|
|
99
103
|
buildImageProxyUrl,
|
|
100
104
|
renderReferenceLink,
|
|
105
|
+
repoContext,
|
|
101
106
|
}: ConsoleMarkdownViewProps) => {
|
|
102
107
|
const segments = useMemo(() => splitMarkdownSegments(body), [body]);
|
|
103
108
|
|
|
@@ -116,6 +121,7 @@ export const ConsoleMarkdownContent = ({
|
|
|
116
121
|
source={segment.source}
|
|
117
122
|
buildImageProxyUrl={buildImageProxyUrl}
|
|
118
123
|
renderReferenceLink={renderReferenceLink}
|
|
124
|
+
repoContext={repoContext}
|
|
119
125
|
/>
|
|
120
126
|
),
|
|
121
127
|
)}
|
|
@@ -3,7 +3,7 @@ import { consoleChangedFilesFixture } from '../../testing/fixtures';
|
|
|
3
3
|
import { ConsoleChangedFileList } from './ConsoleChangedFileList';
|
|
4
4
|
|
|
5
5
|
describe('ConsoleChangedFileList', () => {
|
|
6
|
-
it('renders each file
|
|
6
|
+
it('renders each file basename, status badge and additions/deletions', () => {
|
|
7
7
|
const { getByText, getAllByText } = render(
|
|
8
8
|
<ConsoleChangedFileList
|
|
9
9
|
files={consoleChangedFilesFixture}
|
|
@@ -11,14 +11,29 @@ describe('ConsoleChangedFileList', () => {
|
|
|
11
11
|
error={null}
|
|
12
12
|
/>,
|
|
13
13
|
);
|
|
14
|
-
expect(
|
|
15
|
-
getByText('src/adapter/entry-points/console/consoleServer.ts'),
|
|
16
|
-
).toBeInTheDocument();
|
|
14
|
+
expect(getByText('consoleServer.ts')).toBeInTheDocument();
|
|
17
15
|
expect(getByText('+312')).toBeInTheDocument();
|
|
18
16
|
expect(getAllByText('A').length).toBe(2);
|
|
19
17
|
expect(getByText('M')).toBeInTheDocument();
|
|
20
18
|
});
|
|
21
19
|
|
|
20
|
+
it('nests files under their directory nodes as a tree', () => {
|
|
21
|
+
const { container, getByText } = render(
|
|
22
|
+
<ConsoleChangedFileList
|
|
23
|
+
files={consoleChangedFilesFixture}
|
|
24
|
+
isLoading={false}
|
|
25
|
+
error={null}
|
|
26
|
+
/>,
|
|
27
|
+
);
|
|
28
|
+
expect(getByText('adapter')).toBeInTheDocument();
|
|
29
|
+
expect(getByText('console')).toBeInTheDocument();
|
|
30
|
+
expect(getByText('package.json')).toBeInTheDocument();
|
|
31
|
+
const directoryNames = [
|
|
32
|
+
...container.querySelectorAll('.console-file-tree-dir-name'),
|
|
33
|
+
].map((node) => node.textContent);
|
|
34
|
+
expect(directoryNames).toContain('📁src');
|
|
35
|
+
});
|
|
36
|
+
|
|
22
37
|
it('keeps the diff hidden until the file row is clicked', () => {
|
|
23
38
|
const { container, getByText } = render(
|
|
24
39
|
<ConsoleChangedFileList
|
|
@@ -28,9 +43,7 @@ describe('ConsoleChangedFileList', () => {
|
|
|
28
43
|
/>,
|
|
29
44
|
);
|
|
30
45
|
expect(container.querySelector('.console-file-diff')).toBeNull();
|
|
31
|
-
fireEvent.click(
|
|
32
|
-
getByText('src/adapter/entry-points/console/consoleServer.ts'),
|
|
33
|
-
);
|
|
46
|
+
fireEvent.click(getByText('consoleServer.ts'));
|
|
34
47
|
expect(container.querySelector('.console-file-diff')).toBeInTheDocument();
|
|
35
48
|
const codeText = [...container.querySelectorAll('.console-diff-code')].map(
|
|
36
49
|
(cell) => cell.textContent,
|
|
@@ -46,7 +59,7 @@ describe('ConsoleChangedFileList', () => {
|
|
|
46
59
|
error={null}
|
|
47
60
|
/>,
|
|
48
61
|
);
|
|
49
|
-
const path = getByText('
|
|
62
|
+
const path = getByText('consoleServer.ts');
|
|
50
63
|
fireEvent.click(path);
|
|
51
64
|
expect(container.querySelector('.console-file-diff')).toBeInTheDocument();
|
|
52
65
|
fireEvent.click(path);
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { fileStatusBadge } from '../../logic/fileStatus';
|
|
3
|
+
import {
|
|
4
|
+
buildConsoleFileTree,
|
|
5
|
+
type ConsoleFileTreeNode,
|
|
6
|
+
} from '../../logic/fileTree';
|
|
3
7
|
import type { ConsoleChangedFile } from '../../logic/types';
|
|
4
8
|
import {
|
|
5
9
|
type ConsoleAddInlineComment,
|
|
@@ -13,11 +17,19 @@ export type ConsoleChangedFileListProps = {
|
|
|
13
17
|
onAddInlineComment?: ConsoleAddInlineComment;
|
|
14
18
|
};
|
|
15
19
|
|
|
20
|
+
const indentStyle = (depth: number): { paddingLeft: string } => ({
|
|
21
|
+
paddingLeft: `${depth * 16}px`,
|
|
22
|
+
});
|
|
23
|
+
|
|
16
24
|
const ConsoleChangedFileRow = ({
|
|
17
25
|
file,
|
|
26
|
+
depth,
|
|
27
|
+
name,
|
|
18
28
|
onAddInlineComment,
|
|
19
29
|
}: {
|
|
20
30
|
file: ConsoleChangedFile;
|
|
31
|
+
depth: number;
|
|
32
|
+
name: string;
|
|
21
33
|
onAddInlineComment?: ConsoleAddInlineComment;
|
|
22
34
|
}) => {
|
|
23
35
|
const [expanded, setExpanded] = useState<boolean>(false);
|
|
@@ -27,6 +39,7 @@ const ConsoleChangedFileRow = ({
|
|
|
27
39
|
<button
|
|
28
40
|
type="button"
|
|
29
41
|
className="console-file-row"
|
|
42
|
+
style={indentStyle(depth)}
|
|
30
43
|
aria-expanded={expanded}
|
|
31
44
|
onClick={() => setExpanded((value) => !value)}
|
|
32
45
|
>
|
|
@@ -37,7 +50,7 @@ const ConsoleChangedFileRow = ({
|
|
|
37
50
|
>
|
|
38
51
|
{badge.label}
|
|
39
52
|
</span>
|
|
40
|
-
<span className="console-file-path">{
|
|
53
|
+
<span className="console-file-path">{name}</span>
|
|
41
54
|
<span className="console-file-stat console-file-add">
|
|
42
55
|
+{file.additions}
|
|
43
56
|
</span>
|
|
@@ -56,6 +69,47 @@ const ConsoleChangedFileRow = ({
|
|
|
56
69
|
);
|
|
57
70
|
};
|
|
58
71
|
|
|
72
|
+
const ConsoleChangedFileTreeNode = ({
|
|
73
|
+
node,
|
|
74
|
+
depth,
|
|
75
|
+
onAddInlineComment,
|
|
76
|
+
}: {
|
|
77
|
+
node: ConsoleFileTreeNode;
|
|
78
|
+
depth: number;
|
|
79
|
+
onAddInlineComment?: ConsoleAddInlineComment;
|
|
80
|
+
}) => {
|
|
81
|
+
if (node.kind === 'file') {
|
|
82
|
+
return (
|
|
83
|
+
<ConsoleChangedFileRow
|
|
84
|
+
file={node.file}
|
|
85
|
+
depth={depth}
|
|
86
|
+
name={node.name}
|
|
87
|
+
onAddInlineComment={onAddInlineComment}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return (
|
|
92
|
+
<li className="console-file-tree-dir">
|
|
93
|
+
<div className="console-file-tree-dir-name" style={indentStyle(depth)}>
|
|
94
|
+
<span className="console-file-tree-dir-icon" aria-hidden="true">
|
|
95
|
+
📁
|
|
96
|
+
</span>
|
|
97
|
+
{node.name}
|
|
98
|
+
</div>
|
|
99
|
+
<ul className="console-file-tree-children">
|
|
100
|
+
{node.children.map((child) => (
|
|
101
|
+
<ConsoleChangedFileTreeNode
|
|
102
|
+
key={child.path}
|
|
103
|
+
node={child}
|
|
104
|
+
depth={depth + 1}
|
|
105
|
+
onAddInlineComment={onAddInlineComment}
|
|
106
|
+
/>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
</li>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
59
113
|
export const ConsoleChangedFileList = ({
|
|
60
114
|
files,
|
|
61
115
|
isLoading,
|
|
@@ -78,12 +132,15 @@ export const ConsoleChangedFileList = ({
|
|
|
78
132
|
return <p className="console-files-empty">No changed files.</p>;
|
|
79
133
|
}
|
|
80
134
|
|
|
135
|
+
const tree = buildConsoleFileTree(files);
|
|
136
|
+
|
|
81
137
|
return (
|
|
82
|
-
<ul className="console-files">
|
|
83
|
-
{
|
|
84
|
-
<
|
|
85
|
-
key={
|
|
86
|
-
|
|
138
|
+
<ul className="console-files console-file-tree">
|
|
139
|
+
{tree.map((node) => (
|
|
140
|
+
<ConsoleChangedFileTreeNode
|
|
141
|
+
key={node.path}
|
|
142
|
+
node={node}
|
|
143
|
+
depth={0}
|
|
87
144
|
onAddInlineComment={onAddInlineComment}
|
|
88
145
|
/>
|
|
89
146
|
))}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import type { ImageProxyUrlBuilder } from '../../lib/imageProxy';
|
|
3
|
+
import type { ConsoleRepoContext } from '../../logic/references';
|
|
3
4
|
import { formatRelativeTime } from '../../logic/relativeTime';
|
|
4
5
|
import type { ConsoleComment } from '../../logic/types';
|
|
5
6
|
import type { ConsoleReferenceLinkRenderer } from '../content/ConsoleMarkdownContent';
|
|
@@ -12,6 +13,7 @@ export type ConsoleCommentListProps = {
|
|
|
12
13
|
now: number;
|
|
13
14
|
buildImageProxyUrl?: ImageProxyUrlBuilder;
|
|
14
15
|
renderReferenceLink?: ConsoleReferenceLinkRenderer;
|
|
16
|
+
repoContext?: ConsoleRepoContext;
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
export const ConsoleCommentList = ({
|
|
@@ -21,6 +23,7 @@ export const ConsoleCommentList = ({
|
|
|
21
23
|
now,
|
|
22
24
|
buildImageProxyUrl,
|
|
23
25
|
renderReferenceLink,
|
|
26
|
+
repoContext,
|
|
24
27
|
}: ConsoleCommentListProps) => {
|
|
25
28
|
const [showAll, setShowAll] = useState<boolean>(false);
|
|
26
29
|
|
|
@@ -68,6 +71,7 @@ export const ConsoleCommentList = ({
|
|
|
68
71
|
body={comment.body}
|
|
69
72
|
buildImageProxyUrl={buildImageProxyUrl}
|
|
70
73
|
renderReferenceLink={renderReferenceLink}
|
|
74
|
+
repoContext={repoContext}
|
|
71
75
|
/>
|
|
72
76
|
</article>
|
|
73
77
|
))}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { ImageProxyUrlBuilder } from '../../lib/imageProxy';
|
|
3
3
|
import { colorFromEnum } from '../../logic/colors';
|
|
4
|
+
import { parseNameWithOwner } from '../../logic/references';
|
|
4
5
|
import {
|
|
5
6
|
formatFullTimestamp,
|
|
6
7
|
formatRelativeTime,
|
|
@@ -97,6 +98,7 @@ export const ConsoleItemDetail = ({
|
|
|
97
98
|
const merged = state?.merged ?? false;
|
|
98
99
|
const closedStateLabel =
|
|
99
100
|
!item.isPr && resolvedState === 'closed' ? 'Closed' : null;
|
|
101
|
+
const repoContext = parseNameWithOwner(item.nameWithOwner) ?? undefined;
|
|
100
102
|
const storyPalette = colorFromEnum(storyColorEnum);
|
|
101
103
|
const statusPalette = overlayStatus
|
|
102
104
|
? colorFromEnum(overlayStatus.color)
|
|
@@ -224,6 +226,7 @@ export const ConsoleItemDetail = ({
|
|
|
224
226
|
body={body}
|
|
225
227
|
buildImageProxyUrl={buildImageProxyUrl}
|
|
226
228
|
renderReferenceLink={renderReferenceLink}
|
|
229
|
+
repoContext={repoContext}
|
|
227
230
|
/>
|
|
228
231
|
)}
|
|
229
232
|
</ConsolePanel>
|
|
@@ -251,6 +254,7 @@ export const ConsoleItemDetail = ({
|
|
|
251
254
|
now={now}
|
|
252
255
|
buildImageProxyUrl={buildImageProxyUrl}
|
|
253
256
|
renderReferenceLink={renderReferenceLink}
|
|
257
|
+
repoContext={repoContext}
|
|
254
258
|
/>
|
|
255
259
|
</ConsolePanel>
|
|
256
260
|
|
|
@@ -54,6 +54,43 @@ describe('renderMarkdownToSafeHtml', () => {
|
|
|
54
54
|
expect(html).not.toContain(':sparkles:');
|
|
55
55
|
expect(html).not.toContain(':rocket:');
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
it('links a bare #N reference to the provided repo issues path', () => {
|
|
59
|
+
const html = renderMarkdownToSafeHtml('close #127', {
|
|
60
|
+
owner: 'HiromiShikata',
|
|
61
|
+
repo: 'npm-cli-github-issue-tower-defence-management',
|
|
62
|
+
});
|
|
63
|
+
expect(html).toContain(
|
|
64
|
+
'<a href="https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/127">#127</a>',
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps a bare #N reference as plain text when no repo context is given', () => {
|
|
69
|
+
const html = renderMarkdownToSafeHtml('close #127');
|
|
70
|
+
expect(html).toContain('#127');
|
|
71
|
+
expect(html).not.toContain('<a ');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps a full GitHub URL as a rendered link', () => {
|
|
75
|
+
const html = renderMarkdownToSafeHtml(
|
|
76
|
+
'see https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/42',
|
|
77
|
+
{
|
|
78
|
+
owner: 'HiromiShikata',
|
|
79
|
+
repo: 'npm-cli-github-issue-tower-defence-management',
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
expect(html).toContain(
|
|
83
|
+
'href="https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/42"',
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('preserves the href attribute on issue reference anchors after sanitization', () => {
|
|
88
|
+
const html = renderMarkdownToSafeHtml('resolves #9', {
|
|
89
|
+
owner: 'owner',
|
|
90
|
+
repo: 'repo',
|
|
91
|
+
});
|
|
92
|
+
expect(html).toContain('href="https://github.com/owner/repo/issues/9"');
|
|
93
|
+
});
|
|
57
94
|
});
|
|
58
95
|
|
|
59
96
|
describe('splitMarkdownSegments', () => {
|
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
import DOMPurify from 'dompurify';
|
|
2
2
|
import { nameToEmoji } from 'gemoji';
|
|
3
|
-
import { marked } from 'marked';
|
|
3
|
+
import { marked, type Tokens } from 'marked';
|
|
4
4
|
import { markedEmoji } from 'marked-emoji';
|
|
5
|
+
import type { ConsoleRepoContext } from '../logic/references';
|
|
6
|
+
|
|
7
|
+
export type { ConsoleRepoContext } from '../logic/references';
|
|
8
|
+
|
|
9
|
+
type IssueReferenceToken = Tokens.Generic & {
|
|
10
|
+
type: 'consoleIssueReference';
|
|
11
|
+
raw: string;
|
|
12
|
+
numberText: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const ISSUE_REFERENCE_PATTERN = /^#(\d+)\b/;
|
|
16
|
+
|
|
17
|
+
let activeRepoContext: ConsoleRepoContext | null = null;
|
|
18
|
+
|
|
19
|
+
const issueReferenceHref = (numberText: string): string | null => {
|
|
20
|
+
if (activeRepoContext === null) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const { owner, repo } = activeRepoContext;
|
|
24
|
+
return `https://github.com/${owner}/${repo}/issues/${numberText}`;
|
|
25
|
+
};
|
|
5
26
|
|
|
6
27
|
marked.use(
|
|
7
28
|
markedEmoji({
|
|
@@ -10,15 +31,58 @@ marked.use(
|
|
|
10
31
|
}),
|
|
11
32
|
);
|
|
12
33
|
|
|
13
|
-
|
|
34
|
+
marked.use({
|
|
35
|
+
extensions: [
|
|
36
|
+
{
|
|
37
|
+
name: 'consoleIssueReference',
|
|
38
|
+
level: 'inline',
|
|
39
|
+
start(src: string) {
|
|
40
|
+
const index = src.indexOf('#');
|
|
41
|
+
return index === -1 ? undefined : index;
|
|
42
|
+
},
|
|
43
|
+
tokenizer(src: string): IssueReferenceToken | undefined {
|
|
44
|
+
if (activeRepoContext === null) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const match = ISSUE_REFERENCE_PATTERN.exec(src);
|
|
48
|
+
if (match === null) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
type: 'consoleIssueReference',
|
|
53
|
+
raw: match[0],
|
|
54
|
+
numberText: match[1],
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
renderer(token: Tokens.Generic): string {
|
|
58
|
+
const reference = token as IssueReferenceToken;
|
|
59
|
+
const href = issueReferenceHref(reference.numberText);
|
|
60
|
+
if (href === null) {
|
|
61
|
+
return reference.raw;
|
|
62
|
+
}
|
|
63
|
+
return `<a href="${href}">#${reference.numberText}</a>`;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const renderMarkdownToSafeHtml = (
|
|
70
|
+
source: string,
|
|
71
|
+
repoContext?: ConsoleRepoContext,
|
|
72
|
+
): string => {
|
|
14
73
|
const trimmed = source.trim();
|
|
15
74
|
if (trimmed === '') {
|
|
16
75
|
return '';
|
|
17
76
|
}
|
|
77
|
+
activeRepoContext = repoContext ?? null;
|
|
18
78
|
marked.setOptions({ gfm: true, breaks: true });
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
79
|
+
try {
|
|
80
|
+
const parsed = marked.parse(source, { async: false });
|
|
81
|
+
const rawHtml = typeof parsed === 'string' ? parsed : '';
|
|
82
|
+
return DOMPurify.sanitize(rawHtml);
|
|
83
|
+
} finally {
|
|
84
|
+
activeRepoContext = null;
|
|
85
|
+
}
|
|
22
86
|
};
|
|
23
87
|
|
|
24
88
|
export type ConsoleMarkdownSegment =
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { buildConsoleFileTree } from './fileTree';
|
|
2
|
+
import type { ConsoleChangedFile } from './types';
|
|
3
|
+
|
|
4
|
+
const changedFile = (path: string): ConsoleChangedFile => ({
|
|
5
|
+
path,
|
|
6
|
+
additions: 1,
|
|
7
|
+
deletions: 0,
|
|
8
|
+
status: 'modified',
|
|
9
|
+
patch: null,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('buildConsoleFileTree', () => {
|
|
13
|
+
it('nests files under their directory segments', () => {
|
|
14
|
+
const tree = buildConsoleFileTree([
|
|
15
|
+
changedFile('src/features/console/markdown.ts'),
|
|
16
|
+
]);
|
|
17
|
+
expect(tree).toEqual([
|
|
18
|
+
{
|
|
19
|
+
kind: 'directory',
|
|
20
|
+
name: 'src',
|
|
21
|
+
path: 'src',
|
|
22
|
+
children: [
|
|
23
|
+
{
|
|
24
|
+
kind: 'directory',
|
|
25
|
+
name: 'features',
|
|
26
|
+
path: 'src/features',
|
|
27
|
+
children: [
|
|
28
|
+
{
|
|
29
|
+
kind: 'directory',
|
|
30
|
+
name: 'console',
|
|
31
|
+
path: 'src/features/console',
|
|
32
|
+
children: [
|
|
33
|
+
{
|
|
34
|
+
kind: 'file',
|
|
35
|
+
name: 'markdown.ts',
|
|
36
|
+
path: 'src/features/console/markdown.ts',
|
|
37
|
+
file: changedFile('src/features/console/markdown.ts'),
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('groups multiple files that share a directory under a single directory node', () => {
|
|
49
|
+
const tree = buildConsoleFileTree([
|
|
50
|
+
changedFile('src/one.ts'),
|
|
51
|
+
changedFile('src/two.ts'),
|
|
52
|
+
]);
|
|
53
|
+
expect(tree).toHaveLength(1);
|
|
54
|
+
const [directory] = tree;
|
|
55
|
+
expect(directory.kind).toBe('directory');
|
|
56
|
+
if (directory.kind !== 'directory') {
|
|
57
|
+
throw new Error('expected a directory node');
|
|
58
|
+
}
|
|
59
|
+
expect(directory.children.map((child) => child.name)).toEqual([
|
|
60
|
+
'one.ts',
|
|
61
|
+
'two.ts',
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('sorts directories before files and both alphabetically', () => {
|
|
66
|
+
const tree = buildConsoleFileTree([
|
|
67
|
+
changedFile('zeta.ts'),
|
|
68
|
+
changedFile('alpha.ts'),
|
|
69
|
+
changedFile('lib/util.ts'),
|
|
70
|
+
changedFile('components/view.ts'),
|
|
71
|
+
]);
|
|
72
|
+
expect(tree.map((node) => node.name)).toEqual([
|
|
73
|
+
'components',
|
|
74
|
+
'lib',
|
|
75
|
+
'alpha.ts',
|
|
76
|
+
'zeta.ts',
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('places a root-level file as a leaf at the top level', () => {
|
|
81
|
+
const tree = buildConsoleFileTree([changedFile('package.json')]);
|
|
82
|
+
expect(tree).toEqual([
|
|
83
|
+
{
|
|
84
|
+
kind: 'file',
|
|
85
|
+
name: 'package.json',
|
|
86
|
+
path: 'package.json',
|
|
87
|
+
file: changedFile('package.json'),
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns an empty tree for no files', () => {
|
|
93
|
+
expect(buildConsoleFileTree([])).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ConsoleChangedFile } from './types';
|
|
2
|
+
|
|
3
|
+
export type ConsoleFileTreeFileNode = {
|
|
4
|
+
kind: 'file';
|
|
5
|
+
name: string;
|
|
6
|
+
path: string;
|
|
7
|
+
file: ConsoleChangedFile;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ConsoleFileTreeDirectoryNode = {
|
|
11
|
+
kind: 'directory';
|
|
12
|
+
name: string;
|
|
13
|
+
path: string;
|
|
14
|
+
children: ConsoleFileTreeNode[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ConsoleFileTreeNode =
|
|
18
|
+
| ConsoleFileTreeDirectoryNode
|
|
19
|
+
| ConsoleFileTreeFileNode;
|
|
20
|
+
|
|
21
|
+
type DirectoryBuilder = {
|
|
22
|
+
directories: Map<string, DirectoryBuilder>;
|
|
23
|
+
files: ConsoleChangedFile[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const createDirectoryBuilder = (): DirectoryBuilder => ({
|
|
27
|
+
directories: new Map(),
|
|
28
|
+
files: [],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const splitPathSegments = (path: string): string[] =>
|
|
32
|
+
path.split('/').filter((segment) => segment !== '');
|
|
33
|
+
|
|
34
|
+
const compareByName = (
|
|
35
|
+
left: { name: string },
|
|
36
|
+
right: { name: string },
|
|
37
|
+
): number => left.name.localeCompare(right.name);
|
|
38
|
+
|
|
39
|
+
const buildNodes = (
|
|
40
|
+
builder: DirectoryBuilder,
|
|
41
|
+
parentPath: string,
|
|
42
|
+
): ConsoleFileTreeNode[] => {
|
|
43
|
+
const directoryNodes: ConsoleFileTreeDirectoryNode[] = [];
|
|
44
|
+
builder.directories.forEach((childBuilder, name) => {
|
|
45
|
+
const path = parentPath === '' ? name : `${parentPath}/${name}`;
|
|
46
|
+
directoryNodes.push({
|
|
47
|
+
kind: 'directory',
|
|
48
|
+
name,
|
|
49
|
+
path,
|
|
50
|
+
children: buildNodes(childBuilder, path),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
directoryNodes.sort(compareByName);
|
|
54
|
+
|
|
55
|
+
const fileNodes: ConsoleFileTreeFileNode[] = builder.files.map((file) => ({
|
|
56
|
+
kind: 'file',
|
|
57
|
+
name: splitPathSegments(file.path).at(-1) ?? file.path,
|
|
58
|
+
path: file.path,
|
|
59
|
+
file,
|
|
60
|
+
}));
|
|
61
|
+
fileNodes.sort(compareByName);
|
|
62
|
+
|
|
63
|
+
return [...directoryNodes, ...fileNodes];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const buildConsoleFileTree = (
|
|
67
|
+
files: ConsoleChangedFile[],
|
|
68
|
+
): ConsoleFileTreeNode[] => {
|
|
69
|
+
const root = createDirectoryBuilder();
|
|
70
|
+
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const segments = splitPathSegments(file.path);
|
|
73
|
+
if (segments.length === 0) {
|
|
74
|
+
root.files.push(file);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const directorySegments = segments.slice(0, -1);
|
|
78
|
+
let cursor = root;
|
|
79
|
+
for (const segment of directorySegments) {
|
|
80
|
+
const existing = cursor.directories.get(segment);
|
|
81
|
+
if (existing === undefined) {
|
|
82
|
+
const created = createDirectoryBuilder();
|
|
83
|
+
cursor.directories.set(segment, created);
|
|
84
|
+
cursor = created;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
cursor = existing;
|
|
88
|
+
}
|
|
89
|
+
cursor.files.push(file);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return buildNodes(root, '');
|
|
93
|
+
};
|
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
import {
|
|
2
2
|
parseGitHubReferenceUrl,
|
|
3
|
+
parseNameWithOwner,
|
|
3
4
|
referenceStateToIconInput,
|
|
4
5
|
} from './references';
|
|
5
6
|
|
|
7
|
+
describe('parseNameWithOwner', () => {
|
|
8
|
+
it('splits owner and repo on the first slash', () => {
|
|
9
|
+
expect(parseNameWithOwner('HiromiShikata/repo-name')).toEqual({
|
|
10
|
+
owner: 'HiromiShikata',
|
|
11
|
+
repo: 'repo-name',
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns null when there is no slash', () => {
|
|
16
|
+
expect(parseNameWithOwner('repo-name')).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns null when owner or repo is empty', () => {
|
|
20
|
+
expect(parseNameWithOwner('/repo')).toBeNull();
|
|
21
|
+
expect(parseNameWithOwner('owner/')).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns null when the value contains extra path segments', () => {
|
|
25
|
+
expect(parseNameWithOwner('owner/repo/extra')).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
6
29
|
describe('parseGitHubReferenceUrl', () => {
|
|
7
30
|
it('parses an issue url', () => {
|
|
8
31
|
expect(
|