git-stack-cli 1.0.2 → 1.0.4

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 (79) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/index.cjs +3 -3
  3. package/package.json +15 -8
  4. package/rollup.config.mjs +46 -0
  5. package/scripts/.eslintrc.cjs +61 -0
  6. package/scripts/build-standalone.ts +73 -0
  7. package/scripts/core/create_asset.ts +21 -0
  8. package/scripts/core/file.ts +36 -0
  9. package/scripts/core/spawn.ts +62 -0
  10. package/scripts/npm-prepublishOnly.ts +8 -0
  11. package/scripts/release-brew.ts +69 -0
  12. package/scripts/release-github.ts +34 -0
  13. package/scripts/release-npm.ts +109 -0
  14. package/scripts/tsconfig.json +35 -0
  15. package/src/__fixtures__/metadata.ts +666 -0
  16. package/src/app/App.tsx +65 -0
  17. package/src/app/AutoUpdate.tsx +229 -0
  18. package/src/app/Await.tsx +82 -0
  19. package/src/app/Brackets.tsx +22 -0
  20. package/src/app/Command.tsx +19 -0
  21. package/src/app/Debug.tsx +52 -0
  22. package/src/app/DependencyCheck.tsx +155 -0
  23. package/src/app/Exit.tsx +25 -0
  24. package/src/app/FormatText.tsx +26 -0
  25. package/src/app/GatherMetadata.tsx +145 -0
  26. package/src/app/GithubApiError.tsx +78 -0
  27. package/src/app/LocalCommitStatus.tsx +70 -0
  28. package/src/app/LocalMergeRebase.tsx +230 -0
  29. package/src/app/LogTimestamp.tsx +12 -0
  30. package/src/app/Main.tsx +52 -0
  31. package/src/app/ManualRebase.tsx +308 -0
  32. package/src/app/MultiSelect.tsx +246 -0
  33. package/src/app/Output.tsx +37 -0
  34. package/src/app/Parens.tsx +21 -0
  35. package/src/app/PostRebaseStatus.tsx +33 -0
  36. package/src/app/PreLocalMergeRebase.tsx +31 -0
  37. package/src/app/PreSelectCommitRanges.tsx +31 -0
  38. package/src/app/Providers.tsx +11 -0
  39. package/src/app/RebaseCheck.tsx +96 -0
  40. package/src/app/SelectCommitRanges.tsx +372 -0
  41. package/src/app/Status.tsx +82 -0
  42. package/src/app/StatusTable.tsx +155 -0
  43. package/src/app/Store.tsx +252 -0
  44. package/src/app/Table.tsx +137 -0
  45. package/src/app/TextInput.tsx +88 -0
  46. package/src/app/Url.tsx +19 -0
  47. package/src/app/Waterfall.tsx +37 -0
  48. package/src/app/YesNoPrompt.tsx +73 -0
  49. package/src/command.ts +78 -0
  50. package/src/core/CommitMetadata.ts +212 -0
  51. package/src/core/Metadata.test.ts +41 -0
  52. package/src/core/Metadata.ts +51 -0
  53. package/src/core/StackSummaryTable.test.ts +157 -0
  54. package/src/core/StackSummaryTable.ts +127 -0
  55. package/src/core/Timer.ts +44 -0
  56. package/src/core/assertNever.ts +4 -0
  57. package/src/core/cache.ts +49 -0
  58. package/src/core/capitalize.ts +5 -0
  59. package/src/core/chalk.ts +103 -0
  60. package/src/core/clamp.ts +6 -0
  61. package/src/core/cli.ts +161 -0
  62. package/src/core/colors.ts +23 -0
  63. package/src/core/date.ts +25 -0
  64. package/src/core/fetch_json.ts +26 -0
  65. package/src/core/github.tsx +215 -0
  66. package/src/core/invariant.ts +5 -0
  67. package/src/core/is_command_available.ts +21 -0
  68. package/src/core/is_finite_value.ts +3 -0
  69. package/src/core/json.ts +32 -0
  70. package/src/core/match_group.ts +10 -0
  71. package/src/core/read_json.ts +12 -0
  72. package/src/core/safe_quote.ts +10 -0
  73. package/src/core/semver_compare.ts +27 -0
  74. package/src/core/short_id.ts +87 -0
  75. package/src/core/sleep.ts +3 -0
  76. package/src/core/wrap_index.ts +11 -0
  77. package/src/index.tsx +22 -0
  78. package/src/types/global.d.ts +7 -0
  79. package/tsconfig.json +53 -0
