@yannelli/paseo-linear-plugin 0.3.0 → 1.0.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/OVERVIEW.md +1 -0
- package/README.md +9 -3
- package/client/autosave.ts +77 -0
- package/client/issue-description.tsx +206 -0
- package/client/issue-detail.tsx +4 -12
- package/client/markdown.tsx +70 -8
- package/client/queries.ts +31 -0
- package/package.json +1 -1
- package/paseo-plugin.json +1 -1
- package/shared/markdown.ts +45 -0
package/OVERVIEW.md
CHANGED
|
@@ -16,6 +16,7 @@ To use a different Linear key for one Paseo project, add it under **Project keys
|
|
|
16
16
|
- Select **Sub-issues** to nest sub-issues under their parent. The breadcrumbs above an issue show its team and parent chain.
|
|
17
17
|
- Change the status, priority, and assignee. Add comments, or create an issue.
|
|
18
18
|
- Descriptions and comments show as Markdown.
|
|
19
|
+
- Edit the description in place. It saves to Linear about one second after you stop typing, and the line under the editor confirms each save. Select a task list checkbox to check or uncheck it without opening the editor.
|
|
19
20
|
- `/linear` opens the panel. `/linear ENG-123` opens an issue.
|
|
20
21
|
|
|
21
22
|
## Start an agent
|
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ A Paseo plugin for Linear. It adds a Linear screen and a workspace panel where y
|
|
|
7
7
|
- Browse issues in a list next to the issue view. Search and filter the list, and change its sort order.
|
|
8
8
|
- Nest sub-issues under their parent. Breadcrumbs show the parent chain of an issue.
|
|
9
9
|
- Read descriptions and comments as Markdown.
|
|
10
|
+
- Edit the description in place. It saves as you type. Check off task list items without opening the editor.
|
|
10
11
|
- Change the status, priority, and assignee. Add comments and create issues.
|
|
11
12
|
- Start an agent or a review from an issue. Choose the prompt, agent, model, effort, project, and where the agent runs.
|
|
12
13
|
- Use a different Linear key for a Paseo project.
|
|
@@ -30,9 +31,11 @@ The images are illustrations with sample data. They come from `docs/graphics`; s
|
|
|
30
31
|
|
|
31
32
|
```sh
|
|
32
33
|
paseo plugin add npm:@yannelli/paseo-linear-plugin
|
|
33
|
-
paseo plugin ls linear
|
|
34
|
+
paseo plugin ls paseo-linear-plugin
|
|
34
35
|
```
|
|
35
36
|
|
|
37
|
+
Version 0.4.0 and earlier used the id `linear`. To upgrade from those versions, run `paseo plugin remove linear` first, then add the plugin again. Your saved keys stay in place.
|
|
38
|
+
|
|
36
39
|
To pin a version, add it to the source, for example `npm:@yannelli/paseo-linear-plugin@0.1.0`. To install a Git tag instead, run `paseo plugin add github:yannelli/paseo-linear-plugin --ref v0.1.0`. Plugins run trusted code with the daemon user's access.
|
|
37
40
|
|
|
38
41
|
## Connect Linear
|
|
@@ -87,6 +90,8 @@ The issue header shows breadcrumbs: the team, the parent chain, then the issue,
|
|
|
87
90
|
|
|
88
91
|
- Select the status, priority, or assignee chip to change it.
|
|
89
92
|
- Descriptions and comments show as Markdown: headings, lists, task lists, code, quotes, tables, and links.
|
|
93
|
+
- Select the pencil next to **Description** to edit it as Markdown. The plugin saves about one second after you stop typing, and when you leave the box. The line under the box shows **Saving…**, then **Saved to Linear**. If a save fails, it shows **Could not save** and a **Retry** button. **Done** (or Escape) closes the editor only after Linear accepts the text.
|
|
94
|
+
- Select a task list checkbox in the description to check or uncheck it. The plugin changes only that box in the Markdown and saves it to Linear. If the save fails, the issue reloads from Linear.
|
|
90
95
|
- Add a comment in the **Activity** section.
|
|
91
96
|
- The header buttons refresh the issue, copy its branch name, and open it in Linear.
|
|
92
97
|
- Select **New issue**, or run **Linear: Create issue**, to create an issue. Set the team, status, priority, assignee, title, and a Markdown description.
|
|
@@ -137,6 +142,7 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
|
|
|
137
142
|
| `client/browser.tsx` | App | Screen and panel: picks the key, then shows the list, the issue, or the agent setup page |
|
|
138
143
|
| `client/issue-list.tsx`, `client/issue-row.tsx`, `client/issue-tree.ts` | App | Search, filters, sort, rows, status groups, and sub-issue nesting |
|
|
139
144
|
| `client/issue-detail.tsx`, `client/issue-sections.tsx`, `client/breadcrumbs.tsx` | App | Issue view: properties, breadcrumbs, sub-issues, links, agents, and comments |
|
|
145
|
+
| `client/issue-description.tsx`, `client/autosave.ts` | App | Description editor, task list checkboxes, and debounced saves |
|
|
140
146
|
| `client/markdown.tsx` | App | Renders Markdown |
|
|
141
147
|
| `client/launch.tsx`, `client/launch-fields.tsx`, `client/launch-choices.ts`, `client/launch-plan.ts`, `client/agent-options.ts` | App | Agent setup page, Run in options, and agent start |
|
|
142
148
|
| `client/model-browser.tsx`, `client/provider-icon.tsx` | App | Model picker and provider icons |
|
|
@@ -149,7 +155,7 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
|
|
|
149
155
|
| `shared/issues.ts` | Both | The `issues.search` RPC and the composer attachment source |
|
|
150
156
|
| `shared/settings.ts` | Both | Settings schema: templates, agent defaults, and project settings |
|
|
151
157
|
| `shared/prompts.ts` | Both | Default templates and the issue snapshot |
|
|
152
|
-
| `shared/markdown.ts` | Both | Markdown parser |
|
|
158
|
+
| `shared/markdown.ts` | Both | Markdown parser and task list toggle |
|
|
153
159
|
| `server/handlers.ts` | Daemon | RPC handlers, key lookup, and cache use |
|
|
154
160
|
| `server/credentials.ts` | Daemon | Key file and key order |
|
|
155
161
|
| `server/cache.ts` | Daemon | Response cache in memory |
|
|
@@ -170,7 +176,7 @@ npm run check
|
|
|
170
176
|
paseo plugin add "$PWD"
|
|
171
177
|
```
|
|
172
178
|
|
|
173
|
-
After you edit the source, run `paseo plugin reload linear`. `npm run check` runs the typecheck and the tests. The tests cover the Linear client, the key file, the cache, the handlers, sub-issue nesting, the Markdown parser, the agent options, and the release scripts. They use a local GraphQL server and do not call Linear.
|
|
179
|
+
After you edit the source, run `paseo plugin reload paseo-linear-plugin`. `npm run check` runs the typecheck and the tests. The tests cover the Linear client, the key file, the cache, the handlers, sub-issue nesting, the Markdown parser and task list toggle, the description autosave, the agent options, and the release scripts. They use a local GraphQL server and do not call Linear.
|
|
174
180
|
|
|
175
181
|
## Graphics
|
|
176
182
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Debounced, single-flight saving for one text value. Pure so the tests can drive it
|
|
2
|
+
// with fake timers. Only the latest value is kept, and one save runs at a time, so
|
|
3
|
+
// saves always reach the server in edit order.
|
|
4
|
+
|
|
5
|
+
export type SaveStatus = "idle" | "pending" | "saving" | "saved" | "error";
|
|
6
|
+
|
|
7
|
+
export interface AutosaveOptions {
|
|
8
|
+
delayMs: number;
|
|
9
|
+
save(value: string): Promise<void>;
|
|
10
|
+
onStatus(status: SaveStatus, error?: unknown): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Autosave {
|
|
14
|
+
/** Queues `value` and saves it after `delayMs` (or the given delay) without changes. */
|
|
15
|
+
schedule(value: string, delayMs?: number): void;
|
|
16
|
+
/** Saves the queued value now. Resolves when nothing is left to save. */
|
|
17
|
+
flush(): Promise<void>;
|
|
18
|
+
/** Drops the queued value. A save in flight still finishes. */
|
|
19
|
+
cancel(): void;
|
|
20
|
+
/** True while a value is queued or saving. */
|
|
21
|
+
busy(): boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createAutosave(options: AutosaveOptions): Autosave {
|
|
25
|
+
let queued: string | null = null;
|
|
26
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
let inFlight: Promise<void> | null = null;
|
|
28
|
+
|
|
29
|
+
function clearTimer() {
|
|
30
|
+
if (timer !== null) clearTimeout(timer);
|
|
31
|
+
timer = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Starts the next save unless one is running. Rejects with the save error.
|
|
35
|
+
function run(): Promise<void> {
|
|
36
|
+
clearTimer();
|
|
37
|
+
if (inFlight) return inFlight.then(() => (queued === null ? undefined : run()));
|
|
38
|
+
if (queued === null) return Promise.resolve();
|
|
39
|
+
const value = queued;
|
|
40
|
+
queued = null;
|
|
41
|
+
options.onStatus("saving");
|
|
42
|
+
inFlight = options.save(value).then(
|
|
43
|
+
() => {
|
|
44
|
+
inFlight = null;
|
|
45
|
+
if (queued === null) options.onStatus(timer === null ? "saved" : "pending");
|
|
46
|
+
else if (timer === null) return run();
|
|
47
|
+
},
|
|
48
|
+
(error: unknown) => {
|
|
49
|
+
inFlight = null;
|
|
50
|
+
// Keep the failed value for a retry unless a newer edit replaced it.
|
|
51
|
+
if (queued === null) queued = value;
|
|
52
|
+
if (timer === null) options.onStatus("error", error);
|
|
53
|
+
throw error;
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
return inFlight;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function background() {
|
|
60
|
+
run().catch(() => undefined);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
schedule(value, delayMs = options.delayMs) {
|
|
65
|
+
queued = value;
|
|
66
|
+
clearTimer();
|
|
67
|
+
options.onStatus("pending");
|
|
68
|
+
timer = setTimeout(background, delayMs);
|
|
69
|
+
},
|
|
70
|
+
flush: run,
|
|
71
|
+
cancel() {
|
|
72
|
+
clearTimer();
|
|
73
|
+
queued = null;
|
|
74
|
+
},
|
|
75
|
+
busy: () => queued !== null || inFlight !== null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { TextInput, useToast } from "@getpaseo/plugin/client/react-native";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
type NativeSyntheticEvent,
|
|
5
|
+
Pressable,
|
|
6
|
+
Text,
|
|
7
|
+
type TextInputKeyPressEventData,
|
|
8
|
+
View,
|
|
9
|
+
} from "react-native";
|
|
10
|
+
import type { IssueDetail } from "../shared/linear";
|
|
11
|
+
import { toggleTask } from "../shared/markdown";
|
|
12
|
+
import { createAutosave, type SaveStatus } from "./autosave";
|
|
13
|
+
import { Markdown } from "./markdown";
|
|
14
|
+
import { useDescriptionSaver } from "./queries";
|
|
15
|
+
import { Button, errorMessage, IconButton, SectionLabel, type Theme } from "./ui";
|
|
16
|
+
|
|
17
|
+
// Typing saves after a pause. Checkbox taps save sooner and coalesce rapid taps.
|
|
18
|
+
const EDIT_DELAY_MS = 1200;
|
|
19
|
+
const TOGGLE_DELAY_MS = 400;
|
|
20
|
+
|
|
21
|
+
const STATUS_COPY: Record<SaveStatus, string> = {
|
|
22
|
+
idle: "Saves as you type",
|
|
23
|
+
pending: "Unsaved changes",
|
|
24
|
+
saving: "Saving…",
|
|
25
|
+
saved: "Saved to Linear",
|
|
26
|
+
error: "Could not save",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const HEADER_STYLE = {
|
|
30
|
+
flexDirection: "row",
|
|
31
|
+
alignItems: "center",
|
|
32
|
+
justifyContent: "space-between",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
const noop = () => undefined;
|
|
36
|
+
|
|
37
|
+
export function IssueDescription(props: { theme: Theme; compact: boolean; issue: IssueDetail }) {
|
|
38
|
+
const { theme, compact, issue } = props;
|
|
39
|
+
const { colors } = theme;
|
|
40
|
+
const toast = useToast();
|
|
41
|
+
const saver = useDescriptionSaver(issue.id);
|
|
42
|
+
const [editing, setEditing] = useState(false);
|
|
43
|
+
const [draft, setDraft] = useState("");
|
|
44
|
+
const [status, setStatus] = useState<SaveStatus>("idle");
|
|
45
|
+
const [closing, setClosing] = useState(false);
|
|
46
|
+
// Read by the autosave callbacks and the unmount flush, which outlive a render.
|
|
47
|
+
const live = useRef({ editing, draft, toast });
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
live.current = { editing, draft, toast };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const autosave = useMemo(() => {
|
|
53
|
+
let lastSaved: string | null = null;
|
|
54
|
+
const instance = createAutosave({
|
|
55
|
+
delayMs: EDIT_DELAY_MS,
|
|
56
|
+
save: async (value) => {
|
|
57
|
+
await saver.save(value);
|
|
58
|
+
lastSaved = value;
|
|
59
|
+
},
|
|
60
|
+
onStatus: (next, error) => {
|
|
61
|
+
setStatus(next);
|
|
62
|
+
// A refetch during the save may have brought back older text. Nothing is queued now.
|
|
63
|
+
if (next === "saved" && lastSaved !== null) saver.show(lastSaved);
|
|
64
|
+
if (next !== "error" || live.current.editing) return;
|
|
65
|
+
// A checkbox save failed: drop it and show what Linear has.
|
|
66
|
+
instance.cancel();
|
|
67
|
+
live.current.toast.error(`Could not update the checklist: ${errorMessage(error)}`);
|
|
68
|
+
saver.reload();
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
return instance;
|
|
72
|
+
}, [saver]);
|
|
73
|
+
|
|
74
|
+
// Leaving the issue saves any pending text instead of losing it.
|
|
75
|
+
useEffect(
|
|
76
|
+
() => () => {
|
|
77
|
+
if (!autosave.busy()) return;
|
|
78
|
+
const { editing: wasEditing, draft: text, toast: notify } = live.current;
|
|
79
|
+
if (wasEditing) saver.show(text);
|
|
80
|
+
autosave.flush().catch((error: unknown) => {
|
|
81
|
+
notify.error(`Description not saved: ${errorMessage(error)}`);
|
|
82
|
+
saver.reload();
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
[autosave, saver],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const description = issue.description ?? "";
|
|
89
|
+
const startEditing = useCallback(() => {
|
|
90
|
+
setDraft(description);
|
|
91
|
+
setStatus("idle");
|
|
92
|
+
setEditing(true);
|
|
93
|
+
}, [description]);
|
|
94
|
+
const change = useCallback(
|
|
95
|
+
(text: string) => {
|
|
96
|
+
setDraft(text);
|
|
97
|
+
autosave.schedule(text);
|
|
98
|
+
},
|
|
99
|
+
[autosave],
|
|
100
|
+
);
|
|
101
|
+
const flushQuietly = useCallback(() => void autosave.flush().catch(noop), [autosave]);
|
|
102
|
+
// Closes only after Linear confirms the text. A failure keeps the editor open.
|
|
103
|
+
const finish = useCallback(() => {
|
|
104
|
+
setClosing(true);
|
|
105
|
+
autosave
|
|
106
|
+
.flush()
|
|
107
|
+
.then(() => {
|
|
108
|
+
saver.show(live.current.draft);
|
|
109
|
+
setEditing(false);
|
|
110
|
+
setStatus("idle");
|
|
111
|
+
}, noop)
|
|
112
|
+
.finally(() => setClosing(false));
|
|
113
|
+
}, [autosave, saver]);
|
|
114
|
+
const keyPress = useCallback(
|
|
115
|
+
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
116
|
+
if (event.nativeEvent.key === "Escape") finish();
|
|
117
|
+
},
|
|
118
|
+
[finish],
|
|
119
|
+
);
|
|
120
|
+
const toggle = useCallback(
|
|
121
|
+
(ordinal: number) => {
|
|
122
|
+
const next = toggleTask(description, ordinal);
|
|
123
|
+
if (next === null) {
|
|
124
|
+
live.current.toast.error("The checklist changed. Refresh the issue and try again.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
saver.show(next);
|
|
128
|
+
autosave.schedule(next, TOGGLE_DELAY_MS);
|
|
129
|
+
},
|
|
130
|
+
[description, saver, autosave],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const styles = useMemo(
|
|
134
|
+
() =>
|
|
135
|
+
({
|
|
136
|
+
input: {
|
|
137
|
+
minHeight: 200,
|
|
138
|
+
padding: 10,
|
|
139
|
+
borderRadius: 8,
|
|
140
|
+
borderWidth: 1,
|
|
141
|
+
borderColor: colors.border,
|
|
142
|
+
backgroundColor: colors.surface1,
|
|
143
|
+
color: colors.foreground,
|
|
144
|
+
fontSize: 14,
|
|
145
|
+
lineHeight: 21,
|
|
146
|
+
textAlignVertical: "top",
|
|
147
|
+
},
|
|
148
|
+
footer: { flexDirection: "row", alignItems: "center", gap: 8, marginTop: 8 },
|
|
149
|
+
status: {
|
|
150
|
+
flex: 1,
|
|
151
|
+
fontSize: 12,
|
|
152
|
+
color: status === "error" ? colors.statusDanger : colors.foregroundMuted,
|
|
153
|
+
},
|
|
154
|
+
empty: { color: colors.foregroundMuted, fontSize: 14, lineHeight: 21 },
|
|
155
|
+
}) as const,
|
|
156
|
+
[colors, status],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (editing) {
|
|
160
|
+
return (
|
|
161
|
+
<View>
|
|
162
|
+
<SectionLabel theme={theme}>Description</SectionLabel>
|
|
163
|
+
<TextInput
|
|
164
|
+
value={draft}
|
|
165
|
+
onChangeText={change}
|
|
166
|
+
onBlur={flushQuietly}
|
|
167
|
+
onKeyPress={keyPress}
|
|
168
|
+
multiline
|
|
169
|
+
autoFocus
|
|
170
|
+
placeholder="Add a description in Markdown…"
|
|
171
|
+
placeholderTextColor={colors.foregroundMuted}
|
|
172
|
+
accessibilityLabel="Issue description"
|
|
173
|
+
style={styles.input}
|
|
174
|
+
/>
|
|
175
|
+
<View style={styles.footer}>
|
|
176
|
+
<Text accessibilityLiveRegion="polite" style={styles.status}>
|
|
177
|
+
{STATUS_COPY[status]}
|
|
178
|
+
</Text>
|
|
179
|
+
{status === "error" ? (
|
|
180
|
+
<Button theme={theme} icon="RefreshCw" label="Retry" onPress={flushQuietly} />
|
|
181
|
+
) : null}
|
|
182
|
+
<Button theme={theme} icon="Check" label="Done" busy={closing} onPress={finish} />
|
|
183
|
+
</View>
|
|
184
|
+
</View>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return (
|
|
188
|
+
<View>
|
|
189
|
+
<View style={HEADER_STYLE}>
|
|
190
|
+
<SectionLabel theme={theme}>Description</SectionLabel>
|
|
191
|
+
<IconButton theme={theme} icon="Pencil" label="Edit description" onPress={startEditing} />
|
|
192
|
+
</View>
|
|
193
|
+
{description.trim() ? (
|
|
194
|
+
<Markdown theme={theme} compact={compact} source={description} onToggleTask={toggle} />
|
|
195
|
+
) : (
|
|
196
|
+
<Pressable
|
|
197
|
+
accessibilityRole="button"
|
|
198
|
+
accessibilityLabel="Add a description"
|
|
199
|
+
onPress={startEditing}
|
|
200
|
+
>
|
|
201
|
+
<Text style={styles.empty}>No description. Add one…</Text>
|
|
202
|
+
</Pressable>
|
|
203
|
+
)}
|
|
204
|
+
</View>
|
|
205
|
+
);
|
|
206
|
+
}
|
package/client/issue-detail.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import type { IssueDetail, IssuePatch, LinearTeam, LinearUser } from "../shared/
|
|
|
6
6
|
import { ACTION_LABELS } from "../shared/prompts";
|
|
7
7
|
import type { AgentAction } from "../shared/settings";
|
|
8
8
|
import { IssueBreadcrumbs } from "./breadcrumbs";
|
|
9
|
-
import {
|
|
9
|
+
import { IssueDescription } from "./issue-description";
|
|
10
10
|
import { Comments, IssueLinks, IssueRelations, LinkedAgents } from "./issue-sections";
|
|
11
11
|
import type { LaunchTarget } from "./launch";
|
|
12
12
|
import {
|
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
LabelPill,
|
|
28
28
|
priorityMeta,
|
|
29
29
|
PriorityIcon,
|
|
30
|
-
SectionLabel,
|
|
31
30
|
StateIcon,
|
|
32
31
|
type Theme,
|
|
33
32
|
} from "./ui";
|
|
@@ -90,7 +89,8 @@ export function IssueDetailView(props: IssueDetailProps) {
|
|
|
90
89
|
</EmptyState>
|
|
91
90
|
);
|
|
92
91
|
}
|
|
93
|
-
|
|
92
|
+
// Keyed so that switching issues never carries a draft or a pending save across.
|
|
93
|
+
return <LoadedIssue key={issue.data.id} {...props} issue={issue.data} refetch={reload} />;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function LoadedIssue(props: IssueDetailProps & { issue: IssueDetail; refetch(): void }) {
|
|
@@ -108,7 +108,6 @@ function LoadedIssue(props: IssueDetailProps & { issue: IssueDetail; refetch():
|
|
|
108
108
|
fontWeight: "500",
|
|
109
109
|
lineHeight: compact ? 24 : 28,
|
|
110
110
|
},
|
|
111
|
-
description: { color: colors.foregroundMuted, fontSize: 14, lineHeight: 21 },
|
|
112
111
|
} as const;
|
|
113
112
|
}, [colors, compact]);
|
|
114
113
|
return (
|
|
@@ -140,14 +139,7 @@ function LoadedIssue(props: IssueDetailProps & { issue: IssueDetail; refetch():
|
|
|
140
139
|
) : null}
|
|
141
140
|
<LaunchButtons theme={theme} onLaunch={props.onLaunch} />
|
|
142
141
|
<LinkedAgents theme={theme} identifier={issue.identifier} navigation={props.navigation} />
|
|
143
|
-
<
|
|
144
|
-
<SectionLabel theme={theme}>Description</SectionLabel>
|
|
145
|
-
{issue.description?.trim() ? (
|
|
146
|
-
<Markdown theme={theme} compact={compact} source={issue.description} />
|
|
147
|
-
) : (
|
|
148
|
-
<Text style={styles.description}>No description.</Text>
|
|
149
|
-
)}
|
|
150
|
-
</View>
|
|
142
|
+
<IssueDescription theme={theme} compact={compact} issue={issue} />
|
|
151
143
|
<IssueRelations theme={theme} issue={issue} onOpenIssue={props.onOpenIssue} />
|
|
152
144
|
<IssueLinks theme={theme} issue={issue} />
|
|
153
145
|
<Comments theme={theme} issue={issue} />
|
package/client/markdown.tsx
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { openExternalUrl } from "@getpaseo/plugin/client";
|
|
2
2
|
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import { type ReactNode, useCallback, useMemo } from "react";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
Platform,
|
|
6
|
+
Pressable,
|
|
7
|
+
ScrollView,
|
|
8
|
+
type StyleProp,
|
|
9
|
+
Text,
|
|
10
|
+
type TextStyle,
|
|
11
|
+
View,
|
|
12
|
+
} from "react-native";
|
|
13
|
+
import {
|
|
14
|
+
type MarkdownBlock,
|
|
15
|
+
type MarkdownInline,
|
|
16
|
+
type MarkdownListItem,
|
|
17
|
+
parseMarkdown,
|
|
18
|
+
taskItems,
|
|
19
|
+
} from "../shared/markdown";
|
|
6
20
|
import type { Theme } from "./ui";
|
|
7
21
|
|
|
8
22
|
type ListBlock = Extract<MarkdownBlock, { type: "list" }>;
|
|
@@ -73,11 +87,20 @@ type Styles = ReturnType<typeof createStyles>;
|
|
|
73
87
|
|
|
74
88
|
// `s` styles the current blocks. `quoted` is the muted context for blockquotes; inside a
|
|
75
89
|
// quote it is unset, so nested quotes reuse the current (already muted) context.
|
|
90
|
+
// `tasks` maps each task item to its `taskItems` ordinal when checkboxes are pressable.
|
|
76
91
|
interface Context {
|
|
77
92
|
s: Styles;
|
|
78
93
|
quoted?: Context;
|
|
94
|
+
tasks?: TaskToggles;
|
|
79
95
|
}
|
|
80
96
|
|
|
97
|
+
interface TaskToggles {
|
|
98
|
+
ordinals: Map<MarkdownListItem, number>;
|
|
99
|
+
onToggle(ordinal: number): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const CHECKBOX_HIT_SLOP = 8;
|
|
103
|
+
|
|
81
104
|
function Link(props: { url: string; style: StyleProp<TextStyle>; children: ReactNode }) {
|
|
82
105
|
const { url } = props;
|
|
83
106
|
const open = useCallback(() => void openExternalUrl(url), [url]);
|
|
@@ -121,8 +144,37 @@ function renderBlocks(blocks: MarkdownBlock[], ctx: Context): ReactNode[] {
|
|
|
121
144
|
return blocks.map((block, index) => <Block key={index} block={block} ctx={ctx} />);
|
|
122
145
|
}
|
|
123
146
|
|
|
124
|
-
function
|
|
125
|
-
const {
|
|
147
|
+
function TaskBox(props: { checked: boolean; ordinal: number; tasks: TaskToggles; s: Styles }) {
|
|
148
|
+
const { checked, ordinal, tasks } = props;
|
|
149
|
+
const { onToggle } = tasks;
|
|
150
|
+
const toggle = useCallback(() => onToggle(ordinal), [onToggle, ordinal]);
|
|
151
|
+
const a11yState = useMemo(() => ({ checked }), [checked]);
|
|
152
|
+
return (
|
|
153
|
+
<Pressable
|
|
154
|
+
accessibilityRole="checkbox"
|
|
155
|
+
accessibilityState={a11yState}
|
|
156
|
+
accessibilityLabel={checked ? "Uncheck item" : "Check item"}
|
|
157
|
+
hitSlop={CHECKBOX_HIT_SLOP}
|
|
158
|
+
onPress={toggle}
|
|
159
|
+
>
|
|
160
|
+
<Icon name={checked ? "SquareCheck" : "Square"} size={14} color={props.s.ink} />
|
|
161
|
+
</Pressable>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ListMarker(props: {
|
|
166
|
+
list: ListBlock;
|
|
167
|
+
item: MarkdownListItem;
|
|
168
|
+
index: number;
|
|
169
|
+
ctx: Context;
|
|
170
|
+
}) {
|
|
171
|
+
const { list, item, index, ctx } = props;
|
|
172
|
+
const { s, tasks } = ctx;
|
|
173
|
+
const { checked } = item;
|
|
174
|
+
const ordinal = tasks?.ordinals.get(item);
|
|
175
|
+
if (checked !== null && tasks && ordinal !== undefined) {
|
|
176
|
+
return <TaskBox checked={checked} ordinal={ordinal} tasks={tasks} s={s} />;
|
|
177
|
+
}
|
|
126
178
|
if (checked !== null) {
|
|
127
179
|
return <Icon name={checked ? "SquareCheck" : "Square"} size={14} color={s.ink} />;
|
|
128
180
|
}
|
|
@@ -140,7 +192,7 @@ function List({ list, ctx }: { list: ListBlock; ctx: Context }) {
|
|
|
140
192
|
{list.items.map((item, index) => (
|
|
141
193
|
<View key={index} style={s.item}>
|
|
142
194
|
<View style={s.marker}>
|
|
143
|
-
<ListMarker list={list}
|
|
195
|
+
<ListMarker list={list} item={item} index={index} ctx={ctx} />
|
|
144
196
|
</View>
|
|
145
197
|
<View style={s.itemBody}>{renderBlocks(item.children, ctx)}</View>
|
|
146
198
|
</View>
|
|
@@ -209,7 +261,11 @@ function Block({ block, ctx }: { block: MarkdownBlock; ctx: Context }) {
|
|
|
209
261
|
</View>
|
|
210
262
|
);
|
|
211
263
|
case "blockquote":
|
|
212
|
-
return
|
|
264
|
+
return (
|
|
265
|
+
<View style={s.quote}>
|
|
266
|
+
{renderBlocks(block.children, ctx.quoted ? { ...ctx.quoted, tasks: ctx.tasks } : ctx)}
|
|
267
|
+
</View>
|
|
268
|
+
);
|
|
213
269
|
case "list":
|
|
214
270
|
return <List list={block} ctx={ctx} />;
|
|
215
271
|
case "table":
|
|
@@ -224,17 +280,23 @@ export interface MarkdownProps {
|
|
|
224
280
|
source: string;
|
|
225
281
|
compact?: boolean;
|
|
226
282
|
muted?: boolean;
|
|
283
|
+
/** Makes task checkboxes pressable. Gets the item's ordinal in `taskItems` order. */
|
|
284
|
+
onToggleTask?(ordinal: number): void;
|
|
227
285
|
}
|
|
228
286
|
|
|
229
287
|
export function Markdown(props: MarkdownProps) {
|
|
230
288
|
const { colors } = props.theme;
|
|
231
289
|
const compact = props.compact === true;
|
|
232
290
|
const muted = props.muted === true;
|
|
291
|
+
const { onToggleTask } = props;
|
|
233
292
|
const blocks = useMemo(() => parseMarkdown(props.source), [props.source]);
|
|
234
293
|
const ctx = useMemo<Context>(() => {
|
|
235
294
|
const quoted: Context = { s: createStyles(colors, compact, true) };
|
|
236
|
-
|
|
237
|
-
|
|
295
|
+
const tasks = onToggleTask
|
|
296
|
+
? { ordinals: new Map(taskItems(blocks).map((item, n) => [item, n])), onToggle: onToggleTask }
|
|
297
|
+
: undefined;
|
|
298
|
+
return { s: muted ? quoted.s : createStyles(colors, compact, false), quoted, tasks };
|
|
299
|
+
}, [colors, compact, muted, blocks, onToggleTask]);
|
|
238
300
|
if (blocks.length === 0) return null;
|
|
239
301
|
return <View style={ctx.s.root}>{renderBlocks(blocks, ctx)}</View>;
|
|
240
302
|
}
|
package/client/queries.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useQuery,
|
|
7
7
|
useQueryClient,
|
|
8
8
|
} from "@tanstack/react-query";
|
|
9
|
+
import { useMemo } from "react";
|
|
9
10
|
import {
|
|
10
11
|
authStatusRpc,
|
|
11
12
|
cachedCatalogRpc,
|
|
@@ -128,6 +129,36 @@ export function useUpdateIssue(issueId: string) {
|
|
|
128
129
|
});
|
|
129
130
|
}
|
|
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
|
+
|
|
131
162
|
export function useAddComment(issueId: string) {
|
|
132
163
|
const scope = useKeyScope();
|
|
133
164
|
const create = useRpc(createCommentRpc);
|
package/package.json
CHANGED
package/paseo-plugin.json
CHANGED
package/shared/markdown.ts
CHANGED
|
@@ -414,3 +414,48 @@ function readBareUrl(text: string, i: number): Token | null {
|
|
|
414
414
|
if (!safe) return null;
|
|
415
415
|
return { nodes: [{ type: "link", url: safe, children: label }], end: i + url.length };
|
|
416
416
|
}
|
|
417
|
+
|
|
418
|
+
/** Task items in document order. Renderers number checkboxes with this walk. */
|
|
419
|
+
export function taskItems(blocks: MarkdownBlock[]): MarkdownListItem[] {
|
|
420
|
+
const out: MarkdownListItem[] = [];
|
|
421
|
+
const walk = (children: MarkdownBlock[]) => {
|
|
422
|
+
for (const block of children) {
|
|
423
|
+
if (block.type === "blockquote") walk(block.children);
|
|
424
|
+
if (block.type !== "list") continue;
|
|
425
|
+
for (const item of block.items) {
|
|
426
|
+
if (item.checked !== null) out.push(item);
|
|
427
|
+
walk(item.children);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
walk(blocks);
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// A "[ ]" or "[x]" after any quote and list markers. The parser has the final word.
|
|
436
|
+
const TASK_LINE = /^((?:[ \t]*(?:>[ \t]?|(?:[-*+]|\d{1,9}[.)])[ \t]+))+)\[[ xX]\](?=[ \t]|$)/;
|
|
437
|
+
|
|
438
|
+
const taskStates = (source: string) => taskItems(parseMarkdown(source)).map((item) => item.checked);
|
|
439
|
+
|
|
440
|
+
// Flips task `ordinal` (0-based, `taskItems` order) by changing only its box character.
|
|
441
|
+
// Returns null when no line maps to that task, so callers never save a guess.
|
|
442
|
+
export function toggleTask(source: string, ordinal: number): string | null {
|
|
443
|
+
const before = taskStates(source);
|
|
444
|
+
if (!Number.isInteger(ordinal) || ordinal < 0 || ordinal >= before.length) return null;
|
|
445
|
+
const parts = source.split(/(\r\n?|\n)/);
|
|
446
|
+
let offset = 0;
|
|
447
|
+
let candidate = 0;
|
|
448
|
+
for (let k = 0; k < parts.length; k += 2) {
|
|
449
|
+
const match = TASK_LINE.exec(parts[k]);
|
|
450
|
+
// Every task is a candidate and both run in document order, so earlier ones can't match.
|
|
451
|
+
if (match && candidate++ >= ordinal) {
|
|
452
|
+
const at = offset + match[1].length + 1;
|
|
453
|
+
const next = `${source.slice(0, at)}${source[at] === " " ? "x" : " "}${source.slice(at + 1)}`;
|
|
454
|
+
const after = taskStates(next);
|
|
455
|
+
const flipsOnly = (state: boolean | null, n: number) => (n === ordinal) !== (state === before[n]);
|
|
456
|
+
if (after.length === before.length && after.every(flipsOnly)) return next;
|
|
457
|
+
}
|
|
458
|
+
offset += parts[k].length + (parts[k + 1]?.length ?? 0);
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
}
|