paseo-req-kit 1.9.0-beta.1 → 1.9.0-beta.3
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/CHANGELOG.md +40 -0
- package/README.md +2 -0
- package/bin/req-plugin.mjs +226 -0
- package/bin/req.mjs +16 -0
- package/contracts/cli-json.md +2 -1
- package/contracts/feature-files.md +10 -0
- package/contracts/state.md +1 -0
- package/docs/adr/0027-paseo-plugin.md +23 -0
- package/docs/adr/0028-external-integrations.md +22 -0
- package/docs/adr/README.md +2 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +25 -0
- package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +3 -1
- package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +32 -0
- package/engine/src/api.ts +2 -1
- package/engine/src/cli-args.ts +4 -1
- package/engine/src/config.ts +21 -1
- package/engine/src/control.ts +2 -0
- package/engine/src/integration-cmd.ts +161 -0
- package/engine/src/integrations.ts +400 -0
- package/engine/src/launch.ts +12 -0
- package/engine/src/pipeline.ts +31 -1
- package/engine/src/preflight.ts +2 -0
- package/engine/src/state.ts +3 -0
- package/install.mjs +3 -0
- package/package.json +2 -1
- package/paseo-plugin/OVERVIEW.md +7 -0
- package/paseo-plugin/client/data.ts +55 -0
- package/paseo-plugin/client/detail.tsx +303 -0
- package/paseo-plugin/client/format.ts +111 -0
- package/paseo-plugin/client/live.ts +161 -0
- package/paseo-plugin/client/markdown.tsx +219 -0
- package/paseo-plugin/client/overview.tsx +89 -0
- package/paseo-plugin/client/panel.tsx +56 -0
- package/paseo-plugin/client/row.tsx +40 -0
- package/paseo-plugin/client/settings.tsx +90 -0
- package/paseo-plugin/client/sidebar.tsx +27 -0
- package/paseo-plugin/client/stats.tsx +80 -0
- package/paseo-plugin/client/ui.tsx +111 -0
- package/paseo-plugin/index.client.tsx +52 -0
- package/paseo-plugin/index.server.ts +41 -0
- package/paseo-plugin/package.json +22 -0
- package/paseo-plugin/paseo-plugin.json +6 -0
- package/paseo-plugin/server/handlers.ts +45 -0
- package/paseo-plugin/server/req.ts +100 -0
- package/paseo-plugin/shared/contracts.ts +167 -0
- package/paseo-plugin/tsconfig.json +16 -0
- package/skills/req/SKILL.md +1 -0
- package/skills/req/playbooks/new.md +3 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Buttons that follow Paseo's agents and workspaces (registered outside React):
|
|
2
|
+
// - a pill on every step agent of a requirement: which requirement, opens it;
|
|
3
|
+
// - a header button on every workspace that has requirements: how many need a person.
|
|
4
|
+
import type { PluginButtonRegistration, PluginClientContext } from "@getpaseo/plugin/client";
|
|
5
|
+
import { listRpc, type Requirement } from "../shared/contracts";
|
|
6
|
+
import { inWorkspace } from "./panel";
|
|
7
|
+
|
|
8
|
+
/** The step agents' labels (req-kit's runtime sets them when it starts a step). */
|
|
9
|
+
const PIPELINE_LABEL = "requirement-pipeline";
|
|
10
|
+
|
|
11
|
+
interface AgentLike {
|
|
12
|
+
id: string;
|
|
13
|
+
workspaceId?: string | null;
|
|
14
|
+
labels?: Record<string, string> | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface WorkspaceLike {
|
|
18
|
+
id: string;
|
|
19
|
+
workspaceDirectory?: string;
|
|
20
|
+
projectRootPath?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function contributeLive(client: PluginClientContext): () => void {
|
|
24
|
+
const lifetime = new AbortController();
|
|
25
|
+
const pills = new Map<string, PluginButtonRegistration>();
|
|
26
|
+
const headers = new Map<string, PluginButtonRegistration>();
|
|
27
|
+
const workspaces = new Map<string, WorkspaceLike>();
|
|
28
|
+
let requirements: Requirement[] = [];
|
|
29
|
+
|
|
30
|
+
// ----- pills on step agents
|
|
31
|
+
const registerAgent = (agent: AgentLike) => {
|
|
32
|
+
pills.get(agent.id)?.remove();
|
|
33
|
+
pills.delete(agent.id);
|
|
34
|
+
const name = agent.labels?.[PIPELINE_LABEL];
|
|
35
|
+
if (lifetime.signal.aborted || !name || !agent.workspaceId) return;
|
|
36
|
+
pills.set(
|
|
37
|
+
agent.id,
|
|
38
|
+
client.addComposerPill({
|
|
39
|
+
id: "requirement",
|
|
40
|
+
workspaceId: agent.workspaceId,
|
|
41
|
+
agentId: agent.id,
|
|
42
|
+
button: {
|
|
43
|
+
title: `打开需求 ${name}`,
|
|
44
|
+
icon: "ClipboardList",
|
|
45
|
+
label: "需求",
|
|
46
|
+
behavior: { kind: "action", onPress: () => client.openScreen({ screenId: "requirement", params: { name } }) },
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
void client.paseo.agents
|
|
52
|
+
.list({ subscribe: {}, signal: lifetime.signal })
|
|
53
|
+
.then(({ subscription }) => {
|
|
54
|
+
subscription.subscribe({
|
|
55
|
+
snapshot: ({ entries }) => {
|
|
56
|
+
for (const pill of pills.values()) pill.remove();
|
|
57
|
+
pills.clear();
|
|
58
|
+
for (const { agent } of entries) registerAgent(agent as AgentLike);
|
|
59
|
+
},
|
|
60
|
+
update: (message) => {
|
|
61
|
+
if (message.type !== "agent_update") return;
|
|
62
|
+
const update = message.payload;
|
|
63
|
+
if (update.kind === "remove") {
|
|
64
|
+
pills.get(update.agentId)?.remove();
|
|
65
|
+
pills.delete(update.agentId);
|
|
66
|
+
} else registerAgent(update.agent as AgentLike);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return undefined;
|
|
70
|
+
})
|
|
71
|
+
.catch((error: Error) => {
|
|
72
|
+
if (!lifetime.signal.aborted) console.error("[req-kit] 读取 agent 列表失败", error);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ----- header buttons on workspaces with requirements
|
|
76
|
+
const refreshHeaders = () => {
|
|
77
|
+
for (const [id, ws] of workspaces) {
|
|
78
|
+
const mine = inWorkspace(requirements, ws.workspaceDirectory ?? ws.projectRootPath, id);
|
|
79
|
+
const existing = headers.get(id);
|
|
80
|
+
if (mine.length === 0) {
|
|
81
|
+
existing?.remove();
|
|
82
|
+
headers.delete(id);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const waiting = mine.filter((r) => r.attention).length;
|
|
86
|
+
const label = waiting > 0 ? `需求 · 待处理 ${waiting}` : `需求 ${mine.length}`;
|
|
87
|
+
if (existing) existing.update({ label });
|
|
88
|
+
else
|
|
89
|
+
headers.set(
|
|
90
|
+
id,
|
|
91
|
+
client.addHeaderButton({
|
|
92
|
+
id: "requirements",
|
|
93
|
+
workspaceId: id,
|
|
94
|
+
button: {
|
|
95
|
+
title: "这个工作区的需求",
|
|
96
|
+
icon: "ClipboardList",
|
|
97
|
+
label,
|
|
98
|
+
behavior: { kind: "action", onPress: () => client.openPanel("requirements", { workspaceId: id }) },
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
for (const [id, button] of headers) {
|
|
104
|
+
if (!workspaces.has(id)) {
|
|
105
|
+
button.remove();
|
|
106
|
+
headers.delete(id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const poll = async () => {
|
|
111
|
+
try {
|
|
112
|
+
requirements = await client.rpc(listRpc, {});
|
|
113
|
+
refreshHeaders();
|
|
114
|
+
} catch {
|
|
115
|
+
// req-kit missing or too old: the overview explains; no header buttons meanwhile.
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
let releaseWorkspaces: (() => void) | null = null;
|
|
119
|
+
void client.paseo.workspaces
|
|
120
|
+
.list({ subscribe: {} })
|
|
121
|
+
.then(({ subscription }) => {
|
|
122
|
+
// The workspace list takes no abort signal: release it here or at cleanup.
|
|
123
|
+
releaseWorkspaces = () => void subscription.release().catch(() => undefined);
|
|
124
|
+
if (lifetime.signal.aborted) {
|
|
125
|
+
releaseWorkspaces();
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
subscription.subscribe({
|
|
129
|
+
snapshot: ({ entries }) => {
|
|
130
|
+
workspaces.clear();
|
|
131
|
+
for (const ws of entries) workspaces.set(ws.id, ws as WorkspaceLike);
|
|
132
|
+
refreshHeaders();
|
|
133
|
+
},
|
|
134
|
+
update: (message) => {
|
|
135
|
+
if (message.type !== "workspace_update") return;
|
|
136
|
+
const update = message.payload as { kind: string; workspace?: WorkspaceLike; workspaceId?: string; id?: string };
|
|
137
|
+
if (update.kind === "upsert" && update.workspace) workspaces.set(update.workspace.id, update.workspace);
|
|
138
|
+
else {
|
|
139
|
+
const id = update.workspaceId ?? update.id;
|
|
140
|
+
if (id) workspaces.delete(id);
|
|
141
|
+
}
|
|
142
|
+
refreshHeaders();
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
return undefined;
|
|
146
|
+
})
|
|
147
|
+
.catch((error: Error) => {
|
|
148
|
+
if (!lifetime.signal.aborted) console.error("[req-kit] 读取工作区列表失败", error);
|
|
149
|
+
});
|
|
150
|
+
void poll();
|
|
151
|
+
const timer = setInterval(() => void poll(), 30_000);
|
|
152
|
+
|
|
153
|
+
return () => {
|
|
154
|
+
lifetime.abort();
|
|
155
|
+
releaseWorkspaces?.();
|
|
156
|
+
clearInterval(timer);
|
|
157
|
+
for (const b of [...pills.values(), ...headers.values()]) b.remove();
|
|
158
|
+
pills.clear();
|
|
159
|
+
headers.clear();
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// A small Markdown renderer for the requirement documents (spec, design, report…): headings,
|
|
2
|
+
// paragraphs, lists, task lists, tables, code blocks, quotes, rules, and inline bold, code and
|
|
3
|
+
// links. React Native only, colors from the theme, so it reads the same on desktop, web and phones.
|
|
4
|
+
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
|
5
|
+
import { ExternalLink } from "@getpaseo/plugin/client/ui";
|
|
6
|
+
import { type ReactNode, useMemo } from "react";
|
|
7
|
+
import { ScrollView, Text, View } from "react-native";
|
|
8
|
+
|
|
9
|
+
type Theme = PluginHostProps["theme"];
|
|
10
|
+
|
|
11
|
+
export type Block =
|
|
12
|
+
| { kind: "heading"; level: number; text: string }
|
|
13
|
+
| { kind: "paragraph"; text: string }
|
|
14
|
+
| { kind: "list"; ordered: boolean; items: Array<{ text: string; depth: number; checked: boolean | null }> }
|
|
15
|
+
| { kind: "code"; text: string }
|
|
16
|
+
| { kind: "quote"; text: string }
|
|
17
|
+
| { kind: "table"; header: string[]; rows: string[][] }
|
|
18
|
+
| { kind: "rule" };
|
|
19
|
+
|
|
20
|
+
const LIST = /^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?(.*)$/;
|
|
21
|
+
|
|
22
|
+
function cells(line: string): string[] {
|
|
23
|
+
return line
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/^\|/, "")
|
|
26
|
+
.replace(/\|$/, "")
|
|
27
|
+
.replace(/\\\|/g, "\u0000")
|
|
28
|
+
.split("|")
|
|
29
|
+
.map((c) => c.trim().replace(/\u0000/g, "|"));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A tab-separated file (decisions.tsv, lessons.tsv) as a Markdown table. */
|
|
33
|
+
export function tsvToMarkdown(tsv: string): string {
|
|
34
|
+
const rows = tsv.replace(/\r\n?/g, "\n").split("\n").filter((l) => l.trim());
|
|
35
|
+
if (rows.length === 0) return "";
|
|
36
|
+
const line = (r: string) => `| ${r.split("\t").map((c) => c.replace(/\|/g, "\\|")).join(" | ")} |`;
|
|
37
|
+
return [line(rows[0]), `|${rows[0].split("\t").map(() => " --- ").join("|")}|`, ...rows.slice(1).map(line)].join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Splits Markdown into blocks. Anything it does not recognise stays a paragraph. */
|
|
41
|
+
export function parseMarkdown(source: string): Block[] {
|
|
42
|
+
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
43
|
+
const blocks: Block[] = [];
|
|
44
|
+
let i = 0;
|
|
45
|
+
while (i < lines.length) {
|
|
46
|
+
const line = lines.at(i)!;
|
|
47
|
+
if (!line.trim()) {
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const fence = /^\s*(```|~~~)/.exec(line);
|
|
52
|
+
if (fence) {
|
|
53
|
+
const body: string[] = [];
|
|
54
|
+
i++;
|
|
55
|
+
while (i < lines.length && !lines.at(i)!.trim().startsWith(fence[1])) body.push(lines.at(i++)!);
|
|
56
|
+
i++;
|
|
57
|
+
blocks.push({ kind: "code", text: body.join("\n") });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
61
|
+
if (heading) {
|
|
62
|
+
blocks.push({ kind: "heading", level: heading[1].length, text: heading[2] });
|
|
63
|
+
i++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
|
67
|
+
blocks.push({ kind: "rule" });
|
|
68
|
+
i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (line.trim().startsWith("|") && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines.at(i + 1)!)) {
|
|
72
|
+
const header = cells(line);
|
|
73
|
+
const rows: string[][] = [];
|
|
74
|
+
i += 2;
|
|
75
|
+
while (i < lines.length && lines.at(i)!.trim().startsWith("|")) rows.push(cells(lines.at(i++)!));
|
|
76
|
+
blocks.push({ kind: "table", header, rows });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (/^\s*>/.test(line)) {
|
|
80
|
+
const body: string[] = [];
|
|
81
|
+
while (i < lines.length && /^\s*>/.test(lines.at(i)!)) body.push(lines.at(i++)!.replace(/^\s*>\s?/, ""));
|
|
82
|
+
blocks.push({ kind: "quote", text: body.join("\n") });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (LIST.test(line)) {
|
|
86
|
+
const ordered = /^\s*\d/.test(line);
|
|
87
|
+
const items: Array<{ text: string; depth: number; checked: boolean | null }> = [];
|
|
88
|
+
while (i < lines.length) {
|
|
89
|
+
const m = LIST.exec(lines.at(i)!);
|
|
90
|
+
if (m) {
|
|
91
|
+
items.push({ text: m[4], depth: Math.min(3, Math.floor(m[1].replace(/\t/g, " ").length / 2)), checked: m[3] ? /x/i.test(m[3]) : null });
|
|
92
|
+
i++;
|
|
93
|
+
} else if (lines.at(i)!.trim() && /^\s{2,}/.test(lines.at(i)!) && items.length > 0) {
|
|
94
|
+
items.at(-1)!.text += ` ${lines.at(i++)!.trim()}`;
|
|
95
|
+
} else break;
|
|
96
|
+
}
|
|
97
|
+
blocks.push({ kind: "list", ordered, items });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const body: string[] = [];
|
|
101
|
+
while (i < lines.length && lines.at(i)!.trim() && !/^(#{1,6}\s|\s*```|\s*~~~|\s*>|\s*\|)/.test(lines.at(i)!) && !LIST.test(lines.at(i)!)) body.push(lines.at(i++)!.trim());
|
|
102
|
+
blocks.push({ kind: "paragraph", text: body.join(" ") });
|
|
103
|
+
}
|
|
104
|
+
return blocks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Inline **bold**, `code` and [links](https://…). */
|
|
108
|
+
export function inline(text: string, theme: Theme, key = "i"): ReactNode[] {
|
|
109
|
+
const out: ReactNode[] = [];
|
|
110
|
+
const pattern = /(\*\*([^*]+)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\))/g;
|
|
111
|
+
let last = 0;
|
|
112
|
+
let n = 0;
|
|
113
|
+
for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
|
|
114
|
+
if (m.index > last) out.push(text.slice(last, m.index));
|
|
115
|
+
if (m[2] !== undefined) {
|
|
116
|
+
out.push(
|
|
117
|
+
<Text key={`${key}-${n++}`} style={{ fontWeight: "600" }}>
|
|
118
|
+
{m[2]}
|
|
119
|
+
</Text>,
|
|
120
|
+
);
|
|
121
|
+
} else if (m[3] !== undefined) {
|
|
122
|
+
out.push(
|
|
123
|
+
<Text key={`${key}-${n++}`} style={{ fontFamily: "monospace", backgroundColor: theme.colors.surface2 }}>
|
|
124
|
+
{m[3]}
|
|
125
|
+
</Text>,
|
|
126
|
+
);
|
|
127
|
+
} else if (m[5] && /^https?:\/\//.test(m[5])) {
|
|
128
|
+
out.push(
|
|
129
|
+
<ExternalLink key={`${key}-${n++}`} href={m[5]}>
|
|
130
|
+
{m[4]}
|
|
131
|
+
</ExternalLink>,
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
// A link to another file in the repository: show its text and where it points.
|
|
135
|
+
out.push(
|
|
136
|
+
<Text key={`${key}-${n++}`} style={{ color: theme.colors.accent }}>
|
|
137
|
+
{m[4]}
|
|
138
|
+
</Text>,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
last = m.index + m[0].length;
|
|
142
|
+
}
|
|
143
|
+
if (last < text.length) out.push(text.slice(last));
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function Markdown({ theme, source }: { theme: Theme; source: string }) {
|
|
148
|
+
const blocks = useMemo(() => parseMarkdown(source), [source]);
|
|
149
|
+
const text = { color: theme.colors.foreground, fontSize: 14, lineHeight: 21 };
|
|
150
|
+
const muted = { color: theme.colors.foregroundMuted };
|
|
151
|
+
return (
|
|
152
|
+
<View style={{ gap: 10 }}>
|
|
153
|
+
{blocks.map((b, i) => {
|
|
154
|
+
switch (b.kind) {
|
|
155
|
+
case "heading":
|
|
156
|
+
return (
|
|
157
|
+
<Text key={i} selectable style={{ ...text, fontWeight: "600", fontSize: b.level === 1 ? 20 : b.level === 2 ? 17 : 15, marginTop: i === 0 ? 0 : 6 }}>
|
|
158
|
+
{inline(b.text, theme, `h${i}`)}
|
|
159
|
+
</Text>
|
|
160
|
+
);
|
|
161
|
+
case "paragraph":
|
|
162
|
+
return (
|
|
163
|
+
<Text key={i} selectable style={text}>
|
|
164
|
+
{inline(b.text, theme, `p${i}`)}
|
|
165
|
+
</Text>
|
|
166
|
+
);
|
|
167
|
+
case "list":
|
|
168
|
+
return (
|
|
169
|
+
<View key={i} style={{ gap: 4 }}>
|
|
170
|
+
{b.items.map((item, j) => (
|
|
171
|
+
<View key={j} style={{ flexDirection: "row", gap: 6, paddingLeft: item.depth * 16 }}>
|
|
172
|
+
<Text style={{ ...text, ...muted, minWidth: 16 }}>{item.checked === null ? (b.ordered ? `${j + 1}.` : "•") : item.checked ? "☑" : "☐"}</Text>
|
|
173
|
+
<Text selectable style={{ ...text, flex: 1 }}>
|
|
174
|
+
{inline(item.text, theme, `l${i}-${j}`)}
|
|
175
|
+
</Text>
|
|
176
|
+
</View>
|
|
177
|
+
))}
|
|
178
|
+
</View>
|
|
179
|
+
);
|
|
180
|
+
case "code":
|
|
181
|
+
return (
|
|
182
|
+
<ScrollView key={i} horizontal style={{ backgroundColor: theme.colors.surface2, borderRadius: 8 }}>
|
|
183
|
+
<Text selectable style={{ ...text, fontFamily: "monospace", fontSize: 13, padding: 10 }}>
|
|
184
|
+
{b.text}
|
|
185
|
+
</Text>
|
|
186
|
+
</ScrollView>
|
|
187
|
+
);
|
|
188
|
+
case "quote":
|
|
189
|
+
return (
|
|
190
|
+
<View key={i} style={{ borderLeftWidth: 3, borderLeftColor: theme.colors.border, paddingLeft: 10 }}>
|
|
191
|
+
<Text selectable style={{ ...text, ...muted }}>
|
|
192
|
+
{inline(b.text, theme, `q${i}`)}
|
|
193
|
+
</Text>
|
|
194
|
+
</View>
|
|
195
|
+
);
|
|
196
|
+
case "rule":
|
|
197
|
+
return <View key={i} style={{ height: 1, backgroundColor: theme.colors.border }} />;
|
|
198
|
+
case "table":
|
|
199
|
+
return (
|
|
200
|
+
<ScrollView key={i} horizontal>
|
|
201
|
+
<View style={{ borderColor: theme.colors.border, borderWidth: 1, borderRadius: 6 }}>
|
|
202
|
+
{[b.header, ...b.rows].map((row, r) => (
|
|
203
|
+
<View key={r} style={{ flexDirection: "row", backgroundColor: r === 0 ? theme.colors.surface1 : undefined, borderTopWidth: r === 0 ? 0 : 1, borderTopColor: theme.colors.border }}>
|
|
204
|
+
{b.header.map((_, c) => (
|
|
205
|
+
<Text key={c} selectable style={{ ...text, fontSize: 13, fontWeight: r === 0 ? "600" : "400", width: 180, padding: 8 }}>
|
|
206
|
+
{inline(row.at(c) ?? "", theme, `t${i}-${r}-${c}`)}
|
|
207
|
+
</Text>
|
|
208
|
+
))}
|
|
209
|
+
</View>
|
|
210
|
+
))}
|
|
211
|
+
</View>
|
|
212
|
+
</ScrollView>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
})}
|
|
217
|
+
</View>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The overview: every requirement on this machine, grouped by what it needs.
|
|
2
|
+
import type { PluginOpenScreenInput, PluginScreenProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { ScrollView } from "@getpaseo/plugin/client/react-native";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
import { Text, View } from "react-native";
|
|
6
|
+
import type { Requirement } from "../shared/contracts";
|
|
7
|
+
import { useRequirements } from "./data";
|
|
8
|
+
import { type Group, GROUP_LABELS, groupOf } from "./format";
|
|
9
|
+
import { RequirementRow } from "./row";
|
|
10
|
+
import { Button, Chip, Status, useStyles } from "./ui";
|
|
11
|
+
|
|
12
|
+
export const ORDER: Group[] = ["attention", "running", "verify", "done", "other"];
|
|
13
|
+
|
|
14
|
+
/** Opens a requirement's detail screen. */
|
|
15
|
+
export type OpenRequirement = (item: Pick<Requirement, "repo" | "featureDir" | "title">) => void;
|
|
16
|
+
|
|
17
|
+
export function RequirementGroups({
|
|
18
|
+
theme,
|
|
19
|
+
layout,
|
|
20
|
+
items,
|
|
21
|
+
open,
|
|
22
|
+
}: Pick<PluginScreenProps, "theme" | "layout"> & { items: Requirement[]; open: OpenRequirement }) {
|
|
23
|
+
const s = useStyles(theme, layout.compact);
|
|
24
|
+
const [showOther, setShowOther] = useState(false);
|
|
25
|
+
const groups = useMemo(() => {
|
|
26
|
+
const map = new Map<Group, Requirement[]>();
|
|
27
|
+
for (const item of items) map.set(groupOf(item), [...(map.get(groupOf(item)) ?? []), item]);
|
|
28
|
+
return map;
|
|
29
|
+
}, [items]);
|
|
30
|
+
return (
|
|
31
|
+
<View style={{ gap: 20 }}>
|
|
32
|
+
{ORDER.filter((g) => groups.has(g)).map((g) => (
|
|
33
|
+
<View key={g} style={{ gap: 8 }}>
|
|
34
|
+
<View style={s.row}>
|
|
35
|
+
<Text style={s.heading}>
|
|
36
|
+
{GROUP_LABELS[g]}({groups.get(g)!.length})
|
|
37
|
+
</Text>
|
|
38
|
+
{g === "other" ? <Button theme={theme} label={showOther ? "收起" : "展开"} onPress={() => setShowOther((v) => !v)} /> : null}
|
|
39
|
+
</View>
|
|
40
|
+
{g === "other" && !showOther
|
|
41
|
+
? null
|
|
42
|
+
: groups.get(g)!.map((item) => <RequirementRow key={item.key} theme={theme} layout={layout} item={item} onPress={() => open(item)} />)}
|
|
43
|
+
</View>
|
|
44
|
+
))}
|
|
45
|
+
</View>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Screens open other screens through the client context; index.client.tsx passes it in. */
|
|
50
|
+
export type ScreenOpener = (input: PluginOpenScreenInput) => void;
|
|
51
|
+
|
|
52
|
+
export function requirementOpener(openScreen: ScreenOpener): OpenRequirement {
|
|
53
|
+
return (item) => openScreen({ screenId: "requirement", params: { repo: item.repo, featureDir: item.featureDir, title: item.title } });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function OverviewScreen(props: PluginScreenProps & { openScreen: ScreenOpener }) {
|
|
57
|
+
const { theme, layout } = props;
|
|
58
|
+
const s = useStyles(theme, layout.compact);
|
|
59
|
+
const query = useRequirements();
|
|
60
|
+
const [project, setProject] = useState<string | null>(null);
|
|
61
|
+
const items = query.data ?? [];
|
|
62
|
+
const projects = useMemo(() => [...new Set(items.map((i) => i.project))].sort(), [items]);
|
|
63
|
+
const shown = project ? items.filter((i) => i.project === project) : items;
|
|
64
|
+
const open = requirementOpener(props.openScreen);
|
|
65
|
+
return (
|
|
66
|
+
<View style={s.screen}>
|
|
67
|
+
<ScrollView contentContainerStyle={s.body}>
|
|
68
|
+
<View style={s.row}>
|
|
69
|
+
<Button theme={theme} label="使用统计" onPress={() => props.openScreen({ screenId: "stats" })} />
|
|
70
|
+
</View>
|
|
71
|
+
{projects.length > 1 ? (
|
|
72
|
+
<View style={s.row}>
|
|
73
|
+
<Chip theme={theme} label="全部项目" active={project === null} onPress={() => setProject(null)} />
|
|
74
|
+
{projects.map((p) => (
|
|
75
|
+
<Chip key={p} theme={theme} label={p} active={project === p} onPress={() => setProject(p)} />
|
|
76
|
+
))}
|
|
77
|
+
</View>
|
|
78
|
+
) : null}
|
|
79
|
+
<Status
|
|
80
|
+
theme={theme}
|
|
81
|
+
loading={query.isPending}
|
|
82
|
+
error={query.error}
|
|
83
|
+
empty={query.data && items.length === 0 ? "这台机器上还没有记录任何需求。在 Paseo 对话中用 /req 开始一个;1.9 之前运行过的需求,在项目目录运行 req index --rebuild。" : null}
|
|
84
|
+
/>
|
|
85
|
+
<RequirementGroups theme={theme} layout={layout} items={shown} open={open} />
|
|
86
|
+
</ScrollView>
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The panel beside a workspace: the requirements developed in it or started from it.
|
|
2
|
+
import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin/client";
|
|
3
|
+
import { ScrollView } from "@getpaseo/plugin/client/react-native";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Text, View } from "react-native";
|
|
6
|
+
import type { Requirement } from "../shared/contracts";
|
|
7
|
+
import { useRequirements } from "./data";
|
|
8
|
+
import { RequirementView } from "./detail";
|
|
9
|
+
import { RequirementGroups } from "./overview";
|
|
10
|
+
import { Button, Status, useStyles } from "./ui";
|
|
11
|
+
|
|
12
|
+
/** Same directory, ignoring a trailing separator and (on Windows) case. */
|
|
13
|
+
export function sameDir(a: string | null | undefined, b: string | null | undefined): boolean {
|
|
14
|
+
if (!a || !b) return false;
|
|
15
|
+
const norm = (p: string) => p.replace(/[\\/]+$/, "").replace(/\\/g, "/");
|
|
16
|
+
const x = norm(a);
|
|
17
|
+
const y = norm(b);
|
|
18
|
+
return /^[a-z]:\//i.test(x) ? x.toLowerCase() === y.toLowerCase() : x === y;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function inWorkspace(items: Requirement[], directory: string | null | undefined, workspaceId?: string | null): Requirement[] {
|
|
22
|
+
return items.filter((r) => (workspaceId && r.workspaceId === workspaceId) || sameDir(r.repo, directory) || sameDir(r.sourceRepo, directory));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function WorkspacePanel({ theme, layout, navigation, workspaceId }: PluginWorkspacePanelProps) {
|
|
26
|
+
const s = useStyles(theme, layout.compact);
|
|
27
|
+
const workspace = useWorkspace(workspaceId, (w) => ({ directory: w.directory, name: w.name }));
|
|
28
|
+
const query = useRequirements();
|
|
29
|
+
const [picked, setPicked] = useState<Pick<Requirement, "repo" | "featureDir"> | null>(null);
|
|
30
|
+
const items = inWorkspace(query.data ?? [], workspace?.directory, workspaceId);
|
|
31
|
+
const only = items.length === 1 ? items[0] : null;
|
|
32
|
+
const shown = picked ?? only;
|
|
33
|
+
return (
|
|
34
|
+
<View style={s.screen}>
|
|
35
|
+
<ScrollView contentContainerStyle={s.body}>
|
|
36
|
+
<Status
|
|
37
|
+
theme={theme}
|
|
38
|
+
loading={query.isPending}
|
|
39
|
+
error={query.error}
|
|
40
|
+
empty={query.data && items.length === 0 ? `${workspace?.name ?? "这个工作区"}还没有需求。在对话中用 /req 开始一个。` : null}
|
|
41
|
+
/>
|
|
42
|
+
{shown ? (
|
|
43
|
+
<>
|
|
44
|
+
{picked && !only ? <Button theme={theme} label="返回列表" onPress={() => setPicked(null)} /> : null}
|
|
45
|
+
<RequirementView theme={theme} layout={layout} navigation={navigation} repo={shown.repo} featureDir={shown.featureDir} />
|
|
46
|
+
</>
|
|
47
|
+
) : items.length > 1 ? (
|
|
48
|
+
<>
|
|
49
|
+
<Text style={s.muted}>这个工作区有 {items.length} 个需求</Text>
|
|
50
|
+
<RequirementGroups theme={theme} layout={layout} items={items} open={(item) => setPicked(item)} />
|
|
51
|
+
</>
|
|
52
|
+
) : null}
|
|
53
|
+
</ScrollView>
|
|
54
|
+
</View>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// One requirement in a list: title, facts, stage, what is running, what needs a person.
|
|
2
|
+
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { Pressable, Text, View } from "react-native";
|
|
4
|
+
import type { Requirement } from "../shared/contracts";
|
|
5
|
+
import { ago, ATTENTION_LABELS, facts } from "./format";
|
|
6
|
+
import { Badge, useStyles } from "./ui";
|
|
7
|
+
|
|
8
|
+
export function RequirementRow({
|
|
9
|
+
theme,
|
|
10
|
+
layout,
|
|
11
|
+
item,
|
|
12
|
+
onPress,
|
|
13
|
+
}: Pick<PluginHostProps, "theme" | "layout"> & { item: Requirement; onPress(): void }) {
|
|
14
|
+
const s = useStyles(theme, layout.compact);
|
|
15
|
+
return (
|
|
16
|
+
<Pressable accessibilityRole="button" accessibilityLabel={item.title} onPress={onPress} style={s.card}>
|
|
17
|
+
<View style={s.row}>
|
|
18
|
+
<Text style={[s.text, { fontWeight: "600", flexShrink: 1 }]}>{item.title}</Text>
|
|
19
|
+
{item.attention ? (
|
|
20
|
+
<Badge theme={theme} tone={item.attention === "approval" || item.attention === "business" ? "warning" : "danger"}>
|
|
21
|
+
{ATTENTION_LABELS[item.attention]}
|
|
22
|
+
</Badge>
|
|
23
|
+
) : item.running ? (
|
|
24
|
+
<Badge theme={theme} tone="accent">
|
|
25
|
+
运行中
|
|
26
|
+
</Badge>
|
|
27
|
+
) : null}
|
|
28
|
+
{!item.present ? (
|
|
29
|
+
<Badge theme={theme} tone="muted">
|
|
30
|
+
目录已不在
|
|
31
|
+
</Badge>
|
|
32
|
+
) : null}
|
|
33
|
+
</View>
|
|
34
|
+
<Text style={s.muted}>{facts(item)}</Text>
|
|
35
|
+
<Text style={s.text}>{item.stage}</Text>
|
|
36
|
+
{item.current.length > 0 ? <Text style={s.muted}>正在:{item.current.map((c) => c.title).join("、")}</Text> : null}
|
|
37
|
+
<Text style={s.muted}>更新于 {ago(item.updatedAt)}</Text>
|
|
38
|
+
</Pressable>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Settings → Plugins → req-kit: what the plugin found, read-only mode, refresh interval.
|
|
2
|
+
import { type PluginSurfaceProps, type SettingsState, useSettings } from "@getpaseo/plugin/client";
|
|
3
|
+
import { SettingsAction, SettingsCard, SettingsInput, SettingsRow, SettingsSection, SettingsSelect, SettingsSwitch } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Text } from "react-native";
|
|
6
|
+
import { preferences } from "../shared/contracts";
|
|
7
|
+
import { useKitInfo } from "./data";
|
|
8
|
+
|
|
9
|
+
type Ready = Extract<SettingsState<typeof preferences.schema>, { status: "ready" }>;
|
|
10
|
+
|
|
11
|
+
const REFRESH = [
|
|
12
|
+
{ label: "5 秒", value: "5" },
|
|
13
|
+
{ label: "10 秒", value: "10" },
|
|
14
|
+
{ label: "30 秒", value: "30" },
|
|
15
|
+
{ label: "1 分钟", value: "60" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function Controls({ settings, theme }: { settings: Ready; theme: PluginSurfaceProps["theme"] }) {
|
|
19
|
+
const [kitHome, setKitHome] = useState(settings.values.kitHome);
|
|
20
|
+
const info = useKitInfo();
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<SettingsSection title="req-kit">
|
|
24
|
+
<SettingsCard>
|
|
25
|
+
{info.data ? (
|
|
26
|
+
<>
|
|
27
|
+
<SettingsRow label="版本" hint={info.data.kitRoot}>
|
|
28
|
+
<Text style={{ color: theme.colors.foreground }}>{info.data.kitVersion}</Text>
|
|
29
|
+
</SettingsRow>
|
|
30
|
+
<SettingsRow label="支持的 Paseo" hint={`本机配置目录:${info.data.kitHome}`}>
|
|
31
|
+
<Text style={{ color: theme.colors.foreground }}>{info.data.paseo ?? "-"}</Text>
|
|
32
|
+
</SettingsRow>
|
|
33
|
+
</>
|
|
34
|
+
) : (
|
|
35
|
+
<SettingsRow label="状态" error={info.error?.message ?? undefined} hint={info.isPending ? "正在读取…" : undefined} />
|
|
36
|
+
)}
|
|
37
|
+
<SettingsInput
|
|
38
|
+
label="req-kit 的本机配置目录"
|
|
39
|
+
hint="通常不用填:默认是运行 Paseo 的用户的 ~/.req-kit。设置了 REQ_KIT_HOME 时填那个目录。"
|
|
40
|
+
initialValue={settings.values.kitHome}
|
|
41
|
+
placeholder="~/.req-kit"
|
|
42
|
+
onChangeText={setKitHome}
|
|
43
|
+
disabled={settings.saving}
|
|
44
|
+
error={settings.saveError ?? undefined}
|
|
45
|
+
/>
|
|
46
|
+
<SettingsAction
|
|
47
|
+
label="保存配置目录"
|
|
48
|
+
actionLabel="保存"
|
|
49
|
+
disabled={settings.saving || kitHome === settings.values.kitHome}
|
|
50
|
+
onPress={() => void settings.save({ ...settings.values, kitHome: kitHome.trim() }, settings.revision).then(() => info.refetch())}
|
|
51
|
+
/>
|
|
52
|
+
</SettingsCard>
|
|
53
|
+
</SettingsSection>
|
|
54
|
+
<SettingsSection title="显示与操作">
|
|
55
|
+
<SettingsCard>
|
|
56
|
+
<SettingsSwitch
|
|
57
|
+
label="只读"
|
|
58
|
+
hint="只显示需求和文档,不提供确认方案、停止、继续、开始测试验证。能连上这台 daemon 的人都能看到这个插件。"
|
|
59
|
+
value={settings.values.readOnly}
|
|
60
|
+
disabled={settings.saving}
|
|
61
|
+
onValueChange={(readOnly) => void settings.save({ ...settings.values, readOnly }, settings.revision)}
|
|
62
|
+
/>
|
|
63
|
+
<SettingsSelect
|
|
64
|
+
label="刷新间隔"
|
|
65
|
+
value={String(settings.values.refreshSeconds)}
|
|
66
|
+
options={REFRESH}
|
|
67
|
+
disabled={settings.saving}
|
|
68
|
+
onValueChange={(v) => void settings.save({ ...settings.values, refreshSeconds: Number(v) }, settings.revision)}
|
|
69
|
+
/>
|
|
70
|
+
</SettingsCard>
|
|
71
|
+
</SettingsSection>
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function SettingsScreen({ theme }: PluginSurfaceProps) {
|
|
77
|
+
const settings = useSettings(preferences);
|
|
78
|
+
if (settings.status === "loading") return <Text style={{ color: theme.colors.foregroundMuted }}>正在读取…</Text>;
|
|
79
|
+
if (settings.status !== "ready") {
|
|
80
|
+
return (
|
|
81
|
+
<SettingsSection title="设置">
|
|
82
|
+
<SettingsCard>
|
|
83
|
+
<SettingsRow label="设置读取失败" error={settings.error} />
|
|
84
|
+
<SettingsAction label="恢复默认设置" actionLabel="恢复" onPress={() => void settings.reset()} />
|
|
85
|
+
</SettingsCard>
|
|
86
|
+
</SettingsSection>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return <Controls settings={settings} theme={theme} />;
|
|
90
|
+
}
|