@@ -0,0 +1,11 @@
1
+ import * as React from "react";
2
+
3
+ import { IntlProvider } from "react-intl";
4
+
5
+ type Props = {
6
+ children: React.ReactNode;
7
+ };
8
+
9
+ export function Providers(props: Props) {
10
+ return <IntlProvider locale="en">{props.children}</IntlProvider>;
11
+ }
@@ -0,0 +1,96 @@
1
+ import * as React from "react";
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ import * as Ink from "ink-cjs";
7
+
8
+ import { Await } from "~/app/Await";
9
+ import { Store } from "~/app/Store";
10
+ import { YesNoPrompt } from "~/app/YesNoPrompt";
11
+ import { cli } from "~/core/cli";
12
+ import { colors } from "~/core/colors";
13
+ import { invariant } from "~/core/invariant";
14
+
15
+ type Props = {
16
+ children: React.ReactNode;
17
+ };
18
+
19
+ type State = {
20
+ status: "init" | "prompt" | "done";
21
+ };
22
+
23
+ function reducer(state: State, patch: Partial<State>) {
24
+ return { ...state, ...patch };
25
+ }
26
+
27
+ export function RebaseCheck(props: Props) {
28
+ const actions = Store.useActions();
29
+ const argv = Store.useState((state) => state.argv);
30
+ invariant(argv, "argv must exist");
31
+
32
+ const [state, patch] = React.useReducer(reducer, {
33
+ status: "init",
34
+ });
35
+
36
+ switch (state.status) {
37
+ case "done":
38
+ return props.children;
39
+
40
+ case "prompt":
41
+ return (
42
+ <YesNoPrompt
43
+ message={
44
+ <Ink.Text color={colors.yellow}>
45
+ Rebase detected, would you like to abort it?
46
+ </Ink.Text>
47
+ }
48
+ onYes={async () => {
49
+ await cli(`git rebase --abort`);
50
+ patch({ status: "done" });
51
+ }}
52
+ onNo={async () => {
53
+ actions.exit(0);
54
+ }}
55
+ />
56
+ );
57
+
58
+ default:
59
+ return (
60
+ <Await
61
+ fallback={
62
+ <Ink.Text color={colors.yellow}>Checking for rebase...</Ink.Text>
63
+ }
64
+ function={rebase_check}
65
+ />
66
+ );
67
+ }
68
+
69
+ async function rebase_check() {
70
+ const actions = Store.getState().actions;
71
+ const argv = Store.getState().argv;
72
+
73
+ invariant(argv, "argv must exist");
74
+
75
+ try {
76
+ const repo_root = (await cli(`git rev-parse --absolute-git-dir`)).stdout;
77
+
78
+ let is_rebase = false;
79
+ is_rebase ||= fs.existsSync(path.join(repo_root, "rebase-apply"));
80
+ is_rebase ||= fs.existsSync(path.join(repo_root, "rebase-merge"));
81
+
82
+ const status = is_rebase ? "prompt" : "done";
83
+ patch({ status });
84
+ } catch (err) {
85
+ actions.error("Must be run from within a git repository.");
86
+
87
+ if (err instanceof Error) {
88
+ if (actions.isDebug()) {
89
+ actions.error(err.message);
90
+ }
91
+ }
92
+
93
+ actions.exit(9);
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,372 @@
1
+ import * as React from "react";
2
+
3
+ import * as Ink from "ink-cjs";
4
+
5
+ import { Brackets } from "~/app/Brackets";
6
+ import { FormatText } from "~/app/FormatText";
7
+ import { MultiSelect } from "~/app/MultiSelect";
8
+ import { Parens } from "~/app/Parens";
9
+ import { Store } from "~/app/Store";
10
+ import { TextInput } from "~/app/TextInput";
11
+ import { colors } from "~/core/colors";
12
+ import { invariant } from "~/core/invariant";
13
+ import { short_id } from "~/core/short_id";
14
+ import { wrap_index } from "~/core/wrap_index";
15
+
16
+ import type { State } from "~/app/Store";
17
+
18
+ export function SelectCommitRanges() {
19
+ const commit_range = Store.useState((state) => state.commit_range);
20
+
21
+ invariant(commit_range, "commit_range must exist");
22
+
23
+ return <SelectCommitRangesInternal commit_range={commit_range} />;
24
+ }
25
+
26
+ type Props = {
27
+ commit_range: CommitRange;
28
+ };
29
+
30
+ type CommitRange = NonNullable<State["commit_range"]>;
31
+ type SimpleGroup = { id: string; title: string };
32
+
33
+ function SelectCommitRangesInternal(props: Props) {
34
+ const actions = Store.useActions();
35
+
36
+ const [selected_group_id, set_selected_group_id] = React.useState(
37
+ props.commit_range.UNASSIGNED
38
+ );
39
+
40
+ const [group_input, set_group_input] = React.useState(false);
41
+
42
+ const [new_group_list, create_group] = React.useReducer(
43
+ (group_list: Array<SimpleGroup>, group: SimpleGroup) => {
44
+ return group_list.concat(group);
45
+ },
46
+ []
47
+ );
48
+
49
+ const [commit_map, update_commit_map] = React.useReducer(
50
+ (
51
+ map: Map<string, null | string>,
52
+ args: { key: string; value: null | string }
53
+ ) => {
54
+ map.set(args.key, args.value);
55
+
56
+ // console.debug("update_commit_map", map, args);
57
+ return new Map(map);
58
+ },
59
+ new Map(),
60
+ (map) => {
61
+ for (const commit of props.commit_range.commit_list) {
62
+ map.set(commit.sha, commit.branch_id);
63
+ }
64
+
65
+ return new Map(map);
66
+ }
67
+ );
68
+
69
+ const group_list: Array<SimpleGroup> = [];
70
+
71
+ // detect if there are unassigned commits
72
+ let unassigned_count = 0;
73
+ for (const [, group_id] of commit_map.entries()) {
74
+ if (group_id === null) {
75
+ // console.debug("unassigned commit detected", sha);
76
+ unassigned_count++;
77
+ }
78
+ }
79
+
80
+ group_list.push(...new_group_list);
81
+
82
+ const total_group_count =
83
+ new_group_list.length + props.commit_range.group_list.length;
84
+
85
+ for (const group of props.commit_range.group_list) {
86
+ if (group.pr?.state === "MERGED") continue;
87
+
88
+ if (group.id === props.commit_range.UNASSIGNED) {
89
+ // only include unassigned group when there are no other groups
90
+ if (total_group_count === 1) {
91
+ group_list.push({
92
+ id: group.id,
93
+ title: "Unassigned",
94
+ });
95
+ }
96
+
97
+ continue;
98
+ }
99
+
100
+ group_list.push({
101
+ id: group.id,
102
+ title: group.pr?.title || group.id,
103
+ });
104
+ }
105
+
106
+ let current_index = group_list.findIndex((g) => g.id === selected_group_id);
107
+ if (current_index === -1) {
108
+ current_index = 0;
109
+ }
110
+
111
+ Ink.useInput((input, key) => {
112
+ const inputLower = input.toLowerCase();
113
+
114
+ const hasUnassignedCommits = unassigned_count > 0;
115
+
116
+ if (!hasUnassignedCommits && inputLower === "s") {
117
+ actions.set((state) => {
118
+ state.commit_map = {};
119
+
120
+ for (const [sha, id] of commit_map.entries()) {
121
+ if (id) {
122
+ const group = group_list.find((g) => g.id === id);
123
+ // console.debug({ sha, id, group });
124
+ if (group) {
125
+ state.commit_map[sha] = group;
126
+ }
127
+ }
128
+ }
129
+
130
+ switch (inputLower) {
131
+ case "s":
132
+ state.step = "manual-rebase";
133
+ break;
134
+ }
135
+ });
136
+ return;
137
+ }
138
+
139
+ // only allow create when on unassigned group
140
+ if (hasUnassignedCommits && inputLower === "c") {
141
+ set_group_input(true);
142
+ return;
143
+ }
144
+
145
+ if (key.leftArrow) {
146
+ const new_index = wrap_index(current_index - 1, group_list);
147
+ const next_group = group_list[new_index];
148
+ return set_selected_group_id(next_group.id);
149
+ }
150
+
151
+ if (key.rightArrow) {
152
+ const new_index = wrap_index(current_index + 1, group_list);
153
+ const next_group = group_list[new_index];
154
+ return set_selected_group_id(next_group.id);
155
+ }
156
+ });
157
+
158
+ const group = group_list[current_index];
159
+
160
+ const items = props.commit_range.commit_list.map((commit) => {
161
+ const commit_metadata_id = commit_map.get(commit.sha);
162
+
163
+ const selected = commit_metadata_id !== null;
164
+
165
+ let disabled;
166
+
167
+ if (group.id === props.commit_range.UNASSIGNED) {
168
+ disabled = true;
169
+ } else {
170
+ disabled = Boolean(selected && commit_metadata_id !== group.id);
171
+ }
172
+
173
+ return {
174
+ label: commit.subject_line,
175
+ value: commit,
176
+ selected,
177
+ disabled,
178
+ };
179
+ });
180
+
181
+ items.reverse();
182
+
183
+ // <- (2/4) #742 Title A ->
184
+
185
+ const left_arrow = `${SYMBOL.left} `;
186
+ const right_arrow = ` ${SYMBOL.right}`;
187
+ const group_position = `(${current_index + 1}/${group_list.length}) `;
188
+
189
+ const max_group_label_width = 80;
190
+ let group_title_width = max_group_label_width;
191
+ group_title_width -= group_position.length;
192
+ group_title_width -= left_arrow.length + right_arrow.length;
193
+ group_title_width = Math.min(group.title.length, group_title_width);
194
+
195
+ let max_item_width = max_group_label_width;
196
+ max_item_width -= left_arrow.length + right_arrow.length;
197
+
198
+ return (
199
+ <Ink.Box flexDirection="column">
200
+ <Ink.Box height={1} />
201
+
202
+ <MultiSelect
203
+ key={group.id}
204
+ items={items}
205
+ maxWidth={max_item_width}
206
+ disabled={group_input}
207
+ onSelect={(args) => {
208
+ // console.debug("onSelect", args);
209
+
210
+ const key = args.item.sha;
211
+
212
+ let value;
213
+ if (args.selected) {
214
+ value = group.id;
215
+ } else {
216
+ value = null;
217
+ }
218
+
219
+ update_commit_map({ key, value });
220
+ }}
221
+ />
222
+
223
+ <Ink.Box height={1} />
224
+
225
+ <Ink.Box width={max_group_label_width} flexDirection="row">
226
+ <Ink.Text>{left_arrow}</Ink.Text>
227
+ <Ink.Text>{group_position}</Ink.Text>
228
+
229
+ <Ink.Box width={group_title_width} justifyContent="center">
230
+ <Ink.Text wrap="truncate-end">{group.title}</Ink.Text>
231
+ </Ink.Box>
232
+
233
+ <Ink.Text>{right_arrow}</Ink.Text>
234
+ </Ink.Box>
235
+
236
+ <Ink.Box height={1} />
237
+
238
+ {unassigned_count > 0 ? (
239
+ <FormatText
240
+ wrapper={<Ink.Text color={colors.gray} />}
241
+ message="{count} unassigned commits, press {c} to {create} a new group"
242
+ values={{
243
+ count: (
244
+ <Ink.Text color={colors.yellow} bold>
245
+ {unassigned_count}
246
+ </Ink.Text>
247
+ ),
248
+ c: (
249
+ <Ink.Text bold color={colors.green}>
250
+ c
251
+ </Ink.Text>
252
+ ),
253
+ create: (
254
+ <Ink.Text bold color={colors.green}>
255
+ <Parens>c</Parens>reate
256
+ </Ink.Text>
257
+ ),
258
+ }}
259
+ />
260
+ ) : (
261
+ <React.Fragment>
262
+ <FormatText
263
+ wrapper={<Ink.Text />}
264
+ message="🎉 Done! Press {s} to {sync} the commits to Github"
265
+ values={{
266
+ s: (
267
+ <Ink.Text bold color={colors.green}>
268
+ s
269
+ </Ink.Text>
270
+ ),
271
+ sync: (
272
+ <Ink.Text bold color={colors.green}>
273
+ <Parens>s</Parens>ync
274
+ </Ink.Text>
275
+ ),
276
+ }}
277
+ />
278
+ </React.Fragment>
279
+ )}
280
+
281
+ {!group_input ? null : (
282
+ <React.Fragment>
283
+ <Ink.Box height={1} />
284
+
285
+ <FormatText
286
+ wrapper={<Ink.Text color={colors.gray} />}
287
+ message="Enter a title for the PR {note}"
288
+ values={{
289
+ note: (
290
+ <Parens>
291
+ <FormatText
292
+ message="press {enter} to submit"
293
+ values={{
294
+ enter: (
295
+ <Ink.Text bold color={colors.green}>
296
+ {SYMBOL.enter}
297
+ </Ink.Text>
298
+ ),
299
+ }}
300
+ />
301
+ </Parens>
302
+ ),
303
+ }}
304
+ />
305
+
306
+ <TextInput onSubmit={submit_group_input} />
307
+
308
+ <Ink.Box height={1} />
309
+ </React.Fragment>
310
+ )}
311
+
312
+ <Ink.Box>
313
+ <FormatText
314
+ wrapper={<Ink.Text color={colors.gray} />}
315
+ message="Press {left} and {right} to view PR groups"
316
+ values={{
317
+ left: (
318
+ <Ink.Text bold color={colors.green}>
319
+ {SYMBOL.left}
320
+ </Ink.Text>
321
+ ),
322
+ right: (
323
+ <Ink.Text bold color={colors.green}>
324
+ {SYMBOL.right}
325
+ </Ink.Text>
326
+ ),
327
+ }}
328
+ />
329
+ </Ink.Box>
330
+
331
+ <Ink.Box>
332
+ <FormatText
333
+ wrapper={<Ink.Text color={colors.gray} />}
334
+ message="Press {enter} to toggle commit selection"
335
+ values={{
336
+ enter: (
337
+ <Ink.Text bold color={colors.green}>
338
+ {SYMBOL.enter}
339
+ </Ink.Text>
340
+ ),
341
+ }}
342
+ />
343
+ </Ink.Box>
344
+ </Ink.Box>
345
+ );
346
+
347
+ function submit_group_input(title: string) {
348
+ const id = short_id();
349
+
350
+ actions.output(
351
+ <FormatText
352
+ wrapper={<Ink.Text dimColor />}
353
+ message="Created new group {group} {note}"
354
+ values={{
355
+ group: <Brackets>{title}</Brackets>,
356
+ note: <Parens>{id}</Parens>,
357
+ }}
358
+ />
359
+ );
360
+
361
+ // console.debug("submit_group_input", { title, id });
362
+ create_group({ id, title });
363
+ set_selected_group_id(id);
364
+ set_group_input(false);
365
+ }
366
+ }
367
+
368
+ const SYMBOL = {
369
+ left: "←",
370
+ right: "→",
371
+ enter: "Enter",
372
+ };
@@ -0,0 +1,82 @@
1
+ import * as React from "react";
2
+
3
+ import * as Ink from "ink-cjs";
4
+
5
+ import { Await } from "~/app/Await";
6
+ import { StatusTable } from "~/app/StatusTable";
7
+ import { Store } from "~/app/Store";
8
+ import { colors } from "~/core/colors";
9
+ import { invariant } from "~/core/invariant";
10
+
11
+ import type { Argv } from "~/command";
12
+
13
+ export function Status() {
14
+ const argv = Store.useState((state) => state.argv);
15
+ invariant(argv, "argv must exist");
16
+
17
+ return <Await fallback={null} function={() => run({ argv })} />;
18
+ }
19
+
20
+ type Args = {
21
+ argv: Argv;
22
+ };
23
+
24
+ async function run(args: Args) {
25
+ const actions = Store.getState().actions;
26
+ const commit_range = Store.getState().commit_range;
27
+
28
+ invariant(commit_range, "commit_range must exist");
29
+
30
+ actions.output(<StatusTable />);
31
+
32
+ let needs_rebase = false;
33
+ let needs_update = false;
34
+
35
+ for (const group of commit_range.group_list) {
36
+ if (group.dirty) {
37
+ needs_update = true;
38
+ }
39
+
40
+ if (group.pr?.state === "MERGED") {
41
+ needs_rebase = true;
42
+ }
43
+ }
44
+
45
+ for (let i = 0; i < commit_range.commit_list.length; i++) {
46
+ const commit = commit_range.commit_list[i];
47
+ const commit_pr = commit_range.pr_lookup[commit.branch_id || ""];
48
+
49
+ if (commit.branch_id && !commit_pr) {
50
+ needs_rebase = true;
51
+ }
52
+ }
53
+
54
+ if (args.argv.check) {
55
+ actions.exit(0);
56
+ } else if (needs_rebase) {
57
+ Store.setState((state) => {
58
+ state.step = "pre-local-merge-rebase";
59
+ });
60
+ } else if (needs_update) {
61
+ Store.setState((state) => {
62
+ state.step = "pre-select-commit-ranges";
63
+ });
64
+ } else if (args.argv.force) {
65
+ Store.setState((state) => {
66
+ state.step = "select-commit-ranges";
67
+ });
68
+ } else {
69
+ actions.output(<Ink.Text>✅ Everything up to date.</Ink.Text>);
70
+ actions.output(
71
+ <Ink.Text color={colors.gray}>
72
+ <Ink.Text>Run with</Ink.Text>
73
+ <Ink.Text bold color={colors.yellow}>
74
+ {` --force `}
75
+ </Ink.Text>
76
+ <Ink.Text>to force update all pull requests.</Ink.Text>
77
+ </Ink.Text>
78
+ );
79
+
80
+ actions.exit(0);
81
+ }
82
+ }
@@ -0,0 +1,155 @@
1
+ import * as React from "react";
2
+
3
+ import * as Ink from "ink-cjs";
4
+
5
+ import { Store } from "~/app/Store";
6
+ import { Table } from "~/app/Table";
7
+ import { Url } from "~/app/Url";
8
+ import { assertNever } from "~/core/assertNever";
9
+ import { colors } from "~/core/colors";
10
+ import { invariant } from "~/core/invariant";
11
+
12
+ import type { TableColumnProps } from "~/app/Table";
13
+
14
+ export function StatusTable() {
15
+ const commit_range = Store.useState((state) => state.commit_range);
16
+
17
+ invariant(commit_range, "commit_range must exist");
18
+
19
+ const row_list = [];
20
+
21
+ for (const group of commit_range.group_list) {
22
+ const row: Row = {
23
+ count: "",
24
+ status: "NEW",
25
+ title: "",
26
+ url: "",
27
+ };
28
+
29
+ if (group.id === commit_range.UNASSIGNED) {
30
+ row.status = "NEW";
31
+ row.title = "Unassigned";
32
+ row.count = `0/${group.commits.length}`;
33
+ row.url = "";
34
+ } else {
35
+ if (group.dirty) {
36
+ row.status = "OUTDATED";
37
+ } else {
38
+ row.status = "SYNCED";
39
+ }
40
+
41
+ if (group.pr) {
42
+ if (group.pr.state === "MERGED") {
43
+ row.status = "MERGED";
44
+ }
45
+
46
+ row.title = group.pr.title;
47
+ row.count = `${group.pr.commits.length}/${group.commits.length}`;
48
+ row.url = group.pr.url;
49
+ } else {
50
+ row.title = group.title || group.id;
51
+ row.count = `0/${group.commits.length}`;
52
+ }
53
+ }
54
+
55
+ row_list.push(row);
56
+ }
57
+
58
+ return (
59
+ <Table
60
+ data={row_list}
61
+ fillColumn="title"
62
+ maxWidth={{
63
+ status: (v) => v + 2,
64
+ }}
65
+ columnGap={3}
66
+ columns={{
67
+ status: StatusColumn,
68
+ count: CountColumn,
69
+ title: TitleColumn,
70
+ url: UrlColumn,
71
+ }}
72
+ />
73
+ );
74
+ }
75
+
76
+ type Row = {
77
+ status: "NEW" | "OUTDATED" | "MERGED" | "SYNCED";
78
+ count: string;
79
+ title: string;
80
+ url: string;
81
+ };
82
+
83
+ function StatusColumn(props: TableColumnProps<Row>) {
84
+ const value = props.row[props.column];
85
+
86
+ return (
87
+ <Ink.Text
88
+ color={get_status_color(props.row)}
89
+ bold={get_status_bold(props.row)}
90
+ >
91
+ {get_status_icon(props.row)} {value}
92
+ </Ink.Text>
93
+ );
94
+ }
95
+
96
+ function CountColumn(props: TableColumnProps<Row>) {
97
+ const value = props.row[props.column];
98
+
99
+ return <Ink.Text dimColor>{value}</Ink.Text>;
100
+ }
101
+
102
+ function TitleColumn(props: TableColumnProps<Row>) {
103
+ const value = props.row[props.column];
104
+
105
+ return <Ink.Text wrap="truncate-end">{value}</Ink.Text>;
106
+ }
107
+
108
+ function UrlColumn(props: TableColumnProps<Row>) {
109
+ const value = props.row[props.column];
110
+
111
+ return <Url dimColor>{value}</Url>;
112
+ }
113
+
114
+ function get_status_icon(row: Row) {
115
+ switch (row.status) {
116
+ case "NEW":
117
+ return "⭑";
118
+ case "OUTDATED":
119
+ return "!";
120
+ case "MERGED":
121
+ return "↗";
122
+ case "SYNCED":
123
+ return "✔";
124
+ default:
125
+ assertNever(row.status);
126
+ return "?";
127
+ // unicode question mark in box
128
+ }
129
+ }
130
+
131
+ function get_status_color(row: Row) {
132
+ switch (row.status) {
133
+ case "NEW":
134
+ return colors.yellow;
135
+ case "OUTDATED":
136
+ return colors.red;
137
+ case "MERGED":
138
+ return colors.purple;
139
+ case "SYNCED":
140
+ return colors.green;
141
+ default:
142
+ assertNever(row.status);
143
+ return colors.gray;
144
+ }
145
+ }
146
+
147
+ function get_status_bold(row: Row) {
148
+ switch (row.status) {
149
+ case "NEW":
150
+ case "OUTDATED":
151
+ return true;
152
+ default:
153
+ return false;
154
+ }
155
+ }