@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,110 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import type { IssueDetail, IssueRef } from "../shared/linear";
|
|
4
|
+
import { crumbTrail } from "./issue-tree";
|
|
5
|
+
import type { Theme } from "./ui";
|
|
6
|
+
|
|
7
|
+
const ROW = { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 4 } as const;
|
|
8
|
+
|
|
9
|
+
// Team, then the parent chain, then the issue. Each parent opens that issue.
|
|
10
|
+
export function IssueBreadcrumbs(props: {
|
|
11
|
+
theme: Theme;
|
|
12
|
+
compact: boolean;
|
|
13
|
+
issue: IssueDetail;
|
|
14
|
+
onOpenIssue(issueId: string): void;
|
|
15
|
+
}) {
|
|
16
|
+
const { theme, compact, issue, onOpenIssue } = props;
|
|
17
|
+
const { colors } = theme;
|
|
18
|
+
const [expanded, setExpanded] = useState(false);
|
|
19
|
+
const styles = useMemo(
|
|
20
|
+
() =>
|
|
21
|
+
({
|
|
22
|
+
muted: { color: colors.foregroundMuted, fontSize: 13, flexShrink: 1 },
|
|
23
|
+
link: { color: colors.foregroundMuted, fontSize: 13, textDecorationLine: "underline" },
|
|
24
|
+
current: { color: colors.foreground, fontSize: 13, fontWeight: "500", flexShrink: 0 },
|
|
25
|
+
separator: { color: colors.foregroundMuted, fontSize: 13 },
|
|
26
|
+
}) as const,
|
|
27
|
+
[colors],
|
|
28
|
+
);
|
|
29
|
+
const expand = useCallback(() => setExpanded(true), []);
|
|
30
|
+
const trail = crumbTrail(issue.ancestors, expanded ? issue.ancestors.length : compact ? 2 : 4);
|
|
31
|
+
const showTeam = !compact || issue.ancestors.length === 0;
|
|
32
|
+
return (
|
|
33
|
+
<View style={ROW} accessibilityRole="header">
|
|
34
|
+
{showTeam ? (
|
|
35
|
+
<>
|
|
36
|
+
<Text style={styles.muted} numberOfLines={1}>
|
|
37
|
+
{issue.team.name}
|
|
38
|
+
</Text>
|
|
39
|
+
<Text style={styles.separator}>›</Text>
|
|
40
|
+
</>
|
|
41
|
+
) : null}
|
|
42
|
+
{trail.map((crumb) =>
|
|
43
|
+
crumb.kind === "more" ? (
|
|
44
|
+
<CrumbLink
|
|
45
|
+
key="more"
|
|
46
|
+
label="…"
|
|
47
|
+
accessibilityLabel={`Show ${crumb.hidden} more parent issues`}
|
|
48
|
+
style={styles.link}
|
|
49
|
+
separator={styles.separator}
|
|
50
|
+
onPress={expand}
|
|
51
|
+
/>
|
|
52
|
+
) : (
|
|
53
|
+
<ParentCrumb
|
|
54
|
+
key={crumb.ref.id}
|
|
55
|
+
parent={crumb.ref}
|
|
56
|
+
style={styles.link}
|
|
57
|
+
separator={styles.separator}
|
|
58
|
+
onOpenIssue={onOpenIssue}
|
|
59
|
+
/>
|
|
60
|
+
),
|
|
61
|
+
)}
|
|
62
|
+
<Text style={styles.current} numberOfLines={1}>
|
|
63
|
+
{issue.identifier}
|
|
64
|
+
</Text>
|
|
65
|
+
</View>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ParentCrumb(props: {
|
|
70
|
+
parent: IssueRef;
|
|
71
|
+
style: object;
|
|
72
|
+
separator: object;
|
|
73
|
+
onOpenIssue(issueId: string): void;
|
|
74
|
+
}) {
|
|
75
|
+
const { parent, onOpenIssue } = props;
|
|
76
|
+
const open = useCallback(() => onOpenIssue(parent.id), [onOpenIssue, parent.id]);
|
|
77
|
+
return (
|
|
78
|
+
<CrumbLink
|
|
79
|
+
label={parent.identifier}
|
|
80
|
+
accessibilityLabel={`Open parent issue ${parent.identifier}: ${parent.title}`}
|
|
81
|
+
style={props.style}
|
|
82
|
+
separator={props.separator}
|
|
83
|
+
onPress={open}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function CrumbLink(props: {
|
|
89
|
+
label: string;
|
|
90
|
+
accessibilityLabel: string;
|
|
91
|
+
style: object;
|
|
92
|
+
separator: object;
|
|
93
|
+
onPress(): void;
|
|
94
|
+
}) {
|
|
95
|
+
return (
|
|
96
|
+
<>
|
|
97
|
+
<Pressable
|
|
98
|
+
accessibilityRole="link"
|
|
99
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
100
|
+
hitSlop={4}
|
|
101
|
+
onPress={props.onPress}
|
|
102
|
+
>
|
|
103
|
+
<Text style={props.style} numberOfLines={1}>
|
|
104
|
+
{props.label}
|
|
105
|
+
</Text>
|
|
106
|
+
</Pressable>
|
|
107
|
+
<Text style={props.separator}>›</Text>
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type PluginSurfaceProps,
|
|
3
|
+
type PluginWorkspacePanelProps,
|
|
4
|
+
useSettings,
|
|
5
|
+
useWorkspace,
|
|
6
|
+
} from "@getpaseo/plugin/client";
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
8
|
+
import { type LayoutChangeEvent, View } from "react-native";
|
|
9
|
+
import { type AgentAction, linearSettings } from "../shared/settings";
|
|
10
|
+
import { ConnectCard } from "./connect";
|
|
11
|
+
import { CreateIssueModal } from "./create-issue";
|
|
12
|
+
import { IssueDetailView } from "./issue-detail";
|
|
13
|
+
import { IssueList } from "./issue-list";
|
|
14
|
+
import { effectiveKeyScope, KeyScopeProvider } from "./key-scope";
|
|
15
|
+
import { LaunchPage, type LaunchTarget } from "./launch";
|
|
16
|
+
import { useAuthStatus, useCatalog, useProjects } from "./queries";
|
|
17
|
+
import { panelScope, SCREEN_SCOPE, useBrowserState } from "./store";
|
|
18
|
+
import { Button, EmptyState, errorMessage, type Theme } from "./ui";
|
|
19
|
+
|
|
20
|
+
const SPLIT_WIDTH = 820;
|
|
21
|
+
const NARROW_WIDTH = 560;
|
|
22
|
+
const SCREEN_TARGET: LaunchTarget = { workspace: null };
|
|
23
|
+
|
|
24
|
+
interface BrowserProps {
|
|
25
|
+
theme: Theme;
|
|
26
|
+
compact: boolean;
|
|
27
|
+
navigation: PluginSurfaceProps["navigation"];
|
|
28
|
+
scope: string;
|
|
29
|
+
target: LaunchTarget;
|
|
30
|
+
/** The Paseo project to use the Linear key of. Null uses the default key. */
|
|
31
|
+
keyProjectId: string | null;
|
|
32
|
+
/** Shows a picker for the Linear key. The screen has one; panels follow their workspace. */
|
|
33
|
+
canSwitchKey: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Catalog = NonNullable<ReturnType<typeof useCatalog>["data"]>;
|
|
37
|
+
|
|
38
|
+
function LoadError(props: { theme: Theme; title: string; error: unknown; retry(): unknown }) {
|
|
39
|
+
const { retry } = props;
|
|
40
|
+
const tryAgain = useCallback(() => void retry(), [retry]);
|
|
41
|
+
return (
|
|
42
|
+
<EmptyState
|
|
43
|
+
theme={props.theme}
|
|
44
|
+
icon="TriangleAlert"
|
|
45
|
+
tone="danger"
|
|
46
|
+
title={props.title}
|
|
47
|
+
detail={errorMessage(props.error)}
|
|
48
|
+
>
|
|
49
|
+
<Button theme={props.theme} label="Try again" icon="RefreshCw" onPress={tryAgain} />
|
|
50
|
+
</EmptyState>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function IssueBrowser(props: BrowserProps) {
|
|
55
|
+
const { theme } = props;
|
|
56
|
+
const auth = useAuthStatus();
|
|
57
|
+
const keyScope = effectiveKeyScope(props.keyProjectId, auth.data?.projectKeys);
|
|
58
|
+
if (auth.isPending) {
|
|
59
|
+
return <EmptyState theme={theme} icon="RefreshCw" title="Connecting to Linear" />;
|
|
60
|
+
}
|
|
61
|
+
if (auth.isError) {
|
|
62
|
+
return (
|
|
63
|
+
<LoadError
|
|
64
|
+
theme={theme}
|
|
65
|
+
title="Linear plugin is not responding"
|
|
66
|
+
error={auth.error}
|
|
67
|
+
retry={auth.refetch}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (keyScope === null && !auth.data.configured) {
|
|
72
|
+
return <ConnectCard theme={theme} compact={props.compact} />;
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
<KeyScopeProvider key={keyScope ?? "default"} projectId={keyScope}>
|
|
76
|
+
<ScopedBrowser {...props} projectKeys={auth.data.projectKeys} />
|
|
77
|
+
</KeyScopeProvider>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type ProjectKeys = readonly { projectId: string }[];
|
|
82
|
+
|
|
83
|
+
function ScopedBrowser(props: BrowserProps & { projectKeys: ProjectKeys }) {
|
|
84
|
+
const { theme } = props;
|
|
85
|
+
const catalog = useCatalog();
|
|
86
|
+
if (catalog.isPending) {
|
|
87
|
+
return <EmptyState theme={theme} icon="RefreshCw" title="Loading your Linear workspace" />;
|
|
88
|
+
}
|
|
89
|
+
if (catalog.isError) {
|
|
90
|
+
return (
|
|
91
|
+
<LoadError
|
|
92
|
+
theme={theme}
|
|
93
|
+
title="Could not reach Linear"
|
|
94
|
+
error={catalog.error}
|
|
95
|
+
retry={catalog.refetch}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return <ConnectedBrowser {...props} catalog={catalog.data} />;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function useKeyOptions(enabled: boolean, projectKeys: ProjectKeys) {
|
|
103
|
+
const projects = useProjects();
|
|
104
|
+
return useMemo(() => {
|
|
105
|
+
if (!enabled || projectKeys.length === 0) return [];
|
|
106
|
+
const names = new Map(
|
|
107
|
+
projects.data?.map((project) => [project.projectId, project.displayName]),
|
|
108
|
+
);
|
|
109
|
+
return [
|
|
110
|
+
{ value: "", label: "Default key" },
|
|
111
|
+
...projectKeys.map((entry) => ({
|
|
112
|
+
value: entry.projectId,
|
|
113
|
+
label: names.get(entry.projectId) ?? entry.projectId,
|
|
114
|
+
})),
|
|
115
|
+
];
|
|
116
|
+
}, [enabled, projectKeys, projects.data]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ConnectedBrowser(props: BrowserProps & { catalog: Catalog; projectKeys: ProjectKeys }) {
|
|
120
|
+
const { theme, catalog } = props;
|
|
121
|
+
const { colors } = theme;
|
|
122
|
+
const [state, update] = useBrowserState(props.scope);
|
|
123
|
+
const keyOptions = useKeyOptions(props.canSwitchKey, props.projectKeys);
|
|
124
|
+
const [width, setWidth] = useState(0);
|
|
125
|
+
const split = !props.compact && width >= SPLIT_WIDTH;
|
|
126
|
+
const showDetail = state.issueId !== null;
|
|
127
|
+
const showList = split || !showDetail;
|
|
128
|
+
const sideBySide = split && showDetail;
|
|
129
|
+
const narrow = props.compact || width < NARROW_WIDTH;
|
|
130
|
+
|
|
131
|
+
const styles = useMemo(
|
|
132
|
+
() => ({
|
|
133
|
+
root: { flex: 1, flexDirection: "row" as const, backgroundColor: colors.surface0 },
|
|
134
|
+
// Not `flex: 0`: on web it sets flex-basis 0, which overrides the width.
|
|
135
|
+
list: sideBySide
|
|
136
|
+
? {
|
|
137
|
+
flexGrow: 0,
|
|
138
|
+
flexShrink: 0,
|
|
139
|
+
width: Math.min(460, Math.round(width * 0.42)),
|
|
140
|
+
borderRightWidth: 1,
|
|
141
|
+
borderRightColor: colors.border,
|
|
142
|
+
overflow: "hidden" as const,
|
|
143
|
+
}
|
|
144
|
+
: { flex: 1, minWidth: 0, overflow: "hidden" as const },
|
|
145
|
+
detail: { flex: 1, minWidth: 0, overflow: "hidden" as const },
|
|
146
|
+
}),
|
|
147
|
+
[colors, sideBySide, width],
|
|
148
|
+
);
|
|
149
|
+
const measure = useCallback(
|
|
150
|
+
(event: LayoutChangeEvent) => setWidth(event.nativeEvent.layout.width),
|
|
151
|
+
[],
|
|
152
|
+
);
|
|
153
|
+
const back = useCallback(() => update({ issueId: null }), [update]);
|
|
154
|
+
const openIssue = useCallback((issueId: string) => update({ issueId }), [update]);
|
|
155
|
+
const closeCreate = useCallback(() => update({ creating: false }), [update]);
|
|
156
|
+
const created = useCallback((issueId: string) => update({ creating: false, issueId }), [update]);
|
|
157
|
+
const openLaunch = useCallback(
|
|
158
|
+
(action: AgentAction) =>
|
|
159
|
+
state.issueId && update({ launch: { issueId: state.issueId, action } }),
|
|
160
|
+
[update, state.issueId],
|
|
161
|
+
);
|
|
162
|
+
const closeLaunch = useCallback(() => update({ launch: null }), [update]);
|
|
163
|
+
|
|
164
|
+
if (state.launch) {
|
|
165
|
+
return (
|
|
166
|
+
<LaunchPage
|
|
167
|
+
key={`${state.launch.issueId}:${state.launch.action}`}
|
|
168
|
+
theme={theme}
|
|
169
|
+
compact={narrow}
|
|
170
|
+
issueId={state.launch.issueId}
|
|
171
|
+
action={state.launch.action}
|
|
172
|
+
target={props.target}
|
|
173
|
+
navigation={props.navigation}
|
|
174
|
+
onBack={closeLaunch}
|
|
175
|
+
onStarted={closeLaunch}
|
|
176
|
+
/>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<View style={styles.root} onLayout={measure}>
|
|
182
|
+
{showList ? (
|
|
183
|
+
<View style={styles.list}>
|
|
184
|
+
<IssueList
|
|
185
|
+
theme={theme}
|
|
186
|
+
compact={narrow || sideBySide}
|
|
187
|
+
teams={catalog.teams}
|
|
188
|
+
state={state}
|
|
189
|
+
update={update}
|
|
190
|
+
keyOptions={keyOptions}
|
|
191
|
+
/>
|
|
192
|
+
</View>
|
|
193
|
+
) : null}
|
|
194
|
+
{state.issueId ? (
|
|
195
|
+
<View style={styles.detail}>
|
|
196
|
+
<IssueDetailView
|
|
197
|
+
key={state.issueId}
|
|
198
|
+
theme={theme}
|
|
199
|
+
compact={narrow}
|
|
200
|
+
issueId={state.issueId}
|
|
201
|
+
teams={catalog.teams}
|
|
202
|
+
users={catalog.users}
|
|
203
|
+
viewerId={catalog.viewer.id}
|
|
204
|
+
target={props.target}
|
|
205
|
+
navigation={props.navigation}
|
|
206
|
+
onBack={back}
|
|
207
|
+
onOpenIssue={openIssue}
|
|
208
|
+
onLaunch={openLaunch}
|
|
209
|
+
/>
|
|
210
|
+
</View>
|
|
211
|
+
) : null}
|
|
212
|
+
{state.creating ? (
|
|
213
|
+
<CreateIssueModal
|
|
214
|
+
theme={theme}
|
|
215
|
+
teams={catalog.teams}
|
|
216
|
+
users={catalog.users}
|
|
217
|
+
viewerId={catalog.viewer.id}
|
|
218
|
+
defaultTeamId={state.teamId}
|
|
219
|
+
onClose={closeCreate}
|
|
220
|
+
onCreated={created}
|
|
221
|
+
/>
|
|
222
|
+
) : null}
|
|
223
|
+
</View>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function LinearScreen({ theme, layout, navigation }: PluginSurfaceProps) {
|
|
228
|
+
const [state] = useBrowserState(SCREEN_SCOPE);
|
|
229
|
+
return (
|
|
230
|
+
<IssueBrowser
|
|
231
|
+
theme={theme}
|
|
232
|
+
compact={layout.compact}
|
|
233
|
+
navigation={navigation}
|
|
234
|
+
scope={SCREEN_SCOPE}
|
|
235
|
+
target={SCREEN_TARGET}
|
|
236
|
+
keyProjectId={state.keyProjectId}
|
|
237
|
+
canSwitchKey
|
|
238
|
+
/>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const selectProjectId = (workspace: { projectId: string }) => workspace.projectId;
|
|
243
|
+
const selectName = (workspace: { name: string }) => workspace.name;
|
|
244
|
+
|
|
245
|
+
export function LinearPanel({ theme, layout, navigation, workspaceId }: PluginWorkspacePanelProps) {
|
|
246
|
+
const scope = panelScope(workspaceId);
|
|
247
|
+
const projectId = useWorkspace(workspaceId, selectProjectId);
|
|
248
|
+
const name = useWorkspace(workspaceId, selectName);
|
|
249
|
+
const settings = useSettings(linearSettings);
|
|
250
|
+
const [, update] = useBrowserState(scope);
|
|
251
|
+
const seeded = useRef(false);
|
|
252
|
+
const target = useMemo(
|
|
253
|
+
() => ({ workspace: projectId ? { id: workspaceId, projectId, name: name ?? "" } : null }),
|
|
254
|
+
[workspaceId, projectId, name],
|
|
255
|
+
);
|
|
256
|
+
// Default the panel's team filter to the team mapped to this workspace's project, once.
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (seeded.current || settings.status !== "ready" || !projectId) return;
|
|
259
|
+
seeded.current = true;
|
|
260
|
+
const mapped = settings.values.projects.find((entry) => entry.projectId === projectId);
|
|
261
|
+
const teamId = mapped?.teamIds[0];
|
|
262
|
+
if (teamId) update({ teamId, assignee: "anyone" });
|
|
263
|
+
}, [settings, projectId, update]);
|
|
264
|
+
return (
|
|
265
|
+
<IssueBrowser
|
|
266
|
+
theme={theme}
|
|
267
|
+
compact={layout.compact}
|
|
268
|
+
navigation={navigation}
|
|
269
|
+
scope={scope}
|
|
270
|
+
target={target}
|
|
271
|
+
keyProjectId={projectId ?? null}
|
|
272
|
+
canSwitchKey={false}
|
|
273
|
+
/>
|
|
274
|
+
);
|
|
275
|
+
}
|
package/client/compat.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import * as hostUi from "@getpaseo/plugin/client/ui";
|
|
3
|
+
import { type ComponentType, createElement } from "react";
|
|
4
|
+
|
|
5
|
+
// Paseo 0.11 replaced surfaces with screens and sidebar header items. 0.10 only has
|
|
6
|
+
// addSurface/addSidebarItem; later releases drop those aliases. Detect once, register once.
|
|
7
|
+
type Cleanup = () => void;
|
|
8
|
+
|
|
9
|
+
interface ScreenRef {
|
|
10
|
+
screenId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SidebarItemProps {
|
|
14
|
+
currentScreen: ScreenRef | null;
|
|
15
|
+
openScreen(input: ScreenRef): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ScreenClient {
|
|
19
|
+
addScreen(input: {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
Component: ComponentType<PluginSurfaceProps>;
|
|
23
|
+
}): Cleanup;
|
|
24
|
+
addSidebarHeaderItem(input: {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
Component: ComponentType<SidebarItemProps>;
|
|
28
|
+
}): Cleanup;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SurfaceClient {
|
|
32
|
+
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): Cleanup;
|
|
33
|
+
addSidebarItem(input: { id: string; title: string; icon: string; surface: string }): Cleanup;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type SidebarRowComponent = ComponentType<{
|
|
37
|
+
icon?: string;
|
|
38
|
+
label?: string;
|
|
39
|
+
active?: boolean;
|
|
40
|
+
onPress(): void;
|
|
41
|
+
}>;
|
|
42
|
+
|
|
43
|
+
function hasScreens(client: object): client is ScreenClient {
|
|
44
|
+
return "addScreen" in client && typeof client.addScreen === "function";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ScreenRegistration {
|
|
48
|
+
id: string;
|
|
49
|
+
title: string;
|
|
50
|
+
icon: string;
|
|
51
|
+
Component: ComponentType<PluginSurfaceProps>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function registerScreen(client: PluginClientContext, screen: ScreenRegistration): Cleanup {
|
|
55
|
+
const SidebarRow = (hostUi as Record<string, unknown>).SidebarRow as
|
|
56
|
+
| SidebarRowComponent
|
|
57
|
+
| undefined;
|
|
58
|
+
if (hasScreens(client) && SidebarRow) {
|
|
59
|
+
const removeScreen = client.addScreen({
|
|
60
|
+
id: screen.id,
|
|
61
|
+
title: screen.title,
|
|
62
|
+
Component: screen.Component,
|
|
63
|
+
});
|
|
64
|
+
function SidebarItem({ currentScreen, openScreen }: SidebarItemProps) {
|
|
65
|
+
return createElement(SidebarRow as SidebarRowComponent, {
|
|
66
|
+
icon: screen.icon,
|
|
67
|
+
active: currentScreen?.screenId === screen.id,
|
|
68
|
+
onPress: () => openScreen({ screenId: screen.id }),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const removeItem = client.addSidebarHeaderItem({
|
|
72
|
+
id: screen.id,
|
|
73
|
+
title: screen.title,
|
|
74
|
+
Component: SidebarItem,
|
|
75
|
+
});
|
|
76
|
+
return () => {
|
|
77
|
+
removeItem();
|
|
78
|
+
removeScreen();
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const legacy = client as unknown as SurfaceClient;
|
|
82
|
+
const removeSurface = legacy.addSurface(screen.id, screen.Component);
|
|
83
|
+
const removeItem = legacy.addSidebarItem({
|
|
84
|
+
id: screen.id,
|
|
85
|
+
title: screen.title,
|
|
86
|
+
icon: screen.icon,
|
|
87
|
+
surface: screen.id,
|
|
88
|
+
});
|
|
89
|
+
return () => {
|
|
90
|
+
removeItem();
|
|
91
|
+
removeSurface();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface NavigationCapabilities {
|
|
96
|
+
openScreen?: (input: ScreenRef) => void;
|
|
97
|
+
openSurface?: (id: string) => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function openPluginScreen(capabilities: object, screenId: string): void {
|
|
101
|
+
const navigation = capabilities as NavigationCapabilities;
|
|
102
|
+
if (typeof navigation.openScreen === "function") {
|
|
103
|
+
navigation.openScreen({ screenId });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
navigation.openSurface?.(screenId);
|
|
107
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { ExternalLink } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { useCallback, useMemo, useState } from "react";
|
|
6
|
+
import { Text, View } from "react-native";
|
|
7
|
+
import { authSaveRpc } from "../shared/linear";
|
|
8
|
+
import { Button, errorMessage, type Theme } from "./ui";
|
|
9
|
+
|
|
10
|
+
export const LINEAR_KEY_SETTINGS_URL = "https://linear.app/settings/account/security";
|
|
11
|
+
|
|
12
|
+
export function useSaveApiKey(projectId: string | null = null) {
|
|
13
|
+
const save = useRpc(authSaveRpc);
|
|
14
|
+
const queries = useQueryClient();
|
|
15
|
+
return useMutation({
|
|
16
|
+
mutationFn: (apiKey: string) => save({ apiKey, projectId }),
|
|
17
|
+
onSuccess: () => {
|
|
18
|
+
void queries.invalidateQueries({ queryKey: ["linear"] });
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ApiKeyForm(props: {
|
|
24
|
+
theme: Theme;
|
|
25
|
+
/** Saves the key for this Paseo project only. Null saves the default key. */
|
|
26
|
+
projectId?: string | null;
|
|
27
|
+
submitLabel?: string;
|
|
28
|
+
}) {
|
|
29
|
+
const { theme } = props;
|
|
30
|
+
const [apiKey, setApiKey] = useState("");
|
|
31
|
+
const saveKey = useSaveApiKey(props.projectId ?? null);
|
|
32
|
+
const toast = useToast();
|
|
33
|
+
const { colors } = theme;
|
|
34
|
+
const { mutate } = saveKey;
|
|
35
|
+
const submit = useCallback(() => {
|
|
36
|
+
if (!apiKey.trim()) return;
|
|
37
|
+
mutate(apiKey, {
|
|
38
|
+
onSuccess: ({ viewerName, organizationName }) => {
|
|
39
|
+
setApiKey("");
|
|
40
|
+
toast.show(`Connected to ${organizationName} as ${viewerName}`, { variant: "success" });
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}, [apiKey, mutate, toast]);
|
|
44
|
+
const styles = useMemo(
|
|
45
|
+
() => ({
|
|
46
|
+
root: { gap: 10, alignSelf: "stretch" as const },
|
|
47
|
+
input: {
|
|
48
|
+
height: 40,
|
|
49
|
+
paddingHorizontal: 12,
|
|
50
|
+
borderRadius: 8,
|
|
51
|
+
borderWidth: 1,
|
|
52
|
+
borderColor: colors.border,
|
|
53
|
+
backgroundColor: colors.surface2,
|
|
54
|
+
color: colors.foreground,
|
|
55
|
+
fontSize: 14,
|
|
56
|
+
},
|
|
57
|
+
error: { color: colors.statusDanger, fontSize: 13 },
|
|
58
|
+
}),
|
|
59
|
+
[colors],
|
|
60
|
+
);
|
|
61
|
+
return (
|
|
62
|
+
<View style={styles.root}>
|
|
63
|
+
<TextInput
|
|
64
|
+
value={apiKey}
|
|
65
|
+
onChangeText={setApiKey}
|
|
66
|
+
onSubmitEditing={submit}
|
|
67
|
+
placeholder="lin_api_..."
|
|
68
|
+
placeholderTextColor={colors.foregroundMuted}
|
|
69
|
+
secureTextEntry
|
|
70
|
+
autoCapitalize="none"
|
|
71
|
+
autoCorrect={false}
|
|
72
|
+
accessibilityLabel="Linear API key"
|
|
73
|
+
style={styles.input}
|
|
74
|
+
/>
|
|
75
|
+
{saveKey.error ? (
|
|
76
|
+
<Text accessibilityRole="alert" style={styles.error}>
|
|
77
|
+
{errorMessage(saveKey.error)}
|
|
78
|
+
</Text>
|
|
79
|
+
) : null}
|
|
80
|
+
<Button
|
|
81
|
+
theme={theme}
|
|
82
|
+
variant="primary"
|
|
83
|
+
size="md"
|
|
84
|
+
label={props.submitLabel ?? "Connect Linear"}
|
|
85
|
+
icon="KeyRound"
|
|
86
|
+
busy={saveKey.isPending}
|
|
87
|
+
disabled={!apiKey.trim()}
|
|
88
|
+
onPress={submit}
|
|
89
|
+
/>
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ConnectCard({ theme, compact }: { theme: Theme; compact: boolean }) {
|
|
95
|
+
const { colors } = theme;
|
|
96
|
+
const styles = useMemo(
|
|
97
|
+
() => ({
|
|
98
|
+
root: {
|
|
99
|
+
flex: 1,
|
|
100
|
+
alignItems: "center" as const,
|
|
101
|
+
justifyContent: "center" as const,
|
|
102
|
+
padding: compact ? 20 : 32,
|
|
103
|
+
},
|
|
104
|
+
card: {
|
|
105
|
+
width: "100%" as const,
|
|
106
|
+
maxWidth: 440,
|
|
107
|
+
gap: 16,
|
|
108
|
+
padding: compact ? 20 : 28,
|
|
109
|
+
borderRadius: 12,
|
|
110
|
+
borderWidth: 1,
|
|
111
|
+
borderColor: colors.border,
|
|
112
|
+
backgroundColor: colors.surface1,
|
|
113
|
+
},
|
|
114
|
+
header: { flexDirection: "row" as const, alignItems: "center" as const, gap: 10 },
|
|
115
|
+
title: { color: colors.foreground, fontSize: 17, fontWeight: "500" as const },
|
|
116
|
+
body: { color: colors.foregroundMuted, fontSize: 14, lineHeight: 20 },
|
|
117
|
+
}),
|
|
118
|
+
[colors, compact],
|
|
119
|
+
);
|
|
120
|
+
return (
|
|
121
|
+
<View style={styles.root}>
|
|
122
|
+
<View style={styles.card}>
|
|
123
|
+
<View style={styles.header}>
|
|
124
|
+
<Icon name="SquareKanban" size={22} color={colors.foreground} />
|
|
125
|
+
<Text style={styles.title}>Connect Linear</Text>
|
|
126
|
+
</View>
|
|
127
|
+
<Text style={styles.body}>
|
|
128
|
+
Create a personal API key in Linear under Settings, Security & access. Paseo stores it on
|
|
129
|
+
this host only, readable by the daemon user, and never sends it back to the app.
|
|
130
|
+
</Text>
|
|
131
|
+
<ExternalLink href={LINEAR_KEY_SETTINGS_URL}>Open Linear API key settings</ExternalLink>
|
|
132
|
+
<ApiKeyForm theme={theme} />
|
|
133
|
+
</View>
|
|
134
|
+
</View>
|
|
135
|
+
);
|
|
136
|
+
}
|