opencode-gh-ci 0.1.0 → 0.2.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/package.json +1 -2
- package/server.ts +43 -99
- package/shared.ts +131 -133
- package/tui.tsx +140 -157
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "opencode-gh-ci",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "GitHub Actions CI status in the OpenCode sidebar with live elapsed timers.",
|
|
7
7
|
"license": "MIT",
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@opencode-ai/plugin": "*",
|
|
40
|
-
"@opentui/core": "*",
|
|
41
40
|
"@opentui/solid": "*",
|
|
42
41
|
"solid-js": "*"
|
|
43
42
|
}
|
package/server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Plugin } from "@opencode-ai/plugin"
|
|
|
5
5
|
import {
|
|
6
6
|
PLUGIN_ID,
|
|
7
7
|
parseOptions,
|
|
8
|
+
readCacheText,
|
|
8
9
|
buildCachePath,
|
|
9
10
|
buildRegistryPath,
|
|
10
11
|
registerSession,
|
|
@@ -14,56 +15,45 @@ import {
|
|
|
14
15
|
emptyCache,
|
|
15
16
|
nowISO,
|
|
16
17
|
} from "./shared"
|
|
17
|
-
|
|
18
|
-
import type { WorkflowJob, WorkflowRun, CICache } from "./shared"
|
|
18
|
+
import type { WorkflowJob, WorkflowRun } from "./shared"
|
|
19
19
|
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
|
-
// Git
|
|
21
|
+
// Git helpers
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
23
|
|
|
24
24
|
async function detectBranch(cwd: string): Promise<string> {
|
|
25
25
|
const proc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
26
|
-
cwd,
|
|
27
|
-
stdout: "pipe",
|
|
28
|
-
stderr: "pipe",
|
|
26
|
+
cwd, stdout: "pipe", stderr: "pipe",
|
|
29
27
|
})
|
|
30
28
|
const out = await new Response(proc.stdout).text()
|
|
31
|
-
|
|
32
|
-
if (code !== 0) return ""
|
|
33
|
-
return out.trim()
|
|
29
|
+
return (await proc.exited) === 0 ? out.trim() : ""
|
|
34
30
|
}
|
|
35
31
|
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// GitHub CLI data fetching
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
32
|
async function runCmd(args: string[], cwd: string): Promise<string> {
|
|
41
33
|
const proc = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
|
|
42
34
|
const out = await new Response(proc.stdout).text()
|
|
43
|
-
|
|
44
|
-
if (code !== 0) throw new Error(`exit ${code}: ${args.join(" ")}`)
|
|
35
|
+
if ((await proc.exited) !== 0) throw new Error(`exit: ${args.join(" ")}`)
|
|
45
36
|
return out.trim()
|
|
46
37
|
}
|
|
47
38
|
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// GitHub CLI data fetching
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
48
43
|
async function fetchJobsForRun(runId: number, cwd: string): Promise<WorkflowJob[]> {
|
|
49
|
-
const
|
|
50
|
-
["gh", "api",
|
|
51
|
-
`repos/{owner}/{repo}/actions/runs/${runId}/jobs`,
|
|
44
|
+
const raw = await runCmd(
|
|
45
|
+
["gh", "api", `repos/{owner}/{repo}/actions/runs/${runId}/jobs`,
|
|
52
46
|
"--jq", ".jobs[] | {name,status,conclusion,started_at,completed_at}"],
|
|
53
47
|
cwd,
|
|
54
48
|
)
|
|
55
|
-
return
|
|
56
|
-
.
|
|
57
|
-
.
|
|
58
|
-
.map((l: string) => { try { return JSON.parse(l) } catch { return null } })
|
|
49
|
+
return raw.split("\n")
|
|
50
|
+
.filter((l) => l.trim())
|
|
51
|
+
.map((l) => { try { return JSON.parse(l) } catch { return null } })
|
|
59
52
|
.filter(Boolean)
|
|
60
53
|
}
|
|
61
54
|
|
|
62
55
|
async function fetchRuns(
|
|
63
|
-
branch: string,
|
|
64
|
-
cwd: string,
|
|
65
|
-
maxRuns: number,
|
|
66
|
-
pushWindowMs: number,
|
|
56
|
+
branch: string, cwd: string, maxRuns: number, pushWindowMs: number,
|
|
67
57
|
): Promise<WorkflowRun[]> {
|
|
68
58
|
const raw = await runCmd(
|
|
69
59
|
["gh", "run", "list", "--branch", branch, "--limit", String(maxRuns),
|
|
@@ -75,54 +65,37 @@ async function fetchRuns(
|
|
|
75
65
|
|
|
76
66
|
const latestTime = new Date(allRuns[0].createdAt).getTime()
|
|
77
67
|
const pushRuns = allRuns.filter(
|
|
78
|
-
(r: any) => Math.abs(new Date(r.createdAt).getTime() - latestTime) < pushWindowMs
|
|
68
|
+
(r: any) => Math.abs(new Date(r.createdAt).getTime() - latestTime) < pushWindowMs,
|
|
79
69
|
)
|
|
80
70
|
|
|
81
|
-
|
|
82
|
-
pushRuns.map(async (r: any) => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
jobs,
|
|
91
|
-
}
|
|
92
|
-
})
|
|
71
|
+
return Promise.all(
|
|
72
|
+
pushRuns.map(async (r: any) => ({
|
|
73
|
+
id: r.databaseId,
|
|
74
|
+
name: r.name,
|
|
75
|
+
status: r.status,
|
|
76
|
+
conclusion: r.conclusion,
|
|
77
|
+
head_branch: r.headBranch,
|
|
78
|
+
jobs: await fetchJobsForRun(r.databaseId, cwd),
|
|
79
|
+
})),
|
|
93
80
|
)
|
|
94
|
-
|
|
95
|
-
return results
|
|
96
81
|
}
|
|
97
82
|
|
|
98
83
|
// ---------------------------------------------------------------------------
|
|
99
84
|
// Cache I/O
|
|
100
85
|
// ---------------------------------------------------------------------------
|
|
101
86
|
|
|
102
|
-
async function readCache(filePath: string)
|
|
103
|
-
try {
|
|
104
|
-
|
|
105
|
-
const parsed = JSON.parse(text)
|
|
106
|
-
if (!parsed || typeof parsed !== "object") return emptyCache()
|
|
107
|
-
return {
|
|
108
|
-
version: 1,
|
|
109
|
-
updatedAt: parsed.updatedAt || nowISO(),
|
|
110
|
-
branch: parsed.branch || "",
|
|
111
|
-
runs: parsed.runs || (parsed.run ? [parsed.run] : []),
|
|
112
|
-
error: parsed.error || null,
|
|
113
|
-
}
|
|
114
|
-
} catch {
|
|
115
|
-
return emptyCache()
|
|
116
|
-
}
|
|
87
|
+
async function readCache(filePath: string) {
|
|
88
|
+
try { return readCacheText(await readFile(filePath, "utf8")) }
|
|
89
|
+
catch { return emptyCache() }
|
|
117
90
|
}
|
|
118
91
|
|
|
119
|
-
async function writeCache(filePath: string, cache:
|
|
92
|
+
async function writeCache(filePath: string, cache: any): Promise<void> {
|
|
120
93
|
await mkdir(dirname(filePath), { recursive: true })
|
|
121
|
-
await writeFile(filePath,
|
|
94
|
+
await writeFile(filePath, JSON.stringify(cache, null, 2) + "\n", "utf8")
|
|
122
95
|
}
|
|
123
96
|
|
|
124
97
|
// ---------------------------------------------------------------------------
|
|
125
|
-
// Plugin
|
|
98
|
+
// Plugin
|
|
126
99
|
// ---------------------------------------------------------------------------
|
|
127
100
|
|
|
128
101
|
const server: Plugin = async (input, options) => {
|
|
@@ -133,17 +106,9 @@ const server: Plugin = async (input, options) => {
|
|
|
133
106
|
const cachePath = buildCachePath()
|
|
134
107
|
const registryPath = buildRegistryPath(cwd)
|
|
135
108
|
|
|
136
|
-
// Clean up orphaned sessions from dead processes
|
|
137
109
|
await cleanupOrphans(registryPath)
|
|
110
|
+
await registerSession(registryPath, { cachePath, pid: process.pid, startedAt: nowISO() })
|
|
138
111
|
|
|
139
|
-
// Register this session
|
|
140
|
-
await registerSession(registryPath, {
|
|
141
|
-
cachePath,
|
|
142
|
-
pid: process.pid,
|
|
143
|
-
startedAt: nowISO(),
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
// Global last-refresh timestamp — shared by poll timer and event handlers
|
|
147
112
|
let lastRefreshAt = 0
|
|
148
113
|
|
|
149
114
|
const refresh = async () => {
|
|
@@ -153,65 +118,44 @@ const server: Plugin = async (input, options) => {
|
|
|
153
118
|
await writeCache(cachePath, { ...emptyCache(), error: "Not a git repo" })
|
|
154
119
|
return
|
|
155
120
|
}
|
|
156
|
-
|
|
157
|
-
const runs = await fetchRuns(branch, cwd, opts.max_runs, opts.push_window_ms)
|
|
158
121
|
await writeCache(cachePath, {
|
|
159
|
-
version: 1,
|
|
160
|
-
|
|
161
|
-
branch,
|
|
162
|
-
runs,
|
|
122
|
+
version: 1, updatedAt: nowISO(), branch,
|
|
123
|
+
runs: await fetchRuns(branch, cwd, opts.max_runs, opts.push_window_ms),
|
|
163
124
|
error: null,
|
|
164
125
|
})
|
|
165
126
|
} catch (e: any) {
|
|
166
127
|
const prev = await readCache(cachePath)
|
|
167
|
-
await writeCache(cachePath, {
|
|
168
|
-
...prev,
|
|
169
|
-
updatedAt: nowISO(),
|
|
170
|
-
error: e?.message ?? "fetch failed",
|
|
171
|
-
})
|
|
128
|
+
await writeCache(cachePath, { ...prev, updatedAt: nowISO(), error: e?.message ?? "fetch failed" })
|
|
172
129
|
}
|
|
173
130
|
lastRefreshAt = Date.now()
|
|
174
131
|
}
|
|
175
132
|
|
|
176
|
-
// Global debounce — skips if any source (poll or event) already refreshed recently
|
|
177
133
|
const maybeRefresh = () => {
|
|
178
|
-
if (Date.now() - lastRefreshAt >= opts.debounce_ms)
|
|
179
|
-
void refresh()
|
|
180
|
-
}
|
|
134
|
+
if (Date.now() - lastRefreshAt >= opts.debounce_ms) void refresh()
|
|
181
135
|
}
|
|
182
136
|
|
|
183
|
-
// Initial fetch + periodic poll (also debounced)
|
|
184
137
|
void refresh()
|
|
185
|
-
const timer = setInterval(
|
|
138
|
+
const timer = setInterval(maybeRefresh, opts.server_poll_ms)
|
|
186
139
|
timer.unref?.()
|
|
187
140
|
|
|
188
|
-
// Cleanup on graceful shutdown
|
|
189
141
|
const cleanup = async () => {
|
|
190
142
|
clearInterval(timer)
|
|
191
143
|
await cleanupSession(cachePath)
|
|
192
144
|
await deregisterSession(registryPath, cachePath)
|
|
193
145
|
}
|
|
194
146
|
|
|
195
|
-
|
|
196
|
-
process.on("
|
|
197
|
-
process.on("
|
|
198
|
-
process.on("SIGTERM", onExit)
|
|
147
|
+
process.on("exit", () => void cleanup())
|
|
148
|
+
process.on("SIGINT", () => void cleanup())
|
|
149
|
+
process.on("SIGTERM", () => void cleanup())
|
|
199
150
|
|
|
200
|
-
// Build return object with dynamic event handlers
|
|
201
151
|
const handlers: Record<string, unknown> = { dispose: cleanup }
|
|
202
|
-
|
|
203
152
|
if (opts.refresh_on_events) {
|
|
204
153
|
for (const event of opts.refresh_on_events) {
|
|
205
|
-
handlers[event] = async () =>
|
|
206
|
-
maybeRefresh()
|
|
207
|
-
}
|
|
154
|
+
handlers[event] = async () => maybeRefresh()
|
|
208
155
|
}
|
|
209
156
|
}
|
|
210
157
|
|
|
211
158
|
return handlers
|
|
212
159
|
}
|
|
213
160
|
|
|
214
|
-
export default {
|
|
215
|
-
id: PLUGIN_ID,
|
|
216
|
-
server,
|
|
217
|
-
}
|
|
161
|
+
export default { id: PLUGIN_ID, server }
|
package/shared.ts
CHANGED
|
@@ -8,10 +8,70 @@ export const PLUGIN_ID = "gh-ci"
|
|
|
8
8
|
const BASE_DIR = path.join(os.tmpdir(), "opencode-gh-ci")
|
|
9
9
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
|
-
//
|
|
11
|
+
// Types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface WorkflowJob {
|
|
15
|
+
name: string
|
|
16
|
+
status: "queued" | "in_progress" | "completed" | "waiting"
|
|
17
|
+
conclusion: string | null
|
|
18
|
+
started_at: string | null
|
|
19
|
+
completed_at: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkflowRun {
|
|
23
|
+
id: number
|
|
24
|
+
name: string
|
|
25
|
+
status: string
|
|
26
|
+
conclusion: string | null
|
|
27
|
+
head_branch: string
|
|
28
|
+
jobs: WorkflowJob[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CICache {
|
|
32
|
+
version: 1
|
|
33
|
+
updatedAt: string
|
|
34
|
+
branch: string
|
|
35
|
+
runs: WorkflowRun[]
|
|
36
|
+
error: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RegistryEntry {
|
|
40
|
+
cachePath: string
|
|
41
|
+
pid: number
|
|
42
|
+
startedAt: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface HideFilters {
|
|
46
|
+
workflows: RegExp[]
|
|
47
|
+
jobs: RegExp[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type DetailLevel = "overall" | "workflows" | "jobs"
|
|
51
|
+
|
|
52
|
+
export interface JobsDetailOptions {
|
|
53
|
+
collapse_single_workflow: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PluginOptions {
|
|
57
|
+
enabled: boolean
|
|
58
|
+
server_poll_ms: number
|
|
59
|
+
tui_poll_ms: number
|
|
60
|
+
debounce_ms: number
|
|
61
|
+
push_window_ms: number
|
|
62
|
+
max_runs: number
|
|
63
|
+
max_name_length: number
|
|
64
|
+
right_align_elapsed: boolean
|
|
65
|
+
refresh_on_events: string[] | false
|
|
66
|
+
hide: HideFilters
|
|
67
|
+
detail: DetailLevel
|
|
68
|
+
jobs_detail: JobsDetailOptions
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Options parsing
|
|
12
73
|
// ---------------------------------------------------------------------------
|
|
13
74
|
|
|
14
|
-
// All plugin trigger events that can be subscribed to
|
|
15
75
|
const ALL_EVENTS = [
|
|
16
76
|
"chat.message",
|
|
17
77
|
"tool.execute.before",
|
|
@@ -20,8 +80,6 @@ const ALL_EVENTS = [
|
|
|
20
80
|
"shell.env",
|
|
21
81
|
] as const
|
|
22
82
|
|
|
23
|
-
const DEFAULT_EVENTS = [...ALL_EVENTS] as string[]
|
|
24
|
-
|
|
25
83
|
const DEFAULTS = {
|
|
26
84
|
enabled: true,
|
|
27
85
|
server_poll_ms: 10_000,
|
|
@@ -29,23 +87,18 @@ const DEFAULTS = {
|
|
|
29
87
|
debounce_ms: 10_000,
|
|
30
88
|
push_window_ms: 60_000,
|
|
31
89
|
max_runs: 10,
|
|
90
|
+
max_name_length: 24,
|
|
91
|
+
right_align_elapsed: true,
|
|
32
92
|
} as const
|
|
33
93
|
|
|
34
|
-
|
|
35
|
-
workflows: RegExp[]
|
|
36
|
-
jobs: RegExp[]
|
|
37
|
-
}
|
|
94
|
+
const DEFAULT_JOBS_DETAIL: JobsDetailOptions = { collapse_single_workflow: true }
|
|
38
95
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
max_runs: number
|
|
46
|
-
refresh_on_events: string[] | false
|
|
47
|
-
hide: HideFilters
|
|
48
|
-
collapse_single_workflow: boolean
|
|
96
|
+
const EMPTY_OPTIONS: PluginOptions = {
|
|
97
|
+
...DEFAULTS,
|
|
98
|
+
refresh_on_events: [...ALL_EVENTS],
|
|
99
|
+
hide: { workflows: [], jobs: [] },
|
|
100
|
+
detail: "jobs",
|
|
101
|
+
jobs_detail: { ...DEFAULT_JOBS_DETAIL },
|
|
49
102
|
}
|
|
50
103
|
|
|
51
104
|
const toRecord = (v: unknown): Record<string, unknown> | undefined =>
|
|
@@ -53,8 +106,9 @@ const toRecord = (v: unknown): Record<string, unknown> | undefined =>
|
|
|
53
106
|
? Object.fromEntries(Object.entries(v))
|
|
54
107
|
: undefined
|
|
55
108
|
|
|
56
|
-
const toBool = (v: unknown,
|
|
57
|
-
|
|
109
|
+
const toBool = (v: unknown, fb: boolean) => (typeof v === "boolean" ? v : fb)
|
|
110
|
+
const toNum = (v: unknown, fb: number, min = 0) =>
|
|
111
|
+
typeof v === "number" && Number.isFinite(v) ? Math.max(min, v) : fb
|
|
58
112
|
|
|
59
113
|
const toRegexList = (v: unknown): RegExp[] => {
|
|
60
114
|
if (!Array.isArray(v)) return []
|
|
@@ -64,37 +118,42 @@ const toRegexList = (v: unknown): RegExp[] => {
|
|
|
64
118
|
.filter((r): r is RegExp => r !== null)
|
|
65
119
|
}
|
|
66
120
|
|
|
67
|
-
const toNum = (v: unknown, fallback: number, min = 0): number => {
|
|
68
|
-
if (typeof v !== "number" || !Number.isFinite(v)) return fallback
|
|
69
|
-
return Math.max(min, v)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
121
|
export function parseOptions(raw: unknown): PluginOptions {
|
|
73
122
|
const rec = toRecord(raw)
|
|
74
|
-
if (!rec) return
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
// false → disabled
|
|
78
|
-
// true → all available events
|
|
79
|
-
// undefined → default events (chat.message)
|
|
80
|
-
// ["chat.message", "tool.execute.after"] → specific events
|
|
81
|
-
let refreshOnEvents: string[] | false
|
|
123
|
+
if (!rec) return EMPTY_OPTIONS
|
|
124
|
+
|
|
125
|
+
// refresh_on_events: false | true | string[]
|
|
82
126
|
const roe = rec.refresh_on_events
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
refreshOnEvents = roe.filter((e): e is string => typeof e === "string")
|
|
89
|
-
} else {
|
|
90
|
-
refreshOnEvents = [...DEFAULT_EVENTS]
|
|
91
|
-
}
|
|
127
|
+
const refreshOnEvents: string[] | false =
|
|
128
|
+
roe === false ? false
|
|
129
|
+
: roe === true ? [...ALL_EVENTS]
|
|
130
|
+
: Array.isArray(roe) ? roe.filter((e): e is string => typeof e === "string")
|
|
131
|
+
: [...ALL_EVENTS]
|
|
92
132
|
|
|
93
|
-
//
|
|
133
|
+
// hide: { workflows: [...], jobs: [...] }
|
|
94
134
|
const hideRec = toRecord(rec.hide)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
135
|
+
|
|
136
|
+
// detail: "overall" | "workflows" | "jobs" | { jobs: {...} } | { workflows: {} } | { overall: {} }
|
|
137
|
+
const VALID_LEVELS: DetailLevel[] = ["overall", "workflows", "jobs"]
|
|
138
|
+
let detail: DetailLevel = "jobs"
|
|
139
|
+
let jobsDetail: JobsDetailOptions = { ...DEFAULT_JOBS_DETAIL }
|
|
140
|
+
|
|
141
|
+
const d = rec.detail
|
|
142
|
+
if (typeof d === "string" && VALID_LEVELS.includes(d as DetailLevel)) {
|
|
143
|
+
detail = d as DetailLevel
|
|
144
|
+
} else if (d && typeof d === "object" && !Array.isArray(d)) {
|
|
145
|
+
const dRec = d as Record<string, unknown>
|
|
146
|
+
if ("jobs" in dRec) {
|
|
147
|
+
detail = "jobs"
|
|
148
|
+
const jRec = toRecord(dRec.jobs)
|
|
149
|
+
jobsDetail = {
|
|
150
|
+
collapse_single_workflow: toBool(jRec?.collapse_single_workflow, DEFAULT_JOBS_DETAIL.collapse_single_workflow),
|
|
151
|
+
}
|
|
152
|
+
} else if ("workflows" in dRec) {
|
|
153
|
+
detail = "workflows"
|
|
154
|
+
} else if ("overall" in dRec) {
|
|
155
|
+
detail = "overall"
|
|
156
|
+
}
|
|
98
157
|
}
|
|
99
158
|
|
|
100
159
|
return {
|
|
@@ -104,9 +163,15 @@ export function parseOptions(raw: unknown): PluginOptions {
|
|
|
104
163
|
debounce_ms: toNum(rec.debounce_ms, DEFAULTS.debounce_ms, 1000),
|
|
105
164
|
push_window_ms: toNum(rec.push_window_ms, DEFAULTS.push_window_ms, 10_000),
|
|
106
165
|
max_runs: toNum(rec.max_runs, DEFAULTS.max_runs, 1),
|
|
166
|
+
max_name_length: toNum(rec.max_name_length, DEFAULTS.max_name_length, 10),
|
|
167
|
+
right_align_elapsed: toBool(rec.right_align_elapsed, DEFAULTS.right_align_elapsed),
|
|
107
168
|
refresh_on_events: refreshOnEvents,
|
|
108
|
-
hide
|
|
109
|
-
|
|
169
|
+
hide: {
|
|
170
|
+
workflows: toRegexList(hideRec?.workflows),
|
|
171
|
+
jobs: toRegexList(hideRec?.jobs),
|
|
172
|
+
},
|
|
173
|
+
detail,
|
|
174
|
+
jobs_detail: jobsDetail,
|
|
110
175
|
}
|
|
111
176
|
}
|
|
112
177
|
|
|
@@ -114,7 +179,6 @@ export function parseOptions(raw: unknown): PluginOptions {
|
|
|
114
179
|
// Filtering
|
|
115
180
|
// ---------------------------------------------------------------------------
|
|
116
181
|
|
|
117
|
-
/** Filter runs and jobs based on hide regexes. Returns a new array (no mutation). */
|
|
118
182
|
export function filterRuns(runs: WorkflowRun[] | undefined, hide: HideFilters): WorkflowRun[] {
|
|
119
183
|
if (!runs?.length) return []
|
|
120
184
|
if (!hide.workflows.length && !hide.jobs.length) return runs
|
|
@@ -123,48 +187,13 @@ export function filterRuns(runs: WorkflowRun[] | undefined, hide: HideFilters):
|
|
|
123
187
|
.filter((r) => !hide.workflows.some((re) => re.test(r.name)))
|
|
124
188
|
.map((r) => {
|
|
125
189
|
if (!hide.jobs.length) return r
|
|
126
|
-
const
|
|
127
|
-
return
|
|
190
|
+
const filtered = r.jobs.filter((j) => !hide.jobs.some((re) => re.test(j.name)))
|
|
191
|
+
return filtered.length === r.jobs.length ? r : { ...r, jobs: filtered }
|
|
128
192
|
})
|
|
129
193
|
}
|
|
130
194
|
|
|
131
195
|
// ---------------------------------------------------------------------------
|
|
132
|
-
//
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
export interface WorkflowJob {
|
|
136
|
-
name: string
|
|
137
|
-
status: "queued" | "in_progress" | "completed" | "waiting"
|
|
138
|
-
conclusion: string | null
|
|
139
|
-
started_at: string | null
|
|
140
|
-
completed_at: string | null
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export interface WorkflowRun {
|
|
144
|
-
id: number
|
|
145
|
-
name: string
|
|
146
|
-
status: string
|
|
147
|
-
conclusion: string | null
|
|
148
|
-
head_branch: string
|
|
149
|
-
jobs: WorkflowJob[]
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export interface CICache {
|
|
153
|
-
version: 1
|
|
154
|
-
updatedAt: string
|
|
155
|
-
branch: string
|
|
156
|
-
runs: WorkflowRun[]
|
|
157
|
-
error: string | null
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export interface RegistryEntry {
|
|
161
|
-
cachePath: string
|
|
162
|
-
pid: number
|
|
163
|
-
startedAt: string
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
167
|
-
// Cache path helpers
|
|
196
|
+
// Cache paths
|
|
168
197
|
// ---------------------------------------------------------------------------
|
|
169
198
|
|
|
170
199
|
export const nowISO = () => new Date().toISOString()
|
|
@@ -177,25 +206,22 @@ export const emptyCache = (): CICache => ({
|
|
|
177
206
|
error: null,
|
|
178
207
|
})
|
|
179
208
|
|
|
180
|
-
/** Random per-session cache directory: <tmpdir>/opencode-gh-ci/<uuid>/ci.json */
|
|
181
209
|
export function buildCachePath(): string {
|
|
182
210
|
return path.join(BASE_DIR, randomUUID(), "ci.json")
|
|
183
211
|
}
|
|
184
212
|
|
|
185
|
-
/** Deterministic registry file per project: <tmpdir>/opencode-gh-ci/<hash>.registry.json */
|
|
186
213
|
export function buildRegistryPath(root: string): string {
|
|
187
214
|
const digest = createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 12)
|
|
188
215
|
return path.join(BASE_DIR, `${digest}.registry.json`)
|
|
189
216
|
}
|
|
190
217
|
|
|
191
218
|
// ---------------------------------------------------------------------------
|
|
192
|
-
// Registry
|
|
219
|
+
// Registry
|
|
193
220
|
// ---------------------------------------------------------------------------
|
|
194
221
|
|
|
195
222
|
export async function readRegistry(registryPath: string): Promise<RegistryEntry[]> {
|
|
196
223
|
try {
|
|
197
|
-
const
|
|
198
|
-
const parsed = JSON.parse(text)
|
|
224
|
+
const parsed = JSON.parse(await readFile(registryPath, "utf8"))
|
|
199
225
|
return Array.isArray(parsed) ? parsed : []
|
|
200
226
|
} catch {
|
|
201
227
|
return []
|
|
@@ -208,35 +234,20 @@ async function writeRegistry(registryPath: string, entries: RegistryEntry[]): Pr
|
|
|
208
234
|
}
|
|
209
235
|
|
|
210
236
|
function isPidAlive(pid: number): boolean {
|
|
211
|
-
try {
|
|
212
|
-
process.kill(pid, 0)
|
|
213
|
-
return true
|
|
214
|
-
} catch {
|
|
215
|
-
return false
|
|
216
|
-
}
|
|
237
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
217
238
|
}
|
|
218
239
|
|
|
219
|
-
|
|
220
|
-
export async function registerSession(
|
|
221
|
-
registryPath: string,
|
|
222
|
-
entry: RegistryEntry,
|
|
223
|
-
): Promise<void> {
|
|
240
|
+
export async function registerSession(registryPath: string, entry: RegistryEntry): Promise<void> {
|
|
224
241
|
const entries = await readRegistry(registryPath)
|
|
225
242
|
entries.push(entry)
|
|
226
243
|
await writeRegistry(registryPath, entries)
|
|
227
244
|
}
|
|
228
245
|
|
|
229
|
-
|
|
230
|
-
export async function deregisterSession(
|
|
231
|
-
registryPath: string,
|
|
232
|
-
cachePath: string,
|
|
233
|
-
): Promise<void> {
|
|
246
|
+
export async function deregisterSession(registryPath: string, cachePath: string): Promise<void> {
|
|
234
247
|
const entries = await readRegistry(registryPath)
|
|
235
|
-
|
|
236
|
-
await writeRegistry(registryPath, filtered)
|
|
248
|
+
await writeRegistry(registryPath, entries.filter((e) => e.cachePath !== cachePath))
|
|
237
249
|
}
|
|
238
250
|
|
|
239
|
-
/** Scan registry, remove entries with dead PIDs, delete their cache directories */
|
|
240
251
|
export async function cleanupOrphans(registryPath: string): Promise<void> {
|
|
241
252
|
const entries = await readRegistry(registryPath)
|
|
242
253
|
const alive: RegistryEntry[] = []
|
|
@@ -245,26 +256,15 @@ export async function cleanupOrphans(registryPath: string): Promise<void> {
|
|
|
245
256
|
if (isPidAlive(entry.pid)) {
|
|
246
257
|
alive.push(entry)
|
|
247
258
|
} else {
|
|
248
|
-
try {
|
|
249
|
-
const dir = path.dirname(entry.cachePath)
|
|
250
|
-
await rm(dir, { recursive: true, force: true })
|
|
251
|
-
} catch {
|
|
252
|
-
// ignore
|
|
253
|
-
}
|
|
259
|
+
try { await rm(path.dirname(entry.cachePath), { recursive: true, force: true }) } catch {}
|
|
254
260
|
}
|
|
255
261
|
}
|
|
256
262
|
|
|
257
263
|
await writeRegistry(registryPath, alive)
|
|
258
264
|
}
|
|
259
265
|
|
|
260
|
-
/** Delete this session's cache directory */
|
|
261
266
|
export async function cleanupSession(cachePath: string): Promise<void> {
|
|
262
|
-
try {
|
|
263
|
-
const dir = path.dirname(cachePath)
|
|
264
|
-
await rm(dir, { recursive: true, force: true })
|
|
265
|
-
} catch {
|
|
266
|
-
// ignore
|
|
267
|
-
}
|
|
267
|
+
try { await rm(path.dirname(cachePath), { recursive: true, force: true }) } catch {}
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
// ---------------------------------------------------------------------------
|
|
@@ -279,7 +279,7 @@ export function readCacheText(text: string): CICache {
|
|
|
279
279
|
version: 1,
|
|
280
280
|
updatedAt: parsed.updatedAt || nowISO(),
|
|
281
281
|
branch: parsed.branch || "",
|
|
282
|
-
runs: parsed.runs ||
|
|
282
|
+
runs: parsed.runs || [],
|
|
283
283
|
error: parsed.error || null,
|
|
284
284
|
}
|
|
285
285
|
} catch {
|
|
@@ -294,22 +294,20 @@ export function readCacheText(text: string): CICache {
|
|
|
294
294
|
export const DOT = "\u2022"
|
|
295
295
|
export const PULSE_FRAMES = ["\u2022", "\u25E6"]
|
|
296
296
|
|
|
297
|
-
|
|
297
|
+
function formatElapsed(seconds: number): string {
|
|
298
298
|
if (seconds < 0) seconds = 0
|
|
299
299
|
const m = Math.floor(seconds / 60)
|
|
300
300
|
const s = seconds % 60
|
|
301
|
-
|
|
302
|
-
return `${s}s`
|
|
301
|
+
return m > 0 ? `${m}m ${s}s` : `${s}s`
|
|
303
302
|
}
|
|
304
303
|
|
|
305
304
|
export function getJobElapsed(job: WorkflowJob, nowSec: number): string {
|
|
306
305
|
if (!job.started_at) return ""
|
|
307
306
|
const started = Math.floor(new Date(job.started_at).getTime() / 1000)
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
return formatElapsed(nowSec - started)
|
|
307
|
+
const end = job.completed_at
|
|
308
|
+
? Math.floor(new Date(job.completed_at).getTime() / 1000)
|
|
309
|
+
: nowSec
|
|
310
|
+
return formatElapsed(end - started)
|
|
313
311
|
}
|
|
314
312
|
|
|
315
313
|
export function truncate(s: string, max: number): string {
|
package/tui.tsx
CHANGED
|
@@ -17,21 +17,18 @@ import {
|
|
|
17
17
|
getJobElapsed,
|
|
18
18
|
truncate,
|
|
19
19
|
} from "./shared"
|
|
20
|
-
import type { CICache, HideFilters, WorkflowJob, WorkflowRun } from "./shared"
|
|
20
|
+
import type { CICache, DetailLevel, HideFilters, JobsDetailOptions, WorkflowJob, WorkflowRun } from "./shared"
|
|
21
21
|
|
|
22
22
|
type Api = Parameters<TuiPlugin>[0]
|
|
23
23
|
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
|
-
//
|
|
25
|
+
// Helpers
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
function dotColor(theme: any, status: string, conclusion: string | null) {
|
|
29
29
|
if (status === "completed" && conclusion === "success") return theme.success
|
|
30
30
|
if (status === "completed" && conclusion === "failure") return theme.error
|
|
31
|
-
if (status === "completed" && conclusion === "skipped") return theme.textMuted
|
|
32
|
-
if (status === "completed" && conclusion === "cancelled") return theme.textMuted
|
|
33
31
|
if (status === "in_progress") return theme.warning
|
|
34
|
-
if (status === "queued" || status === "waiting") return theme.textMuted
|
|
35
32
|
return theme.textMuted
|
|
36
33
|
}
|
|
37
34
|
|
|
@@ -45,38 +42,74 @@ function overallStatus(runs: WorkflowRun[]): { status: string; conclusion: strin
|
|
|
45
42
|
return { status: "completed", conclusion: runs[0]?.conclusion ?? null }
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
function countItems(items: { status: string; conclusion: string | null }[]): string {
|
|
46
|
+
const pending = items.filter((i) => i.status === "in_progress" || i.status === "queued" || i.status === "waiting").length
|
|
47
|
+
const success = items.filter((i) => i.status === "completed" && i.conclusion === "success").length
|
|
48
|
+
const failed = items.filter((i) => i.status === "completed" && i.conclusion === "failure").length
|
|
49
|
+
const skipped = items.filter((i) => i.status === "completed" && (i.conclusion === "skipped" || i.conclusion === "cancelled")).length
|
|
50
|
+
const parts: string[] = []
|
|
51
|
+
if (pending) parts.push(`${pending} pending`)
|
|
52
|
+
if (success) parts.push(`${success} passed`)
|
|
53
|
+
if (failed) parts.push(`${failed} failed`)
|
|
54
|
+
if (skipped) parts.push(`${skipped} skipped`)
|
|
55
|
+
return parts.join(", ")
|
|
56
|
+
}
|
|
51
57
|
|
|
52
58
|
async function readCache(filePath: string): Promise<CICache> {
|
|
53
|
-
try {
|
|
54
|
-
|
|
55
|
-
} catch {
|
|
56
|
-
return emptyCache()
|
|
57
|
-
}
|
|
59
|
+
try { return readCacheText(await readFile(filePath, "utf8")) }
|
|
60
|
+
catch { return emptyCache() }
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
/** Find this session's cache file via the registry (matched by PID) */
|
|
61
63
|
async function discoverCachePath(registryPath: string): Promise<string | null> {
|
|
62
64
|
const entries = await readRegistry(registryPath)
|
|
63
|
-
|
|
64
|
-
return mine?.cachePath ?? null
|
|
65
|
+
return entries.find((e) => e.pid === process.pid)?.cachePath ?? null
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// ---------------------------------------------------------------------------
|
|
68
|
-
//
|
|
69
|
+
// Job row
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
const JobRow = (props: {
|
|
73
|
+
job: WorkflowJob; theme: any; nowSec: number; pulseFrame: number
|
|
74
|
+
indent: string; maxLen: number; rightAlign: boolean
|
|
75
|
+
}) => {
|
|
76
|
+
const elapsed = () => getJobElapsed(props.job, props.nowSec)
|
|
77
|
+
const isActive = () => props.job.status === "in_progress"
|
|
78
|
+
const dot = () => isActive() ? PULSE_FRAMES[props.pulseFrame % PULSE_FRAMES.length] : DOT
|
|
79
|
+
const nameLen = () => props.maxLen - props.indent.length
|
|
80
|
+
|
|
81
|
+
const label = () => (
|
|
82
|
+
<text flexGrow={props.rightAlign ? 1 : undefined}>
|
|
83
|
+
<span style={{ fg: dotColor(props.theme, props.job.status, props.job.conclusion) }}>
|
|
84
|
+
{props.indent}{dot()}
|
|
85
|
+
</span>
|
|
86
|
+
<span style={{ fg: props.theme.text }}>
|
|
87
|
+
{" "}{truncate(props.job.name, nameLen())}
|
|
88
|
+
</span>
|
|
89
|
+
{!props.rightAlign && elapsed() ? <span style={{ fg: props.theme.textMuted }}> {elapsed()}</span> : null}
|
|
90
|
+
</text>
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if (!props.rightAlign) return label()
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<box flexDirection="row" width="100%" gap={0}>
|
|
97
|
+
{label()}
|
|
98
|
+
{elapsed() ? <text fg={props.theme.textMuted}>{elapsed()}</text> : null}
|
|
99
|
+
</box>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Workflow row (multi-workflow mode)
|
|
69
105
|
// ---------------------------------------------------------------------------
|
|
70
106
|
|
|
71
107
|
const WorkflowRow = (props: {
|
|
72
|
-
run: WorkflowRun
|
|
73
|
-
|
|
74
|
-
nowSec: number
|
|
75
|
-
pulseFrame: number
|
|
76
|
-
expanded: boolean
|
|
77
|
-
onToggle: () => void
|
|
108
|
+
run: WorkflowRun; theme: any; nowSec: number; pulseFrame: number
|
|
109
|
+
expanded: boolean; onToggle: () => void; maxLen: number; rightAlign: boolean
|
|
78
110
|
}) => {
|
|
79
111
|
const arrowColor = () => dotColor(props.theme, props.run.status, props.run.conclusion)
|
|
112
|
+
const visibleJobs = createMemo(() => props.expanded ? props.run.jobs : [])
|
|
80
113
|
|
|
81
114
|
return (
|
|
82
115
|
<box flexDirection="column" gap={0}>
|
|
@@ -86,33 +119,15 @@ const WorkflowRow = (props: {
|
|
|
86
119
|
{props.expanded ? "▼" : "▶"}
|
|
87
120
|
</span>
|
|
88
121
|
<span style={{ fg: props.theme.text }}>
|
|
89
|
-
{" "}{truncate(props.run.name,
|
|
122
|
+
{" "}{truncate(props.run.name, props.maxLen)}
|
|
90
123
|
</span>
|
|
91
124
|
</text>
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
{(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return (
|
|
99
|
-
<box flexDirection="row" width="100%" gap={0}>
|
|
100
|
-
<text flexGrow={1}>
|
|
101
|
-
<span style={{ fg: dotColor(props.theme, job().status, job().conclusion) }}>
|
|
102
|
-
{" "}{jobDot()}
|
|
103
|
-
</span>
|
|
104
|
-
<span style={{ fg: props.theme.text }}>
|
|
105
|
-
{" "}{truncate(job().name, 22)}
|
|
106
|
-
</span>
|
|
107
|
-
</text>
|
|
108
|
-
<Show when={elapsed()}>
|
|
109
|
-
<text fg={props.theme.textMuted}>{elapsed()}</text>
|
|
110
|
-
</Show>
|
|
111
|
-
</box>
|
|
112
|
-
)
|
|
113
|
-
}}
|
|
114
|
-
</Index>
|
|
115
|
-
</Show>
|
|
125
|
+
<Index each={visibleJobs()}>
|
|
126
|
+
{(job) => (
|
|
127
|
+
<JobRow job={job()} theme={props.theme} nowSec={props.nowSec} pulseFrame={props.pulseFrame}
|
|
128
|
+
indent={" "} maxLen={props.maxLen} rightAlign={props.rightAlign} />
|
|
129
|
+
)}
|
|
130
|
+
</Index>
|
|
116
131
|
</box>
|
|
117
132
|
)
|
|
118
133
|
}
|
|
@@ -121,59 +136,42 @@ const WorkflowRow = (props: {
|
|
|
121
136
|
// Main sidebar card
|
|
122
137
|
// ---------------------------------------------------------------------------
|
|
123
138
|
|
|
124
|
-
const CICard = (props: {
|
|
139
|
+
const CICard = (props: {
|
|
140
|
+
api: Api; theme: any; tuiPollMs: number; hide: HideFilters
|
|
141
|
+
detail: DetailLevel; jobsDetail: JobsDetailOptions
|
|
142
|
+
maxNameLength: number; rightAlignElapsed: boolean
|
|
143
|
+
}) => {
|
|
125
144
|
const [cache, setCache] = createSignal<CICache>(emptyCache())
|
|
126
145
|
const [nowSec, setNowSec] = createSignal(Math.floor(Date.now() / 1000))
|
|
127
146
|
const [collapsed, setCollapsed] = createSignal(false)
|
|
128
147
|
const [pulseFrame, setPulseFrame] = createSignal(0)
|
|
129
148
|
const [cachePath, setCachePath] = createSignal<string | null>(null)
|
|
130
|
-
|
|
131
|
-
// Toggle state keyed by run ID — persists across cache updates
|
|
132
149
|
const [expandedMap, setExpandedMap] = createSignal<Record<number, boolean>>({})
|
|
133
150
|
|
|
134
|
-
const toggleRun = (
|
|
135
|
-
setExpandedMap((
|
|
136
|
-
|
|
137
|
-
[runId]: prev[runId] === undefined ? false : !prev[runId],
|
|
138
|
-
}))
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const isRunExpanded = (runId: number) => {
|
|
142
|
-
const map = expandedMap()
|
|
143
|
-
return map[runId] === undefined ? true : map[runId]
|
|
144
|
-
}
|
|
151
|
+
const toggleRun = (id: number) =>
|
|
152
|
+
setExpandedMap((m) => ({ ...m, [id]: m[id] === undefined ? false : !m[id] }))
|
|
153
|
+
const isRunExpanded = (id: number) => expandedMap()[id] ?? true
|
|
145
154
|
|
|
146
155
|
const registryPath = buildRegistryPath(props.api.state.path.directory)
|
|
147
156
|
|
|
148
157
|
const load = async () => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
path = await discoverCachePath(registryPath)
|
|
153
|
-
if (path) setCachePath(path)
|
|
154
|
-
}
|
|
155
|
-
if (!path) return
|
|
156
|
-
|
|
157
|
-
setCache(await readCache(path))
|
|
158
|
+
let p = cachePath()
|
|
159
|
+
if (!p) { p = await discoverCachePath(registryPath); if (p) setCachePath(p) }
|
|
160
|
+
if (p) setCache(await readCache(p))
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
onMount(() => {
|
|
161
164
|
void load()
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
onCleanup(() => {
|
|
165
|
-
clearInterval(pollTimer)
|
|
166
|
-
clearInterval(tickTimer)
|
|
167
|
-
})
|
|
165
|
+
const poll = setInterval(() => void load(), props.tuiPollMs)
|
|
166
|
+
const tick = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000)
|
|
167
|
+
onCleanup(() => { clearInterval(poll); clearInterval(tick) })
|
|
168
168
|
})
|
|
169
169
|
|
|
170
|
-
// Pulse animation — only runs when something is in progress
|
|
171
170
|
const hasActive = createMemo(() =>
|
|
172
171
|
(cache().runs ?? []).some((r) =>
|
|
173
|
-
r.status === "in_progress" || r.jobs.some((j) => j.status === "in_progress")
|
|
174
|
-
)
|
|
172
|
+
r.status === "in_progress" || r.jobs.some((j) => j.status === "in_progress"),
|
|
173
|
+
),
|
|
175
174
|
)
|
|
176
|
-
|
|
177
175
|
createEffect(() => {
|
|
178
176
|
if (!hasActive()) return
|
|
179
177
|
const id = setInterval(() => setPulseFrame((f) => f + 1), 500)
|
|
@@ -185,115 +183,99 @@ const CICard = (props: { api: Api; theme: any; tuiPollMs: number; hide: HideFilt
|
|
|
185
183
|
const hasRuns = createMemo(() => runs().length > 0)
|
|
186
184
|
const overall = createMemo(() => hasRuns() ? overallStatus(runs()) : null)
|
|
187
185
|
|
|
188
|
-
const
|
|
189
|
-
|
|
186
|
+
const canToggle = () => props.detail !== "overall"
|
|
187
|
+
const isSingle = createMemo(() =>
|
|
188
|
+
props.detail === "jobs" && props.jobsDetail.collapse_single_workflow && runs().length === 1,
|
|
190
189
|
)
|
|
191
190
|
|
|
192
|
-
function countItems(items: { status: string; conclusion: string | null }[]) {
|
|
193
|
-
const pending = items.filter((i) => i.status === "in_progress" || i.status === "queued" || i.status === "waiting").length
|
|
194
|
-
const success = items.filter((i) => i.status === "completed" && i.conclusion === "success").length
|
|
195
|
-
const failed = items.filter((i) => i.status === "completed" && i.conclusion === "failure").length
|
|
196
|
-
const skipped = items.filter((i) => i.status === "completed" && (i.conclusion === "skipped" || i.conclusion === "cancelled")).length
|
|
197
|
-
const parts: string[] = []
|
|
198
|
-
if (pending) parts.push(`${pending} pending`)
|
|
199
|
-
if (success) parts.push(`${success} passed`)
|
|
200
|
-
if (failed) parts.push(`${failed} failed`)
|
|
201
|
-
if (skipped) parts.push(`${skipped} skipped`)
|
|
202
|
-
return parts.join(", ")
|
|
203
|
-
}
|
|
204
|
-
|
|
205
191
|
const summary = createMemo(() => {
|
|
206
192
|
const all = runs()
|
|
207
193
|
if (!all.length) return ""
|
|
208
|
-
|
|
209
|
-
return `(${text})`
|
|
194
|
+
return `(${isSingle() ? countItems(all[0].jobs) : countItems(all)})`
|
|
210
195
|
})
|
|
211
196
|
|
|
212
|
-
|
|
213
|
-
const globalStatusIcon = createMemo(() => {
|
|
197
|
+
const globalIcon = createMemo(() => {
|
|
214
198
|
const o = overall()
|
|
215
199
|
if (!o) return { icon: "", color: "" }
|
|
216
200
|
if (o.status === "in_progress" || o.status === "queued")
|
|
217
201
|
return { icon: PULSE_FRAMES[pulseFrame() % PULSE_FRAMES.length], color: props.theme.warning }
|
|
218
|
-
if (o.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
202
|
+
if (o.conclusion === "success") return { icon: "\u2713", color: props.theme.success }
|
|
203
|
+
if (o.conclusion === "failure") return { icon: "\u2717", color: props.theme.error }
|
|
204
|
+
return { icon: "-", color: props.theme.textMuted }
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Computed arrays — empty arrays produce zero DOM nodes
|
|
208
|
+
const statusMessages = createMemo(() => {
|
|
209
|
+
if (error()) return [{ text: ` ${error()}`, color: props.theme.error }]
|
|
210
|
+
if (!hasRuns()) return [{ text: " waiting...", color: props.theme.textMuted }]
|
|
211
|
+
return [] as { text: string; color: any }[]
|
|
225
212
|
})
|
|
226
213
|
|
|
227
|
-
//
|
|
214
|
+
// "workflows" detail: workflow names with status dots
|
|
215
|
+
const workflowRows = createMemo(() => {
|
|
216
|
+
if (props.detail !== "workflows" || !hasRuns() || collapsed()) return []
|
|
217
|
+
return runs()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// "jobs" detail with single workflow collapsed: flat job list
|
|
228
221
|
const flatJobs = createMemo(() => {
|
|
229
|
-
if (!hasRuns() || collapsed() || !
|
|
222
|
+
if (props.detail !== "jobs" || !hasRuns() || collapsed() || !isSingle()) return []
|
|
230
223
|
return runs()[0]?.jobs ?? []
|
|
231
224
|
})
|
|
232
225
|
|
|
233
|
-
//
|
|
226
|
+
// "jobs" detail with multiple workflows
|
|
234
227
|
const displayRuns = createMemo(() => {
|
|
235
|
-
if (!hasRuns() || collapsed() ||
|
|
228
|
+
if (props.detail !== "jobs" || !hasRuns() || collapsed() || isSingle()) return []
|
|
236
229
|
return runs()
|
|
237
230
|
})
|
|
238
231
|
|
|
239
|
-
// Status messages as a computed array (empty = no node rendered)
|
|
240
|
-
const statusMessages = createMemo(() => {
|
|
241
|
-
if (error()) return [{ text: ` ${error()}`, color: props.theme.error }]
|
|
242
|
-
if (!hasRuns()) return [{ text: " waiting...", color: props.theme.textMuted }]
|
|
243
|
-
return [] as { text: string; color: any }[]
|
|
244
|
-
})
|
|
245
|
-
|
|
246
232
|
return (
|
|
247
233
|
<box flexDirection="column" gap={0}>
|
|
248
|
-
|
|
234
|
+
{/* Header */}
|
|
235
|
+
<box flexDirection="row" width="100%" gap={0} onMouseDown={() => canToggle() && hasRuns() && setCollapsed((c) => !c)}>
|
|
249
236
|
<text flexGrow={1} fg={props.theme.text}>
|
|
250
237
|
<Show when={hasRuns()} fallback={<b>CI</b>}>
|
|
251
238
|
<span>
|
|
252
|
-
{collapsed() ? "▶" : "▼"
|
|
253
|
-
<span style={{ fg: props.theme.textMuted }}>
|
|
254
|
-
{" "}{summary()}
|
|
255
|
-
</span>
|
|
239
|
+
{canToggle() ? (collapsed() ? "▶ " : "▼ ") : ""}<b>CI</b>
|
|
240
|
+
<span style={{ fg: props.theme.textMuted }}>{" "}{summary()}</span>
|
|
256
241
|
</span>
|
|
257
242
|
</Show>
|
|
258
243
|
</text>
|
|
259
244
|
<Show when={hasRuns()}>
|
|
260
|
-
<text fg={
|
|
245
|
+
<text fg={globalIcon().color}>{globalIcon().icon}</text>
|
|
261
246
|
</Show>
|
|
262
247
|
</box>
|
|
248
|
+
{/* Status messages (error / waiting) */}
|
|
263
249
|
<Index each={statusMessages()}>
|
|
264
250
|
{(msg) => <text fg={msg().color}>{msg().text}</text>}
|
|
265
251
|
</Index>
|
|
252
|
+
{/* Workflows detail */}
|
|
253
|
+
<Index each={workflowRows()}>
|
|
254
|
+
{(run) => (
|
|
255
|
+
<text>
|
|
256
|
+
<span style={{ fg: dotColor(props.theme, run().status, run().conclusion) }}>
|
|
257
|
+
{" "}{DOT}
|
|
258
|
+
</span>
|
|
259
|
+
<span style={{ fg: props.theme.text }}>
|
|
260
|
+
{" "}{truncate(run().name, props.maxNameLength)}
|
|
261
|
+
</span>
|
|
262
|
+
</text>
|
|
263
|
+
)}
|
|
264
|
+
</Index>
|
|
265
|
+
{/* Jobs detail: single workflow collapsed */}
|
|
266
266
|
<Index each={flatJobs()}>
|
|
267
|
-
{(job) =>
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
return (
|
|
272
|
-
<box flexDirection="row" width="100%" gap={0}>
|
|
273
|
-
<text flexGrow={1}>
|
|
274
|
-
<span style={{ fg: dotColor(props.theme, job().status, job().conclusion) }}>
|
|
275
|
-
{jobDot()}
|
|
276
|
-
</span>
|
|
277
|
-
<span style={{ fg: props.theme.text }}>
|
|
278
|
-
{" "}{truncate(job().name, 28)}
|
|
279
|
-
</span>
|
|
280
|
-
</text>
|
|
281
|
-
<Show when={elapsed()}>
|
|
282
|
-
<text fg={props.theme.textMuted}>{elapsed()}</text>
|
|
283
|
-
</Show>
|
|
284
|
-
</box>
|
|
285
|
-
)
|
|
286
|
-
}}
|
|
267
|
+
{(job) => (
|
|
268
|
+
<JobRow job={job()} theme={props.theme} nowSec={nowSec()} pulseFrame={pulseFrame()}
|
|
269
|
+
indent={""} maxLen={props.maxNameLength} rightAlign={props.rightAlignElapsed} />
|
|
270
|
+
)}
|
|
287
271
|
</Index>
|
|
272
|
+
{/* Jobs detail: multiple workflows */}
|
|
288
273
|
<For each={displayRuns()}>
|
|
289
274
|
{(run) => (
|
|
290
275
|
<WorkflowRow
|
|
291
|
-
run={run}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
pulseFrame={pulseFrame()}
|
|
295
|
-
expanded={isRunExpanded(run.id)}
|
|
296
|
-
onToggle={() => toggleRun(run.id)}
|
|
276
|
+
run={run} theme={props.theme} nowSec={nowSec()} pulseFrame={pulseFrame()}
|
|
277
|
+
expanded={isRunExpanded(run.id)} onToggle={() => toggleRun(run.id)}
|
|
278
|
+
maxLen={props.maxNameLength} rightAlign={props.rightAlignElapsed}
|
|
297
279
|
/>
|
|
298
280
|
)}
|
|
299
281
|
</For>
|
|
@@ -313,15 +295,16 @@ const tui: TuiPlugin = async (api, options) => {
|
|
|
313
295
|
order: 350,
|
|
314
296
|
slots: {
|
|
315
297
|
sidebar_content(ctx) {
|
|
316
|
-
return
|
|
298
|
+
return (
|
|
299
|
+
<CICard
|
|
300
|
+
api={api} theme={ctx.theme.current} tuiPollMs={opts.tui_poll_ms}
|
|
301
|
+
hide={opts.hide} detail={opts.detail} jobsDetail={opts.jobs_detail}
|
|
302
|
+
maxNameLength={opts.max_name_length} rightAlignElapsed={opts.right_align_elapsed}
|
|
303
|
+
/>
|
|
304
|
+
)
|
|
317
305
|
},
|
|
318
306
|
},
|
|
319
307
|
})
|
|
320
308
|
}
|
|
321
309
|
|
|
322
|
-
|
|
323
|
-
id: PLUGIN_ID,
|
|
324
|
-
tui,
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export default plugin
|
|
310
|
+
export default { id: PLUGIN_ID, tui } satisfies TuiPluginModule & { id: string }
|