Browse the current repo's Forgejo or Gitea issues in a pane

Small Claude Code mods: plugins of function hooks that change what Claude Code shows or does, in the terminal and in the desktop app's Code tab.
In a terminal Claude Code session:
/plugin install <mod> --marketplace enhki/claude-mods
Answer y to add the marketplace, then pick the user scope so the mod loads in every session, desktop ones included.
| Mod | What it does |
|---|---|
statusline-desktop | Draws your terminal status line above the prompt in the desktop app |
forgejo-issues | Browse, search and manage the current repo's Forgejo or Gitea issues in a pane |
work-session | Kicks off and wraps up working sessions with your own start and end commands, and titles each session |
walkthrough | A checklist pane for testing by hand: mark each step, add notes, send the results back in one message |
The desktop app doesn't run your statusLine command. This mod does: it picks the command the way Claude Code does (project .claude/settings.local.json, then .claude/settings.json, then ~/.claude/settings.json), pipes it the same JSON the terminal would (model, cwd, cost, context window, rate limits), and draws the first line it prints in the band above the prompt.
config-file) sets one; otherwise they map to the app's own theme colours.current_usage reads slightly lower than in the terminal (output tokens come through as 0)./issues opens a pane on the issues of the repo the session runs in. The forge and repo come from git remote get-url origin (ssh or https), so it works for any Forgejo or Gitea host, Codeberg included.
kind/bug sit under their scope, exclusive scopes pick one); a milestone picker.label:bug, -label:wontfix, label:kind/, milestone:"Some name", is:closed. Enter opens the top match.#N mentions as chips that open in the pane.Token. The mod needs an API token with issue read/write and repository read. Put it in ~/.config/claude-mods/secrets/forgejo.env (folder 700, file 600), keyed by host so a token only ever goes to its own forge:
FORGEJO_TOKEN_CODEBERG_ORG=...
FORGEJO_TOKEN_GIT_EXAMPLE_COM=...
Without one, it falls back to fgj's config for that host. The mod also stops Claude's own file and shell tools from reading that secrets folder.
Settings (the plugin's options):
| Setting | Default | What it does |
|---|---|---|
apiUrl | empty | The API base when it isn't https://<remote host>/api/v1 |
requireCommentOnClose | on | Closing needs a comment, posted before the close |
requireMilestone | off | New issues need a milestone |
requireLabelFrom | empty | New issues need one of these comma-separated labels |
editorCommand | empty | Adds Open in editor: e.g. ghostty --gtk-single-instance=false --class=popup.editor -e nvim {file} |
Desktop notes. The desktop app currently drops Button and Markdown-link presses from plugin panes, so every control here is a small Client that posts a message instead; and it passes no paste into a Client, so for long or pasted text use Open in editor. The editor command must wait until you close the file; one that returns at once (a launcher that forks) leaves a Use editor text button to pull the file back by hand. Ghostty needs --gtk-single-instance=false for this, or it may hand the window to a running instance and return at once.
For projects where you start and end each working session with your own slash commands (a "continue working session" and an "end working session", say /cws and /ews), this mod offers them at the right moments and gives each session a title you can tell apart in the session list.
/clear, not a resume) in a project that has the start command, a band above the prompt offers it: press 1 to run it, 2 to skip, or just type something else. Set Kickoff to auto to run it straight away, or off.set_session_title tool and a line of context asking it to title the session once its focus is clear: <Project> S<n> · <focus> when the project numbers its sessions, else <Project> · <focus>. The title shows from your next message on. A /rename of your own wins: the mod stops setting it.Projects without the start command see nothing. In the desktop app the band draws above the desktop status line rather than in place of it.
Settings (the plugin's options):
| Setting | Default | What it does |
|---|---|---|
startCommand | cws | The command that kicks a session off, without the slash |
endCommand | ews | The command that wraps one up |
kickoff | band | band offers it, auto runs it at start, off never |
wrapAtPercent | 75 | Context fullness that brings up the wrap offer; 0 turns it off |
When Claude asks you to check something by hand (run a demo, resize a window, try a key), it publishes the steps to a Walkthrough pane instead of a list in chat, and you send the results back from there.
publish_walkthrough tool refuses a step that doesn't say what to expect. Publishing again replaces the list./walkthrough. The step you're on is drawn open: its command with Copy, what to expect, Pass / Fail / Skip and a note. Marking it opens the next one; done steps fold to one line you can click to reopen. Pressing a mark again takes it back.p pass, f fail, s skip, c copy, r send.Screenshots don't go through the pane: paste them in the prompt as usual. No settings.
A mod is a plugin of function hooks: a plugin.json, a hooks/hooks.json naming one TypeScript module, and that module's register(on, options). In a Claude Code session, the bundled plugin-authoring skill holds the full API (types, examples, the test kit); load it before writing one.
| Kind | Where | Loads |
|---|---|---|
| General, shareable | here, mods/<name>/ + an entry in .claude-plugin/marketplace.json | everywhere, once installed at user scope |
| Tied to one project, or naming private hosts | that project's .claude/skills/<name>/ | in that project's sessions only, watched for edits |
| Just for you, everywhere, private | ~/.claude/skills/<name>/ | every session, watched for edits |
Anything that names a private host, a person or a machine stays out of this public repo: make it a project mod, or read the value from a setting.
plugin-authoring skill gives a mods folder that hot-reloads when each turn ends: the quickest loop.mods/<name>/, list it in the marketplace file, and install it from this clone (below). From then on, edits are live after /reload-plugins.claude plugin validate mods/<name>, claude plugin test mods/<name>, and a type-check. Bump version in the mod's plugin.json when behaviour changes, so GitHub installs update.claude plugin test runs *.test.ts(x) against the engine itself; a test stands in for engine calls with on('<event>', () => ({ value: … })).userConfig (shown in the plugin's options); nothing personal is hard-coded.~/.config/claude-mods/secrets/<service>.env (folder 700, file 600), keyed by host where a token belongs to a host. A mod that reads one also keeps Claude's own tools out of that folder (see forgejo-issues/hooks/guard.ts).$.state) outlives reloads and upgrades: read and update it through helpers that fill in defaults, or a field a new version adds arrives undefined.ui_press not handled in ~/.config/Claude/logs/claude.ai-web.log). Input, Select and Client messages do. Make each pressable control its own small Client that posts a message (forgejo-issues/hooks/pill.tsx).Client's pointer and key listeners once, on its first draw. Each set is a message to the page, and many Clients re-setting them on every redraw get unmounted for flooding it.Client per clickable item makes hit-testing unnecessary.Client, and the native Input is one line at a fixed width. For long text, hand off to an external editor (forgejo-issues's Open in editor).<mod>: <event> hook skipped: threw … names the handler that did.Clone, then add the clone itself as your marketplace so edits are live after /reload-plugins with no reinstall:
claude plugin marketplace add ~/repos/claude-mods
claude plugin install <mod>@claude-mods --scope user
Check a mod before committing:
claude plugin validate mods/<mod>
claude plugin test mods/<mod>hooks/register.tsx 597 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as $, Register } from 'claude-code'
3
4import type { Data, Detail, Format, RepoInfo, Ui } from '../types'
5import { parseRemote, pickToken, tokenKey, toComments, toIssues, toLabels, toMilestones } from './forge'
6import { touchesSecrets } from './guard'
7import { labelIds, milestoneId, newIssueProblem, parseLabelList, toggleScoped } from './triage'
8import { editorArgv } from './editing'
9import type { TriageRules } from './triage'
10import { filterIssues } from './search'
11import { detailView, listView, newIssueView } from './views'
12
13const PANE = 'forgejo-issues'
14const GUARDED = new Set(['Read', 'Write', 'Edit', 'Grep', 'Glob', 'Bash'])
15// Forgejo's default page cap; a smaller one only means more requests.
16const PAGE = 50
17const MAX_PAGES = 40
18
19const DARK_BORDER = '#3a3a3a'
20const LIGHT_BORDER = '#d0d0d0'
21const DARK_RAISED = '#232323'
22const LIGHT_RAISED = '#f4f4f4'
23const EMPTY_DATA: Data = { status: 'idle', repo: null, issues: [], labels: [], milestones: [], message: null, tokenSource: null, fetchedAt: 0, border: DARK_BORDER, raised: DARK_RAISED }
24const EMPTY_UI: Ui = {
25 query: '',
26 chips: [],
27 milestone: '',
28 state: 'open',
29 grouped: true,
30 open: null,
31 draft: '',
32 armed: null,
33 busy: false,
34 notice: null,
35 editingLabels: null,
36 creating: null,
37 sync: {},
38 fmt: {},
39 preview: {},
40 external: null,
41}
42const data = atom({ plugin: 'forgejo-issues', key: 'data' } as const, EMPTY_DATA)
43const ui = atom({ plugin: 'forgejo-issues', key: 'ui' } as const, EMPTY_UI)
44const detail = atom({ plugin: 'forgejo-issues', key: 'detail' } as const, null as Detail | null)
45
46let apiUrl = ''
47let requireCommentOnClose = true
48let triageRules: TriageRules = { requireMilestone: false, requireLabelFrom: [] }
49let editorCommand = ''
50let home = ''
51let configHome = ''
52// Held in the module, never in $.state: the token stays out of anything drawn or logged.
53let auth: { apiBase: string; token: string; repo: RepoInfo; source: 'secrets' | 'fgj' } | null = null
54
55// State saved by an older version of the mod lacks newer fields: fill in the
56// defaults on every read so a missing field never reaches the drawing.
57async function readUi($: $): Promise<Ui> {
58 return { ...EMPTY_UI, ...(await read($, ui)) }
59}
60
61async function readData($: $): Promise<Data> {
62 return { ...EMPTY_DATA, ...(await read($, data)) }
63}
64
65// Updates get the same defaults: the value handed to an update is the raw
66// saved one, so without this a field newer than the save reaches the code.
67function updateUi($: $, fn: (u: Ui) => Ui) {
68 return update($, ui, (x): Ui => fn({ ...EMPTY_UI, ...x }))
69}
70
71function updateData($: $, fn: (d: Data) => Data) {
72 return update($, data, (x): Data => fn({ ...EMPTY_DATA, ...x }))
73}
74
75async function readOrNull($: $, path: string): Promise<string | null> {
76 try {
77 return await $.fs.read(path)
78 } catch {
79 return null
80 }
81}
82
83async function paths($: $): Promise<void> {
84 if (home) return
85 home = (await $.env.get('HOME')) ?? ''
86 configHome = (await $.env.get('XDG_CONFIG_HOME')) || `${home}/.config`
87}
88
89function secretsDir(): string {
90 return `${configHome}/claude-mods/secrets`
91}
92
93/** Finds the repo and its token; a string is why it could not. */
94async function resolveAuth($: $): Promise<string | null> {
95 await paths($)
96 const cwd = await $.session.cwd()
97 const remote = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd, timeoutMs: 5_000 })
98 if (remote.exitCode !== 0) return 'This folder has no git remote named origin.'
99 const ref = parseRemote(remote.stdout, apiUrl)
100 if (!ref) return `Could not read the remote: ${remote.stdout.trim()}`
101 const repo = { host: ref.host, owner: ref.owner, repo: ref.repo, webBase: ref.webBase }
102 await updateData($, (d): Data => ({ ...d, repo }))
103 const found = pickToken(
104 ref.host,
105 await readOrNull($, `${secretsDir()}/forgejo.env`),
106 await readOrNull($, `${home}/.config/fgj/config.yaml`),
107 )
108 if (!found) return `No token for ${ref.host}. Add ${tokenKey(ref.host)}=<token> to ${secretsDir()}/forgejo.env`
109 auth = { apiBase: ref.apiBase, token: found.token, repo, source: found.source }
110 return null
111}
112
113/** Calls the API; a string is the failure, worded for the pane. */
114async function request(
115 $: $,
116 method: 'GET' | 'POST' | 'PATCH' | 'PUT',
117 path: string,
118 body?: unknown,
119): Promise<{ text: string; total: number | null } | string> {
120 if (!auth) return 'Not connected.'
121 let res
122 try {
123 res = await $.http.fetch(`${auth.apiBase}${path}`, {
124 method,
125 headers: {
126 Authorization: `token ${auth.token}`,
127 Accept: 'application/json',
128 ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
129 },
130 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
131 })
132 } catch (err) {
133 return `Could not reach ${auth.repo.host}: ${err instanceof Error ? err.message : String(err)}`
134 }
135 const from = auth.source === 'secrets' ? tokenKey(auth.repo.host) : 'fgj'
136 if (res.status === 401) return `${auth.repo.host} refused the token from ${from} (401).`
137 if (res.status === 403) {
138 return method === 'GET'
139 ? `${auth.repo.host} refused the token from ${from} (403).`
140 : `${auth.repo.host} refused the change (403): the token from ${from} may lack write access to issues.`
141 }
142 if (res.status === 404) return `${auth.repo.owner}/${auth.repo.repo} is not on ${auth.repo.host}, or is not a Forgejo/Gitea repo.`
143 if (!res.ok) return `${auth.repo.host} answered ${res.status}.`
144 const header = Object.entries(res.headers).find(([k]) => k.toLowerCase() === 'x-total-count')?.[1]
145 return { text: res.text, total: header ? Number(header) : null }
146}
147
148function get($: $, path: string) {
149 return request($, 'GET', path)
150}
151
152/** Every page of a list endpoint, parsed and joined. */
153async function getAll<T>($: $, path: string, parse: (json: string) => T[]): Promise<T[] | string> {
154 const out: T[] = []
155 const sep = path.includes('?') ? '&' : '?'
156 for (let page = 1; page <= MAX_PAGES; page++) {
157 const res = await get($, `${path}${sep}limit=${PAGE}&page=${page}`)
158 if (typeof res === 'string') return res
159 const items = parse(res.text)
160 out.push(...items)
161 if (items.length < PAGE || (res.total !== null && out.length >= res.total)) break
162 }
163 return out
164}
165
166async function fail($: $, message: string): Promise<void> {
167 await updateData($, (d): Data => ({ ...d, status: 'error', message }))
168}
169
170async function load($: $): Promise<void> {
171 await updateData($, (d): Data => ({ ...d, status: 'loading', message: null }))
172 const why = await resolveAuth($)
173 if (why) return fail($, why)
174 const base = `/repos/${auth!.repo.owner}/${auth!.repo.repo}`
175 try {
176 const [issues, labels, milestones] = await Promise.all([
177 getAll($, `${base}/issues?state=all&type=issues`, toIssues),
178 getAll($, `${base}/labels`, toLabels),
179 getAll($, `${base}/milestones?state=all`, toMilestones),
180 ])
181 if (typeof issues === 'string') return fail($, issues)
182 if (typeof labels === 'string') return fail($, labels)
183 if (typeof milestones === 'string') return fail($, milestones)
184 const fetchedAt = await $.clock.now()
185 const tokenSource = auth!.source
186 // The theme names light ones as such (light, light-daltonized, ...); anything else draws dark.
187 const theme = String((await $.config.list()).find(row => row.key === 'theme')?.value ?? '')
188 const isLight = theme.includes('light')
189 const border = isLight ? LIGHT_BORDER : DARK_BORDER
190 const raised = isLight ? LIGHT_RAISED : DARK_RAISED
191 await updateData($, (d): Data => ({ ...d, status: 'ready', message: null, issues, labels, milestones, tokenSource, fetchedAt, border, raised }))
192 } catch (err) {
193 await fail($, `Unexpected answer from ${auth!.repo.host}: ${err instanceof Error ? err.message : String(err)}`)
194 }
195}
196
197async function openIssue($: $, n: number): Promise<void> {
198 await updateUi($, (u): Ui => bump({ ...u, open: n, draft: '', armed: null, notice: null }, 'comment'))
199 const d = await readData($)
200 if (!d.issues.some(i => i.number === n)) return
201 await update($, detail, (): Detail => ({ number: n, status: 'loading', comments: [], message: null }))
202 if (!auth) {
203 const why = await resolveAuth($)
204 if (why) {
205 await update($, detail, (): Detail => ({ number: n, status: 'error', comments: [], message: why }))
206 return
207 }
208 }
209 const res = await getAll($, `/repos/${auth!.repo.owner}/${auth!.repo.repo}/issues/${n}/comments`, toComments)
210 await update($, detail, (cur): Detail | null =>
211 cur?.number !== n
212 ? cur
213 : typeof res === 'string'
214 ? { number: n, status: 'error', comments: [], message: res }
215 : { number: n, status: 'ready', comments: res, message: null },
216 )
217}
218
219async function openFirst($: $): Promise<void> {
220 const [d, u] = await Promise.all([readData($), readUi($)])
221 const top = filterIssues(d.issues, u)[0]
222 if (top) await openIssue($, top.issue.number)
223}
224
225async function toggleChip($: $, name: string): Promise<void> {
226 const d = await readData($)
227 await updateUi($, (u): Ui => ({ ...u, chips: toggleScoped(u.chips, name, d.labels) }))
228}
229
230async function notify($: $, tone: 'ok' | 'error', text: string): Promise<void> {
231 await updateUi($, (u): Ui => ({ ...u, notice: { tone, text }, armed: null }))
232}
233
234/** Swaps in the issue the API answered for a write, so the list and the detail view agree. */
235async function replaceIssue($: $, json: string): Promise<void> {
236 const fresh = toIssues(`[${json}]`)[0]
237 if (fresh) await updateData($, (d): Data => ({ ...d, issues: d.issues.map(i => (i.number === fresh.number ? fresh : i)) }))
238}
239
240/** Posts the draft as a comment on issue `n`; false when it did not land. */
241async function postComment($: $, n: number, text: string): Promise<boolean> {
242 const res = await request($, 'POST', `/repos/${auth!.repo.owner}/${auth!.repo.repo}/issues/${n}/comments`, { body: text })
243 if (typeof res === 'string') {
244 await notify($, 'error', res)
245 return false
246 }
247 const posted = toComments(`[${res.text}]`)
248 await update($, detail, (cur): Detail | null =>
249 cur?.number === n ? { ...cur, comments: [...cur.comments, ...posted] } : cur,
250 )
251 await updateData($, (d): Data => ({
252 ...d,
253 issues: d.issues.map(i => (i.number === n ? { ...i, comments: i.comments + 1 } : i)),
254 }))
255 await updateUi($, (u): Ui => bump({ ...u, draft: '' }, 'comment'))
256 return true
257}
258
259/** Runs one write with the pane marked busy, so a second press waits for it. */
260async function write($: $, run: () => Promise<void>): Promise<void> {
261 const u = await readUi($)
262 if (u.busy) return
263 await updateUi($, (x): Ui => ({ ...x, busy: true, notice: null }))
264 try {
265 if (!auth) {
266 const why = await resolveAuth($)
267 if (why) return notify($, 'error', why)
268 }
269 await run()
270 } finally {
271 await updateUi($, (x): Ui => ({ ...x, busy: false }))
272 }
273}
274
275async function comment($: $): Promise<void> {
276 const u = await readUi($)
277 const n = u.open
278 const text = u.draft.trim()
279 if (n === null || !text) return
280 await write($, async () => {
281 if (await postComment($, n, text)) await notify($, 'ok', `Commented on #${n}.`)
282 })
283}
284
285/** Close or reopen: the first press arms it (and checks the comment rule), Confirm runs it. */
286async function arm($: $, action: 'close' | 'reopen'): Promise<void> {
287 const u = await readUi($)
288 if (action === 'close' && requireCommentOnClose && !u.draft.trim()) {
289 return notify($, 'error', 'Write a comment first: closing posts it, then closes, so the issue says why.')
290 }
291 await updateUi($, (x): Ui => ({ ...x, armed: action, notice: null }))
292}
293
294async function confirm($: $): Promise<void> {
295 const u = await readUi($)
296 const n = u.open
297 const action = u.armed
298 if (n === null || (action !== 'close' && action !== 'reopen')) return
299 const text = u.draft.trim()
300 await write($, async () => {
301 if (text && !(await postComment($, n, text))) return
302 const res = await request($, 'PATCH', `/repos/${auth!.repo.owner}/${auth!.repo.repo}/issues/${n}`, {
303 state: action === 'close' ? 'closed' : 'open',
304 })
305 if (typeof res === 'string') return notify($, 'error', res)
306 await replaceIssue($, res.text)
307 await notify($, 'ok', `${action === 'close' ? 'Closed' : 'Reopened'} #${n}${text ? ' with a comment' : ''}.`)
308 })
309}
310
311function repoPath(): string {
312 return `/repos/${auth!.repo.owner}/${auth!.repo.repo}`
313}
314
315/** Re-reads one issue after a write whose answer is not the issue itself. */
316async function refetchIssue($: $, n: number): Promise<string | null> {
317 const res = await get($, `${repoPath()}/issues/${n}`)
318 if (typeof res === 'string') return res
319 await replaceIssue($, res.text)
320 return null
321}
322
323async function saveLabels($: $): Promise<void> {
324 const [d, u] = await Promise.all([readData($), readUi($)])
325 const n = u.open
326 const names = u.editingLabels
327 if (n === null || names === null) return
328 await write($, async () => {
329 const res = await request($, 'PUT', `${repoPath()}/issues/${n}/labels`, { labels: labelIds(names, d.labels) })
330 if (typeof res === 'string') return notify($, 'error', res)
331 const why = await refetchIssue($, n)
332 if (why) return notify($, 'error', why)
333 await updateUi($, (x): Ui => ({ ...x, editingLabels: null }))
334 await notify($, 'ok', `Labels saved on #${n}.`)
335 })
336}
337
338async function setMilestone($: $, title: string): Promise<void> {
339 const [d, u] = await Promise.all([readData($), readUi($)])
340 const n = u.open
341 if (n === null) return
342 await write($, async () => {
343 const res = await request($, 'PATCH', `${repoPath()}/issues/${n}`, { milestone: milestoneId(title || null, d.milestones) })
344 if (typeof res === 'string') return notify($, 'error', res)
345 await replaceIssue($, res.text)
346 await notify($, 'ok', title ? `#${n} moved to ${title}.` : `#${n} has no milestone now.`)
347 })
348}
349
350async function createIssue($: $): Promise<void> {
351 const [d, u] = await Promise.all([readData($), readUi($)])
352 const draft = u.creating
353 if (!draft) return
354 const problem = newIssueProblem(draft, triageRules)
355 if (problem) return notify($, 'error', problem)
356 await write($, async () => {
357 const res = await request($, 'POST', `${repoPath()}/issues`, {
358 title: draft.title.trim(),
359 body: draft.body.trim(),
360 labels: labelIds(draft.labels, d.labels),
361 ...(draft.milestone ? { milestone: milestoneId(draft.milestone, d.milestones) } : {}),
362 })
363 if (typeof res === 'string') return notify($, 'error', res)
364 const created = toIssues(`[${res.text}]`)[0]
365 if (!created) return notify($, 'error', 'The forge answered without the new issue.')
366 await updateData($, (x): Data => ({ ...x, issues: [created, ...x.issues] }))
367 await updateUi($, (x): Ui => ({ ...x, creating: null }))
368 await openIssue($, created.number)
369 await notify($, 'ok', `Filed #${created.number}.`)
370 })
371}
372
373/** The text behind an editor id: the comment draft, or a field of the new-issue form. */
374function getField(u: Ui, id: string): string {
375 if (id === 'comment') return u.draft
376 if (id === 'new-title') return u.creating?.title ?? ''
377 if (id === 'new-body') return u.creating?.body ?? ''
378 return ''
379}
380
381function setField(u: Ui, id: string, text: string): Ui {
382 if (id === 'comment') return { ...u, draft: text, armed: null }
383 if (id === 'new-title' && u.creating) return { ...u, creating: { ...u.creating, title: text } }
384 if (id === 'new-body' && u.creating) return { ...u, creating: { ...u.creating, body: text } }
385 return u
386}
387
388/** Marks an editor's text as replaced from here, so the editor adopts it. */
389function bump(u: Ui, id: string): Ui {
390 return { ...u, sync: { ...u.sync, [id]: (u.sync[id] ?? 0) + 1 } }
391}
392
393function isFormat(f: string | undefined): f is Format {
394 return f === 'bold' || f === 'italic' || f === 'code' || f === 'link' || f === 'heading' || f === 'list' || f === 'quote'
395}
396
397/**
398 * Hands an editor's text to the external editor: writes it to a file, runs
399 * the configured command, and takes the file back when the command exits. A
400 * command that returns at once (a launcher that forks) leaves the session
401 * open; the "Use editor text" pill takes the file back by hand.
402 */
403async function openExternal($: $, id: string): Promise<void> {
404 if (!editorCommand) return notify($, 'error', 'Set the External editor command in the mod settings first.')
405 const u = await readUi($)
406 const dir = (await $.env.get('XDG_RUNTIME_DIR')) || '/tmp'
407 const file = `${dir}/claude-mods-forgejo-${id}.md`
408 await $.fs.write(file, getField(u, id))
409 await updateUi($, (x): Ui => ({ ...x, external: { id, file }, notice: { tone: 'ok', text: 'Editing in your editor: save and quit to bring the text back.' } }))
410 const started = await $.clock.now()
411 try {
412 const stream = $.process.spawn({ argv: editorArgv(editorCommand, file) })
413 for await (const _ of stream) {
414 // The editor's own output is not used; reading it keeps the process ours.
415 }
416 const done = await stream.result
417 if (done.code !== 0 && done.code !== null) return notify($, 'error', `The editor command exited with ${done.code}.`)
418 } catch (err) {
419 return notify($, 'error', `Could not start the editor: ${err instanceof Error ? err.message : String(err)}`)
420 }
421 if ((await $.clock.now()) - started < 1500) {
422 return notify($, 'ok', 'The editor command returned straight away. When you are done, press Use editor text.')
423 }
424 await pullExternal($)
425}
426
427async function pullExternal($: $): Promise<void> {
428 const u = await readUi($)
429 if (!u.external) return
430 const { id, file } = u.external
431 let text: string
432 try {
433 text = await $.fs.read(file)
434 } catch {
435 return notify($, 'error', 'The editor file is gone; nothing to bring back.')
436 }
437 await updateUi($, (x): Ui => bump({ ...setField(x, id, text.replace(/\n$/, '')), external: null }, id))
438 await $.process.run(['rm', '-f', file])
439 await notify($, 'ok', 'Brought the text back from your editor.')
440}
441
442/** A pill's id, as pill.tsx posts it: `state:open`, `chip:<label>`, `open:<n>`, `back`, ... */
443async function press($: $, id: string): Promise<void> {
444 const [kind, ...rest] = id.split(':')
445 const arg = rest.join(':')
446 if (kind === 'state' && (arg === 'open' || arg === 'closed' || arg === 'all')) {
447 await updateUi($, (x): Ui => ({ ...x, state: arg }))
448 } else if (kind === 'grouped') {
449 await updateUi($, (x): Ui => ({ ...x, grouped: !x.grouped }))
450 } else if (kind === 'refresh') {
451 await load($)
452 } else if (kind === 'chip') {
453 await toggleChip($, arg)
454 } else if (kind === 'back') {
455 await updateUi($, (x): Ui => ({ ...x, open: null }))
456 } else if (kind === 'open' && /^\d+$/.test(arg)) {
457 await openIssue($, Number(arg))
458 } else if (kind === 'comment') {
459 await comment($)
460 } else if (kind === 'close' || kind === 'reopen') {
461 await arm($, kind)
462 } else if (kind === 'confirm') {
463 await confirm($)
464 } else if (kind === 'cancel') {
465 await updateUi($, (x): Ui => ({ ...x, armed: null }))
466 } else if (kind === 'labels') {
467 await pressLabels($, arg)
468 } else if (kind === 'new') {
469 await pressNew($, arg)
470 } else if (kind === 'fmt') {
471 const [id, format] = arg.split(':')
472 if (id && isFormat(format)) {
473 await updateUi($, (x): Ui => ({ ...x, fmt: { ...x.fmt, [id]: { seq: (x.fmt[id]?.seq ?? 0) + 1, format } } }))
474 }
475 } else if (kind === 'preview') {
476 await updateUi($, (x): Ui => ({ ...x, preview: { ...x.preview, [arg]: !x.preview[arg] } }))
477 } else if (kind === 'editor') {
478 // Not awaited: an editing session outlasts any hook's time budget.
479 void openExternal($, arg)
480 } else if (kind === 'editor-pull') {
481 await pullExternal($)
482 }
483}
484
485/** `labels:edit`, `labels:save`, `labels:cancel`, `labels:toggle:<name>` in the detail view. */
486async function pressLabels($: $, arg: string): Promise<void> {
487 const d = await readData($)
488 if (arg === 'edit') {
489 const u = await readUi($)
490 const issue = d.issues.find(i => i.number === u.open)
491 if (issue) await updateUi($, (x): Ui => ({ ...x, editingLabels: issue.labels.map(l => l.name), notice: null }))
492 } else if (arg === 'save') {
493 await saveLabels($)
494 } else if (arg === 'cancel') {
495 await updateUi($, (x): Ui => ({ ...x, editingLabels: null }))
496 } else if (arg.startsWith('toggle:')) {
497 const name = arg.slice('toggle:'.length)
498 await updateUi($, (x): Ui => (x.editingLabels ? { ...x, editingLabels: toggleScoped(x.editingLabels, name, d.labels) } : x))
499 }
500}
501
502/** `new:start`, `new:create`, `new:cancel`, `new:toggle:<name>` for the new-issue form. */
503async function pressNew($: $, arg: string): Promise<void> {
504 const d = await readData($)
505 if (arg === 'start') {
506 await updateUi($, (x): Ui =>
507 bump(bump({ ...x, open: null, notice: null, creating: { title: '', body: '', labels: [], milestone: '' } }, 'new-title'), 'new-body'),
508 )
509 } else if (arg === 'create') {
510 await createIssue($)
511 } else if (arg === 'cancel' || arg === 'back') {
512 await updateUi($, (x): Ui => ({ ...x, creating: null, notice: null }))
513 } else if (arg.startsWith('toggle:')) {
514 const name = arg.slice('toggle:'.length)
515 await updateUi($, (x): Ui =>
516 x.creating ? { ...x, creating: { ...x.creating, labels: toggleScoped(x.creating.labels, name, d.labels) } } : x,
517 )
518 }
519}
520
521async function guard($: $, tool: string, input: Record<string, unknown>): Promise<string | null> {
522 await paths($)
523 if (!configHome || !touchesSecrets(tool, input, secretsDir(), home)) return null
524 return `${$.plugin.name}: ${secretsDir()} holds tokens for claude-mods and is kept from the model. Ask the user if something there needs checking.`
525}
526
527export const register: Register = (on, options) => {
528 apiUrl = typeof options?.apiUrl === 'string' ? options.apiUrl : ''
529 requireCommentOnClose = options?.requireCommentOnClose !== false
530 editorCommand = typeof options?.editorCommand === 'string' ? options.editorCommand.trim() : ''
531 triageRules = {
532 requireMilestone: options?.requireMilestone === true,
533 requireLabelFrom: parseLabelList(typeof options?.requireLabelFrom === 'string' ? options.requireLabelFrom : ''),
534 }
535
536 on('session.start', async ($, e, next) => {
537 await $.command.register({ name: 'issues', description: "Show this repo's Forgejo issues in a pane" })
538 return next(e)
539 })
540
541 on('command.run', { command: 'issues' }, async $ => {
542 await $.ui.open({ id: PANE, title: 'Issues' })
543 void load($)
544 return { text: 'Issues pane opened.' }
545 })
546
547 // One hook for every tool: builds differ in which file tools they have
548 // (some search with Grep/Glob, some only through Bash).
549 on('tool.call', async ($, e, next) => {
550 if (!GUARDED.has(e.tool)) return next(e)
551 const deny = await guard($, e.tool, e as unknown as Record<string, unknown>)
552 return deny ? { deny } : next(e)
553 })
554
555 // Every pressable control is a Client (cards, pills) posting a message.
556 on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
557 const msg = e.data as { open?: unknown; press?: unknown; edit?: unknown; text?: unknown; submit?: unknown } | null
558 if (typeof msg?.edit === 'string' && typeof msg.text === 'string') {
559 const id = msg.edit
560 const text = msg.text
561 await updateUi($, (x): Ui => setField(x, id, text))
562 }
563 if (msg?.submit === 'comment') await comment($)
564 if (msg?.submit === 'new-title' || msg?.submit === 'new-body') await createIssue($)
565 if (typeof msg?.open === 'number') await openIssue($, msg.open)
566 if (typeof msg?.press === 'string') await press($, msg.press)
567 return {}
568 })
569
570 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
571 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
572 const { Text } = $.ui.resolve(e)
573 return <Text>The issues pane draws in the terminal and the desktop app.</Text>
574 }
575 const el = $.ui.resolve(e)
576 const [d, u, dt] = await Promise.all([readData($), readUi($), read($, detail)])
577 // Ages count from the fetch: drawing stays a pure function of state.
578 const now = d.fetchedAt
579 const issue = u.open === null ? undefined : d.issues.find(i => i.number === u.open)
580 if (u.creating) {
581 return newIssueView(el, d, u, u.creating, triageRules, editorCommand !== '', {
582 milestone: value => updateUi($, (x): Ui => (x.creating ? { ...x, creating: { ...x.creating, milestone: value } } : x)),
583 })
584 }
585 if (issue) {
586 return detailView(el, d, issue, dt, u, now, editorCommand !== '', {
587 milestone: value => setMilestone($, value),
588 })
589 }
590 return listView(el, d, u, now, e.surface === 'desktop', {
591 query: text => updateUi($, (x): Ui => ({ ...x, query: text })),
592 openFirst: () => openFirst($),
593 milestone: value => updateUi($, (x): Ui => ({ ...x, milestone: value })),
594 })
595 })
596}
597hooks/forge.ts 195 lines1import type { Comment, Issue, Label, Milestone } from '../types'
2
3/** Where a repository lives on a Forgejo/Gitea host. */
4export type RepoRef = {
5 host: string
6 owner: string
7 repo: string
8 webBase: string
9 apiBase: string
10}
11
12/**
13 * Reads a git remote URL in any of its spellings: scp-like
14 * (`git@host:owner/repo.git`), `ssh://`, `https://` (with or without a port or
15 * credentials). The API is assumed on https at the same host; `apiOverride`
16 * replaces it where the SSH host is not the web host.
17 */
18export function parseRemote(url: string, apiOverride = ''): RepoRef | null {
19 const trimmed = url.trim()
20 let host: string | undefined
21 let path: string | undefined
22 const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/\/)(.+)$/.exec(trimmed)
23 const full = /^(?:ssh|https?|git):\/\/(?:[^@/\s]+@)?([^/:\s]+)(?::\d+)?\/(.+)$/.exec(trimmed)
24 if (full) [, host, path] = full
25 else if (scp) [, host, path] = scp
26 if (!host || !path) return null
27 const parts = path.replace(/\.git\/?$/, '').replace(/\/$/, '').split('/')
28 const repo = parts.pop()
29 const owner = parts.pop()
30 if (!owner || !repo) return null
31 const webBase = `https://${host}`
32 const apiBase = apiOverride ? apiOverride.replace(/\/+$/, '') : `${webBase}/api/v1`
33 return { host, owner, repo, webBase, apiBase }
34}
35
36/** The secrets-file key holding the token for `host`: `FORGEJO_TOKEN_GIT_EXAMPLE_ORG`. */
37export function tokenKey(host: string): string {
38 return `FORGEJO_TOKEN_${host.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`
39}
40
41/** Parses `KEY=value` lines (comments, blank lines, `export`, quotes allowed). */
42export function parseEnv(text: string): Map<string, string> {
43 const out = new Map<string, string>()
44 for (const line of text.split('\n')) {
45 const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line)
46 if (!m || !m[1]) continue
47 let value = m[2] ?? ''
48 if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1)
49 out.set(m[1], value)
50 }
51 return out
52}
53
54/**
55 * The token fgj keeps for `host` in its config.yaml
56 * (`hosts: { <host>: { token } }`), read without a YAML library.
57 */
58export function fgjToken(yaml: string, host: string): string | undefined {
59 let inHost = false
60 let hostIndent = -1
61 for (const line of yaml.split('\n')) {
62 if (!line.trim() || line.trim().startsWith('#')) continue
63 const indent = line.length - line.trimStart().length
64 const key = /^\s*([^:\s]+):\s*(.*)$/.exec(line)
65 if (!key) continue
66 if (inHost && indent <= hostIndent) inHost = false
67 if (key[1] === host && !key[2]) {
68 inHost = true
69 hostIndent = indent
70 } else if (inHost && key[1] === 'token' && key[2]) {
71 return key[2].replace(/^(['"])(.*)\1$/, '$2')
72 }
73 }
74 return undefined
75}
76
77/** Where a token was found, for the pane to say (never the token itself). */
78export type TokenSource = 'secrets' | 'fgj'
79
80/**
81 * The token for `host`, first match wins: the claude-mods secrets file's
82 * host-bound key, then fgj's entry for that host. Never a token keyed to
83 * another host, so a repo on one forge cannot leak another forge's token.
84 */
85export function pickToken(
86 host: string,
87 secretsText: string | null,
88 fgjYaml: string | null,
89): { token: string; source: TokenSource } | null {
90 const fromSecrets = secretsText ? parseEnv(secretsText).get(tokenKey(host)) : undefined
91 if (fromSecrets) return { token: fromSecrets, source: 'secrets' }
92 const fromFgj = fgjYaml ? fgjToken(fgjYaml, host) : undefined
93 if (fromFgj) return { token: fromFgj, source: 'fgj' }
94 return null
95}
96
97type ApiIssue = {
98 number: number
99 title: string
100 body?: string
101 state: string
102 comments: number
103 updated_at: string
104 html_url: string
105 user?: { login?: string }
106 labels?: { name: string; color: string }[]
107 milestone?: { title?: string } | null
108 pull_request?: unknown
109}
110
111function list<T>(json: string, what: string): T[] {
112 const raw = JSON.parse(json) as unknown
113 if (!Array.isArray(raw)) throw new Error(`${what} is not a list`)
114 return raw as T[]
115}
116
117const hex = (c: string) => `#${c.replace(/^#/, '')}`
118
119/** Narrows the API's issue list to what the pane draws; pull requests are left out. */
120export function toIssues(json: string): Issue[] {
121 return list<ApiIssue>(json, 'issue list')
122 .filter(i => !i.pull_request)
123 .map(i => ({
124 number: i.number,
125 title: i.title,
126 body: i.body ?? '',
127 state: i.state === 'closed' ? 'closed' : 'open',
128 comments: i.comments,
129 updatedAt: i.updated_at,
130 url: i.html_url,
131 author: i.user?.login ?? '',
132 labels: (i.labels ?? []).map(l => ({ name: l.name, color: hex(l.color) })),
133 milestone: i.milestone?.title ?? null,
134 }))
135}
136
137export function toLabels(json: string): Label[] {
138 return list<{ id: number; name: string; color: string; exclusive?: boolean; description?: string }>(json, 'label list').map(
139 l => ({ id: l.id, name: l.name, color: hex(l.color), exclusive: Boolean(l.exclusive), description: l.description ?? '' }),
140 )
141}
142
143export function toMilestones(json: string): Milestone[] {
144 return list<{ id: number; title: string; state?: string; open_issues?: number; closed_issues?: number }>(json, 'milestone list').map(m => ({
145 id: m.id,
146 title: m.title,
147 state: m.state === 'closed' ? 'closed' : 'open',
148 open: m.open_issues ?? 0,
149 closed: m.closed_issues ?? 0,
150 }))
151}
152
153export function toComments(json: string): Comment[] {
154 return list<{ user?: { login?: string }; body?: string; created_at: string }>(json, 'comment list').map(c => ({
155 author: c.user?.login ?? '',
156 body: c.body ?? '',
157 createdAt: c.created_at,
158 }))
159}
160
161/** Splits a scoped label (`kind/bug`, `area/ui/menu`) at its last slash, as Forgejo does. */
162export function scopeOf(name: string): { scope: string | null; leaf: string } {
163 const at = name.lastIndexOf('/')
164 return at > 0 && at < name.length - 1 ? { scope: name.slice(0, at), leaf: name.slice(at + 1) } : { scope: null, leaf: name }
165}
166
167/**
168 * Turns bare `#123` references into links to the issue page, outside code
169 * spans and fences, so the pane can catch a press on one and open it in place.
170 */
171export function linkifyRefs(markdown: string, issueBase: string): string {
172 return markdown
173 .split(/(```[\s\S]*?(?:```|$))/)
174 .map((block, i) =>
175 i % 2 === 1
176 ? block
177 : block
178 .split(/(`[^`\n]*`)/)
179 .map((part, j) =>
180 j % 2 === 1 ? part : part.replace(/(^|[^\w&#\[\/])#(\d+)\b/g, (_, pre: string, n: string) => `${pre}[#${n}](${issueBase}/${n})`),
181 )
182 .join(''),
183 )
184 .join('')
185}
186
187/** "3d", "5h", "just now": how long ago `iso` was, from `now` (ms). */
188export function ago(iso: string, now: number): string {
189 const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000))
190 if (s < 60) return 'now'
191 const units: [number, string][] = [[31_536_000, 'y'], [2_592_000, 'mo'], [86_400, 'd'], [3_600, 'h'], [60, 'm']]
192 for (const [size, unit] of units) if (s >= size) return `${Math.floor(s / size)}${unit}`
193 return 'now'
194}
195hooks/guard.ts 68 lines1/**
2 * Whether a tool call by the model reaches into the claude-mods secrets
3 * folder. A mod reads that folder itself; the model has no reason to, and a
4 * stray `cat` or `grep -r ~/.config` would put a token in the transcript.
5 *
6 * Bash is matched on the command text, so a determined command can get past
7 * it; it is there to stop the accidental reads.
8 */
9export function touchesSecrets(
10 tool: string,
11 input: Record<string, unknown>,
12 secretsDir: string,
13 home: string,
14): boolean {
15 const dir = secretsDir.replace(/\/+$/, '')
16 const spellings = [dir]
17 if (home && dir.startsWith(`${home}/`)) {
18 const rest = dir.slice(home.length)
19 spellings.push(`~${rest}`, `$HOME${rest}`, `\${HOME}${rest}`)
20 }
21 // The folder itself or anything in it: the spelling ends at a path end, a
22 // slash, or a character no path segment holds (a quote, space, `;`, `|`, ...).
23 const pattern = new RegExp(`(?:${spellings.map(escape).join('|')})(?=$|[/\\s'"\`;|&)<>])`)
24 const hits = (text: unknown) => typeof text === 'string' && pattern.test(text)
25
26 switch (tool) {
27 case 'Read':
28 case 'Write':
29 case 'Edit':
30 return hits(input.file_path)
31 case 'Grep':
32 case 'Glob':
33 // A search rooted above the folder is let through: blocking those would
34 // stop every search of ~/.config. Only a search aimed at it is refused.
35 return hits(input.path) || hits(input.pattern)
36 case 'Bash': {
37 // A heredoc's body is text on its way somewhere (a file, a commit
38 // message), not a path being opened; only the command itself counts.
39 const command = stripHeredocs(String(input.command ?? ''))
40 const relative = home && dir.startsWith(`${home}/`) ? dir.slice(home.length + 1) : null
41 return hits(command) || (relative !== null && new RegExp(`(^|[\\s'"=])${escape(relative)}\\b`).test(command))
42 }
43 default:
44 return false
45 }
46}
47
48/** The command with every heredoc body (`<<EOF` … `EOF`, any quoting or `<<-`) taken out. */
49export function stripHeredocs(command: string): string {
50 const lines = command.split('\n')
51 const out: string[] = []
52 const pending: string[] = []
53 for (const line of lines) {
54 if (pending.length > 0) {
55 if (line.replace(/^\t+/, '') === pending[0]) pending.shift()
56 continue
57 }
58 out.push(line)
59 for (const m of line.matchAll(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g)) pending.push(m[2]!)
60 }
61 return out.join('\n')
62}
63
64function escape(text: string): string {
65 return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
66}
67
68hooks/triage.ts 51 lines1import type { Label, Milestone, NewIssue } from '../types'
2
3/**
4 * `selected` with `name` toggled. Turning on a label from an exclusive scope
5 * (`kind/bug` where `kind/` is exclusive) turns its siblings off, as Forgejo
6 * itself only keeps one per such scope.
7 */
8export function toggleScoped(selected: string[], name: string, labels: Label[]): string[] {
9 if (selected.includes(name)) return selected.filter(s => s !== name)
10 const at = name.lastIndexOf('/')
11 const scope = at > 0 ? name.slice(0, at + 1) : null
12 const exclusive = scope !== null && labels.some(l => l.exclusive && l.name.startsWith(scope))
13 const kept = exclusive && scope ? selected.filter(s => !s.startsWith(scope)) : selected
14 return [...kept, name]
15}
16
17/** The ids of the labels named, in the repo's label list; unknown names are skipped. */
18export function labelIds(names: string[], labels: Label[]): number[] {
19 const byName = new Map(labels.map(l => [l.name, l.id]))
20 return names.map(n => byName.get(n)).filter((id): id is number => id !== undefined)
21}
22
23/** The id of the milestone titled `title`, 0 for none (what the API takes to clear it). */
24export function milestoneId(title: string | null, milestones: Milestone[]): number {
25 return title ? (milestones.find(m => m.title === title)?.id ?? 0) : 0
26}
27
28export type TriageRules = {
29 requireMilestone: boolean
30 /** One of these labels must be set; empty for no such rule. */
31 requireLabelFrom: string[]
32}
33
34/** Parses the comma-separated `requireLabelFrom` setting. */
35export function parseLabelList(text: string): string[] {
36 return text
37 .split(',')
38 .map(s => s.trim())
39 .filter(Boolean)
40}
41
42/** Why `draft` cannot be filed yet, worded for the form, or null when it can. */
43export function newIssueProblem(draft: NewIssue, rules: TriageRules): string | null {
44 if (!draft.title.trim()) return 'Give the issue a title.'
45 if (rules.requireMilestone && !draft.milestone) return 'Pick a milestone: every issue needs one here.'
46 if (rules.requireLabelFrom.length > 0 && !draft.labels.some(l => rules.requireLabelFrom.includes(l))) {
47 return `Pick one of: ${rules.requireLabelFrom.join(', ')}.`
48 }
49 return null
50}
51hooks/editing.ts 125 lines1import type { Format } from '../types'
2
3/** Pure text editing for the editor Client: a text and a caret (an index into it). */
4export type Buffer = { text: string; caret: number }
5
6function lineStart(text: string, at: number): number {
7 return text.lastIndexOf('\n', at - 1) + 1
8}
9
10function lineEnd(text: string, at: number): number {
11 const end = text.indexOf('\n', at)
12 return end === -1 ? text.length : end
13}
14
15export function insert(b: Buffer, s: string): Buffer {
16 return { text: b.text.slice(0, b.caret) + s + b.text.slice(b.caret), caret: b.caret + s.length }
17}
18
19function wordStart(text: string, at: number): number {
20 let i = at
21 while (i > 0 && /\s/.test(text[i - 1]!)) i--
22 while (i > 0 && !/\s/.test(text[i - 1]!)) i--
23 return i
24}
25
26function wordEnd(text: string, at: number): number {
27 let i = at
28 while (i < text.length && /\s/.test(text[i]!)) i++
29 while (i < text.length && !/\s/.test(text[i]!)) i++
30 return i
31}
32
33/** Applies one key the Client received; null when the key is not an edit (submit, focus moves). */
34export function applyKey(b: Buffer, key: string, mods: { ctrl?: boolean; meta?: boolean }, multiline: boolean): Buffer | null {
35 const { text, caret } = b
36 const word = Boolean(mods.ctrl || mods.meta)
37 switch (key) {
38 case 'left':
39 return { text, caret: word ? wordStart(text, caret) : Math.max(0, caret - 1) }
40 case 'right':
41 return { text, caret: word ? wordEnd(text, caret) : Math.min(text.length, caret + 1) }
42 case 'home':
43 return { text, caret: lineStart(text, caret) }
44 case 'end':
45 return { text, caret: lineEnd(text, caret) }
46 case 'up':
47 case 'down': {
48 const start = lineStart(text, caret)
49 const col = caret - start
50 if (key === 'up') {
51 if (start === 0) return { text, caret: 0 }
52 const prev = lineStart(text, start - 1)
53 return { text, caret: Math.min(prev + col, start - 1) }
54 }
55 const end = lineEnd(text, caret)
56 if (end === text.length) return { text, caret: text.length }
57 const nextEnd = lineEnd(text, end + 1)
58 return { text, caret: Math.min(end + 1 + col, nextEnd) }
59 }
60 case 'backspace': {
61 if (caret === 0) return b
62 const from = word ? wordStart(text, caret) : caret - 1
63 return { text: text.slice(0, from) + text.slice(caret), caret: from }
64 }
65 case 'delete': {
66 if (caret === text.length) return b
67 const to = word ? wordEnd(text, caret) : caret + 1
68 return { text: text.slice(0, caret) + text.slice(to), caret }
69 }
70 case 'return':
71 return multiline && !word ? insert(b, '\n') : null
72 case 'tab':
73 return multiline ? insert(b, ' ') : null
74 case 'space':
75 return insert(b, ' ')
76 default:
77 // A printable character arrives as itself; any other named key is ignored.
78 if ([...key].length === 1 && !mods.ctrl && !mods.meta) return insert(b, key)
79 return null
80 }
81}
82
83/** Inserts Markdown syntax at the caret: inline marks wrap the caret, line marks prefix the line. */
84export function applyFormat(b: Buffer, f: Format): Buffer {
85 const { text, caret } = b
86 const wrap = (open: string, close: string) => ({ text: text.slice(0, caret) + open + close + text.slice(caret), caret: caret + open.length })
87 const prefix = (p: string) => {
88 const start = lineStart(text, caret)
89 if (text.startsWith(p, start)) return b
90 return { text: text.slice(0, start) + p + text.slice(start), caret: caret + p.length }
91 }
92 switch (f) {
93 case 'bold':
94 return wrap('**', '**')
95 case 'italic':
96 return wrap('_', '_')
97 case 'code':
98 return wrap('`', '`')
99 case 'link':
100 return wrap('[', '](url)')
101 case 'heading':
102 return prefix('## ')
103 case 'list':
104 return prefix('- ')
105 case 'quote':
106 return prefix('> ')
107 }
108}
109
110/** The caret's line and column, for drawing it. */
111export function caretPosition(b: Buffer): { line: number; col: number } {
112 const before = b.text.slice(0, b.caret)
113 const line = before.split('\n').length - 1
114 return { line, col: b.caret - lineStart(b.text, b.caret) }
115}
116
117/** Splits a command setting into argv, honouring quotes, with `{file}` replaced (appended when absent). */
118export function editorArgv(command: string, file: string): string[] {
119 const args: string[] = []
120 for (const m of command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) args.push(m[1] ?? m[2] ?? m[3] ?? '')
121 const hasFile = args.some(a => a.includes('{file}'))
122 const out = args.map(a => a.replaceAll('{file}', file))
123 return hasFile ? out : [...out, file]
124}
125hooks/search.ts 152 lines1import type { Issue, Milestone, StateFilter, Ui } from '../types'
2
3export type Query = {
4 /** Fuzzy terms; each must match (fzf's extended-mode AND). */
5 terms: string[]
6 labels: string[]
7 notLabels: string[]
8 milestones: string[]
9 state: StateFilter | null
10}
11
12/**
13 * Reads the search box: bare words are fuzzy terms; `label:x`, `-label:x`,
14 * `milestone:x` and `is:open|closed|all` filter. Values may be quoted
15 * (`milestone:"Theming System"`). `l:` and `m:` are short forms.
16 */
17export function parseQuery(text: string): Query {
18 const q: Query = { terms: [], labels: [], notLabels: [], milestones: [], state: null }
19 const re = /(-?)([a-z]+):("[^"]*"?|\S*)|"([^"]*)"?|(\S+)/gi
20 for (const m of text.matchAll(re)) {
21 const [, neg, key, raw, quoted, word] = m
22 if (key !== undefined) {
23 const value = (raw ?? '').replace(/^"|"$/g, '').toLowerCase()
24 const k = key.toLowerCase()
25 if (!value) continue
26 if (k === 'label' || k === 'l') (neg ? q.notLabels : q.labels).push(value)
27 else if (k === 'milestone' || k === 'm') q.milestones.push(value)
28 else if (k === 'is' && (value === 'open' || value === 'closed' || value === 'all')) q.state = value
29 else q.terms.push(`${neg}${key}:${value}`.toLowerCase())
30 } else {
31 const t = (quoted ?? word ?? '').toLowerCase()
32 if (t) q.terms.push(t)
33 }
34 }
35 return q
36}
37
38export type Match = { score: number; positions: number[] }
39
40const isWord = (c: string | undefined) => c !== undefined && /[\p{L}\p{N}]/u.test(c)
41
42/**
43 * fzf-style fuzzy match: `pattern`'s characters in order, not necessarily
44 * adjacent. Finds the shortest window holding them (forward, then backward
45 * from where the forward scan ended) and scores it: points per character,
46 * more at word starts and in runs, less for gaps. Case-insensitive.
47 */
48export function fuzzy(text: string, pattern: string): Match | null {
49 if (!pattern) return { score: 0, positions: [] }
50 const t = text.toLowerCase()
51 const p = pattern.toLowerCase()
52 let pi = 0
53 let end = -1
54 for (let i = 0; i < t.length; i++) {
55 if (t[i] === p[pi] && ++pi === p.length) {
56 end = i
57 break
58 }
59 }
60 if (end < 0) return null
61 let start = end
62 for (let i = end, back = p.length - 1; i >= 0; i--) {
63 if (t[i] === p[back]) {
64 start = i
65 if (--back < 0) break
66 }
67 }
68 const positions: number[] = []
69 let score = 0
70 let prev = -2
71 for (let i = start, k = 0; i <= end && k < p.length; i++) {
72 if (t[i] !== p[k]) continue
73 let s = 16
74 if (!isWord(text[i - 1])) s += 10
75 else if (text[i] !== t[i] && text[i - 1] === t[i - 1]) s += 8
76 // A run is worth as much as a word start (fzf's rule); a gap costs more
77 // the longer it is, so a tight match beats letters scattered at word starts.
78 if (i === prev + 1) s += 10
79 else if (prev >= 0) s -= Math.min(12, 3 + (i - prev - 2))
80 score += s
81 positions.push(i)
82 prev = i
83 k++
84 }
85 return { score: score - start * 0.1, positions }
86}
87
88function labelHit(names: string[], want: string): boolean {
89 return names.some(n => {
90 const name = n.toLowerCase()
91 if (want.endsWith('/')) return name.startsWith(want)
92 return name === want || name.endsWith(`/${want}`) || name.startsWith(`${want}/`)
93 })
94}
95
96export type Row = { issue: Issue; positions: number[] }
97
98/** The issues the box and the chips let through, best fuzzy match first, else most recently updated. */
99export function filterIssues(issues: Issue[], ui: Pick<Ui, 'query' | 'chips' | 'milestone' | 'state'>): Row[] {
100 const q = parseQuery(ui.query)
101 const state = q.state ?? ui.state
102 const rows: (Row & { score: number })[] = []
103 for (const issue of issues) {
104 if (state !== 'all' && issue.state !== state) continue
105 const names = issue.labels.map(l => l.name)
106 if (!ui.chips.every(c => names.includes(c))) continue
107 if (!q.labels.every(l => labelHit(names, l))) continue
108 if (q.notLabels.some(l => labelHit(names, l))) continue
109 if (ui.milestone === '-' ? issue.milestone !== null : ui.milestone && issue.milestone !== ui.milestone) continue
110 const ms = (issue.milestone ?? '').toLowerCase()
111 if (!q.milestones.every(m => (m === 'none' ? !issue.milestone : ms.includes(m)))) continue
112
113 const haystack = `#${issue.number} ${issue.title}`
114 let score = 0
115 const positions = new Set<number>()
116 let ok = true
117 for (const term of q.terms) {
118 const m = fuzzy(haystack, term)
119 if (!m) {
120 ok = false
121 break
122 }
123 score += m.score
124 m.positions.forEach(p => positions.add(p))
125 }
126 if (!ok) continue
127 // Positions are reported against the title alone.
128 const offset = haystack.length - issue.title.length
129 rows.push({
130 issue,
131 score,
132 positions: [...positions].filter(p => p >= offset).map(p => p - offset).sort((a, b) => a - b),
133 })
134 }
135 const byDate = (a: Row, b: Row) => b.issue.updatedAt.localeCompare(a.issue.updatedAt)
136 rows.sort((a, b) => (q.terms.length && b.score !== a.score ? b.score - a.score : byDate(a, b)))
137 return rows.map(({ issue, positions }) => ({ issue, positions }))
138}
139
140/** Rows under their milestones, in the repo's milestone order, those without one last. */
141export function groupRows(rows: Row[], milestones: Milestone[]): { title: string; rows: Row[] }[] {
142 const order = new Map(milestones.map((m, i) => [m.title, i]))
143 const groups = new Map<string, Row[]>()
144 for (const r of rows) {
145 const key = r.issue.milestone ?? ''
146 groups.set(key, [...(groups.get(key) ?? []), r])
147 }
148 return [...groups.entries()]
149 .sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : (order.get(a) ?? 1e9) - (order.get(b) ?? 1e9) || a.localeCompare(b)))
150 .map(([title, rows]) => ({ title: title || 'No milestone', rows }))
151}
152hooks/views.tsx 401 lines1import type { Elements } from 'claude-code'
2
3import type { Data, Detail, Issue, NewIssue, Ui } from '../types'
4import { ago, linkifyRefs, scopeOf } from './forge'
5import type { CardProps } from './card'
6import type { PillProps } from './pill'
7import { filterIssues, groupRows } from './search'
8import type { TriageRules } from './triage'
9import type { EditorProps } from './editor'
10import type { Format } from '../types'
11
12/** The elements both drawing surfaces with a pane share. */
13export type El = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Input' | 'Select' | 'Link' | 'Markdown' | 'Client'>
14
15/** What the native controls (Input, Select) call; every pressable control is a pill posting its id. */
16export type ListActions = {
17 query: (text: string) => void
18 openFirst: () => void
19 milestone: (value: string) => void
20}
21
22/** What the detail view's composer calls. */
23export type DetailActions = { milestone: (title: string) => void }
24
25/** What the new-issue form's native controls call. */
26export type NewIssueActions = { milestone: (title: string) => void }
27
28/** A pressable control: a Client posting `{ press: id }` (see pill.tsx for why not a Button). */
29function pill(el: El, props: PillProps) {
30 const { Client } = el
31 return <Client key={`p-${props.id}`} module="./pill.tsx" props={props} />
32}
33
34const SEARCH_HINT = 'Search · label: · milestone: · is:'
35
36export function repoUrl(data: Data): string {
37 return data.repo ? `${data.repo.webBase}/${data.repo.owner}/${data.repo.repo}` : ''
38}
39
40function header(el: El, data: Data, ui: Ui, shown: number) {
41 const { Box, Text, Link } = el
42 const total = data.issues.filter(i => ui.state === 'all' || i.state === ui.state).length
43 return (
44 <Box key="head" flexDirection="row" justifyContent="space-between" flexWrap="wrap" alignItems="center">
45 <Box flexDirection="row">
46 {data.repo ? <Link href={repoUrl(data)} label={`${data.repo.owner}/${data.repo.repo}`} /> : <Text bold>Issues</Text>}
47 <Text dimColor>
48 {data.status === 'ready' ? ` · ${shown === total ? total : `${shown} of ${total}`} ${ui.state === 'all' ? '' : ui.state}` : ''}
49 </Text>
50 </Box>
51 <Box flexDirection="row" gap={1}>
52 {(['open', 'closed', 'all'] as const).map(st =>
53 pill(el, { id: `state:${st}`, kind: 'button', label: st[0]!.toUpperCase() + st.slice(1), active: ui.state === st, border: data.border }),
54 )}
55 {pill(el, { id: 'grouped', kind: 'button', label: 'Grouped', active: ui.grouped, border: data.border })}
56 {pill(el, { id: 'refresh', kind: 'button', label: data.status === 'loading' ? 'Loading…' : 'Refresh', border: data.border })}
57 {pill(el, { id: 'new:start', kind: 'button', label: 'New issue', border: data.border })}
58 </Box>
59 </Box>
60 )
61}
62
63/** Label chips with counts over the issues in the current state; scoped labels sit under their scope. */
64function chips(el: El, data: Data, ui: Ui) {
65 const { Box, Text } = el
66 const counts = new Map<string, number>()
67 for (const i of data.issues) {
68 if (ui.state !== 'all' && i.state !== ui.state) continue
69 for (const l of i.labels) counts.set(l.name, (counts.get(l.name) ?? 0) + 1)
70 }
71 const known = new Map(data.labels.map(l => [l.name, l]))
72 for (const name of counts.keys()) if (!known.has(name)) known.set(name, { id: -1, name, color: '#888888', exclusive: false, description: '' })
73 const shown = [...known.values()].filter(l => counts.has(l.name) || ui.chips.includes(l.name))
74 const scopes = new Map<string, typeof shown>()
75 for (const l of shown) {
76 const scope = scopeOf(l.name).scope ?? ''
77 scopes.set(scope, [...(scopes.get(scope) ?? []), l])
78 }
79 const order = [...scopes.keys()].sort((a, b) => (a === '' ? -1 : b === '' ? 1 : a.localeCompare(b)))
80 return (
81 <Box key="chips" flexDirection="column">
82 {order.map(scope => (
83 <Box key={`scope-${scope}`} flexDirection="row" flexWrap="wrap" columnGap={2}>
84 {scope && <Text dimColor>{scope}/</Text>}
85 {scopes.get(scope)!.sort((a, b) => a.name.localeCompare(b.name)).map(l => {
86 return pill(el, {
87 id: `chip:${l.name}`,
88 kind: 'chip',
89 label: scopeOf(l.name).leaf,
90 dot: l.color,
91 count: counts.get(l.name) ?? 0,
92 active: ui.chips.includes(l.name),
93 })
94 })}
95 </Box>
96 ))}
97 </Box>
98 )
99}
100
101function milestonePicker(el: El, data: Data, ui: Ui, act: ListActions) {
102 const { Select } = el
103 const options = [
104 { value: '', label: 'Any milestone' },
105 { value: '-', label: 'No milestone' },
106 ...data.milestones.map(m => ({ value: m.title, label: `${m.title} (${ui.state === 'closed' ? m.closed : ui.state === 'all' ? m.open + m.closed : m.open})` })),
107 ]
108 return <Select key="milestone" options={options} value={ui.milestone} onSelect={act.milestone} />
109}
110
111export function listView(el: El, data: Data, ui: Ui, now: number, proportional: boolean, act: ListActions) {
112 const { Box, Text, Input, Client } = el
113 const rows = filterIssues(data.issues, ui)
114 const numberWidth = Math.max(2, ...data.issues.map(i => String(i.number).length))
115 // Desktop text is proportional, about a fifth narrower than its cell grid.
116 const textScale = proportional ? 1.2 : 1
117 const card = (r: (typeof rows)[number]) => {
118 const props: CardProps = {
119 number: r.issue.number,
120 title: r.issue.title,
121 positions: r.positions,
122 labels: r.issue.labels,
123 age: ago(r.issue.updatedAt, now),
124 comments: r.issue.comments,
125 closed: r.issue.state === 'closed',
126 numberWidth,
127 textScale,
128 border: data.border,
129 }
130 return <Client key={`card-${r.issue.number}`} module="./card.tsx" props={props} width="100%" />
131 }
132 // A search ranks by match, so it reads best flat; groups return when it clears.
133 const groups = ui.grouped && !ui.query.trim() ? groupRows(rows, data.milestones) : [{ title: '', rows }]
134 const list = (
135 <Box key="list" flexDirection="column" gap={2}>
136 {groups.map(g => (
137 <Box key={`g-${g.title}`} flexDirection="column" gap={1}>
138 {g.title && (
139 <Box flexDirection="row">
140 <Text bold>{g.title}</Text>
141 <Text dimColor> {g.rows.length}</Text>
142 </Box>
143 )}
144 {g.rows.map(card)}
145 </Box>
146 ))}
147 </Box>
148 )
149
150 return (
151 <Box flexDirection="column" gap={1}>
152 {header(el, data, ui, rows.length)}
153 <Box key="search-row" flexDirection="row">
154 <Box flexGrow={1} flexDirection="column">
155 <Input key="search" placeholder={SEARCH_HINT} value={ui.query} onInput={act.query} onSubmit={act.openFirst} />
156 </Box>
157 </Box>
158 {data.status === 'ready' && chips(el, data, ui)}
159 {data.status === 'ready' && milestonePicker(el, data, ui, act)}
160 {data.status === 'loading' && <Text dimColor>Loading issues…</Text>}
161 {data.status === 'idle' && <Text dimColor>Run /issues to load this repo's issues.</Text>}
162 {data.message && <Text color="error">{data.message}</Text>}
163 {data.status === 'ready' && rows.length === 0 && <Text dimColor>No issues match. Clear a filter to see more.</Text>}
164 {rows.length > 0 && list}
165 </Box>
166 )
167}
168
169/** Issues `text` mentions as `#N`, outside code, that exist here, in order of first mention. */
170function mentions(texts: string[], data: Data, self: number): number[] {
171 const known = new Set(data.issues.map(i => i.number))
172 const seen: number[] = []
173 for (const t of texts) {
174 const plain = t.replace(/```[\s\S]*?(```|$)/g, '').replace(/`[^`\n]*`/g, '')
175 for (const m of plain.matchAll(/(?:^|[^\w&#/])#(\d+)\b/g)) {
176 const n = Number(m[1])
177 if (n !== self && known.has(n) && !seen.includes(n)) seen.push(n)
178 }
179 }
180 return seen
181}
182
183export function detailView(el: El, data: Data, issue: Issue, detail: Detail | null, ui: Ui, now: number, hasEditor: boolean, act: DetailActions) {
184 const { Box, Text, Link, Markdown, Select } = el
185 const base = `${repoUrl(data)}/issues`
186 const comments = detail?.number === issue.number ? detail : null
187 const refs = mentions([issue.body, ...(comments?.comments ?? []).map(c => c.body)], data, issue.number)
188 const titleOf = (n: number) => data.issues.find(i => i.number === n)?.title ?? ''
189
190 return (
191 <Box flexDirection="column" gap={1}>
192 <Box key="top" flexDirection="row" justifyContent="space-between" alignItems="center">
193 {pill(el, { id: 'back', kind: 'button', label: '← Issues', border: data.border })}
194 <Link href={issue.url} label="Open on web" />
195 </Box>
196 <Box key="title" flexDirection="row" flexWrap="wrap">
197 <Text bold>{issue.title}</Text>
198 <Text dimColor> #{issue.number}</Text>
199 </Box>
200 <Box key="meta" flexDirection="row" flexWrap="wrap" columnGap={2}>
201 <Text color={issue.state === 'open' ? 'success' : 'merged'}>{issue.state === 'open' ? '● Open' : '● Closed'}</Text>
202 {issue.labels.map(l => (
203 <Box key={`ml-${l.name}`} flexDirection="row">
204 <Text color={l.color}>● </Text>
205 <Text dimColor>{l.name}</Text>
206 </Box>
207 ))}
208 <Text dimColor>
209 {issue.author} · updated {ago(issue.updatedAt, now)} ago
210 </Text>
211 </Box>
212 <Box key="triage" flexDirection="row" flexWrap="wrap" gap={1} alignItems="center">
213 <Select
214 key="issue-milestone"
215 options={milestoneOptions(data, issue.milestone)}
216 value={issue.milestone ?? ''}
217 onSelect={act.milestone}
218 />
219 {ui.editingLabels === null && pill(el, { id: 'labels:edit', kind: 'button', label: 'Edit labels', border: data.border })}
220 </Box>
221 {ui.editingLabels !== null && (
222 <Box key="label-editor" flexDirection="column" gap={1} borderStyle="round" borderColor={data.border} paddingX={1}>
223 {labelPicker(el, data, ui.editingLabels, 'labels:toggle:')}
224 <Box flexDirection="row" gap={1}>
225 {pill(el, { id: 'labels:save', kind: 'button', label: ui.busy ? 'Saving…' : 'Save labels', active: true, border: data.border })}
226 {pill(el, { id: 'labels:cancel', kind: 'button', label: 'Cancel', border: data.border })}
227 </Box>
228 </Box>
229 )}
230 {refs.length > 0 && (
231 <Box key="refs" flexDirection="row" flexWrap="wrap" columnGap={2}>
232 <Text dimColor>Mentions</Text>
233 {refs.map(n => pill(el, { id: `open:${n}`, kind: 'chip', label: `#${n} ${titleOf(n).slice(0, 40)}${titleOf(n).length > 40 ? '…' : ''}` }))}
234 </Box>
235 )}
236 {/* The issue itself on a raised panel, so it reads apart from the thread below. */}
237 <Box
238 key="body"
239 flexDirection="column"
240 borderStyle="round"
241 borderColor={data.border}
242 backgroundColor={data.raised}
243 paddingX={2}
244 paddingY={1}
245 >
246 <Markdown key="body-md" text={linkifyRefs(issue.body.trim() || '_No description._', base)} />
247 </Box>
248 <Box key="c-head" flexDirection="row" marginTop={1}>
249 <Text bold>{issue.comments === 1 ? '1 comment' : `${issue.comments} comments`}</Text>
250 </Box>
251 {comments?.status === 'loading' && <Text dimColor>Loading comments…</Text>}
252 {comments?.message && <Text color="error">{comments.message}</Text>}
253 {(comments?.comments ?? []).map((c, i) => (
254 <Box key={`cm${i}`} flexDirection="column" borderStyle="round" borderColor={data.border} paddingX={1}>
255 <Box flexDirection="row">
256 <Text bold>{c.author}</Text>
257 <Text dimColor> · {ago(c.createdAt, now)} ago</Text>
258 </Box>
259 <Markdown key={`cb${i}`} text={linkifyRefs(c.body, base)} />
260 </Box>
261 ))}
262 {composer(el, data, issue, ui, hasEditor)}
263 </Box>
264 )
265}
266
267/** The comment field and the write actions: comment, then close or reopen behind a Confirm. */
268function composer(el: El, data: Data, issue: Issue, ui: Ui, hasEditor: boolean) {
269 const { Box, Text, Input } = el
270 const hasDraft = ui.draft.trim().length > 0
271 const isOpen = issue.state === 'open'
272 const closeLabel = hasDraft ? 'Comment and close' : 'Close issue'
273 const b = data.border
274 return (
275 <Box key="composer" flexDirection="column" gap={1} marginTop={1}>
276 {editor(el, data, ui, 'comment', 'Leave a comment · Ctrl+Enter posts it', true, 3)}
277 {editorTools(el, data, ui, 'comment', hasEditor)}
278 {ui.armed ? (
279 <Box key="confirm" flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
280 <Text>
281 {ui.armed === 'close' ? `Close #${issue.number}${hasDraft ? ' with this comment' : ''}?` : `Reopen #${issue.number}${hasDraft ? ' with this comment' : ''}?`}
282 </Text>
283 {pill(el, { id: 'confirm', kind: 'button', label: ui.busy ? 'Working…' : 'Confirm', active: true, border: b })}
284 {pill(el, { id: 'cancel', kind: 'button', label: 'Cancel', border: b })}
285 </Box>
286 ) : (
287 <Box key="actions" flexDirection="row" gap={1} flexWrap="wrap">
288 {pill(el, { id: 'comment', kind: 'button', label: ui.busy ? 'Working…' : 'Comment', border: b })}
289 {isOpen
290 ? pill(el, { id: 'close', kind: 'button', label: closeLabel, border: b })
291 : pill(el, { id: 'reopen', kind: 'button', label: hasDraft ? 'Comment and reopen' : 'Reopen issue', border: b })}
292 </Box>
293 )}
294 {ui.notice && <Text color={ui.notice.tone === 'ok' ? 'success' : 'error'}>{ui.notice.text}</Text>}
295 </Box>
296 )
297}
298
299/** The milestone picker's options: none, then the open milestones (and the current one if closed). */
300function milestoneOptions(data: Data, current: string | null) {
301 const shown = data.milestones.filter(m => m.state === 'open' || m.title === current)
302 return [{ value: '', label: 'No milestone' }, ...shown.map(m => ({ value: m.title, label: m.title }))]
303}
304
305/** Every repo label as a toggle chip, scoped ones under their scope; each posts `<idPrefix><name>`. */
306function labelPicker(el: El, data: Data, selected: string[], idPrefix: string) {
307 const { Box, Text } = el
308 const scopes = new Map<string, typeof data.labels>()
309 for (const l of data.labels) {
310 const scope = scopeOf(l.name).scope ?? ''
311 scopes.set(scope, [...(scopes.get(scope) ?? []), l])
312 }
313 const order = [...scopes.keys()].sort((a, b) => (a === '' ? -1 : b === '' ? 1 : a.localeCompare(b)))
314 return (
315 <Box key="picker" flexDirection="column">
316 {order.map(scope => (
317 <Box key={`pk-${scope}`} flexDirection="row" flexWrap="wrap" columnGap={2}>
318 {scope && <Text dimColor>{scope}/</Text>}
319 {scopes
320 .get(scope)!
321 .sort((a, b) => a.name.localeCompare(b.name))
322 .map(l =>
323 pill(el, { id: `${idPrefix}${l.name}`, kind: 'chip', label: scopeOf(l.name).leaf, dot: l.color, active: selected.includes(l.name) }),
324 )}
325 </Box>
326 ))}
327 </Box>
328 )
329}
330
331/** The new-issue form: title, a one-line body, labels and a milestone. */
332export function newIssueView(el: El, data: Data, ui: Ui, draft: NewIssue, rules: TriageRules, hasEditor: boolean, act: NewIssueActions) {
333 const { Box, Text, Input, Select } = el
334 const need = [rules.requireMilestone ? 'a milestone' : '', rules.requireLabelFrom.length ? `one of ${rules.requireLabelFrom.join(', ')}` : '']
335 .filter(Boolean)
336 .join(' and ')
337 return (
338 <Box flexDirection="column" gap={1}>
339 <Box key="top" flexDirection="row" justifyContent="space-between" alignItems="center">
340 {pill(el, { id: 'new:back', kind: 'button', label: '← Issues', border: data.border })}
341 <Text bold>New issue{data.repo ? ` in ${data.repo.owner}/${data.repo.repo}` : ''}</Text>
342 </Box>
343 {editor(el, data, ui, 'new-title', 'Title · Enter files the issue', false, 1)}
344 {editor(el, data, ui, 'new-body', 'Description in Markdown · Ctrl+Enter files the issue', true, 6)}
345 {editorTools(el, data, ui, 'new-body', hasEditor)}
346 <Text dimColor>Labels</Text>
347 {labelPicker(el, data, draft.labels, 'new:toggle:')}
348 <Select key="new-milestone" options={milestoneOptions(data, null)} value={draft.milestone} onSelect={act.milestone} />
349 {need && <Text dimColor>This repo's issues need {need}.</Text>}
350 <Box key="actions" flexDirection="row" gap={1}>
351 {pill(el, { id: 'new:create', kind: 'button', label: ui.busy ? 'Filing…' : 'Create issue', active: true, border: data.border })}
352 {pill(el, { id: 'new:cancel', kind: 'button', label: 'Cancel', border: data.border })}
353 </Box>
354 {ui.notice && <Text color={ui.notice.tone === 'ok' ? 'success' : 'error'}>{ui.notice.text}</Text>}
355 </Box>
356 )
357}
358
359/** One editor Client: its text comes from the field behind `id`; see editor.tsx. */
360function editor(el: El, data: Data, ui: Ui, id: string, placeholder: string, multiline: boolean, minRows: number) {
361 const { Client } = el
362 const value = id === 'comment' ? ui.draft : id === 'new-title' ? (ui.creating?.title ?? '') : (ui.creating?.body ?? '')
363 const props: EditorProps = {
364 id,
365 value,
366 sync: ui.sync[id] ?? 0,
367 placeholder,
368 multiline,
369 preview: ui.preview[id] === true,
370 minRows,
371 border: data.border,
372 cmd: ui.fmt[id] ?? null,
373 }
374 return <Client key={`ed-${id}`} module="./editor.tsx" props={props} width="100%" />
375}
376
377const FORMATS: [Format, string][] = [
378 ['bold', 'Bold'],
379 ['italic', 'Italic'],
380 ['code', 'Code'],
381 ['link', 'Link'],
382 ['heading', 'Heading'],
383 ['list', 'List'],
384 ['quote', 'Quote'],
385]
386
387/** The toolbar under a multi-line editor: Markdown marks, Write/Preview, and the external editor. */
388function editorTools(el: El, data: Data, ui: Ui, id: string, hasEditor: boolean) {
389 const { Box } = el
390 const previewing = ui.preview[id] === true
391 const out = ui.external?.id === id
392 return (
393 <Box key={`tools-${id}`} flexDirection="row" flexWrap="wrap" columnGap={2} alignItems="center">
394 {!previewing && FORMATS.map(([f, label]) => pill(el, { id: `fmt:${id}:${f}`, kind: 'chip', label }))}
395 {pill(el, { id: `preview:${id}`, kind: 'chip', label: previewing ? 'Write' : 'Preview', active: previewing })}
396 {hasEditor && !out && pill(el, { id: `editor:${id}`, kind: 'chip', label: 'Open in editor' })}
397 {out && pill(el, { id: 'editor-pull', kind: 'chip', label: 'Use editor text', active: true })}
398 </Box>
399 )
400}
401hooks/card.tsx 125 lines1import type { ClientModule } from 'claude-code'
2
3import { packLabels, wrapTitle } from './cards'
4
5export type CardProps = {
6 number: number
7 title: string
8 /** Title characters the search matched, drawn highlighted. */
9 positions: number[]
10 labels: { name: string; color: string }[]
11 age: string
12 comments: number
13 closed: boolean
14 numberWidth: number
15 /** Border colour at rest. */
16 border: string
17 /** Characters per cell: above 1 where the surface draws a proportional font (the desktop). */
18 textScale: number
19}
20
21type Local = { hover: boolean }
22
23const AGE_COLS = 5
24// The card's border and one-cell padding, each side.
25const FRAME_COLS = 4
26
27function segments(text: string, start: number, hits: Set<number>): { t: string; hi: boolean }[] {
28 const out: { t: string; hi: boolean }[] = []
29 ;[...text].forEach((ch, i) => {
30 const hi = hits.has(start + i)
31 const last = out[out.length - 1]
32 if (last && last.hi === hi) last.t += ch
33 else out.push({ t: ch, hi })
34 })
35 return out
36}
37
38/**
39 * One issue as a card. Each card is its own Client, so a hover or a click
40 * belongs to the card under the pointer whatever the surface makes of
41 * borders and spacing (on the desktop a border is a thin line, not a row).
42 */
43const Card: ClientModule<CardProps, Local> = (props, surface) => {
44 const { Box, Text } = surface.elements
45 const state = surface.state ?? { hover: false }
46 // Listeners are set once, on the first draw: each set is a message to the
47 // page, and a list of a hundred cards re-setting them on every redraw gets
48 // its Clients unmounted for flooding it. A card's key is its issue number,
49 // so the number the listeners close over never changes.
50 if (surface.state === undefined) {
51 const number = props.number
52 surface.onPointer(e => {
53 const cur = surface.state ?? { hover: false }
54 if (e.type === 'leave') {
55 if (cur.hover) surface.setState({ hover: false })
56 } else if (!cur.hover) {
57 surface.setState({ hover: true })
58 }
59 if (e.type === 'down' && (e.button ?? 'left') === 'left') surface.post({ open: number })
60 })
61 surface.onKey(e => {
62 if (e.key === 'return') surface.post({ open: number })
63 })
64 surface.setState(state)
65 }
66
67 const inner = Math.max(20, (surface.columns || 80) - FRAME_COLS)
68 const numberCols = props.numberWidth + 2
69 const titleCols = Math.max(10, inner - numberCols - AGE_COLS)
70 const titleLines = wrapTitle(props.title, Math.floor(titleCols * props.textScale))
71 const labelLines = packLabels(props.labels, props.comments, Math.floor((inner - numberCols) * props.textScale))
72 const hits = new Set(props.positions)
73 const lit = state.hover
74
75 return (
76 <Box flexDirection="column" borderStyle="round" borderColor={lit ? 'suggestion' : props.border} paddingX={1}>
77 {titleLines.map((line, li) => (
78 <Box key={`t${li}`} flexDirection="row">
79 <Box width={numberCols} flexShrink={0}>
80 <Text dimColor>{li === 0 ? `#${props.number}` : ''}</Text>
81 </Box>
82 <Box width={titleCols} flexShrink={0} flexDirection="row" overflow="hidden">
83 {segments(line.text, line.start, hits).map((s, j) => (
84 <Text
85 key={`s${j}`}
86 bold={s.hi || lit}
87 color={s.hi ? 'warning' : undefined}
88 dimColor={props.closed && !s.hi}
89 strikethrough={props.closed}
90 wrap="truncate-end"
91 >
92 {s.t}
93 </Text>
94 ))}
95 </Box>
96 <Box width={AGE_COLS} flexShrink={0} justifyContent="flex-end">
97 <Text dimColor>{li === 0 ? props.age : ''}</Text>
98 </Box>
99 </Box>
100 ))}
101 {labelLines.map((line, li) => (
102 <Box key={`l${li}`} flexDirection="row" paddingLeft={numberCols} overflow="hidden">
103 {line.map(item =>
104 item.kind === 'label' ? (
105 <Box key={`lb${item.name}`} flexDirection="row" marginRight={2}>
106 <Text color={item.color}>● </Text>
107 {item.scope && <Text dimColor>{item.scope}</Text>}
108 <Text dimColor wrap="truncate-end">
109 {item.leaf}
110 </Text>
111 </Box>
112 ) : (
113 <Text key="cm" dimColor>
114 {item.text}
115 </Text>
116 ),
117 )}
118 </Box>
119 ))}
120 </Box>
121 )
122}
123
124export default Card
125hooks/pill.tsx 77 lines1import type { ClientModule } from 'claude-code'
2
3/**
4 * A pressable control drawn as a Client: a click posts `{ press: id }` to the
5 * hooks module. Used in place of Button because the desktop app drops Button
6 * presses for plugin panes (`ui_press not handled`), while Client messages
7 * arrive; each control is its own Client so a click needs no hit-testing.
8 */
9export type PillProps = {
10 id: string
11 label: string
12 /** `button`: a bordered control; `chip`: a coloured dot and text. */
13 kind: 'button' | 'chip'
14 active?: boolean
15 /** A label colour, drawn as the chip's dot. */
16 dot?: string
17 /** A count drawn dim after the label. */
18 count?: number
19 /** Border colour at rest (buttons). */
20 border?: string
21}
22
23type Local = { hover: boolean }
24
25const Pill: ClientModule<PillProps, Local> = (props, surface) => {
26 const { Box, Text } = surface.elements
27 const state = surface.state ?? { hover: false }
28 // Set once on the first draw (each set is a message to the page; see
29 // card.tsx). The id is the Client's key, so it never changes under them.
30 if (surface.state === undefined) {
31 const id = props.id
32 surface.onPointer(e => {
33 const cur = surface.state ?? { hover: false }
34 if (e.type === 'leave') {
35 if (cur.hover) surface.setState({ hover: false })
36 } else if (!cur.hover) {
37 surface.setState({ hover: true })
38 }
39 if (e.type === 'down' && (e.button ?? 'left') === 'left') surface.post({ press: id })
40 })
41 surface.onKey(e => {
42 if (e.key === 'return' || e.key === ' ') surface.post({ press: id })
43 })
44 surface.setState(state)
45 }
46
47 const lit = state.hover
48 const count = props.count === undefined ? null : <Text dimColor> {props.count}</Text>
49
50 if (props.kind === 'chip') {
51 return (
52 <Box flexDirection="row">
53 {props.dot && <Text color={props.dot}>● </Text>}
54 <Text bold={props.active} underline={lit} dimColor={!props.active && !lit}>
55 {props.active ? `✓ ${props.label}` : props.label}
56 </Text>
57 {count}
58 </Box>
59 )
60 }
61 return (
62 <Box
63 flexDirection="row"
64 borderStyle="round"
65 borderColor={props.active || lit ? 'suggestion' : (props.border ?? 'subtle')}
66 paddingX={1}
67 >
68 <Text bold={props.active} color={props.active ? 'suggestion' : undefined}>
69 {props.label}
70 </Text>
71 {count}
72 </Box>
73 )
74}
75
76export default Pill
77hooks/editor.tsx 106 lines1import type { ClientModule } from 'claude-code'
2
3import { applyFormat, applyKey, caretPosition } from './editing'
4import type { Format } from '../types'
5
6/**
7 * A full-width text editor drawn as a Client: the desktop's Input is one
8 * line at a fixed width. It keeps its text locally and posts `{ edit, text }`
9 * (batched every FLUSH_MS) and `{ submit }` on Ctrl+Enter (Enter in a
10 * one-line editor). The desktop passes no paste to a Client: long or pasted
11 * text goes through the external editor instead.
12 */
13export type EditorProps = {
14 id: string
15 value: string
16 /** Bumped by the hooks module when it replaces the text (cleared, imported); the editor then adopts `value`. */
17 sync: number
18 placeholder: string
19 multiline: boolean
20 preview: boolean
21 minRows: number
22 border: string
23 /** A Markdown format to apply at the caret, run once per `seq`. */
24 cmd: { seq: number; format: Format } | null
25}
26
27type Local = { text: string; caret: number; sync: number; cmdSeq: number; dirty: boolean }
28
29const FLUSH_MS = 150
30
31const Editor: ClientModule<EditorProps, Local> = (props, surface) => {
32 const { Box, Text, Markdown } = surface.elements
33 let st: Local = surface.state ?? { text: props.value, caret: props.value.length, sync: props.sync, cmdSeq: props.cmd?.seq ?? 0, dirty: false }
34
35 if (surface.state === undefined) {
36 // Set once (each set is a message to the page). The id and multiline-ness
37 // belong to the editor's key, so the closures may keep them.
38 const id = props.id
39 const multiline = props.multiline
40 const flush = () => {
41 const cur = surface.state
42 if (!cur?.dirty) return
43 surface.setState({ ...cur, dirty: false })
44 surface.post({ edit: id, text: cur.text })
45 }
46 surface.every(FLUSH_MS, flush)
47 surface.onKey(e => {
48 const cur = surface.state ?? st
49 const submits = e.key === 'return' && (Boolean(e.ctrl || e.meta) || !multiline)
50 if (submits) {
51 flush()
52 surface.post({ submit: id })
53 return
54 }
55 const next = applyKey(cur, e.key, { ctrl: e.ctrl, meta: e.meta }, multiline)
56 if (next && (next.text !== cur.text || next.caret !== cur.caret)) {
57 surface.setState({ ...cur, ...next, dirty: cur.dirty || next.text !== cur.text })
58 }
59 })
60 surface.setState(st)
61 }
62
63 // The hooks module replaced the text (a clear after posting, an import from the external editor).
64 if (props.sync !== st.sync) {
65 st = { ...st, text: props.value, caret: props.value.length, sync: props.sync, dirty: false }
66 surface.setState(st)
67 }
68 // A toolbar press: apply the format at the caret, once.
69 if (props.cmd && props.cmd.seq !== st.cmdSeq) {
70 const next = applyFormat(st, props.cmd.format)
71 st = { ...st, ...next, cmdSeq: props.cmd.seq, dirty: true }
72 surface.setState(st)
73 }
74
75 const frame = (children: unknown) => (
76 <Box flexDirection="column" borderStyle="round" borderColor={props.border} paddingX={1} minHeight={props.minRows + 2}>
77 {children as never}
78 </Box>
79 )
80
81 if (props.preview) {
82 return frame(st.text.trim() ? <Markdown text={st.text} /> : <Text dimColor>Nothing to preview yet.</Text>)
83 }
84
85 const lines = st.text.split('\n')
86 const at = caretPosition(st)
87 return frame(
88 lines.map((line, i) => {
89 if (i !== at.line) return <Text key={`l${i}`}>{line || ' '}</Text>
90 const before = line.slice(0, at.col)
91 const under = line.slice(at.col, at.col + 1)
92 const after = line.slice(at.col + 1)
93 return (
94 <Box key={`l${i}`} flexDirection="row" flexWrap="wrap">
95 {before && <Text>{before}</Text>}
96 <Text inverse>{under || ' '}</Text>
97 {after && <Text>{after}</Text>}
98 {!st.text && <Text dimColor> {props.placeholder}</Text>}
99 </Box>
100 )
101 }),
102 )
103}
104
105export default Editor
106hooks/cards.ts 54 lines1export type LabelItem =
2 | { kind: 'label'; name: string; color: string; scope: string | null; leaf: string }
3 | { kind: 'comments'; text: string }
4
5const MAX_TITLE_LINES = 3
6
7/** Word-wraps `text` to lines of at most `width` characters, the last one cut with an ellipsis. */
8export function wrapTitle(text: string, width: number, maxLines = MAX_TITLE_LINES): { text: string; start: number }[] {
9 const lines: { text: string; start: number }[] = []
10 let start = 0
11 while (start < text.length && lines.length < maxLines) {
12 while (text[start] === ' ') start++
13 const rest = text.length - start
14 if (rest <= width) {
15 lines.push({ text: text.slice(start), start })
16 return lines
17 }
18 if (lines.length === maxLines - 1) {
19 lines.push({ text: `${text.slice(start, start + width - 1).trimEnd()}…`, start })
20 return lines
21 }
22 const space = text.lastIndexOf(' ', start + width)
23 const end = space > start ? space : start + width
24 lines.push({ text: text.slice(start, end), start })
25 start = end
26 }
27 return lines
28}
29
30/** Packs labels, then the comment count, into lines of at most `width` characters. */
31export function packLabels(labels: { name: string; color: string }[], comments: number, width: number): LabelItem[][] {
32 const items: LabelItem[] = labels.map(l => {
33 const at = l.name.lastIndexOf('/')
34 return at > 0
35 ? { kind: 'label', name: l.name, color: l.color, scope: l.name.slice(0, at + 1), leaf: l.name.slice(at + 1) }
36 : { kind: 'label', name: l.name, color: l.color, scope: null, leaf: l.name }
37 })
38 if (comments > 0) items.push({ kind: 'comments', text: comments === 1 ? '1 comment' : `${comments} comments` })
39 const lines: LabelItem[][] = []
40 let used = 0
41 for (const item of items) {
42 // "● name" plus the two-cell gap after it.
43 const w = item.kind === 'label' ? item.name.length + 4 : item.text.length
44 if (lines.length === 0 || used + w > width) {
45 lines.push([item])
46 used = w
47 } else {
48 lines[lines.length - 1]!.push(item)
49 used += w
50 }
51 }
52 return lines
53}
54types/index.d.ts 95 lines1/** A Markdown mark the editor toolbar inserts at the caret. */
2export type Format = 'bold' | 'italic' | 'code' | 'link' | 'heading' | 'list' | 'quote'
3
4export type IssueLabel = { name: string; color: string }
5
6/** A repo label; `scope/leaf` names are Forgejo's scoped labels. */
7export type Label = IssueLabel & { id: number; exclusive: boolean; description: string }
8
9export type Milestone = { id: number; title: string; state: 'open' | 'closed'; open: number; closed: number }
10
11export type Issue = {
12 number: number
13 title: string
14 body: string
15 state: 'open' | 'closed'
16 comments: number
17 updatedAt: string
18 url: string
19 author: string
20 labels: IssueLabel[]
21 milestone: string | null
22}
23
24export type Comment = { author: string; body: string; createdAt: string }
25
26export type RepoInfo = { host: string; owner: string; repo: string; webBase: string }
27
28/** Everything fetched for the repo, and how the fetch went. */
29export type Data = {
30 status: 'idle' | 'loading' | 'ready' | 'error'
31 repo: RepoInfo | null
32 issues: Issue[]
33 labels: Label[]
34 milestones: Milestone[]
35 /** Why the list is empty or failed, worded for the person. */
36 message: string | null
37 tokenSource: 'secrets' | 'fgj' | null
38 fetchedAt: number
39 /** The border colour for cards and controls at rest, from the theme setting. */
40 border: string
41 /** A background a shade off the pane's, for the issue body. */
42 raised: string
43}
44
45export type StateFilter = 'open' | 'closed' | 'all'
46
47/** What the person has chosen to see. */
48export type Ui = {
49 query: string
50 /** Label names toggled on as chips; an issue must carry every one. */
51 chips: string[]
52 /** A milestone title, `''` for any, `'-'` for none. */
53 milestone: string
54 state: StateFilter
55 grouped: boolean
56 /** The issue shown in the detail view, or null for the list. */
57 open: number | null
58 /** The comment being written on the open issue. */
59 draft: string
60 /** A write waiting for its Confirm press (`close`, `reopen`), or null. */
61 armed: string | null
62 /** True while a write is in flight, so a second press waits. */
63 busy: boolean
64 /** The outcome of the last write, shown in the detail view. */
65 notice: { tone: 'ok' | 'error'; text: string } | null
66 /** The open issue's labels while they are being edited, or null. */
67 editingLabels: string[] | null
68 /** The new-issue form while it is open, or null. */
69 creating: NewIssue | null
70 /** Per editor id: bumped when the text is replaced from here, so the editor adopts it. */
71 sync: Record<string, number>
72 /** Per editor id: the last toolbar format, run once per seq. */
73 fmt: Record<string, { seq: number; format: Format }>
74 /** Per editor id: showing the rendered Markdown instead of the text. */
75 preview: Record<string, boolean>
76 /** The editor whose text is out in the external editor, and its file. */
77 external: { id: string; file: string } | null
78}
79
80/** A new issue being written: title, a one-line body, labels and a milestone title. */
81export type NewIssue = { title: string; body: string; labels: string[]; milestone: string }
82
83export type Detail = {
84 number: number
85 status: 'loading' | 'ready' | 'error'
86 comments: Comment[]
87 message: string | null
88}
89
90declare module 'claude-code' {
91 interface PluginState {
92 'forgejo-issues': { data: Data; ui: Ui; detail: Detail | null }
93 }
94}
95