@yannelli/paseo-linear-plugin 0.0.0-stage → 0.3.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/LICENSE +201 -0
- package/NOTICE +13 -0
- package/OVERVIEW.md +33 -0
- package/README.md +207 -2
- package/client/agent-options.ts +200 -0
- package/client/breadcrumbs.tsx +110 -0
- package/client/browser.tsx +275 -0
- package/client/compat.ts +107 -0
- package/client/connect.tsx +136 -0
- package/client/create-issue.tsx +291 -0
- package/client/glyphs.tsx +117 -0
- package/client/issue-detail.tsx +366 -0
- package/client/issue-list.tsx +373 -0
- package/client/issue-row.tsx +152 -0
- package/client/issue-sections.tsx +325 -0
- package/client/issue-tree.ts +132 -0
- package/client/key-scope.tsx +23 -0
- package/client/launch-choices.ts +125 -0
- package/client/launch-fields.tsx +152 -0
- package/client/launch-plan.ts +227 -0
- package/client/launch.tsx +484 -0
- package/client/markdown.tsx +240 -0
- package/client/model-browser.tsx +296 -0
- package/client/pickers.tsx +238 -0
- package/client/provider-icon.tsx +78 -0
- package/client/queries.ts +211 -0
- package/client/settings-connection.tsx +109 -0
- package/client/settings-fields.tsx +173 -0
- package/client/settings-keys.tsx +117 -0
- package/client/settings-projects.tsx +281 -0
- package/client/settings-prompts.tsx +198 -0
- package/client/store.ts +71 -0
- package/client/timeline-card.tsx +63 -0
- package/client/ui.tsx +352 -0
- package/index.client.tsx +111 -0
- package/index.server.ts +12 -0
- package/package.json +51 -4
- package/paseo-plugin.json +7 -0
- package/server/cache.ts +68 -0
- package/server/credentials.ts +101 -0
- package/server/graphql.ts +68 -0
- package/server/handlers.ts +172 -0
- package/server/linear.ts +130 -0
- package/server/queries.ts +280 -0
- package/shared/issues.ts +31 -0
- package/shared/linear.ts +268 -0
- package/shared/markdown.ts +416 -0
- package/shared/prompts.ts +150 -0
- package/shared/settings.ts +68 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { Icon, TextInput } from "@getpaseo/plugin/client/react-native";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { FlatList, type ListRenderItemInfo, ScrollView, Text, View } from "react-native";
|
|
4
|
+
import type { AssigneeFilter, IssueSort, LinearTeam, StatusFilter } from "../shared/linear";
|
|
5
|
+
import { IssueRow, StateHeader } from "./issue-row";
|
|
6
|
+
import { buildIssueRows, type IssueListRow, toggleCollapsed } from "./issue-tree";
|
|
7
|
+
import { PickerModal } from "./pickers";
|
|
8
|
+
import { useIssues } from "./queries";
|
|
9
|
+
import type { BrowserState } from "./store";
|
|
10
|
+
import { Button, EmptyState, errorMessage, Segmented, type Theme } from "./ui";
|
|
11
|
+
|
|
12
|
+
const ASSIGNEE_OPTIONS: readonly { value: AssigneeFilter; label: string }[] = [
|
|
13
|
+
{ value: "me", label: "Mine" },
|
|
14
|
+
{ value: "anyone", label: "Everyone" },
|
|
15
|
+
{ value: "unassigned", label: "Unassigned" },
|
|
16
|
+
];
|
|
17
|
+
const STATUS_OPTIONS: readonly { value: StatusFilter; label: string }[] = [
|
|
18
|
+
{ value: "active", label: "Active" },
|
|
19
|
+
{ value: "backlog", label: "Backlog" },
|
|
20
|
+
{ value: "done", label: "Done" },
|
|
21
|
+
{ value: "all", label: "All" },
|
|
22
|
+
];
|
|
23
|
+
export const SORT_OPTIONS: readonly { value: IssueSort; label: string }[] = [
|
|
24
|
+
{ value: "updated", label: "Last updated" },
|
|
25
|
+
{ value: "created", label: "Newest" },
|
|
26
|
+
{ value: "priority", label: "Priority" },
|
|
27
|
+
{ value: "due", label: "Due date" },
|
|
28
|
+
{ value: "title", label: "Title" },
|
|
29
|
+
];
|
|
30
|
+
const ALL_TEAMS = "__all__";
|
|
31
|
+
const ROOT_STYLE = { flex: 1, minHeight: 0 } as const;
|
|
32
|
+
const FILTER_SCROLL_STYLE = { flexGrow: 0 } as const;
|
|
33
|
+
const ROW_STYLE = { flexDirection: "row", gap: 8, alignItems: "center" } as const;
|
|
34
|
+
|
|
35
|
+
type Update = (patch: Partial<BrowserState>) => void;
|
|
36
|
+
type Picker = "team" | "sort" | "key";
|
|
37
|
+
const KEY_DEFAULT = "";
|
|
38
|
+
|
|
39
|
+
function rowKey(row: IssueListRow): string {
|
|
40
|
+
return row.key;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface KeyOption {
|
|
44
|
+
value: string;
|
|
45
|
+
label: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function IssueList(props: {
|
|
49
|
+
theme: Theme;
|
|
50
|
+
compact: boolean;
|
|
51
|
+
teams: readonly LinearTeam[];
|
|
52
|
+
state: BrowserState;
|
|
53
|
+
update: Update;
|
|
54
|
+
/** Linear keys to switch between. Empty hides the key picker. */
|
|
55
|
+
keyOptions: readonly KeyOption[];
|
|
56
|
+
}) {
|
|
57
|
+
const { theme, state, update, keyOptions } = props;
|
|
58
|
+
const [picker, setPicker] = useState<Picker | null>(null);
|
|
59
|
+
const team = props.teams.find((entry) => entry.id === state.teamId) ?? null;
|
|
60
|
+
const keyValue = state.keyProjectId ?? KEY_DEFAULT;
|
|
61
|
+
const keyLabel = keyOptions.find((option) => option.value === keyValue)?.label ?? "Default key";
|
|
62
|
+
const sortLabel = SORT_OPTIONS.find((option) => option.value === state.sort)?.label ?? "Sort";
|
|
63
|
+
const picked = useMemo(() => {
|
|
64
|
+
if (picker === "sort") {
|
|
65
|
+
return { title: "Sort by", icon: "ArrowUpDown", options: SORT_OPTIONS, value: state.sort };
|
|
66
|
+
}
|
|
67
|
+
if (picker === "key") {
|
|
68
|
+
return { title: "Linear key", icon: "KeyRound", options: keyOptions, value: keyValue };
|
|
69
|
+
}
|
|
70
|
+
const options = [
|
|
71
|
+
{ value: ALL_TEAMS, label: "All teams" },
|
|
72
|
+
...props.teams.map((entry) => ({ value: entry.id, label: entry.name, detail: entry.key })),
|
|
73
|
+
];
|
|
74
|
+
return { title: "Team", icon: "Users", options, value: state.teamId ?? ALL_TEAMS };
|
|
75
|
+
}, [picker, props.teams, keyOptions, keyValue, state.sort, state.teamId]);
|
|
76
|
+
const open = useCallback((kind: Picker) => setPicker(kind), []);
|
|
77
|
+
const close = useCallback(() => setPicker(null), []);
|
|
78
|
+
const select = useCallback(
|
|
79
|
+
(value: string) => {
|
|
80
|
+
setPicker(null);
|
|
81
|
+
if (picker === "sort") update({ sort: value as IssueSort });
|
|
82
|
+
else if (picker === "key") {
|
|
83
|
+
update({
|
|
84
|
+
keyProjectId: value === KEY_DEFAULT ? null : value,
|
|
85
|
+
issueId: null,
|
|
86
|
+
teamId: null,
|
|
87
|
+
launch: null,
|
|
88
|
+
});
|
|
89
|
+
} else update({ teamId: value === ALL_TEAMS ? null : value });
|
|
90
|
+
},
|
|
91
|
+
[picker, update],
|
|
92
|
+
);
|
|
93
|
+
return (
|
|
94
|
+
<View style={ROOT_STYLE}>
|
|
95
|
+
<IssueFilters
|
|
96
|
+
theme={theme}
|
|
97
|
+
compact={props.compact}
|
|
98
|
+
state={state}
|
|
99
|
+
teamName={team ? team.name : "All teams"}
|
|
100
|
+
sortLabel={sortLabel}
|
|
101
|
+
keyLabel={keyOptions.length > 0 ? keyLabel : null}
|
|
102
|
+
update={update}
|
|
103
|
+
onOpen={open}
|
|
104
|
+
/>
|
|
105
|
+
<IssueResults theme={theme} compact={props.compact} state={state} update={update} />
|
|
106
|
+
<PickerModal
|
|
107
|
+
theme={theme}
|
|
108
|
+
title={picked.title}
|
|
109
|
+
icon={picked.icon}
|
|
110
|
+
open={picker !== null}
|
|
111
|
+
options={picked.options}
|
|
112
|
+
value={picked.value}
|
|
113
|
+
searchable={picker === "team" && props.teams.length > 8}
|
|
114
|
+
onClose={close}
|
|
115
|
+
onSelect={select}
|
|
116
|
+
/>
|
|
117
|
+
</View>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function IssueFilters(props: {
|
|
122
|
+
theme: Theme;
|
|
123
|
+
compact: boolean;
|
|
124
|
+
state: BrowserState;
|
|
125
|
+
teamName: string;
|
|
126
|
+
sortLabel: string;
|
|
127
|
+
keyLabel: string | null;
|
|
128
|
+
update: Update;
|
|
129
|
+
onOpen(picker: Picker): void;
|
|
130
|
+
}) {
|
|
131
|
+
const { theme, compact, state, update, onOpen } = props;
|
|
132
|
+
const { colors } = theme;
|
|
133
|
+
const [draft, setDraft] = useState(state.query);
|
|
134
|
+
useEffect(() => setDraft(state.query), [state.query]);
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
if (draft !== state.query) update({ query: draft });
|
|
138
|
+
}, 300);
|
|
139
|
+
return () => clearTimeout(timer);
|
|
140
|
+
}, [draft, state.query, update]);
|
|
141
|
+
const styles = useMemo(() => {
|
|
142
|
+
const padding = compact ? 12 : 16;
|
|
143
|
+
return {
|
|
144
|
+
root: { paddingHorizontal: padding, paddingTop: padding, gap: 10 },
|
|
145
|
+
search: {
|
|
146
|
+
flex: 1,
|
|
147
|
+
height: 36,
|
|
148
|
+
flexDirection: "row",
|
|
149
|
+
alignItems: "center",
|
|
150
|
+
gap: 8,
|
|
151
|
+
paddingHorizontal: 10,
|
|
152
|
+
borderRadius: 8,
|
|
153
|
+
borderWidth: 1,
|
|
154
|
+
borderColor: colors.border,
|
|
155
|
+
backgroundColor: colors.surface1,
|
|
156
|
+
},
|
|
157
|
+
input: { flex: 1, height: 34, color: colors.foreground, fontSize: 14 },
|
|
158
|
+
} as const;
|
|
159
|
+
}, [colors, compact]);
|
|
160
|
+
const create = useCallback(() => update({ creating: true, issueId: null }), [update]);
|
|
161
|
+
const changeAssignee = useCallback((assignee: AssigneeFilter) => update({ assignee }), [update]);
|
|
162
|
+
const changeStatus = useCallback((status: StatusFilter) => update({ status }), [update]);
|
|
163
|
+
const toggleNested = useCallback(() => update({ nested: !state.nested }), [update, state.nested]);
|
|
164
|
+
const openTeams = useCallback(() => onOpen("team"), [onOpen]);
|
|
165
|
+
const openSort = useCallback(() => onOpen("sort"), [onOpen]);
|
|
166
|
+
const openKey = useCallback(() => onOpen("key"), [onOpen]);
|
|
167
|
+
return (
|
|
168
|
+
<View style={styles.root}>
|
|
169
|
+
<View style={ROW_STYLE}>
|
|
170
|
+
<View style={styles.search}>
|
|
171
|
+
<Icon name="Search" size={15} color={colors.foregroundMuted} />
|
|
172
|
+
<TextInput
|
|
173
|
+
value={draft}
|
|
174
|
+
onChangeText={setDraft}
|
|
175
|
+
placeholder="Search issues or jump to ENG-123"
|
|
176
|
+
placeholderTextColor={colors.foregroundMuted}
|
|
177
|
+
autoCapitalize="none"
|
|
178
|
+
autoCorrect={false}
|
|
179
|
+
accessibilityLabel="Search Linear issues"
|
|
180
|
+
style={styles.input}
|
|
181
|
+
/>
|
|
182
|
+
</View>
|
|
183
|
+
<Button
|
|
184
|
+
theme={theme}
|
|
185
|
+
variant="primary"
|
|
186
|
+
icon="Plus"
|
|
187
|
+
label={compact ? "New" : "New issue"}
|
|
188
|
+
onPress={create}
|
|
189
|
+
/>
|
|
190
|
+
</View>
|
|
191
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={FILTER_SCROLL_STYLE}>
|
|
192
|
+
<View style={ROW_STYLE}>
|
|
193
|
+
<Segmented
|
|
194
|
+
theme={theme}
|
|
195
|
+
accessibilityLabel="Assignee"
|
|
196
|
+
value={state.assignee}
|
|
197
|
+
options={ASSIGNEE_OPTIONS}
|
|
198
|
+
onChange={changeAssignee}
|
|
199
|
+
/>
|
|
200
|
+
<Segmented
|
|
201
|
+
theme={theme}
|
|
202
|
+
accessibilityLabel="Status"
|
|
203
|
+
value={state.status}
|
|
204
|
+
options={STATUS_OPTIONS}
|
|
205
|
+
onChange={changeStatus}
|
|
206
|
+
/>
|
|
207
|
+
<Button
|
|
208
|
+
theme={theme}
|
|
209
|
+
size="xs"
|
|
210
|
+
variant="ghost"
|
|
211
|
+
icon="Users"
|
|
212
|
+
label={props.teamName}
|
|
213
|
+
onPress={openTeams}
|
|
214
|
+
/>
|
|
215
|
+
<Button
|
|
216
|
+
theme={theme}
|
|
217
|
+
size="xs"
|
|
218
|
+
variant="ghost"
|
|
219
|
+
icon="ArrowUpDown"
|
|
220
|
+
label={props.sortLabel}
|
|
221
|
+
accessibilityLabel={`Sort by ${props.sortLabel}`}
|
|
222
|
+
onPress={openSort}
|
|
223
|
+
/>
|
|
224
|
+
<Button
|
|
225
|
+
theme={theme}
|
|
226
|
+
size="xs"
|
|
227
|
+
variant={state.nested ? "secondary" : "ghost"}
|
|
228
|
+
icon="ListTree"
|
|
229
|
+
label="Sub-issues"
|
|
230
|
+
accessibilityLabel={state.nested ? "Show sub-issues flat" : "Nest sub-issues"}
|
|
231
|
+
onPress={toggleNested}
|
|
232
|
+
/>
|
|
233
|
+
{props.keyLabel ? (
|
|
234
|
+
<Button
|
|
235
|
+
theme={theme}
|
|
236
|
+
size="xs"
|
|
237
|
+
variant="ghost"
|
|
238
|
+
icon="KeyRound"
|
|
239
|
+
label={props.keyLabel}
|
|
240
|
+
accessibilityLabel={`Linear key: ${props.keyLabel}`}
|
|
241
|
+
onPress={openKey}
|
|
242
|
+
/>
|
|
243
|
+
) : null}
|
|
244
|
+
</View>
|
|
245
|
+
</ScrollView>
|
|
246
|
+
</View>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function IssueResults(props: {
|
|
251
|
+
theme: Theme;
|
|
252
|
+
compact: boolean;
|
|
253
|
+
state: BrowserState;
|
|
254
|
+
update: Update;
|
|
255
|
+
}) {
|
|
256
|
+
const { theme, compact, state, update } = props;
|
|
257
|
+
const { colors } = theme;
|
|
258
|
+
const issues = useIssues({
|
|
259
|
+
teamId: state.teamId,
|
|
260
|
+
assignee: state.assignee,
|
|
261
|
+
status: state.status,
|
|
262
|
+
query: state.query,
|
|
263
|
+
sort: state.sort,
|
|
264
|
+
});
|
|
265
|
+
const { refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = issues;
|
|
266
|
+
const collapsed = useMemo(() => new Set(state.collapsed), [state.collapsed]);
|
|
267
|
+
const rows = useMemo(
|
|
268
|
+
() =>
|
|
269
|
+
buildIssueRows(issues.data?.pages.flatMap((page) => page.issues) ?? [], {
|
|
270
|
+
nested: state.nested,
|
|
271
|
+
collapsed,
|
|
272
|
+
}),
|
|
273
|
+
[issues.data, state.nested, collapsed],
|
|
274
|
+
);
|
|
275
|
+
const styles = useMemo(
|
|
276
|
+
() =>
|
|
277
|
+
({
|
|
278
|
+
list: { flex: 1, minHeight: 0 },
|
|
279
|
+
content: { paddingHorizontal: compact ? 4 : 8, paddingVertical: 8 },
|
|
280
|
+
footer: { color: colors.foregroundMuted, textAlign: "center", padding: 12 },
|
|
281
|
+
updating: {
|
|
282
|
+
color: colors.foregroundMuted,
|
|
283
|
+
fontSize: 12,
|
|
284
|
+
paddingHorizontal: compact ? 12 : 16,
|
|
285
|
+
paddingTop: 8,
|
|
286
|
+
},
|
|
287
|
+
}) as const,
|
|
288
|
+
[colors, compact],
|
|
289
|
+
);
|
|
290
|
+
const retry = useCallback(() => void refetch(), [refetch]);
|
|
291
|
+
const loadMore = useCallback(() => {
|
|
292
|
+
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
|
|
293
|
+
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
294
|
+
const select = useCallback((issueId: string) => update({ issueId, creating: false }), [update]);
|
|
295
|
+
const toggle = useCallback(
|
|
296
|
+
(issueId: string) => update({ collapsed: toggleCollapsed(state.collapsed, issueId) }),
|
|
297
|
+
[update, state.collapsed],
|
|
298
|
+
);
|
|
299
|
+
const renderItem = useCallback(
|
|
300
|
+
({ item }: ListRenderItemInfo<IssueListRow>) =>
|
|
301
|
+
item.kind === "header" ? (
|
|
302
|
+
<StateHeader theme={theme} state={item.state} count={item.count} />
|
|
303
|
+
) : (
|
|
304
|
+
<IssueRow
|
|
305
|
+
theme={theme}
|
|
306
|
+
compact={compact}
|
|
307
|
+
issue={item.issue}
|
|
308
|
+
depth={item.depth}
|
|
309
|
+
childCount={item.childCount}
|
|
310
|
+
collapsed={item.collapsed}
|
|
311
|
+
nested={state.nested}
|
|
312
|
+
selected={item.issue.id === state.issueId}
|
|
313
|
+
onSelect={select}
|
|
314
|
+
onToggle={toggle}
|
|
315
|
+
/>
|
|
316
|
+
),
|
|
317
|
+
[theme, compact, state.issueId, state.nested, select, toggle],
|
|
318
|
+
);
|
|
319
|
+
const footer = useMemo(
|
|
320
|
+
() => (isFetchingNextPage ? <Text style={styles.footer}>Loading more…</Text> : null),
|
|
321
|
+
[isFetchingNextPage, styles.footer],
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
if (issues.isPending) return <EmptyState theme={theme} icon="RefreshCw" title="Loading issues" />;
|
|
325
|
+
if (issues.isError) {
|
|
326
|
+
return (
|
|
327
|
+
<EmptyState
|
|
328
|
+
theme={theme}
|
|
329
|
+
icon="TriangleAlert"
|
|
330
|
+
tone="danger"
|
|
331
|
+
title="Could not load issues"
|
|
332
|
+
detail={errorMessage(issues.error)}
|
|
333
|
+
>
|
|
334
|
+
<Button theme={theme} label="Try again" icon="RefreshCw" onPress={retry} />
|
|
335
|
+
</EmptyState>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (rows.length === 0) {
|
|
339
|
+
return (
|
|
340
|
+
<EmptyState
|
|
341
|
+
theme={theme}
|
|
342
|
+
icon="Inbox"
|
|
343
|
+
title="No issues here"
|
|
344
|
+
detail={
|
|
345
|
+
state.query
|
|
346
|
+
? "Try another search or widen the filters."
|
|
347
|
+
: "Change the filters, or create an issue."
|
|
348
|
+
}
|
|
349
|
+
/>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return (
|
|
353
|
+
<>
|
|
354
|
+
{issues.isPlaceholderData ? (
|
|
355
|
+
<Text style={styles.updating} accessibilityLiveRegion="polite">
|
|
356
|
+
Showing saved results. Updating…
|
|
357
|
+
</Text>
|
|
358
|
+
) : null}
|
|
359
|
+
<FlatList
|
|
360
|
+
style={styles.list}
|
|
361
|
+
contentContainerStyle={styles.content}
|
|
362
|
+
data={rows}
|
|
363
|
+
keyExtractor={rowKey}
|
|
364
|
+
refreshing={issues.isRefetching && !isFetchingNextPage}
|
|
365
|
+
onRefresh={retry}
|
|
366
|
+
onEndReachedThreshold={0.4}
|
|
367
|
+
onEndReached={loadMore}
|
|
368
|
+
renderItem={renderItem}
|
|
369
|
+
ListFooterComponent={footer}
|
|
370
|
+
/>
|
|
371
|
+
</>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { Pressable, Text, View } from "react-native";
|
|
4
|
+
import type { IssueSummary } from "../shared/linear";
|
|
5
|
+
import {
|
|
6
|
+
Avatar,
|
|
7
|
+
LabelPill,
|
|
8
|
+
PriorityIcon,
|
|
9
|
+
relativeTime,
|
|
10
|
+
StateIcon,
|
|
11
|
+
type Theme,
|
|
12
|
+
usePressableStyle,
|
|
13
|
+
} from "./ui";
|
|
14
|
+
|
|
15
|
+
export function StateHeader(props: { theme: Theme; state: IssueSummary["state"]; count: number }) {
|
|
16
|
+
const { colors } = props.theme;
|
|
17
|
+
const styles = useMemo(
|
|
18
|
+
() =>
|
|
19
|
+
({
|
|
20
|
+
root: {
|
|
21
|
+
flexDirection: "row",
|
|
22
|
+
alignItems: "center",
|
|
23
|
+
gap: 8,
|
|
24
|
+
paddingHorizontal: 10,
|
|
25
|
+
paddingTop: 14,
|
|
26
|
+
paddingBottom: 6,
|
|
27
|
+
},
|
|
28
|
+
name: { color: colors.foreground, fontSize: 13, fontWeight: "500" },
|
|
29
|
+
count: { color: colors.foregroundMuted, fontSize: 12 },
|
|
30
|
+
}) as const,
|
|
31
|
+
[colors],
|
|
32
|
+
);
|
|
33
|
+
return (
|
|
34
|
+
<View style={styles.root}>
|
|
35
|
+
<StateIcon state={props.state} size={14} />
|
|
36
|
+
<Text style={styles.name}>{props.state.name}</Text>
|
|
37
|
+
<Text style={styles.count}>{props.count}</Text>
|
|
38
|
+
</View>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const INDENT = 18;
|
|
43
|
+
|
|
44
|
+
export function IssueRow(props: {
|
|
45
|
+
theme: Theme;
|
|
46
|
+
compact: boolean;
|
|
47
|
+
issue: IssueSummary;
|
|
48
|
+
depth: number;
|
|
49
|
+
childCount: number;
|
|
50
|
+
collapsed: boolean;
|
|
51
|
+
nested: boolean;
|
|
52
|
+
selected: boolean;
|
|
53
|
+
onSelect(issueId: string): void;
|
|
54
|
+
onToggle(issueId: string): void;
|
|
55
|
+
}) {
|
|
56
|
+
const { theme, issue, compact, selected, onSelect, onToggle, depth } = props;
|
|
57
|
+
const { colors } = theme;
|
|
58
|
+
const styles = useMemo(() => {
|
|
59
|
+
const row = {
|
|
60
|
+
minHeight: 40,
|
|
61
|
+
paddingLeft: 10 + depth * INDENT,
|
|
62
|
+
paddingRight: 10,
|
|
63
|
+
paddingVertical: 8,
|
|
64
|
+
borderRadius: 8,
|
|
65
|
+
flexDirection: "row",
|
|
66
|
+
alignItems: "center",
|
|
67
|
+
gap: 10,
|
|
68
|
+
backgroundColor: "transparent",
|
|
69
|
+
} as const;
|
|
70
|
+
const active = { ...row, backgroundColor: colors.surface2 };
|
|
71
|
+
return {
|
|
72
|
+
idle: selected ? active : row,
|
|
73
|
+
active,
|
|
74
|
+
identifier: { color: colors.foregroundMuted, fontSize: 12, width: 68 },
|
|
75
|
+
body: { flex: 1, minWidth: 0 },
|
|
76
|
+
title: { color: colors.foreground, fontSize: 14 },
|
|
77
|
+
meta: { color: colors.foregroundMuted, fontSize: 12 },
|
|
78
|
+
updated: { color: colors.foregroundMuted, fontSize: 12, minWidth: 56, textAlign: "right" },
|
|
79
|
+
chevron: { width: 18, height: 24, alignItems: "center", justifyContent: "center" },
|
|
80
|
+
count: { color: colors.foregroundMuted, fontSize: 12 },
|
|
81
|
+
} as const;
|
|
82
|
+
}, [colors, selected, depth]);
|
|
83
|
+
const pressStyle = usePressableStyle(styles.idle, styles.active);
|
|
84
|
+
const a11yState = useMemo(() => ({ selected }), [selected]);
|
|
85
|
+
const press = useCallback(() => onSelect(issue.id), [onSelect, issue.id]);
|
|
86
|
+
const toggle = useCallback(() => onToggle(issue.id), [onToggle, issue.id]);
|
|
87
|
+
const subIssues = `${props.childCount} sub-issue${props.childCount === 1 ? "" : "s"}`;
|
|
88
|
+
return (
|
|
89
|
+
<Pressable
|
|
90
|
+
accessibilityRole="button"
|
|
91
|
+
accessibilityLabel={`${issue.identifier} ${issue.title}, ${issue.state.name}`}
|
|
92
|
+
accessibilityState={a11yState}
|
|
93
|
+
onPress={press}
|
|
94
|
+
style={pressStyle}
|
|
95
|
+
>
|
|
96
|
+
{props.nested ? (
|
|
97
|
+
props.childCount > 0 ? (
|
|
98
|
+
<Pressable
|
|
99
|
+
accessibilityRole="button"
|
|
100
|
+
accessibilityLabel={`${props.collapsed ? "Show" : "Hide"} ${subIssues}`}
|
|
101
|
+
hitSlop={6}
|
|
102
|
+
onPress={toggle}
|
|
103
|
+
style={styles.chevron}
|
|
104
|
+
>
|
|
105
|
+
<Icon
|
|
106
|
+
name={props.collapsed ? "ChevronRight" : "ChevronDown"}
|
|
107
|
+
size={14}
|
|
108
|
+
color={colors.foregroundMuted}
|
|
109
|
+
/>
|
|
110
|
+
</Pressable>
|
|
111
|
+
) : (
|
|
112
|
+
<View style={styles.chevron} />
|
|
113
|
+
)
|
|
114
|
+
) : null}
|
|
115
|
+
<PriorityIcon theme={theme} priority={issue.priority} size={15} />
|
|
116
|
+
{compact ? null : (
|
|
117
|
+
<Text numberOfLines={1} style={styles.identifier}>
|
|
118
|
+
{issue.identifier}
|
|
119
|
+
</Text>
|
|
120
|
+
)}
|
|
121
|
+
<StateIcon state={issue.state} size={15} />
|
|
122
|
+
<View style={styles.body}>
|
|
123
|
+
<Text numberOfLines={1} style={styles.title}>
|
|
124
|
+
{issue.title}
|
|
125
|
+
</Text>
|
|
126
|
+
{compact ? (
|
|
127
|
+
<Text numberOfLines={1} style={styles.meta}>
|
|
128
|
+
{issue.identifier} · {relativeTime(issue.updatedAt)}
|
|
129
|
+
</Text>
|
|
130
|
+
) : null}
|
|
131
|
+
{props.collapsed ? (
|
|
132
|
+
<Text numberOfLines={1} style={styles.count}>
|
|
133
|
+
{subIssues} hidden
|
|
134
|
+
</Text>
|
|
135
|
+
) : null}
|
|
136
|
+
</View>
|
|
137
|
+
{compact
|
|
138
|
+
? null
|
|
139
|
+
: issue.labels
|
|
140
|
+
.slice(0, 2)
|
|
141
|
+
.map((label) => (
|
|
142
|
+
<LabelPill key={label.id} theme={theme} name={label.name} color={label.color} />
|
|
143
|
+
))}
|
|
144
|
+
{compact ? null : (
|
|
145
|
+
<Text numberOfLines={1} style={styles.updated}>
|
|
146
|
+
{relativeTime(issue.updatedAt)}
|
|
147
|
+
</Text>
|
|
148
|
+
)}
|
|
149
|
+
<Avatar theme={theme} name={issue.assignee?.displayName ?? null} />
|
|
150
|
+
</Pressable>
|
|
151
|
+
);
|
|
152
|
+
}
|