ghcrawl 0.1.0 → 0.1.1
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/README.md +32 -0
- package/bin/ghcrawl.js +29 -19
- package/dist/init-wizard.d.ts +41 -0
- package/dist/init-wizard.d.ts.map +1 -0
- package/dist/init-wizard.js +255 -0
- package/dist/init-wizard.js.map +1 -0
- package/dist/main.d.ts +18 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +398 -0
- package/dist/main.js.map +1 -0
- package/dist/tui/app.d.ts +37 -0
- package/dist/tui/app.d.ts.map +1 -0
- package/dist/tui/app.js +1055 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/layout.d.ts +17 -0
- package/dist/tui/layout.d.ts.map +1 -0
- package/dist/tui/layout.js +34 -0
- package/dist/tui/layout.js.map +1 -0
- package/dist/tui/state.d.ts +30 -0
- package/dist/tui/state.d.ts.map +1 -0
- package/dist/tui/state.js +101 -0
- package/dist/tui/state.js.map +1 -0
- package/package.json +4 -3
- package/src/init-wizard.test.ts +0 -185
- package/src/init-wizard.ts +0 -323
- package/src/main.test.ts +0 -181
- package/src/main.ts +0 -447
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.test.ts +0 -164
- package/src/tui/app.ts +0 -1210
- package/src/tui/layout.test.ts +0 -19
- package/src/tui/layout.ts +0 -53
- package/src/tui/state.test.ts +0 -116
- package/src/tui/state.ts +0 -121
package/src/tui/layout.test.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
|
|
4
|
-
import { computeTuiLayout } from './layout.js';
|
|
5
|
-
|
|
6
|
-
test('computeTuiLayout uses wide mode for large terminals', () => {
|
|
7
|
-
const layout = computeTuiLayout(160, 40);
|
|
8
|
-
assert.equal(layout.mode, 'wide');
|
|
9
|
-
assert.equal(layout.clusters.top, 1);
|
|
10
|
-
assert.equal(layout.footer.top, 35);
|
|
11
|
-
assert.equal(layout.footer.height, 5);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
test('computeTuiLayout switches to stacked mode for narrow terminals', () => {
|
|
15
|
-
const layout = computeTuiLayout(100, 30);
|
|
16
|
-
assert.equal(layout.mode, 'stacked');
|
|
17
|
-
assert.equal(layout.members.top > layout.clusters.top, true);
|
|
18
|
-
assert.equal(layout.detail.top > layout.members.top, true);
|
|
19
|
-
});
|
package/src/tui/layout.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
export type TuiLayoutMode = 'wide' | 'stacked';
|
|
2
|
-
|
|
3
|
-
export type TuiPaneRect = {
|
|
4
|
-
top: number;
|
|
5
|
-
left: number;
|
|
6
|
-
width: number;
|
|
7
|
-
height: number;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
export type TuiLayout = {
|
|
11
|
-
mode: TuiLayoutMode;
|
|
12
|
-
header: TuiPaneRect;
|
|
13
|
-
clusters: TuiPaneRect;
|
|
14
|
-
members: TuiPaneRect;
|
|
15
|
-
detail: TuiPaneRect;
|
|
16
|
-
footer: TuiPaneRect;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export function computeTuiLayout(width: number, height: number): TuiLayout {
|
|
20
|
-
const safeWidth = Math.max(60, width);
|
|
21
|
-
const safeHeight = Math.max(16, height);
|
|
22
|
-
const footerHeight = 5;
|
|
23
|
-
const contentTop = 1;
|
|
24
|
-
const contentHeight = Math.max(6, safeHeight - 1 - footerHeight);
|
|
25
|
-
const header = { top: 0, left: 0, width: safeWidth, height: 1 };
|
|
26
|
-
const footer = { top: safeHeight - footerHeight, left: 0, width: safeWidth, height: footerHeight };
|
|
27
|
-
|
|
28
|
-
if (safeWidth >= 140) {
|
|
29
|
-
const leftWidth = Math.floor(safeWidth * 0.34);
|
|
30
|
-
const middleWidth = Math.floor(safeWidth * 0.30);
|
|
31
|
-
const rightWidth = safeWidth - leftWidth - middleWidth;
|
|
32
|
-
return {
|
|
33
|
-
mode: 'wide',
|
|
34
|
-
header,
|
|
35
|
-
clusters: { top: contentTop, left: 0, width: leftWidth, height: contentHeight },
|
|
36
|
-
members: { top: contentTop, left: leftWidth, width: middleWidth, height: contentHeight },
|
|
37
|
-
detail: { top: contentTop, left: leftWidth + middleWidth, width: rightWidth, height: contentHeight },
|
|
38
|
-
footer,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const clustersHeight = Math.max(4, Math.floor(contentHeight * 0.38));
|
|
43
|
-
const membersHeight = Math.max(4, Math.floor(contentHeight * 0.27));
|
|
44
|
-
const detailHeight = Math.max(4, contentHeight - clustersHeight - membersHeight);
|
|
45
|
-
return {
|
|
46
|
-
mode: 'stacked',
|
|
47
|
-
header,
|
|
48
|
-
clusters: { top: contentTop, left: 0, width: safeWidth, height: clustersHeight },
|
|
49
|
-
members: { top: contentTop + clustersHeight, left: 0, width: safeWidth, height: membersHeight },
|
|
50
|
-
detail: { top: contentTop + clustersHeight + membersHeight, left: 0, width: safeWidth, height: detailHeight },
|
|
51
|
-
footer,
|
|
52
|
-
};
|
|
53
|
-
}
|
package/src/tui/state.test.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
|
|
4
|
-
import { buildMemberRows, cycleFocusPane, cycleMinSizeFilter, cycleSortMode, findSelectableIndex, moveSelectableIndex, preserveSelectedId, applyClusterFilters } from './state.js';
|
|
5
|
-
import type { TuiClusterDetail, TuiClusterSummary } from '@ghcrawl/api-core';
|
|
6
|
-
|
|
7
|
-
test('cycleSortMode toggles recent and size', () => {
|
|
8
|
-
assert.equal(cycleSortMode('recent'), 'size');
|
|
9
|
-
assert.equal(cycleSortMode('size'), 'recent');
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test('cycleMinSizeFilter rotates through presets', () => {
|
|
13
|
-
assert.equal(cycleMinSizeFilter(1), 10);
|
|
14
|
-
assert.equal(cycleMinSizeFilter(10), 20);
|
|
15
|
-
assert.equal(cycleMinSizeFilter(20), 50);
|
|
16
|
-
assert.equal(cycleMinSizeFilter(50), 0);
|
|
17
|
-
assert.equal(cycleMinSizeFilter(0), 1);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test('cycleFocusPane moves forward and backward', () => {
|
|
21
|
-
assert.equal(cycleFocusPane('clusters', 1), 'members');
|
|
22
|
-
assert.equal(cycleFocusPane('clusters', -1), 'detail');
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test('applyClusterFilters sorts by recent and size and respects min size/search', () => {
|
|
26
|
-
const clusters: TuiClusterSummary[] = [
|
|
27
|
-
{
|
|
28
|
-
clusterId: 1,
|
|
29
|
-
displayTitle: 'Older larger',
|
|
30
|
-
totalCount: 12,
|
|
31
|
-
issueCount: 10,
|
|
32
|
-
pullRequestCount: 2,
|
|
33
|
-
latestUpdatedAt: '2026-03-09T10:00:00Z',
|
|
34
|
-
representativeThreadId: 10,
|
|
35
|
-
representativeNumber: 42,
|
|
36
|
-
representativeKind: 'issue',
|
|
37
|
-
searchText: 'older larger cluster',
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
clusterId: 2,
|
|
41
|
-
displayTitle: 'Newest smaller',
|
|
42
|
-
totalCount: 11,
|
|
43
|
-
issueCount: 8,
|
|
44
|
-
pullRequestCount: 3,
|
|
45
|
-
latestUpdatedAt: '2026-03-09T11:00:00Z',
|
|
46
|
-
representativeThreadId: 11,
|
|
47
|
-
representativeNumber: 43,
|
|
48
|
-
representativeKind: 'pull_request',
|
|
49
|
-
searchText: 'newest smaller cluster',
|
|
50
|
-
},
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
assert.deepEqual(
|
|
54
|
-
applyClusterFilters(clusters, { sortMode: 'recent', minSize: 10, search: '' }).map((cluster) => cluster.clusterId),
|
|
55
|
-
[2, 1],
|
|
56
|
-
);
|
|
57
|
-
assert.deepEqual(
|
|
58
|
-
applyClusterFilters(clusters, { sortMode: 'size', minSize: 10, search: '' }).map((cluster) => cluster.clusterId),
|
|
59
|
-
[1, 2],
|
|
60
|
-
);
|
|
61
|
-
assert.deepEqual(
|
|
62
|
-
applyClusterFilters(clusters, { sortMode: 'recent', minSize: 20, search: '' }),
|
|
63
|
-
[],
|
|
64
|
-
);
|
|
65
|
-
assert.deepEqual(
|
|
66
|
-
applyClusterFilters(clusters, { sortMode: 'recent', minSize: 0, search: 'newest' }).map((cluster) => cluster.clusterId),
|
|
67
|
-
[2],
|
|
68
|
-
);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('preserveSelectedId keeps existing selection and falls back to first', () => {
|
|
72
|
-
assert.equal(preserveSelectedId([10, 11], 11), 11);
|
|
73
|
-
assert.equal(preserveSelectedId([10, 11], 99), 10);
|
|
74
|
-
assert.equal(preserveSelectedId([], 99), null);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('buildMemberRows groups issues and pull requests and selection skips headers', () => {
|
|
78
|
-
const detail: TuiClusterDetail = {
|
|
79
|
-
clusterId: 1,
|
|
80
|
-
displayTitle: 'Cluster 1',
|
|
81
|
-
totalCount: 2,
|
|
82
|
-
issueCount: 1,
|
|
83
|
-
pullRequestCount: 1,
|
|
84
|
-
latestUpdatedAt: '2026-03-09T11:00:00Z',
|
|
85
|
-
representativeThreadId: 10,
|
|
86
|
-
representativeNumber: 42,
|
|
87
|
-
representativeKind: 'issue',
|
|
88
|
-
members: [
|
|
89
|
-
{
|
|
90
|
-
id: 10,
|
|
91
|
-
number: 42,
|
|
92
|
-
kind: 'issue',
|
|
93
|
-
title: 'Issue one',
|
|
94
|
-
updatedAtGh: '2026-03-09T11:00:00Z',
|
|
95
|
-
htmlUrl: 'https://example.com/42',
|
|
96
|
-
labels: ['bug'],
|
|
97
|
-
clusterScore: null,
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
id: 11,
|
|
101
|
-
number: 43,
|
|
102
|
-
kind: 'pull_request',
|
|
103
|
-
title: 'PR one',
|
|
104
|
-
updatedAtGh: '2026-03-09T10:00:00Z',
|
|
105
|
-
htmlUrl: 'https://example.com/43',
|
|
106
|
-
labels: ['bug'],
|
|
107
|
-
clusterScore: 0.92,
|
|
108
|
-
},
|
|
109
|
-
],
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const rows = buildMemberRows(detail);
|
|
113
|
-
assert.equal(rows[0]?.selectable, false);
|
|
114
|
-
assert.equal(findSelectableIndex(rows, 10), 1);
|
|
115
|
-
assert.equal(moveSelectableIndex(rows, 1, 1), 3);
|
|
116
|
-
});
|
package/src/tui/state.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import type { TuiClusterDetail, TuiClusterSortMode, TuiClusterSummary } from '@ghcrawl/api-core';
|
|
2
|
-
|
|
3
|
-
export type TuiFocusPane = 'clusters' | 'members' | 'detail';
|
|
4
|
-
export type TuiMinSizeFilter = 0 | 1 | 10 | 20 | 50;
|
|
5
|
-
|
|
6
|
-
export type MemberListRow =
|
|
7
|
-
| { key: string; label: string; selectable: false }
|
|
8
|
-
| { key: string; label: string; selectable: true; threadId: number };
|
|
9
|
-
|
|
10
|
-
export const SORT_MODE_ORDER: TuiClusterSortMode[] = ['recent', 'size'];
|
|
11
|
-
export const MIN_SIZE_FILTER_ORDER: TuiMinSizeFilter[] = [1, 10, 20, 50, 0];
|
|
12
|
-
export const FOCUS_PANE_ORDER: TuiFocusPane[] = ['clusters', 'members', 'detail'];
|
|
13
|
-
|
|
14
|
-
export function cycleSortMode(current: TuiClusterSortMode): TuiClusterSortMode {
|
|
15
|
-
const index = SORT_MODE_ORDER.indexOf(current);
|
|
16
|
-
return SORT_MODE_ORDER[(index + 1) % SORT_MODE_ORDER.length] ?? 'recent';
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function cycleMinSizeFilter(current: TuiMinSizeFilter): TuiMinSizeFilter {
|
|
20
|
-
const index = MIN_SIZE_FILTER_ORDER.indexOf(current);
|
|
21
|
-
return MIN_SIZE_FILTER_ORDER[(index + 1) % MIN_SIZE_FILTER_ORDER.length] ?? 10;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function cycleFocusPane(current: TuiFocusPane, direction: 1 | -1 = 1): TuiFocusPane {
|
|
25
|
-
const index = FOCUS_PANE_ORDER.indexOf(current);
|
|
26
|
-
const next = (index + direction + FOCUS_PANE_ORDER.length) % FOCUS_PANE_ORDER.length;
|
|
27
|
-
return FOCUS_PANE_ORDER[next] ?? 'clusters';
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function applyClusterFilters(
|
|
31
|
-
clusters: TuiClusterSummary[],
|
|
32
|
-
params: { sortMode: TuiClusterSortMode; minSize: TuiMinSizeFilter; search: string },
|
|
33
|
-
): TuiClusterSummary[] {
|
|
34
|
-
const normalizedSearch = params.search.trim().toLowerCase();
|
|
35
|
-
return clusters
|
|
36
|
-
.filter((cluster) => cluster.totalCount >= params.minSize)
|
|
37
|
-
.filter((cluster) => (normalizedSearch ? cluster.searchText.includes(normalizedSearch) : true))
|
|
38
|
-
.slice()
|
|
39
|
-
.sort((left, right) => compareClusters(left, right, params.sortMode));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function preserveSelectedId(ids: number[], selectedId: number | null): number | null {
|
|
43
|
-
if (selectedId !== null && ids.includes(selectedId)) {
|
|
44
|
-
return selectedId;
|
|
45
|
-
}
|
|
46
|
-
return ids[0] ?? null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function buildMemberRows(detail: TuiClusterDetail | null): MemberListRow[] {
|
|
50
|
-
if (!detail) return [];
|
|
51
|
-
const issues = detail.members.filter((member) => member.kind === 'issue');
|
|
52
|
-
const pullRequests = detail.members.filter((member) => member.kind === 'pull_request');
|
|
53
|
-
const rows: MemberListRow[] = [];
|
|
54
|
-
|
|
55
|
-
if (issues.length > 0) {
|
|
56
|
-
rows.push({ key: 'issues-header', label: `ISSUES (${issues.length})`, selectable: false });
|
|
57
|
-
for (const issue of issues) {
|
|
58
|
-
rows.push({
|
|
59
|
-
key: `thread-${issue.id}`,
|
|
60
|
-
label: formatMemberLabel(issue.number, issue.title, issue.updatedAtGh),
|
|
61
|
-
selectable: true,
|
|
62
|
-
threadId: issue.id,
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (pullRequests.length > 0) {
|
|
68
|
-
rows.push({ key: 'pulls-header', label: `PULL REQUESTS (${pullRequests.length})`, selectable: false });
|
|
69
|
-
for (const pullRequest of pullRequests) {
|
|
70
|
-
rows.push({
|
|
71
|
-
key: `thread-${pullRequest.id}`,
|
|
72
|
-
label: formatMemberLabel(pullRequest.number, pullRequest.title, pullRequest.updatedAtGh),
|
|
73
|
-
selectable: true,
|
|
74
|
-
threadId: pullRequest.id,
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return rows;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function findSelectableIndex(rows: MemberListRow[], threadId: number | null): number {
|
|
83
|
-
if (threadId !== null) {
|
|
84
|
-
const index = rows.findIndex((row) => row.selectable && row.threadId === threadId);
|
|
85
|
-
if (index >= 0) return index;
|
|
86
|
-
}
|
|
87
|
-
return rows.findIndex((row) => row.selectable);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function moveSelectableIndex(rows: MemberListRow[], currentIndex: number, delta: -1 | 1): number {
|
|
91
|
-
if (rows.length === 0) return -1;
|
|
92
|
-
let index = currentIndex;
|
|
93
|
-
for (let attempts = 0; attempts < rows.length; attempts += 1) {
|
|
94
|
-
index += delta;
|
|
95
|
-
if (index < 0) index = rows.length - 1;
|
|
96
|
-
if (index >= rows.length) index = 0;
|
|
97
|
-
if (rows[index]?.selectable) {
|
|
98
|
-
return index;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return currentIndex;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function selectedThreadIdFromRow(rows: MemberListRow[], index: number): number | null {
|
|
105
|
-
const row = rows[index];
|
|
106
|
-
return row && row.selectable ? row.threadId : null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function compareClusters(left: TuiClusterSummary, right: TuiClusterSummary, sortMode: TuiClusterSortMode): number {
|
|
110
|
-
const leftTime = left.latestUpdatedAt ? Date.parse(left.latestUpdatedAt) : 0;
|
|
111
|
-
const rightTime = right.latestUpdatedAt ? Date.parse(right.latestUpdatedAt) : 0;
|
|
112
|
-
if (sortMode === 'size') {
|
|
113
|
-
return right.totalCount - left.totalCount || rightTime - leftTime || left.clusterId - right.clusterId;
|
|
114
|
-
}
|
|
115
|
-
return rightTime - leftTime || right.totalCount - left.totalCount || left.clusterId - right.clusterId;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function formatMemberLabel(number: number, title: string, updatedAtGh: string | null): string {
|
|
119
|
-
const updated = updatedAtGh ? updatedAtGh.slice(5, 16).replace('T', ' ') : 'unknown';
|
|
120
|
-
return `#${number} ${updated} ${title}`;
|
|
121
|
-
}
|