@yannelli/paseo-linear-plugin 1.1.0-beta.3 → 1.1.0-beta.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.
- package/OVERVIEW.md +2 -2
- package/README.md +4 -3
- package/client/graph-camera.tsx +28 -8
- package/client/graph-labels.ts +7 -4
- package/client/live-cursor.tsx +76 -49
- package/client/live-graph.tsx +53 -26
- package/client/plugin-icon.tsx +41 -9
- package/client/settings-icon.tsx +57 -8
- package/client/web.ts +32 -0
- package/index.client.tsx +134 -79
- package/package.json +1 -1
- package/shared/custom-icon.ts +40 -0
- package/shared/graph-camera.ts +28 -8
- package/shared/graph-geometry.ts +30 -19
package/client/plugin-icon.tsx
CHANGED
|
@@ -1,16 +1,50 @@
|
|
|
1
1
|
import { useSettings } from "@getpaseo/plugin/client";
|
|
2
2
|
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
-
import { useMemo } from "react";
|
|
3
|
+
import { useEffect, useMemo } from "react";
|
|
4
4
|
import { Image, Platform } from "react-native";
|
|
5
|
-
import { type IconSettings, iconMarkup, paintColor } from "../shared/custom-icon";
|
|
5
|
+
import { BUILT_IN_ICON, type IconSettings, iconMarkup, paintColor } from "../shared/custom-icon";
|
|
6
6
|
import { linearSettings } from "../shared/settings";
|
|
7
7
|
import { svgDataUri } from "./provider-icon";
|
|
8
8
|
import type { Theme } from "./ui";
|
|
9
|
+
import { readStored, writeStored } from "./web";
|
|
9
10
|
|
|
10
|
-
// The plugin's icon in pills, the sidebar, and plugin screens.
|
|
11
|
-
// images from plugins,
|
|
11
|
+
// The plugin's icon in pills, the sidebar, and plugin screens. Without an SVG, and in native
|
|
12
|
+
// apps, which cannot draw SVG images from plugins, it is the chosen built-in icon.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
// Panels and Command Center items take a Lucide name when they register, outside React.
|
|
15
|
+
// Components that read the settings share the chosen name here. Paseo keeps the icon an open
|
|
16
|
+
// tab got, so the web app remembers the name and registers with it on the next start.
|
|
17
|
+
const MENU_ICON_KEY = "paseo-linear-plugin:menu-icon";
|
|
18
|
+
let menuIcon = readStored(MENU_ICON_KEY) ?? BUILT_IN_ICON;
|
|
19
|
+
|
|
20
|
+
/** The menu icon to register with first: the last one chosen on this device. */
|
|
21
|
+
export function startMenuIcon(): string {
|
|
22
|
+
return menuIcon;
|
|
23
|
+
}
|
|
24
|
+
const menuListeners = new Set<(name: string) => void>();
|
|
25
|
+
|
|
26
|
+
export function publishMenuIcon(name: string): void {
|
|
27
|
+
if (name === menuIcon) return;
|
|
28
|
+
menuIcon = name;
|
|
29
|
+
writeStored(MENU_ICON_KEY, name);
|
|
30
|
+
for (const listener of menuListeners) listener(name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function onMenuIconChange(listener: (name: string) => void): () => void {
|
|
34
|
+
menuListeners.add(listener);
|
|
35
|
+
return () => menuListeners.delete(listener);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The saved icon settings, or null while they load. Shares the menu icon on the way. */
|
|
39
|
+
export function useIconSettings(): IconSettings | null {
|
|
40
|
+
const settings = useSettings(linearSettings);
|
|
41
|
+
const icon = settings.status === "ready" ? settings.values.icon : null;
|
|
42
|
+
const menu = icon?.menu;
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (menu) publishMenuIcon(menu);
|
|
45
|
+
}, [menu]);
|
|
46
|
+
return icon;
|
|
47
|
+
}
|
|
14
48
|
|
|
15
49
|
export interface PluginIconProps {
|
|
16
50
|
size: number;
|
|
@@ -31,13 +65,11 @@ export function IconGlyph(props: PluginIconProps & { icon: IconSettings | null }
|
|
|
31
65
|
return markup ? { uri: svgDataUri(markup, color) } : null;
|
|
32
66
|
}, [svg, color, paint, solid]);
|
|
33
67
|
const style = useMemo(() => ({ width: size, height: size }), [size]);
|
|
34
|
-
if (!source) return <Icon name={BUILT_IN_ICON} size={size} color={paint ?? color} />;
|
|
68
|
+
if (!source) return <Icon name={icon?.menu ?? BUILT_IN_ICON} size={size} color={paint ?? color} />;
|
|
35
69
|
return <Image source={source} style={style} resizeMode="contain" accessibilityIgnoresInvertColors />;
|
|
36
70
|
}
|
|
37
71
|
|
|
38
72
|
/** The plugin icon from the saved settings. The built-in icon shows while they load. */
|
|
39
73
|
export function PluginIcon(props: PluginIconProps) {
|
|
40
|
-
|
|
41
|
-
const icon = settings.status === "ready" ? settings.values.icon : null;
|
|
42
|
-
return <IconGlyph {...props} icon={icon} />;
|
|
74
|
+
return <IconGlyph {...props} icon={useIconSettings()} />;
|
|
43
75
|
}
|
package/client/settings-icon.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
SettingsSelect,
|
|
9
9
|
SettingsSwitch,
|
|
10
10
|
} from "@getpaseo/plugin/client/ui";
|
|
11
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
11
12
|
import { useCallback, useMemo, useState } from "react";
|
|
12
13
|
import { Platform, Text, View } from "react-native";
|
|
13
14
|
import {
|
|
@@ -15,10 +16,11 @@ import {
|
|
|
15
16
|
type IconPaint,
|
|
16
17
|
type IconSettings,
|
|
17
18
|
MAX_SVG_LENGTH,
|
|
19
|
+
MENU_ICONS,
|
|
18
20
|
parseIconSvg,
|
|
19
21
|
} from "../shared/custom-icon";
|
|
20
22
|
import { linearSettings } from "../shared/settings";
|
|
21
|
-
import { IconGlyph } from "./plugin-icon";
|
|
23
|
+
import { IconGlyph, useIconSettings } from "./plugin-icon";
|
|
22
24
|
import { MultilineField, type ReadySettings, SaveBar, SettingsGate, useSettingsDraft } from "./settings-fields";
|
|
23
25
|
import type { Theme } from "./ui";
|
|
24
26
|
import { canPickFiles, pickTextFile } from "./web";
|
|
@@ -37,6 +39,7 @@ const MAX_FILE_BYTES = MAX_SVG_LENGTH * 4;
|
|
|
37
39
|
|
|
38
40
|
export function IconSettingsScreen({ theme }: PluginSurfaceProps) {
|
|
39
41
|
const settings = useSettings(linearSettings);
|
|
42
|
+
useIconSettings();
|
|
40
43
|
const render = useCallback(
|
|
41
44
|
(ready: ReadySettings) => <IconEditor theme={theme} settings={ready} />,
|
|
42
45
|
[theme],
|
|
@@ -85,6 +88,33 @@ function IconPreview({ theme, icon }: { theme: Theme; icon: IconSettings }) {
|
|
|
85
88
|
);
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
function MenuPreview({ theme, name }: { theme: Theme; name: string }) {
|
|
92
|
+
const { colors } = theme;
|
|
93
|
+
const styles = useMemo(
|
|
94
|
+
() => ({
|
|
95
|
+
tab: {
|
|
96
|
+
flexDirection: "row" as const,
|
|
97
|
+
alignSelf: "flex-start" as const,
|
|
98
|
+
alignItems: "center" as const,
|
|
99
|
+
gap: 6,
|
|
100
|
+
marginTop: 8,
|
|
101
|
+
paddingHorizontal: 10,
|
|
102
|
+
paddingVertical: 5,
|
|
103
|
+
borderRadius: 6,
|
|
104
|
+
backgroundColor: colors.surface2,
|
|
105
|
+
},
|
|
106
|
+
label: { color: colors.foreground, fontSize: 12 },
|
|
107
|
+
}),
|
|
108
|
+
[colors],
|
|
109
|
+
);
|
|
110
|
+
return (
|
|
111
|
+
<View style={styles.tab}>
|
|
112
|
+
<Icon name={name} size={14} color={colors.foregroundMuted} />
|
|
113
|
+
<Text style={styles.label}>Linear</Text>
|
|
114
|
+
</View>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
88
118
|
function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
|
|
89
119
|
const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
|
|
90
120
|
const icon = values.icon;
|
|
@@ -123,7 +153,7 @@ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
|
|
|
123
153
|
}),
|
|
124
154
|
[applyMarkup],
|
|
125
155
|
);
|
|
126
|
-
const
|
|
156
|
+
const removeSvg = useCallback(() => {
|
|
127
157
|
setFileName(null);
|
|
128
158
|
applyMarkup("");
|
|
129
159
|
}, [applyMarkup]);
|
|
@@ -145,6 +175,11 @@ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
|
|
|
145
175
|
}, [discard, settings.values.icon.svg]);
|
|
146
176
|
const native = Platform.OS !== "web";
|
|
147
177
|
const custom = icon.svg !== "";
|
|
178
|
+
const menuOptions = useMemo(() => {
|
|
179
|
+
const options = MENU_ICONS.map((entry) => ({ label: entry.label, value: entry.name }));
|
|
180
|
+
if (!options.some((option) => option.value === icon.menu)) options.push({ label: icon.menu, value: icon.menu });
|
|
181
|
+
return options;
|
|
182
|
+
}, [icon.menu]);
|
|
148
183
|
|
|
149
184
|
return (
|
|
150
185
|
<View>
|
|
@@ -155,7 +190,7 @@ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
|
|
|
155
190
|
hint={
|
|
156
191
|
native
|
|
157
192
|
? "The iOS and Android apps cannot draw plugin SVG images, so they show the built-in icon in the color you choose."
|
|
158
|
-
: "Composer pills, the Linear sidebar row, and Linear screens use this icon.
|
|
193
|
+
: "Composer pills, the Linear sidebar row, and Linear screens use this icon. Panel tabs and menus use the built-in icon below."
|
|
159
194
|
}
|
|
160
195
|
>
|
|
161
196
|
<IconPreview theme={theme} icon={icon} />
|
|
@@ -179,11 +214,11 @@ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
|
|
|
179
214
|
onChange={applyMarkup}
|
|
180
215
|
/>
|
|
181
216
|
<SettingsAction
|
|
182
|
-
label="
|
|
183
|
-
hint="
|
|
184
|
-
actionLabel="
|
|
217
|
+
label="Remove SVG"
|
|
218
|
+
hint="Use the built-in icon everywhere."
|
|
219
|
+
actionLabel="Remove"
|
|
185
220
|
disabled={!custom && markup === ""}
|
|
186
|
-
onPress={
|
|
221
|
+
onPress={removeSvg}
|
|
187
222
|
/>
|
|
188
223
|
</SettingsCard>
|
|
189
224
|
</SettingsSection>
|
|
@@ -208,13 +243,27 @@ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
|
|
|
208
243
|
) : null}
|
|
209
244
|
<SettingsSwitch
|
|
210
245
|
label="Solid"
|
|
211
|
-
hint={custom ? "Fill shapes that only have an outline, so line icons show as solid shapes." : "Add
|
|
246
|
+
hint={custom ? "Fill shapes that only have an outline, so line icons show as solid shapes." : "Add an SVG to use this. Built-in icons stay as drawn."}
|
|
212
247
|
value={icon.solid}
|
|
213
248
|
disabled={!custom}
|
|
214
249
|
onValueChange={(solid) => change({ solid })}
|
|
215
250
|
/>
|
|
216
251
|
</SettingsCard>
|
|
217
252
|
</SettingsSection>
|
|
253
|
+
<SettingsSection title="Built-in icon">
|
|
254
|
+
<SettingsCard>
|
|
255
|
+
<SettingsSelect
|
|
256
|
+
label="Built-in icon"
|
|
257
|
+
hint="Panel tabs, the new tab menu, and Command Center take built-in icons only, so they use this one. Without an SVG, every place uses it. Tabs that are open change when you reload Paseo or open them again."
|
|
258
|
+
value={icon.menu}
|
|
259
|
+
options={menuOptions}
|
|
260
|
+
onValueChange={(menu) => change({ menu })}
|
|
261
|
+
/>
|
|
262
|
+
<SettingsRow label="Tab preview">
|
|
263
|
+
<MenuPreview theme={theme} name={icon.menu} />
|
|
264
|
+
</SettingsRow>
|
|
265
|
+
</SettingsCard>
|
|
266
|
+
</SettingsSection>
|
|
218
267
|
<SaveBar
|
|
219
268
|
theme={theme}
|
|
220
269
|
dirty={dirty}
|
package/client/web.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Platform } from "react-native";
|
|
|
3
3
|
// The only module that uses the DOM. React wheel listeners are passive, so they cannot keep
|
|
4
4
|
// the page from scrolling while the wheel zooms the graph. Off the web each export does nothing.
|
|
5
5
|
// The file picker is a hidden file input, because plugins cannot use native document pickers.
|
|
6
|
+
// Local storage keeps small values for the next start; it can be missing or blocked.
|
|
6
7
|
|
|
7
8
|
interface WheelEventLike {
|
|
8
9
|
deltaY: number;
|
|
@@ -89,3 +90,34 @@ export function pickTextFile(accept: string, maxBytes: number, onPick: (file: Pi
|
|
|
89
90
|
};
|
|
90
91
|
input.click();
|
|
91
92
|
}
|
|
93
|
+
|
|
94
|
+
interface StorageLike {
|
|
95
|
+
getItem(key: string): string | null;
|
|
96
|
+
setItem(key: string, value: string): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function storage(): StorageLike | null {
|
|
100
|
+
if (Platform.OS !== "web") return null;
|
|
101
|
+
try {
|
|
102
|
+
return (globalThis as { localStorage?: StorageLike }).localStorage ?? null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A value saved on this device, or null off the web or when storage is blocked. */
|
|
109
|
+
export function readStored(key: string): string | null {
|
|
110
|
+
try {
|
|
111
|
+
return storage()?.getItem(key) ?? null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function writeStored(key: string, value: string): void {
|
|
118
|
+
try {
|
|
119
|
+
storage()?.setItem(key, value);
|
|
120
|
+
} catch {
|
|
121
|
+
// Blocked storage only means the next start uses the built-in icon first.
|
|
122
|
+
}
|
|
123
|
+
}
|
package/index.client.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { openPluginScreen, registerScreen } from "./client/compat";
|
|
|
4
4
|
import { gateAgentPanel } from "./client/access";
|
|
5
5
|
import { LivePanel } from "./client/live-panel";
|
|
6
6
|
import { contributeComposerPills } from "./client/pill";
|
|
7
|
-
import { PluginIcon } from "./client/plugin-icon";
|
|
7
|
+
import { onMenuIconChange, PluginIcon, startMenuIcon } from "./client/plugin-icon";
|
|
8
8
|
import { ConnectionSettings } from "./client/settings-connection";
|
|
9
9
|
import { IconSettingsScreen } from "./client/settings-icon";
|
|
10
10
|
import { LiveSettings } from "./client/settings-live";
|
|
@@ -12,6 +12,7 @@ import { ProjectSettings } from "./client/settings-projects";
|
|
|
12
12
|
import { PromptSettings } from "./client/settings-prompts";
|
|
13
13
|
import { focusIssue, panelScope, SCREEN_SCOPE, updateBrowser } from "./client/store";
|
|
14
14
|
import { IssueCardRow } from "./client/timeline-card";
|
|
15
|
+
import { BUILT_IN_ICON } from "./shared/custom-icon";
|
|
15
16
|
import { issueAttachments } from "./shared/issues";
|
|
16
17
|
import { ISSUE_CARD_KIND, IssueCardSchema, LINEAR_IDENTIFIER } from "./shared/linear";
|
|
17
18
|
|
|
@@ -19,23 +20,140 @@ const SCREEN_ID = "linear";
|
|
|
19
20
|
const PANEL_ID = "issues";
|
|
20
21
|
const LIVE_PANEL_ID = "live";
|
|
21
22
|
|
|
23
|
+
type Cleanup = () => void;
|
|
24
|
+
|
|
25
|
+
// Panels and Command Center items take the menu icon when they register, so a new choice
|
|
26
|
+
// registers the whole group again. One group keeps their order the same.
|
|
27
|
+
function contributeMenus(client: PluginClientContext, icon: string): Cleanup {
|
|
28
|
+
const cleanups: Cleanup[] = [];
|
|
29
|
+
const removeAll = () => {
|
|
30
|
+
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
|
|
31
|
+
};
|
|
32
|
+
try {
|
|
33
|
+
cleanups.push(
|
|
34
|
+
client.addWorkspacePanel({
|
|
35
|
+
id: PANEL_ID,
|
|
36
|
+
title: "Linear",
|
|
37
|
+
icon,
|
|
38
|
+
context: "workspace",
|
|
39
|
+
locations: ["workspace", "explorer"],
|
|
40
|
+
Component: LinearPanel,
|
|
41
|
+
}),
|
|
42
|
+
client.addWorkspacePanel({
|
|
43
|
+
id: LIVE_PANEL_ID,
|
|
44
|
+
title: "Linear Live",
|
|
45
|
+
icon: "Radar",
|
|
46
|
+
context: "agent",
|
|
47
|
+
Component: gateAgentPanel(LivePanel),
|
|
48
|
+
}),
|
|
49
|
+
client.addCommandCenterItem({
|
|
50
|
+
id: "open-issues",
|
|
51
|
+
title: "Linear: Open issues",
|
|
52
|
+
icon,
|
|
53
|
+
keywords: ["linear", "issues", "tickets"],
|
|
54
|
+
context: "global",
|
|
55
|
+
onSelect(context) {
|
|
56
|
+
openPluginScreen(context, SCREEN_ID);
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
client.addCommandCenterItem({
|
|
60
|
+
id: "create-issue",
|
|
61
|
+
title: "Linear: Create issue",
|
|
62
|
+
icon: "Plus",
|
|
63
|
+
keywords: ["linear", "new", "ticket"],
|
|
64
|
+
context: "global",
|
|
65
|
+
onSelect(context) {
|
|
66
|
+
updateBrowser(SCREEN_SCOPE, { creating: true });
|
|
67
|
+
openPluginScreen(context, SCREEN_ID);
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
client.addCommandCenterItem({
|
|
71
|
+
id: "workspace-issues",
|
|
72
|
+
title: "Linear: Show issues in this workspace",
|
|
73
|
+
icon,
|
|
74
|
+
keywords: ["linear", "panel"],
|
|
75
|
+
context: "workspace",
|
|
76
|
+
onSelect({ openPanel }) {
|
|
77
|
+
openPanel(PANEL_ID);
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
client.addCommandCenterItem({
|
|
81
|
+
id: "open-live",
|
|
82
|
+
title: "Linear: Open Linear Live",
|
|
83
|
+
icon: "Radar",
|
|
84
|
+
keywords: ["linear", "live", "progress", "map"],
|
|
85
|
+
context: "agent",
|
|
86
|
+
onSelect({ openPanel }) {
|
|
87
|
+
openPanel(LIVE_PANEL_ID);
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
client.addCommandCenterItem({
|
|
91
|
+
id: "setup-project",
|
|
92
|
+
title: "Linear: Set up project prompts",
|
|
93
|
+
icon: "Sparkles",
|
|
94
|
+
keywords: ["linear", "init", "setup", "agents.md", "guidelines"],
|
|
95
|
+
context: "global",
|
|
96
|
+
onSelect({ openSettings }) {
|
|
97
|
+
openSettings("projects");
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
client.addCommandCenterItem({
|
|
101
|
+
id: "settings",
|
|
102
|
+
title: "Linear: Settings",
|
|
103
|
+
icon: "Settings",
|
|
104
|
+
keywords: ["linear", "api key", "prompts", "projects"],
|
|
105
|
+
context: "global",
|
|
106
|
+
onSelect({ openSettings }) {
|
|
107
|
+
openSettings("connection");
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
removeAll();
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
return removeAll;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Registers the menus with the chosen icon, and again when it changes. */
|
|
119
|
+
function contributeMenusWithIcon(client: PluginClientContext): Cleanup {
|
|
120
|
+
let icon = startMenuIcon();
|
|
121
|
+
let removeMenus: Cleanup;
|
|
122
|
+
try {
|
|
123
|
+
removeMenus = contributeMenus(client, icon);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error("Linear menu icon", error);
|
|
126
|
+
icon = BUILT_IN_ICON;
|
|
127
|
+
removeMenus = contributeMenus(client, icon);
|
|
128
|
+
}
|
|
129
|
+
const stop = onMenuIconChange((next) => {
|
|
130
|
+
if (next === icon) return;
|
|
131
|
+
removeMenus();
|
|
132
|
+
try {
|
|
133
|
+
removeMenus = contributeMenus(client, next);
|
|
134
|
+
icon = next;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// An icon this Paseo does not have: keep the built-in one.
|
|
137
|
+
console.error("Linear menu icon", error);
|
|
138
|
+
removeMenus = contributeMenus(client, BUILT_IN_ICON);
|
|
139
|
+
icon = BUILT_IN_ICON;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
return () => {
|
|
143
|
+
stop();
|
|
144
|
+
removeMenus();
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
22
148
|
export default function contribute(client: PluginClientContext) {
|
|
23
149
|
client.addAttachmentSource(issueAttachments);
|
|
24
150
|
registerScreen(client, {
|
|
25
151
|
id: SCREEN_ID,
|
|
26
152
|
title: "Linear",
|
|
27
|
-
icon:
|
|
153
|
+
icon: BUILT_IN_ICON,
|
|
28
154
|
RowIcon: PluginIcon,
|
|
29
155
|
Component: LinearScreen,
|
|
30
156
|
});
|
|
31
|
-
client.addWorkspacePanel({
|
|
32
|
-
id: PANEL_ID,
|
|
33
|
-
title: "Linear",
|
|
34
|
-
icon: "SquareKanban",
|
|
35
|
-
context: "workspace",
|
|
36
|
-
locations: ["workspace", "explorer"],
|
|
37
|
-
Component: LinearPanel,
|
|
38
|
-
});
|
|
39
157
|
client.addSettingsScreen({
|
|
40
158
|
id: "connection",
|
|
41
159
|
title: "Account",
|
|
@@ -48,13 +166,6 @@ export default function contribute(client: PluginClientContext) {
|
|
|
48
166
|
icon: "MessageSquare",
|
|
49
167
|
Component: PromptSettings,
|
|
50
168
|
});
|
|
51
|
-
client.addWorkspacePanel({
|
|
52
|
-
id: LIVE_PANEL_ID,
|
|
53
|
-
title: "Linear Live",
|
|
54
|
-
icon: "Radar",
|
|
55
|
-
context: "agent",
|
|
56
|
-
Component: gateAgentPanel(LivePanel),
|
|
57
|
-
});
|
|
58
169
|
client.addSettingsScreen({
|
|
59
170
|
id: "live",
|
|
60
171
|
title: "Live",
|
|
@@ -73,67 +184,7 @@ export default function contribute(client: PluginClientContext) {
|
|
|
73
184
|
icon: "FolderGit2",
|
|
74
185
|
Component: ProjectSettings,
|
|
75
186
|
});
|
|
76
|
-
client
|
|
77
|
-
id: "open-issues",
|
|
78
|
-
title: "Linear: Open issues",
|
|
79
|
-
icon: "SquareKanban",
|
|
80
|
-
keywords: ["linear", "issues", "tickets"],
|
|
81
|
-
context: "global",
|
|
82
|
-
onSelect(context) {
|
|
83
|
-
openPluginScreen(context, SCREEN_ID);
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
client.addCommandCenterItem({
|
|
87
|
-
id: "create-issue",
|
|
88
|
-
title: "Linear: Create issue",
|
|
89
|
-
icon: "Plus",
|
|
90
|
-
keywords: ["linear", "new", "ticket"],
|
|
91
|
-
context: "global",
|
|
92
|
-
onSelect(context) {
|
|
93
|
-
updateBrowser(SCREEN_SCOPE, { creating: true });
|
|
94
|
-
openPluginScreen(context, SCREEN_ID);
|
|
95
|
-
},
|
|
96
|
-
});
|
|
97
|
-
client.addCommandCenterItem({
|
|
98
|
-
id: "workspace-issues",
|
|
99
|
-
title: "Linear: Show issues in this workspace",
|
|
100
|
-
icon: "SquareKanban",
|
|
101
|
-
keywords: ["linear", "panel"],
|
|
102
|
-
context: "workspace",
|
|
103
|
-
onSelect({ openPanel }) {
|
|
104
|
-
openPanel(PANEL_ID);
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
client.addCommandCenterItem({
|
|
108
|
-
id: "open-live",
|
|
109
|
-
title: "Linear: Open Linear Live",
|
|
110
|
-
icon: "Radar",
|
|
111
|
-
keywords: ["linear", "live", "progress", "map"],
|
|
112
|
-
context: "agent",
|
|
113
|
-
onSelect({ openPanel }) {
|
|
114
|
-
openPanel(LIVE_PANEL_ID);
|
|
115
|
-
},
|
|
116
|
-
});
|
|
117
|
-
client.addCommandCenterItem({
|
|
118
|
-
id: "setup-project",
|
|
119
|
-
title: "Linear: Set up project prompts",
|
|
120
|
-
icon: "Sparkles",
|
|
121
|
-
keywords: ["linear", "init", "setup", "agents.md", "guidelines"],
|
|
122
|
-
context: "global",
|
|
123
|
-
onSelect({ openSettings }) {
|
|
124
|
-
openSettings("projects");
|
|
125
|
-
},
|
|
126
|
-
});
|
|
127
|
-
client.addCommandCenterItem({
|
|
128
|
-
id: "settings",
|
|
129
|
-
title: "Linear: Settings",
|
|
130
|
-
icon: "Settings",
|
|
131
|
-
keywords: ["linear", "api key", "prompts", "projects"],
|
|
132
|
-
context: "global",
|
|
133
|
-
onSelect({ openSettings }) {
|
|
134
|
-
openSettings("connection");
|
|
135
|
-
},
|
|
136
|
-
});
|
|
187
|
+
const removeMenus = contributeMenusWithIcon(client);
|
|
137
188
|
client.addSlashCommand({
|
|
138
189
|
name: "linear",
|
|
139
190
|
description: "Open Linear issues, or jump to one by key",
|
|
@@ -154,5 +205,9 @@ export default function contribute(client: PluginClientContext) {
|
|
|
154
205
|
schema: IssueCardSchema,
|
|
155
206
|
Component: IssueCardRow,
|
|
156
207
|
});
|
|
157
|
-
|
|
208
|
+
const removePills = contributeComposerPills(client);
|
|
209
|
+
return () => {
|
|
210
|
+
removeMenus();
|
|
211
|
+
removePills();
|
|
212
|
+
};
|
|
158
213
|
}
|
package/package.json
CHANGED
package/shared/custom-icon.ts
CHANGED
|
@@ -12,6 +12,44 @@ export const LINEAR_INDIGO = "#5E6AD2";
|
|
|
12
12
|
export const MAX_SVG_LENGTH = 64_000;
|
|
13
13
|
export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
14
14
|
|
|
15
|
+
/** Paseo draws panel tabs, the new tab menu, and Command Center with Lucide names only. */
|
|
16
|
+
export const BUILT_IN_ICON = "SquareKanban";
|
|
17
|
+
export const MENU_ICONS: readonly { name: string; label: string }[] = [
|
|
18
|
+
{ name: BUILT_IN_ICON, label: "Board (default)" },
|
|
19
|
+
...[
|
|
20
|
+
["Kanban", "Kanban"],
|
|
21
|
+
["LayoutGrid", "Grid"],
|
|
22
|
+
["ListTodo", "To-do list"],
|
|
23
|
+
["ClipboardList", "Clipboard"],
|
|
24
|
+
["CircleDot", "Circle dot"],
|
|
25
|
+
["Target", "Target"],
|
|
26
|
+
["Zap", "Lightning"],
|
|
27
|
+
["Rocket", "Rocket"],
|
|
28
|
+
["Star", "Star"],
|
|
29
|
+
["Hexagon", "Hexagon"],
|
|
30
|
+
["Triangle", "Triangle"],
|
|
31
|
+
["Diamond", "Diamond"],
|
|
32
|
+
["Box", "Box"],
|
|
33
|
+
["Layers", "Layers"],
|
|
34
|
+
["Compass", "Compass"],
|
|
35
|
+
["Flag", "Flag"],
|
|
36
|
+
["Bookmark", "Bookmark"],
|
|
37
|
+
["Ticket", "Ticket"],
|
|
38
|
+
["Bug", "Bug"],
|
|
39
|
+
["Sparkles", "Sparkles"],
|
|
40
|
+
["Bot", "Bot"],
|
|
41
|
+
["Gem", "Gem"],
|
|
42
|
+
["Flame", "Flame"],
|
|
43
|
+
["Shapes", "Shapes"],
|
|
44
|
+
["Orbit", "Orbit"],
|
|
45
|
+
["Workflow", "Workflow"],
|
|
46
|
+
["Puzzle", "Puzzle"],
|
|
47
|
+
["Atom", "Atom"],
|
|
48
|
+
["Command", "Command"],
|
|
49
|
+
].map(([name = "", label = ""]) => ({ name, label })),
|
|
50
|
+
];
|
|
51
|
+
const LUCIDE_NAME = /^[A-Z][A-Za-z0-9]*$/;
|
|
52
|
+
|
|
15
53
|
export const IconSettingsSchema = z.object({
|
|
16
54
|
/** Checked SVG markup. Empty uses the built-in icon. */
|
|
17
55
|
svg: z.string().max(MAX_SVG_LENGTH).default(""),
|
|
@@ -20,6 +58,8 @@ export const IconSettingsSchema = z.object({
|
|
|
20
58
|
color: z.string().regex(HEX_COLOR).default(LINEAR_INDIGO),
|
|
21
59
|
/** Fill shapes that only have an outline. */
|
|
22
60
|
solid: z.boolean().default(false),
|
|
61
|
+
/** The Lucide icon for panel tabs, the new tab menu, and Command Center. */
|
|
62
|
+
menu: z.string().regex(LUCIDE_NAME).default(BUILT_IN_ICON),
|
|
23
63
|
});
|
|
24
64
|
export type IconSettings = z.infer<typeof IconSettingsSchema>;
|
|
25
65
|
|
package/shared/graph-camera.ts
CHANGED
|
@@ -17,6 +17,11 @@ export interface Box {
|
|
|
17
17
|
bottom: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** A box with the point the view centers on when the box still fits around it. */
|
|
21
|
+
export interface Focus extends Box {
|
|
22
|
+
center: Point;
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
export const FIT: Camera = { x: 0, y: 0, zoom: 1 };
|
|
21
26
|
export const MIN_ZOOM = 1;
|
|
22
27
|
export const MAX_ZOOM = 4;
|
|
@@ -27,6 +32,11 @@ const PAD_Y = 56;
|
|
|
27
32
|
|
|
28
33
|
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
|
|
29
34
|
|
|
35
|
+
/** How much nodes, labels, and cursors grow on screen: less than the zoom, so they stay readable. */
|
|
36
|
+
export function growth(zoom: number): number {
|
|
37
|
+
return Math.sqrt(zoom);
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
/** Where a canvas point draws in the view. */
|
|
31
41
|
export function project(camera: Camera, at: Point, width: number, height: number): Point {
|
|
32
42
|
return {
|
|
@@ -61,21 +71,31 @@ export function zoomAt(camera: Camera, at: Point, factor: number, width: number,
|
|
|
61
71
|
return clampCamera({ zoom, x: at.x - cx - zoom * (px - cx), y: at.y - cy - zoom * (py - cy) }, width, height);
|
|
62
72
|
}
|
|
63
73
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
74
|
+
/** The center nearest `wanted` that keeps the span from low to high in a view of this size. */
|
|
75
|
+
function centerFor(wanted: number, low: number, high: number, half: number): number {
|
|
76
|
+
return high - low >= half * 2 ? (low + high) / 2 : clamp(wanted, high - half, low + half);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Shows the box as large as fits in the view, up to the auto zoom, centered on its focus. */
|
|
80
|
+
export function frameBox(box: Box | Focus, width: number, height: number): Camera {
|
|
66
81
|
const fit = Math.min(width / Math.max(1, box.right - box.left), height / Math.max(1, box.bottom - box.top));
|
|
67
82
|
const zoom = clamp(fit, MIN_ZOOM, AUTO_ZOOM);
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
83
|
+
const wanted = "center" in box ? box.center : { x: (box.left + box.right) / 2, y: (box.top + box.bottom) / 2 };
|
|
84
|
+
const middleX = centerFor(wanted.x, box.left, box.right, width / 2 / zoom);
|
|
85
|
+
const middleY = centerFor(wanted.y, box.top, box.bottom, height / 2 / zoom);
|
|
86
|
+
return clampCamera({ zoom, x: zoom * (width / 2 - middleX), y: zoom * (height / 2 - middleY) }, width, height);
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
// The area auto mode shows: the nodes the running agents work on and their neighbors, with
|
|
74
90
|
// room for labels and cursors. Null when no agent works on a node other than the issue.
|
|
75
|
-
export function focusBox(graph: Graph, positions: ReadonlyMap<string, Point>, targets: Iterable<string>):
|
|
91
|
+
export function focusBox(graph: Graph, positions: ReadonlyMap<string, Point>, targets: Iterable<string>): Focus | null {
|
|
76
92
|
const ids = new Set<string>();
|
|
77
93
|
for (const target of targets) if (target !== graph.root) ids.add(target);
|
|
78
94
|
if (ids.size === 0) return null;
|
|
95
|
+
const spots = [...ids].flatMap((id) => positions.get(id) ?? []);
|
|
96
|
+
const center = spots.length
|
|
97
|
+
? { x: spots.reduce((sum, at) => sum + at.x, 0) / spots.length, y: spots.reduce((sum, at) => sum + at.y, 0) / spots.length }
|
|
98
|
+
: null;
|
|
79
99
|
for (const edge of graph.edges) {
|
|
80
100
|
if (ids.has(edge.from) && edge.to !== graph.root) ids.add(edge.to);
|
|
81
101
|
else if (ids.has(edge.to) && edge.from !== graph.root) ids.add(edge.from);
|
|
@@ -88,6 +108,6 @@ export function focusBox(graph: Graph, positions: ReadonlyMap<string, Point>, ta
|
|
|
88
108
|
? { left: Math.min(box.left, at.x), top: Math.min(box.top, at.y), right: Math.max(box.right, at.x), bottom: Math.max(box.bottom, at.y) }
|
|
89
109
|
: { left: at.x, top: at.y, right: at.x, bottom: at.y };
|
|
90
110
|
}
|
|
91
|
-
if (!box) return null;
|
|
92
|
-
return { left: box.left - PAD_X, top: box.top - PAD_Y, right: box.right + PAD_X, bottom: box.bottom + PAD_Y };
|
|
111
|
+
if (!box || !center) return null;
|
|
112
|
+
return { left: box.left - PAD_X, top: box.top - PAD_Y, right: box.right + PAD_X, bottom: box.bottom + PAD_Y, center };
|
|
93
113
|
}
|