@yannelli/paseo-linear-plugin 0.1.0 → 0.4.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/NOTICE +8 -4
- package/OVERVIEW.md +23 -13
- package/README.md +138 -29
- package/client/agent-options.ts +200 -0
- package/client/autosave.ts +77 -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-description.tsx +206 -0
- package/client/issue-detail.tsx +358 -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 +302 -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 +242 -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 +7 -3
- package/package.json +4 -3
- package/paseo-plugin.json +2 -2
- 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 +11 -64
- package/server/queries.ts +280 -0
- package/shared/linear.ts +268 -0
- package/shared/markdown.ts +461 -0
- package/shared/prompts.ts +150 -0
- package/shared/settings.ts +68 -0
- package/index.client.ts +0 -7
- package/server/issues.ts +0 -10
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { Icon, Modal, TextInput } from "@getpaseo/plugin/client/react-native";
|
|
2
|
+
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
3
|
+
import { Pressable, Text, View } from "react-native";
|
|
4
|
+
import type { LinearUser, WorkflowState } from "../shared/linear";
|
|
5
|
+
import { PRIORITIES } from "../shared/linear";
|
|
6
|
+
import { Avatar, OptionRow, PriorityIcon, StateIcon, type Theme, usePressableStyle } from "./ui";
|
|
7
|
+
|
|
8
|
+
export interface PickerOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
detail?: string;
|
|
12
|
+
leading?: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PickerListProps {
|
|
16
|
+
theme: Theme;
|
|
17
|
+
title: string;
|
|
18
|
+
options: readonly PickerOption[];
|
|
19
|
+
value: string | null;
|
|
20
|
+
searchable?: boolean;
|
|
21
|
+
onSelect(value: string): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MODAL_CONTENT_STYLE = { padding: 12, gap: 4 } as const;
|
|
25
|
+
const LIST_STYLE = { gap: 4 } as const;
|
|
26
|
+
|
|
27
|
+
function PickerItem(props: {
|
|
28
|
+
theme: Theme;
|
|
29
|
+
option: PickerOption;
|
|
30
|
+
selected: boolean;
|
|
31
|
+
onSelect(value: string): void;
|
|
32
|
+
}) {
|
|
33
|
+
const { option, onSelect } = props;
|
|
34
|
+
const press = useCallback(() => onSelect(option.value), [onSelect, option.value]);
|
|
35
|
+
return (
|
|
36
|
+
<OptionRow
|
|
37
|
+
theme={props.theme}
|
|
38
|
+
label={option.label}
|
|
39
|
+
detail={option.detail}
|
|
40
|
+
leading={option.leading}
|
|
41
|
+
selected={props.selected}
|
|
42
|
+
onPress={press}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function PickerList(props: PickerListProps) {
|
|
48
|
+
const { colors } = props.theme;
|
|
49
|
+
const [search, setSearch] = useState("");
|
|
50
|
+
const visible = useMemo(() => {
|
|
51
|
+
const needle = search.trim().toLowerCase();
|
|
52
|
+
if (!needle) return props.options;
|
|
53
|
+
return props.options.filter((option) =>
|
|
54
|
+
`${option.label} ${option.detail ?? ""}`.toLowerCase().includes(needle),
|
|
55
|
+
);
|
|
56
|
+
}, [props.options, search]);
|
|
57
|
+
const styles = useMemo(
|
|
58
|
+
() => ({
|
|
59
|
+
input: {
|
|
60
|
+
height: 36,
|
|
61
|
+
marginBottom: 6,
|
|
62
|
+
paddingHorizontal: 10,
|
|
63
|
+
borderRadius: 8,
|
|
64
|
+
borderWidth: 1,
|
|
65
|
+
borderColor: colors.border,
|
|
66
|
+
backgroundColor: colors.surface2,
|
|
67
|
+
color: colors.foreground,
|
|
68
|
+
},
|
|
69
|
+
empty: { color: colors.foregroundMuted, padding: 12 },
|
|
70
|
+
}),
|
|
71
|
+
[colors],
|
|
72
|
+
);
|
|
73
|
+
return (
|
|
74
|
+
<View style={LIST_STYLE}>
|
|
75
|
+
{props.searchable ? (
|
|
76
|
+
<TextInput
|
|
77
|
+
value={search}
|
|
78
|
+
onChangeText={setSearch}
|
|
79
|
+
placeholder="Filter"
|
|
80
|
+
placeholderTextColor={colors.foregroundMuted}
|
|
81
|
+
autoCapitalize="none"
|
|
82
|
+
accessibilityLabel={`Filter ${props.title.toLowerCase()}`}
|
|
83
|
+
style={styles.input}
|
|
84
|
+
/>
|
|
85
|
+
) : null}
|
|
86
|
+
{visible.map((option) => (
|
|
87
|
+
<PickerItem
|
|
88
|
+
key={option.value}
|
|
89
|
+
theme={props.theme}
|
|
90
|
+
option={option}
|
|
91
|
+
selected={option.value === props.value}
|
|
92
|
+
onSelect={props.onSelect}
|
|
93
|
+
/>
|
|
94
|
+
))}
|
|
95
|
+
{visible.length === 0 ? <Text style={styles.empty}>No matches</Text> : null}
|
|
96
|
+
</View>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// For surfaces outside a modal. Inside a modal, use PickerPage so sheets never nest.
|
|
101
|
+
export function PickerModal(
|
|
102
|
+
props: PickerListProps & { icon: string; open: boolean; onClose(): void },
|
|
103
|
+
) {
|
|
104
|
+
const { colors } = props.theme;
|
|
105
|
+
const { onClose } = props;
|
|
106
|
+
const icon = useMemo(
|
|
107
|
+
() => <Icon name={props.icon} size={18} color={colors.foreground} />,
|
|
108
|
+
[props.icon, colors.foreground],
|
|
109
|
+
);
|
|
110
|
+
const openChange = useCallback(
|
|
111
|
+
(next: boolean) => {
|
|
112
|
+
if (!next) onClose();
|
|
113
|
+
},
|
|
114
|
+
[onClose],
|
|
115
|
+
);
|
|
116
|
+
return (
|
|
117
|
+
<Modal title={props.title} icon={icon} open={props.open} onOpenChange={openChange}>
|
|
118
|
+
<Modal.Content contentContainerStyle={MODAL_CONTENT_STYLE}>
|
|
119
|
+
{props.open ? <PickerList {...props} /> : null}
|
|
120
|
+
</Modal.Content>
|
|
121
|
+
</Modal>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function PickerPage(props: PickerListProps & { onBack(): void }) {
|
|
126
|
+
const { colors } = props.theme;
|
|
127
|
+
const styles = useMemo(
|
|
128
|
+
() =>
|
|
129
|
+
({
|
|
130
|
+
root: { gap: 8 },
|
|
131
|
+
back: { flexDirection: "row", alignItems: "center", gap: 6, height: 32 },
|
|
132
|
+
title: { color: colors.foreground, fontSize: 14, fontWeight: "500" },
|
|
133
|
+
}) as const,
|
|
134
|
+
[colors],
|
|
135
|
+
);
|
|
136
|
+
return (
|
|
137
|
+
<View style={styles.root}>
|
|
138
|
+
<Pressable
|
|
139
|
+
accessibilityRole="button"
|
|
140
|
+
accessibilityLabel="Back"
|
|
141
|
+
onPress={props.onBack}
|
|
142
|
+
style={styles.back}
|
|
143
|
+
>
|
|
144
|
+
<Icon name="ChevronLeft" size={16} color={colors.foregroundMuted} />
|
|
145
|
+
<Text style={styles.title}>{props.title}</Text>
|
|
146
|
+
</Pressable>
|
|
147
|
+
<PickerList {...props} />
|
|
148
|
+
</View>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const UNASSIGNED = "__unassigned__";
|
|
153
|
+
|
|
154
|
+
export function stateOptions(states: readonly WorkflowState[]): PickerOption[] {
|
|
155
|
+
return states.map((state) => ({
|
|
156
|
+
value: state.id,
|
|
157
|
+
label: state.name,
|
|
158
|
+
leading: <StateIcon state={state} />,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function priorityOptions(theme: Theme): PickerOption[] {
|
|
163
|
+
return PRIORITIES.map((priority) => ({
|
|
164
|
+
value: String(priority.value),
|
|
165
|
+
label: priority.label,
|
|
166
|
+
leading: <PriorityIcon theme={theme} priority={priority.value} />,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function assigneeOptions(
|
|
171
|
+
theme: Theme,
|
|
172
|
+
users: readonly LinearUser[],
|
|
173
|
+
viewerId: string | null,
|
|
174
|
+
): PickerOption[] {
|
|
175
|
+
const sorted = [...users].sort((a, b) => {
|
|
176
|
+
if (a.id === viewerId) return -1;
|
|
177
|
+
if (b.id === viewerId) return 1;
|
|
178
|
+
return a.displayName.localeCompare(b.displayName);
|
|
179
|
+
});
|
|
180
|
+
return [
|
|
181
|
+
{ value: UNASSIGNED, label: "Unassigned", leading: <Avatar theme={theme} name={null} /> },
|
|
182
|
+
...sorted.map((user) => ({
|
|
183
|
+
value: user.id,
|
|
184
|
+
label: user.id === viewerId ? `${user.displayName} (you)` : user.displayName,
|
|
185
|
+
detail: user.name !== user.displayName ? user.name : undefined,
|
|
186
|
+
leading: <Avatar theme={theme} name={user.displayName} />,
|
|
187
|
+
})),
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function PropertyChip(props: {
|
|
192
|
+
theme: Theme;
|
|
193
|
+
label: string;
|
|
194
|
+
leading: ReactNode;
|
|
195
|
+
accessibilityLabel: string;
|
|
196
|
+
disabled?: boolean;
|
|
197
|
+
onPress(): void;
|
|
198
|
+
}) {
|
|
199
|
+
const { colors } = props.theme;
|
|
200
|
+
const styles = useMemo(() => {
|
|
201
|
+
const idle = {
|
|
202
|
+
height: 30,
|
|
203
|
+
paddingHorizontal: 10,
|
|
204
|
+
borderRadius: 8,
|
|
205
|
+
flexDirection: "row",
|
|
206
|
+
alignItems: "center",
|
|
207
|
+
gap: 6,
|
|
208
|
+
borderWidth: 1,
|
|
209
|
+
borderColor: colors.border,
|
|
210
|
+
backgroundColor: colors.surface1,
|
|
211
|
+
opacity: props.disabled ? 0.6 : 1,
|
|
212
|
+
} as const;
|
|
213
|
+
return {
|
|
214
|
+
idle,
|
|
215
|
+
active: { ...idle, backgroundColor: colors.surface2 },
|
|
216
|
+
label: { color: colors.foreground, fontSize: 13, maxWidth: 180 },
|
|
217
|
+
chevron: { marginLeft: -2 },
|
|
218
|
+
};
|
|
219
|
+
}, [colors, props.disabled]);
|
|
220
|
+
const pressStyle = usePressableStyle(styles.idle, styles.active);
|
|
221
|
+
return (
|
|
222
|
+
<Pressable
|
|
223
|
+
accessibilityRole="button"
|
|
224
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
225
|
+
disabled={props.disabled}
|
|
226
|
+
onPress={props.onPress}
|
|
227
|
+
style={pressStyle}
|
|
228
|
+
>
|
|
229
|
+
{props.leading}
|
|
230
|
+
<Text numberOfLines={1} style={styles.label}>
|
|
231
|
+
{props.label}
|
|
232
|
+
</Text>
|
|
233
|
+
<View style={styles.chevron}>
|
|
234
|
+
<Icon name="ChevronDown" size={14} color={colors.foregroundMuted} />
|
|
235
|
+
</View>
|
|
236
|
+
</Pressable>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { Image, Platform } from "react-native";
|
|
4
|
+
|
|
5
|
+
// Paseo's built-in provider marks, copied from the Paseo app (Apache-2.0). Plugins cannot use
|
|
6
|
+
// react-native-svg, so the web app draws each mark as an SVG image. Native apps show Lucide icons.
|
|
7
|
+
function svg(viewBox: string, rootAttributes: string, paths: string): string {
|
|
8
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="currentColor" ${rootAttributes}>${paths}</svg>`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PROVIDER_SVGS: Record<string, string> = {
|
|
12
|
+
claude: svg(
|
|
13
|
+
"0 0 24 24",
|
|
14
|
+
`fill-rule="evenodd"`,
|
|
15
|
+
`<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/>`,
|
|
16
|
+
),
|
|
17
|
+
codex: svg(
|
|
18
|
+
"0 0 24 24",
|
|
19
|
+
`fill-rule="evenodd"`,
|
|
20
|
+
`<path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"/>`,
|
|
21
|
+
),
|
|
22
|
+
copilot: svg(
|
|
23
|
+
"0 0 512 416",
|
|
24
|
+
``,
|
|
25
|
+
`<path fill-rule="nonzero" d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"/><path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z"/>`,
|
|
26
|
+
),
|
|
27
|
+
minimax: svg(
|
|
28
|
+
"0 0 24 24",
|
|
29
|
+
`fill-rule="evenodd"`,
|
|
30
|
+
`<path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"/>`,
|
|
31
|
+
),
|
|
32
|
+
omp: svg("4 4 56 56", ``, `<path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z"/>`),
|
|
33
|
+
opencode: svg(
|
|
34
|
+
"96 64 288 384",
|
|
35
|
+
``,
|
|
36
|
+
`<path opacity="0.4" d="M320 224V352H192V224H320Z"/><path fill-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"/>`,
|
|
37
|
+
),
|
|
38
|
+
pi: svg(
|
|
39
|
+
"100 100 600 600",
|
|
40
|
+
``,
|
|
41
|
+
`<path fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/><path d="M517.36 400 H634.72 V634.72 H517.36 Z"/>`,
|
|
42
|
+
),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const LUCIDE_PROVIDER_ICONS: Record<string, string> = { kiro: "PackagePlus" };
|
|
46
|
+
|
|
47
|
+
/** The mark Paseo shows for a provider: built-in first, then the provider's own SVG. */
|
|
48
|
+
export function providerSvg(provider: string, iconSvg: string | null): string | null {
|
|
49
|
+
return PROVIDER_SVGS[provider] ?? iconSvg;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An image URI for an SVG that uses `currentColor`, drawn in `color`. */
|
|
53
|
+
export function svgDataUri(markup: string, color: string): string {
|
|
54
|
+
const tinted = markup.replaceAll("currentColor", color);
|
|
55
|
+
const withNamespace = tinted.includes("xmlns=")
|
|
56
|
+
? tinted
|
|
57
|
+
: tinted.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
58
|
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(withNamespace)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function ProviderIcon(props: {
|
|
62
|
+
provider: string;
|
|
63
|
+
iconSvg?: string | null;
|
|
64
|
+
size: number;
|
|
65
|
+
color: string;
|
|
66
|
+
}) {
|
|
67
|
+
const { provider, iconSvg, size, color } = props;
|
|
68
|
+
const markup = Platform.OS === "web" ? providerSvg(provider, iconSvg ?? null) : null;
|
|
69
|
+
const source = useMemo(
|
|
70
|
+
() => (markup ? { uri: svgDataUri(markup, color) } : null),
|
|
71
|
+
[markup, color],
|
|
72
|
+
);
|
|
73
|
+
const style = useMemo(() => ({ width: size, height: size }), [size]);
|
|
74
|
+
if (!source) {
|
|
75
|
+
return <Icon name={LUCIDE_PROVIDER_ICONS[provider] ?? "Bot"} size={size} color={color} />;
|
|
76
|
+
}
|
|
77
|
+
return <Image source={source} style={style} accessibilityIgnoresInvertColors />;
|
|
78
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import {
|
|
3
|
+
type InfiniteData,
|
|
4
|
+
useInfiniteQuery,
|
|
5
|
+
useMutation,
|
|
6
|
+
useQuery,
|
|
7
|
+
useQueryClient,
|
|
8
|
+
} from "@tanstack/react-query";
|
|
9
|
+
import { useMemo } from "react";
|
|
10
|
+
import {
|
|
11
|
+
authStatusRpc,
|
|
12
|
+
cachedCatalogRpc,
|
|
13
|
+
cachedIssueRpc,
|
|
14
|
+
cachedIssuesRpc,
|
|
15
|
+
catalogRpc,
|
|
16
|
+
createCommentRpc,
|
|
17
|
+
getIssueRpc,
|
|
18
|
+
type IssueDetail,
|
|
19
|
+
type IssuePage,
|
|
20
|
+
type IssuePatch,
|
|
21
|
+
type IssueQuery,
|
|
22
|
+
listIssuesRpc,
|
|
23
|
+
updateIssueRpc,
|
|
24
|
+
} from "../shared/linear";
|
|
25
|
+
import { AGENT_LABELS } from "../shared/prompts";
|
|
26
|
+
import { type AgentChoice, agentChoices } from "./agent-options";
|
|
27
|
+
import { useKeyScope } from "./key-scope";
|
|
28
|
+
|
|
29
|
+
type Scope = string | null;
|
|
30
|
+
const scoped = (scope: Scope) => ["linear", "scope", scope ?? "default"] as const;
|
|
31
|
+
|
|
32
|
+
export const queryKeys = {
|
|
33
|
+
auth: ["linear", "auth"] as const,
|
|
34
|
+
scope: scoped,
|
|
35
|
+
catalog: (scope: Scope) => [...scoped(scope), "catalog"] as const,
|
|
36
|
+
issues: (scope: Scope) => [...scoped(scope), "issues"] as const,
|
|
37
|
+
issue: (scope: Scope, id: string) => [...scoped(scope), "issue", id] as const,
|
|
38
|
+
agents: (identifier: string) => ["linear", "agents", identifier] as const,
|
|
39
|
+
projects: ["linear", "projects"] as const,
|
|
40
|
+
providers: ["linear", "providers"] as const,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Stale-while-revalidate: the daemon's last response shows while the Linear request runs.
|
|
44
|
+
function useSaved<T>(
|
|
45
|
+
key: readonly unknown[],
|
|
46
|
+
read: () => Promise<{ value: T | null }>,
|
|
47
|
+
enabled: boolean,
|
|
48
|
+
) {
|
|
49
|
+
const queries = useQueryClient();
|
|
50
|
+
const fresh = queries.getQueryState(key)?.data !== undefined;
|
|
51
|
+
const saved = useQuery({
|
|
52
|
+
queryKey: [...key, "saved"],
|
|
53
|
+
queryFn: async () => (await read()).value,
|
|
54
|
+
enabled: enabled && !fresh,
|
|
55
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
56
|
+
gcTime: 60_000,
|
|
57
|
+
retry: false,
|
|
58
|
+
});
|
|
59
|
+
return saved.data ?? undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useAuthStatus() {
|
|
63
|
+
const status = useRpc(authStatusRpc);
|
|
64
|
+
return useQuery({ queryKey: queryKeys.auth, queryFn: () => status({}), staleTime: 60_000 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function useCatalog(enabled = true) {
|
|
68
|
+
const scope = useKeyScope();
|
|
69
|
+
const catalog = useRpc(catalogRpc);
|
|
70
|
+
const cached = useRpc(cachedCatalogRpc);
|
|
71
|
+
const key = queryKeys.catalog(scope);
|
|
72
|
+
const saved = useSaved(key, () => cached({ projectId: scope }), enabled);
|
|
73
|
+
return useQuery({
|
|
74
|
+
queryKey: key,
|
|
75
|
+
queryFn: () => catalog({ projectId: scope }),
|
|
76
|
+
placeholderData: saved,
|
|
77
|
+
enabled,
|
|
78
|
+
staleTime: 5 * 60_000,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function useIssues(query: Omit<IssueQuery, "after">, enabled = true) {
|
|
83
|
+
const scope = useKeyScope();
|
|
84
|
+
const list = useRpc(listIssuesRpc);
|
|
85
|
+
const cached = useRpc(cachedIssuesRpc);
|
|
86
|
+
const key = [...queryKeys.issues(scope), query] as const;
|
|
87
|
+
const saved = useSaved(key, () => cached({ ...query, after: null, projectId: scope }), enabled);
|
|
88
|
+
const placeholder: InfiniteData<IssuePage, string | null> | undefined = saved
|
|
89
|
+
? { pages: [saved], pageParams: [null] }
|
|
90
|
+
: undefined;
|
|
91
|
+
return useInfiniteQuery({
|
|
92
|
+
queryKey: key,
|
|
93
|
+
queryFn: ({ pageParam }) => list({ ...query, after: pageParam, projectId: scope }),
|
|
94
|
+
initialPageParam: null as string | null,
|
|
95
|
+
getNextPageParam: (page) => (page.hasNextPage ? page.endCursor : undefined),
|
|
96
|
+
placeholderData: placeholder,
|
|
97
|
+
enabled,
|
|
98
|
+
staleTime: 30_000,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function useIssue(id: string | null) {
|
|
103
|
+
const scope = useKeyScope();
|
|
104
|
+
const get = useRpc(getIssueRpc);
|
|
105
|
+
const cached = useRpc(cachedIssueRpc);
|
|
106
|
+
const key = queryKeys.issue(scope, id ?? "");
|
|
107
|
+
const saved = useSaved(key, () => cached({ id: id ?? "", projectId: scope }), id !== null);
|
|
108
|
+
return useQuery({
|
|
109
|
+
queryKey: key,
|
|
110
|
+
queryFn: async () => (await get({ id: id ?? "", projectId: scope })).issue,
|
|
111
|
+
placeholderData: saved?.issue,
|
|
112
|
+
enabled: id !== null,
|
|
113
|
+
staleTime: 15_000,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function useUpdateIssue(issueId: string) {
|
|
118
|
+
const scope = useKeyScope();
|
|
119
|
+
const update = useRpc(updateIssueRpc);
|
|
120
|
+
const queries = useQueryClient();
|
|
121
|
+
return useMutation({
|
|
122
|
+
mutationFn: (patch: IssuePatch) => update({ id: issueId, patch, projectId: scope }),
|
|
123
|
+
onSuccess: ({ issue }) => {
|
|
124
|
+
queries.setQueryData<IssueDetail>(queryKeys.issue(scope, issueId), (current) =>
|
|
125
|
+
current ? { ...current, ...issue } : current,
|
|
126
|
+
);
|
|
127
|
+
void queries.invalidateQueries({ queryKey: queryKeys.issues(scope) });
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Plain RPC calls, not a mutation, so a save that finishes after the view closes still
|
|
133
|
+
// updates the cache. The update returns a summary without the description, so callers
|
|
134
|
+
// show the new text with `show` and the save leaves the cached description alone.
|
|
135
|
+
export function useDescriptionSaver(issueId: string) {
|
|
136
|
+
const scope = useKeyScope();
|
|
137
|
+
const update = useRpc(updateIssueRpc);
|
|
138
|
+
const queries = useQueryClient();
|
|
139
|
+
return useMemo(() => {
|
|
140
|
+
const key = queryKeys.issue(scope, issueId);
|
|
141
|
+
return {
|
|
142
|
+
show(description: string) {
|
|
143
|
+
void queries.cancelQueries({ queryKey: key });
|
|
144
|
+
queries.setQueryData<IssueDetail>(key, (current) =>
|
|
145
|
+
current ? { ...current, description } : current,
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
async save(description: string) {
|
|
149
|
+
const { issue } = await update({ id: issueId, patch: { description }, projectId: scope });
|
|
150
|
+
queries.setQueryData<IssueDetail>(key, (current) =>
|
|
151
|
+
current ? { ...current, ...issue } : current,
|
|
152
|
+
);
|
|
153
|
+
void queries.invalidateQueries({ queryKey: queryKeys.issues(scope) });
|
|
154
|
+
},
|
|
155
|
+
reload() {
|
|
156
|
+
void queries.invalidateQueries({ queryKey: key });
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}, [scope, issueId, update, queries]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function useAddComment(issueId: string) {
|
|
163
|
+
const scope = useKeyScope();
|
|
164
|
+
const create = useRpc(createCommentRpc);
|
|
165
|
+
const queries = useQueryClient();
|
|
166
|
+
return useMutation({
|
|
167
|
+
mutationFn: (body: string) => create({ issueId, body, projectId: scope }),
|
|
168
|
+
onSuccess: ({ comment }) => {
|
|
169
|
+
queries.setQueryData<IssueDetail>(queryKeys.issue(scope, issueId), (current) =>
|
|
170
|
+
current ? { ...current, comments: [...current.comments, comment] } : current,
|
|
171
|
+
);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface PaseoProjectOption {
|
|
177
|
+
projectId: string;
|
|
178
|
+
displayName: string;
|
|
179
|
+
rootPath: string;
|
|
180
|
+
kind: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function useProjects() {
|
|
184
|
+
const paseo = usePaseo();
|
|
185
|
+
return useQuery({
|
|
186
|
+
queryKey: queryKeys.projects,
|
|
187
|
+
queryFn: async (): Promise<PaseoProjectOption[]> => {
|
|
188
|
+
const { projects } = await paseo.projects.list();
|
|
189
|
+
return projects
|
|
190
|
+
.map((project) => ({
|
|
191
|
+
projectId: project.projectId,
|
|
192
|
+
displayName: project.projectDisplayName,
|
|
193
|
+
rootPath: project.projectRootPath,
|
|
194
|
+
kind: project.projectKind,
|
|
195
|
+
}))
|
|
196
|
+
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
197
|
+
},
|
|
198
|
+
staleTime: 60_000,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function useProviders() {
|
|
203
|
+
const paseo = usePaseo();
|
|
204
|
+
return useQuery({
|
|
205
|
+
queryKey: queryKeys.providers,
|
|
206
|
+
queryFn: async (): Promise<AgentChoice[]> => agentChoices(await paseo.providers.snapshot()),
|
|
207
|
+
staleTime: 60_000,
|
|
208
|
+
// Providers report models a few seconds after the daemon starts. Poll until one is ready.
|
|
209
|
+
refetchInterval: (query) => ((query.state.data?.length ?? 0) > 0 ? false : 3_000),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface LinkedAgent {
|
|
214
|
+
id: string;
|
|
215
|
+
title: string | null;
|
|
216
|
+
status: string;
|
|
217
|
+
workspaceId: string | null;
|
|
218
|
+
action: string | null;
|
|
219
|
+
requiresAttention: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function useLinkedAgents(identifier: string | null) {
|
|
223
|
+
const paseo = usePaseo();
|
|
224
|
+
return useQuery({
|
|
225
|
+
queryKey: queryKeys.agents(identifier ?? ""),
|
|
226
|
+
enabled: identifier !== null,
|
|
227
|
+
refetchInterval: 15_000,
|
|
228
|
+
queryFn: async (): Promise<LinkedAgent[]> => {
|
|
229
|
+
const { entries } = await paseo.agents.list({
|
|
230
|
+
filter: { labels: { [AGENT_LABELS.issue]: identifier ?? "" } },
|
|
231
|
+
});
|
|
232
|
+
return entries.map(({ agent }) => ({
|
|
233
|
+
id: agent.id,
|
|
234
|
+
title: agent.title ?? null,
|
|
235
|
+
status: agent.status,
|
|
236
|
+
workspaceId: agent.workspaceId ?? null,
|
|
237
|
+
action: agent.labels?.[AGENT_LABELS.action] ?? null,
|
|
238
|
+
requiresAttention: agent.requiresAttention === true,
|
|
239
|
+
}));
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import {
|
|
4
|
+
ExternalLink,
|
|
5
|
+
SettingsAction,
|
|
6
|
+
SettingsCard,
|
|
7
|
+
SettingsRow,
|
|
8
|
+
SettingsSection,
|
|
9
|
+
} from "@getpaseo/plugin/client/ui";
|
|
10
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
11
|
+
import { useCallback, useMemo } from "react";
|
|
12
|
+
import { Text, View } from "react-native";
|
|
13
|
+
import { authClearRpc } from "../shared/linear";
|
|
14
|
+
import { ApiKeyForm, LINEAR_KEY_SETTINGS_URL } from "./connect";
|
|
15
|
+
import { useAuthStatus, useCatalog } from "./queries";
|
|
16
|
+
import { ProjectKeysSection } from "./settings-keys";
|
|
17
|
+
import { errorMessage, type Theme } from "./ui";
|
|
18
|
+
|
|
19
|
+
type AuthQuery = ReturnType<typeof useAuthStatus>;
|
|
20
|
+
type CatalogQuery = ReturnType<typeof useCatalog>;
|
|
21
|
+
|
|
22
|
+
function statusText(auth: AuthQuery): string {
|
|
23
|
+
const status = auth.data;
|
|
24
|
+
if (!status) return auth.isError ? errorMessage(auth.error) : "Checking…";
|
|
25
|
+
if (!status.configured) return "No default key";
|
|
26
|
+
if (status.source === "environment") {
|
|
27
|
+
return `Using the key from the daemon environment (${status.keyHint ?? ""})`;
|
|
28
|
+
}
|
|
29
|
+
return `Using the key saved on this host (${status.keyHint ?? ""})`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function AccountLine({ theme, catalog }: { theme: Theme; catalog: CatalogQuery }) {
|
|
33
|
+
const { colors } = theme;
|
|
34
|
+
const styles = useMemo(
|
|
35
|
+
() => ({
|
|
36
|
+
muted: { color: colors.foregroundMuted, fontSize: 13 },
|
|
37
|
+
error: { color: colors.statusDanger, fontSize: 13 },
|
|
38
|
+
}),
|
|
39
|
+
[colors],
|
|
40
|
+
);
|
|
41
|
+
if (catalog.data) {
|
|
42
|
+
return (
|
|
43
|
+
<Text style={styles.muted}>
|
|
44
|
+
{catalog.data.viewer.displayName} · {catalog.data.organization.name}
|
|
45
|
+
</Text>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (catalog.isError) return <Text style={styles.error}>{errorMessage(catalog.error)}</Text>;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const FORM_STYLE = { gap: 10, paddingTop: 8 } as const;
|
|
53
|
+
|
|
54
|
+
export function ConnectionSettings({ theme }: PluginSurfaceProps) {
|
|
55
|
+
const toast = useToast();
|
|
56
|
+
const queries = useQueryClient();
|
|
57
|
+
const auth = useAuthStatus();
|
|
58
|
+
const catalog = useCatalog(auth.data?.configured === true);
|
|
59
|
+
const clear = useRpc(authClearRpc);
|
|
60
|
+
const disconnect = useMutation({
|
|
61
|
+
mutationFn: () => clear({}),
|
|
62
|
+
onSuccess: ({ configured }) => {
|
|
63
|
+
queries.removeQueries({ queryKey: ["linear"] });
|
|
64
|
+
const message = configured
|
|
65
|
+
? "Saved key removed. The daemon environment still provides a key."
|
|
66
|
+
: "Default key removed";
|
|
67
|
+
toast.show(message, { variant: "success" });
|
|
68
|
+
},
|
|
69
|
+
onError: (error) => toast.error(errorMessage(error)),
|
|
70
|
+
});
|
|
71
|
+
const { mutate } = disconnect;
|
|
72
|
+
const remove = useCallback(() => mutate(), [mutate]);
|
|
73
|
+
const configured = auth.data?.configured === true;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<View>
|
|
77
|
+
<SettingsSection title="Linear account">
|
|
78
|
+
<SettingsCard>
|
|
79
|
+
<SettingsRow label="Status" hint={statusText(auth)}>
|
|
80
|
+
<AccountLine theme={theme} catalog={catalog} />
|
|
81
|
+
</SettingsRow>
|
|
82
|
+
{auth.data?.source === "file" ? (
|
|
83
|
+
<SettingsAction
|
|
84
|
+
label="Remove the saved key"
|
|
85
|
+
hint="Deletes the key file from this host."
|
|
86
|
+
actionLabel="Disconnect"
|
|
87
|
+
disabled={disconnect.isPending}
|
|
88
|
+
onPress={remove}
|
|
89
|
+
/>
|
|
90
|
+
) : null}
|
|
91
|
+
</SettingsCard>
|
|
92
|
+
</SettingsSection>
|
|
93
|
+
<SettingsSection title={configured ? "Replace the default key" : "Default key"}>
|
|
94
|
+
<SettingsCard>
|
|
95
|
+
<SettingsRow
|
|
96
|
+
label="Personal API key"
|
|
97
|
+
hint="Saved on the daemon host with owner-only permissions. A saved key takes precedence over the daemon environment."
|
|
98
|
+
>
|
|
99
|
+
<View style={FORM_STYLE}>
|
|
100
|
+
<ExternalLink href={LINEAR_KEY_SETTINGS_URL}>Create a key in Linear</ExternalLink>
|
|
101
|
+
<ApiKeyForm theme={theme} />
|
|
102
|
+
</View>
|
|
103
|
+
</SettingsRow>
|
|
104
|
+
</SettingsCard>
|
|
105
|
+
</SettingsSection>
|
|
106
|
+
<ProjectKeysSection theme={theme} />
|
|
107
|
+
</View>
|
|
108
|
+
);
|
|
109
|
+
}
|