SLOPSHOPPER

forgejo-issues

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

newpaneguardcommandprocessnetwork
★ 1v0.2.0MITupdated 2026-10-10enhki/claude-mods/mods/forgejo-issues
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · forgejo-issues
│ ┃ Issues ✕ › fix the failing auth test and add an audit log call │ ┃ Issues▣ client module ./pill.tsx ▣ client mo │ ┃ ⏺ Read(src/auth.ts) │ ┃ Search · label: · milestone: · is: ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Loading issues… ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /issues │ ⎿ forgejo-issues: Issues pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Issues
Issues▣ client module ./pill.tsx ▣ client module ./pill.tsx Search · label: · milestone: · is: Loading issues…
README

claude-mods

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.

Install

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.

Mods

ModWhat it does
statusline-desktopDraws your terminal status line above the prompt in the desktop app
forgejo-issuesBrowse, search and manage the current repo's Forgejo or Gitea issues in a pane
work-sessionKicks off and wraps up working sessions with your own start and end commands, and titles each session
walkthroughA checklist pane for testing by hand: mark each step, add notes, send the results back in one message

statusline-desktop

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.

  • ANSI colours keep the terminal's palette when ghostty's config (and the files it pulls in with config-file) sets one; otherwise they map to the app's own theme colours.
  • Refreshes on session start, after each tool call and turn, and every 30 s.
  • Stays out of the terminal, which already draws the real status line.
  • Only the input side of the context window is known to mods, so a token count your script sums from current_usage reads slightly lower than in the terminal (output tokens come through as 0).

forgejo-issues

/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.

  • Browse: every issue as a card, grouped by milestone; Open, Closed, All; label chips with counts (scoped labels like kind/bug sit under their scope, exclusive scopes pick one); a milestone picker.
  • Search: fzf-style fuzzy matching with highlights, plus label:bug, -label:wontfix, label:kind/, milestone:"Some name", is:closed. Enter opens the top match.
  • Read: the body on a raised panel, comments as cards, #N mentions as chips that open in the pane.
  • Write: comment, close and reopen (behind a Confirm), edit labels, set the milestone, file a new issue. Text goes in a full-width Markdown editor with a toolbar and a preview.

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):

SettingDefaultWhat it does
apiUrlemptyThe API base when it isn't https://<remote host>/api/v1
requireCommentOnCloseonClosing needs a comment, posted before the close
requireMilestoneoffNew issues need a milestone
requireLabelFromemptyNew issues need one of these comma-separated labels
editorCommandemptyAdds 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.

work-session

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.

  • Kickoff: when a session starts fresh (launch or /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.
  • Title: Claude gets a 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.
  • Wrap: once the context window passes Offer the wrap at (75% by default) or a rate limit passes 90%, the band offers the end command, with Later to dismiss it for the session.

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):

SettingDefaultWhat it does
startCommandcwsThe command that kicks a session off, without the slash
endCommandewsThe command that wraps one up
kickoffbandband offers it, auto runs it at start, off never
wrapAtPercent75Context fullness that brings up the wrap offer; 0 turns it off

walkthrough

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.

  • Steps: each one says what to run, where (directory, terminal, window size) and what you should see. Claude's publish_walkthrough tool refuses a step that doesn't say what to expect. Publishing again replaces the list.
  • The pane opens when Claude publishes, or with /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.
  • Keys (terminal, once the pane has the keyboard): p pass, f fail, s skip, c copy, r send.
  • Send results submits one message: a tally, then each step as PASS, FAIL, SKIP or NOT DONE with your note beside it.

Screenshots don't go through the pane: paste them in the prompt as usual. No settings.

Writing a mod

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.

Where it lives

KindWhereLoads
General, shareablehere, mods/<name>/ + an entry in .claude-plugin/marketplace.jsoneverywhere, once installed at user scope
Tied to one project, or naming private hoststhat 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.

Workflow

  1. Sketch in the session's dev folder. The plugin-authoring skill gives a mods folder that hot-reloads when each turn ends: the quickest loop.
  2. Move it here as mods/<name>/, list it in the marketplace file, and install it from this clone (below). From then on, edits are live after /reload-plugins.
  3. Test it on the surface you'll use. The terminal and the desktop app draw the same tree differently (see the notes below); try both when it matters.
  4. Before each commit: 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.

Conventions shared by these mods

  • Pure logic in its own files, with tests; the hooks module stays thin. claude plugin test runs *.test.ts(x) against the engine itself; a test stands in for engine calls with on('<event>', () => ({ value: … })).
  • Settings are the manifest's userConfig (shown in the plugin's options); nothing personal is hard-coded.
  • Secrets go in ~/.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).
  • Saved state ($.state) outlives reloads and upgrades: read and update it through helpers that fill in defaults, or a field a new version adds arrives undefined.

Desktop app notes (Claude Code 2.1.29x)

  • Button and Markdown-link presses from a plugin pane don't arrive (the app logs 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).
  • Set a 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.
  • Don't map pointer rows by arithmetic. Borders are thin lines, not rows, and text is proportional (about 1.2 characters per cell). One Client per clickable item makes hit-testing unnecessary.
  • No paste into a 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).
  • A "Nothing to show yet" pane means the drawing threw; a transcript line <mod>: <event> hook skipped: threw … names the handler that did.

Developing

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>
Source 12 files
hooks/register.tsx 597 lines
1import { 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}
597
hooks/forge.ts 195 lines
1import 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}
195
hooks/guard.ts 68 lines
1/**
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
68
hooks/triage.ts 51 lines
1import 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}
51
hooks/editing.ts 125 lines
1import 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}
125
hooks/search.ts 152 lines
1import 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}
152
hooks/views.tsx 401 lines
1import 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}
401
hooks/card.tsx 125 lines
1import 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
125
hooks/pill.tsx 77 lines
1import 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
77
hooks/editor.tsx 106 lines
1import 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
106
hooks/cards.ts 54 lines
1export 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}
54
types/index.d.ts 95 lines
1/** 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