SLOPSHOPPER

wt-panel

A /wt pane listing the repository's git worktrees: status, changes, copy path, open in VS Code, guarded delete

newpanecommandtoastprocesstimer
★ 1v0.1.0no licenseupdated 2026-10-08Jenwein/claude-mods/wt-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wt-panel
│ ┃ Worktrees ✕ › fix the failing auth test and add an audit log call │ ┃ 当前目录不是 git 仓库 │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ 刷新 ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /wt │ ⎿ wt-panel: 已打开 worktree 面板。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Worktrees
当前目录不是 git 仓库 [ 刷新 ]
README

claude-mods

自己写的 Claude Code Mod(函数钩子插件),整个仓库是一个插件市场。终端和桌面 App 的 Code 标签页都能用。

Mod作用
git-band在输入框上方显示当前 git 分支、未提交文件数和 ↑↓ 提交数,每 3 秒刷新
wt-panel/wt 打开当前仓库的 worktree 总览面板:状态、改动、复制路径、VS Code 打开、带风险提醒的删除
feishu-notify长任务结束或出错、权限弹窗或提问等你超过一定时间时,用 lark-cli 的机器人给你发飞书私信;/feishu-test 发测试消息。需要先 lark-cli auth login

安装

在 Claude Code 的终端会话里,每个 Mod 一行(<mod> 换成上表里的名字),第一次会问是否添加这个插件市场,回答 y,再选安装范围:

/plugin install <mod> --marketplace Jenwein/claude-mods

或者在命令行里:

claude plugin install <mod> --marketplace Jenwein/claude-mods

按用户范围安装后,之后的每个会话都会自动加载。

修改

想边改边用,就把本地克隆加成插件市场再安装,插件会直接从这个文件夹读取:

claude plugin marketplace add <克隆所在的路径>
claude plugin install <mod>@claude-mods

改完文件后,在会话里运行 /reload-plugins 就会生效,不用重装。

检查和测试:

claude plugin validate <mod>
claude plugin test <mod>
Source 3 files
hooks/register.tsx 396 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderSurface, Register } from 'claude-code'
3
4import type { DeleteMode, Worktree } from '../types'
5import type { Entry } from './model'
6import {
7  deleteWarnings,
8  displayName,
9  findCurrent,
10  isAppManaged,
11  nativePath,
12  parseStatus,
13  parseWorktreeList,
14  relativeTime,
15} from './model'
16
17const PANE = 'wt-panel'
18const TITLE = 'Worktrees'
19// Scans every worktree, so slower than the git band's poll.
20const POLL_MS = 30_000
21// Lines of a row's changes shown before the rest is cut.
22const MAX_LINES = 40
23
24const snapshot = atom({ plugin: 'wt-panel', key: 'snapshot' } as const, null)
25const expanded = atom({ plugin: 'wt-panel', key: 'expanded' } as const, null)
26const deleting = atom({ plugin: 'wt-panel', key: 'deleting' } as const, null)
27
28let isScanning = false
29
30const count = (stdout: string) => Number(stdout.trim()) || 0
31
32const firstLine = (text: string) => text.trim().split(/\r?\n/)[0] ?? ''
33
34async function git($: EngineInterface, dir: string, args: string[], timeoutMs = 30_000) {
35  return $.process.run(['git', '-C', dir, ...args], { timeoutMs })
36}
37
38// The main worktree's branch, as its upstream when it has one: a PR merged on
39// the remote shows there before the local branch is pulled.
40async function baseRef($: EngineInterface, root: string, branch: string) {
41  const upstream = await git($, root, ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`])
42  const name = upstream.stdout.trim()
43
44  return upstream.exitCode === 0 && name !== '' ? name : branch
45}
46
47async function inspect(
48  $: EngineInterface,
49  entry: Entry,
50  isMain: boolean,
51  root: string,
52  base: string | null,
53  current: string | null,
54): Promise<Worktree> {
55  const row: Worktree = {
56    path: entry.path,
57    name: displayName(entry.path, root),
58    branch: entry.branch,
59    head: entry.head,
60    isMain,
61    isCurrent: entry.path === current,
62    isAppManaged: isAppManaged(entry.path, root),
63    isLocked: entry.isLocked,
64    isMissing: entry.isMissing,
65    changed: 0,
66    unpushed: 0,
67    orphaned: 0,
68    ahead: 0,
69    behind: 0,
70    isMerged: false,
71    isGone: false,
72    lastCommitAt: 0,
73  }
74
75  if (entry.isMissing) {
76    return row
77  }
78
79  const isCompared = base !== null && !isMain
80  const [status, compared, last] = await Promise.all([
81    git($, entry.path, ['--no-optional-locks', 'status', '--porcelain=v2', '--branch']),
82    isCompared ? git($, entry.path, ['rev-list', '--left-right', '--count', `${base}...HEAD`]) : null,
83    git($, entry.path, ['log', '-1', '--format=%ct']),
84  ])
85  const parsed = parseStatus(status.stdout)
86
87  row.changed = parsed.changed
88  row.isGone = parsed.hasUpstream && parsed.ab === null
89  row.lastCommitAt = count(last.stdout)
90
91  if (compared !== null && compared.exitCode === 0) {
92    const [behind = '0', ahead = '0'] = compared.stdout.trim().split(/\s+/)
93    row.behind = Number(behind) || 0
94    row.ahead = Number(ahead) || 0
95    row.isMerged = entry.branch !== null && row.ahead === 0
96  }
97
98  if (entry.branch === null) {
99    row.orphaned = count((await git($, entry.path, ['rev-list', '--count', 'HEAD', '--not', '--branches', '--remotes'])).stdout)
100  } else if (parsed.ab !== null) {
101    row.unpushed = parsed.ab.ahead
102  } else {
103    row.unpushed = count((await git($, entry.path, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])).stdout)
104  }
105
106  return row
107}
108
109async function scan($: EngineInterface) {
110  if (isScanning) {
111    return
112  }
113
114  isScanning = true
115
116  try {
117    const cwd = await $.session.cwd()
118    const listed = await git($, cwd, ['worktree', 'list', '--porcelain'])
119    const scannedAt = await $.clock.now()
120    const entries = listed.exitCode === 0 ? parseWorktreeList(listed.stdout) : []
121    const main = entries[0]
122
123    if (main === undefined) {
124      await update($, snapshot, () => ({ root: cwd, base: null, worktrees: [], scannedAt, error: '当前目录不是 git 仓库' }))
125
126      return
127    }
128
129    const base = main.branch === null ? null : await baseRef($, main.path, main.branch)
130    const current = findCurrent(
131      entries.map(entry => entry.path),
132      cwd,
133    )
134    const worktrees = await Promise.all(entries.map((entry, i) => inspect($, entry, i === 0, main.path, base, current)))
135
136    await update($, snapshot, () => ({ root: main.path, base, worktrees, scannedAt, error: null }))
137  } finally {
138    isScanning = false
139  }
140}
141
142async function tick($: EngineInterface) {
143  const panes = await $.ui.panes()
144
145  if (panes.some(pane => pane.id === PANE)) {
146    await scan($)
147  }
148}
149
150async function remove($: EngineInterface, row: Worktree, mode: DeleteMode, root: string) {
151  const force = row.isLocked ? ['--force', '--force'] : ['--force']
152  const removed = await git($, root, ['worktree', 'remove', ...force, row.path], 120_000)
153
154  if (removed.exitCode !== 0) {
155    $.ui.toast(`删除失败:${firstLine(removed.stderr)}`)
156    await update($, deleting, () => null)
157
158    return
159  }
160
161  if (mode === 'both' && row.branch !== null) {
162    const dropped = await git($, root, ['branch', '-D', row.branch])
163    $.ui.toast(
164      dropped.exitCode === 0
165        ? `已删除 ${row.name} 和分支 ${row.branch}`
166        : `目录已删除,分支 ${row.branch} 删除失败:${firstLine(dropped.stderr)}`,
167    )
168  } else {
169    $.ui.toast(`已删除 ${row.name}`)
170  }
171
172  await update($, deleting, () => null)
173  await update($, expanded, shown => (shown?.path === row.path ? null : shown))
174  await scan($)
175}
176
177async function copyPath($: EngineInterface, row: Worktree, surface: RenderSurface) {
178  const text = nativePath(row.path)
179  const copied = await $.ui.copy({ text, surface })
180
181  if (!copied.isCopied) {
182    const clipped = /^[A-Za-z]:\\/.test(text) ? await $.process.run(['cmd', '/c', 'clip'], { stdin: text }) : null
183
184    if (clipped === null || clipped.exitCode !== 0) {
185      $.ui.toast(`复制失败,路径是 ${text}`)
186
187      return
188    }
189  }
190
191  $.ui.toast(`已复制 ${text}`)
192}
193
194async function openInCode($: EngineInterface, row: Worktree) {
195  const path = nativePath(row.path)
196  // `code` is a .cmd script on Windows, which only cmd runs.
197  const argv = /^[A-Za-z]:\\/.test(path) ? ['cmd', '/c', 'code', path] : ['code', path]
198  const opened = await $.process.run(argv)
199
200  if (opened.exitCode !== 0) {
201    $.ui.toast(`打开 VS Code 失败:${firstLine(opened.stderr) || '找不到 code 命令'}`)
202  }
203}
204
205async function toggleChanges($: EngineInterface, row: Worktree, base: string | null) {
206  const shown = await read($, expanded)
207
208  if (shown?.path === row.path) {
209    await update($, expanded, () => null)
210
211    return
212  }
213
214  const [commits, uncommitted] = await Promise.all([
215    base !== null && !row.isMain ? git($, row.path, ['diff', '--stat', `${base}...HEAD`]) : null,
216    git($, row.path, ['status', '--short']),
217  ])
218  const sections = [
219    ...(commits === null ? [] : [`相对 ${base} 的提交:\n${commits.stdout.trimEnd() || '(无)'}`]),
220    `未提交:\n${uncommitted.stdout.trimEnd() || '(无)'}`,
221  ]
222  const all = sections.join('\n\n').split('\n')
223  const text = all.length > MAX_LINES ? [...all.slice(0, MAX_LINES), `…还有 ${all.length - MAX_LINES} 行`].join('\n') : all.join('\n')
224
225  await update($, expanded, () => ({ path: row.path, text }))
226}
227
228const summary = (row: Worktree, base: string | null, now: number) => {
229  const arrows = [row.ahead > 0 ? `↑${row.ahead}` : '', row.behind > 0 ? `↓${row.behind}` : ''].filter(Boolean).join(' ')
230
231  return [
232    row.changed > 0 ? `未提交 ${row.changed}` : '',
233    row.branch !== null && row.unpushed > 0 ? `未push ${row.unpushed}` : '',
234    row.branch === null && row.orphaned > 0 ? `游离提交 ${row.orphaned}` : '',
235    base !== null && !row.isMain ? (arrows === '' ? `与 ${base} 相同` : `相对 ${base} ${arrows}`) : '',
236    relativeTime(row.lastCommitAt, now),
237  ]
238    .filter(Boolean)
239    .join(' · ')
240}
241
242const tags = (row: Worktree) =>
243  [
244    row.isMain && { text: '主目录', color: 'subtle' },
245    row.isCurrent && { text: '当前会话', color: 'success' },
246    row.isAppManaged && { text: 'App 管理', color: 'suggestion' },
247    row.isLocked && { text: '已锁定', color: 'warning' },
248    row.isMerged && { text: '已合并', color: 'warning' },
249    row.isGone && { text: '远端已删', color: 'warning' },
250  ].filter((tag): tag is { text: string; color: string } => tag !== false)
251
252export const register: Register = on => {
253  on('session.start', async ($, e, next) => {
254    const started = await next(e)
255    await $.command.register({ name: 'wt', description: '打开 worktree 总览面板' })
256    $.clock.every(POLL_MS, () => void tick($))
257
258    return started
259  })
260
261  on('command.run', { command: 'wt' }, async $ => {
262    await $.ui.open({ id: PANE, title: TITLE })
263    await scan($)
264
265    return { text: '已打开 worktree 面板。' }
266  })
267
268  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
269    const { Box, Button, Code, Text } = $.ui.resolve(e)
270    const shot = await read($, snapshot)
271    const shown = await read($, expanded)
272    const pending = await read($, deleting)
273
274    if (shot === null) {
275      return <Text dimColor>正在扫描…</Text>
276    }
277
278    const refresh = <Button key="refresh" label="刷新" onPress={() => void scan($)} />
279
280    if (shot.error !== null) {
281      return (
282        <Box flexDirection="column" gap={1}>
283          <Text color="error">{shot.error}</Text>
284          {refresh}
285        </Box>
286      )
287    }
288
289    const header = [displayName(shot.root, shot.root), shot.base === null ? '' : `基准 ${shot.base}`, `${shot.worktrees.length} 个 worktree`]
290      .filter(Boolean)
291      .join(' · ')
292
293    const deleteArea = (row: Worktree) => {
294      if (pending === null || pending.path !== row.path) {
295        return null
296      }
297
298      const cancel = <Button key={`cancel:${row.path}`} label="取消" onPress={() => void update($, deleting, () => null)} />
299
300      if (pending.step === 'choose') {
301        return (
302          <Box gap={1}>
303            <Text>{`删除 ${row.name}:`}</Text>
304            <Button
305              key={`dir:${row.path}`}
306              label="只删目录"
307              onPress={() => void update($, deleting, () => ({ path: row.path, step: 'confirm' as const, mode: 'dir' as const }))}
308            />
309            {row.branch === null ? null : (
310              <Button
311                key={`both:${row.path}`}
312                label="目录 + 分支"
313                onPress={() => void update($, deleting, () => ({ path: row.path, step: 'confirm' as const, mode: 'both' as const }))}
314              />
315            )}
316            {cancel}
317          </Box>
318        )
319      }
320
321      const { mode } = pending
322      const warnings = deleteWarnings(row, mode, shot.base)
323
324      return (
325        <Box flexDirection="column">
326          <Text bold>{mode === 'both' ? `将删除目录 ${row.name} 和分支 ${row.branch}` : `将删除目录 ${row.name}`}</Text>
327          {warnings.map(warning => (
328            <Text color="warning">{`⚠ ${warning}`}</Text>
329          ))}
330          <Box gap={1}>
331            <Button
332              key={`confirm:${row.path}`}
333              label={warnings.length > 0 ? '仍然删除' : '确认删除'}
334              variant="primary"
335              onPress={() => void remove($, row, mode, shot.root)}
336            />
337            {cancel}
338          </Box>
339        </Box>
340      )
341    }
342
343    const rowOf = (row: Worktree) => {
344      const status = summary(row, shot.base, shot.scannedAt)
345      const isProtected = row.isMain || row.isCurrent
346
347      return (
348        <Box key={`row:${row.path}`} flexDirection="column">
349          <Box gap={1}>
350            <Text bold>{row.name}</Text>
351            {row.branch === null ? <Text dimColor>{`@${row.head}`}</Text> : <Text color="claude">{row.branch}</Text>}
352            {tags(row).map(tag => (
353              <Text color={tag.color}>{tag.text}</Text>
354            ))}
355          </Box>
356          {row.isMissing ? (
357            <Text color="error">目录已不存在,可运行 git worktree prune 清理</Text>
358          ) : (
359            <Box flexDirection="column">
360              {status === '' ? null : <Text dimColor>{status}</Text>}
361              <Box gap={1}>
362                <Button key={`copy:${row.path}`} label="复制路径" onPress={press => void copyPath($, row, press.surface)} />
363                <Button key={`code:${row.path}`} label="VS Code" onPress={() => void openInCode($, row)} />
364                <Button
365                  key={`changes:${row.path}`}
366                  label={shown?.path === row.path ? '收起' : '改动'}
367                  onPress={() => void toggleChanges($, row, shot.base)}
368                />
369                {isProtected ? null : (
370                  <Button
371                    key={`delete:${row.path}`}
372                    label="删除"
373                    onPress={() => void update($, deleting, () => ({ path: row.path, step: 'choose' as const }))}
374                  />
375                )}
376              </Box>
377              {shown?.path === row.path ? <Code source={shown.text} /> : null}
378              {deleteArea(row)}
379            </Box>
380          )}
381        </Box>
382      )
383    }
384
385    return (
386      <Box flexDirection="column" gap={1}>
387        <Box gap={1}>
388          <Text bold>{header}</Text>
389          {refresh}
390        </Box>
391        {shot.worktrees.map(rowOf)}
392      </Box>
393    )
394  })
395}
396
hooks/model.ts 156 lines
1import type { DeleteMode, Worktree } from '../types'
2
3export type Entry = {
4  path: string
5  head: string
6  branch: string | null
7  isLocked: boolean
8  isMissing: boolean
9}
10
11export type Status = {
12  branch: string | null
13  hasUpstream: boolean
14  /** Null with no upstream, or one deleted on the remote. */
15  ab: { ahead: number; behind: number } | null
16  changed: number
17}
18
19const lines = (stdout: string) => stdout.split(/\r?\n/)
20
21// Reads `git worktree list --porcelain`: blank-line separated records, the
22// main worktree first.
23export const parseWorktreeList = (stdout: string): Entry[] => {
24  const entries: Entry[] = []
25  let entry: Entry | null = null
26
27  for (const line of lines(stdout)) {
28    if (line.startsWith('worktree ')) {
29      entry = { path: line.slice('worktree '.length), head: '', branch: null, isLocked: false, isMissing: false }
30      entries.push(entry)
31    } else if (entry === null) {
32      continue
33    } else if (line.startsWith('HEAD ')) {
34      entry.head = line.slice('HEAD '.length, 'HEAD '.length + 7)
35    } else if (line.startsWith('branch ')) {
36      entry.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
37    } else if (line === 'locked' || line.startsWith('locked ')) {
38      entry.isLocked = true
39    } else if (line === 'prunable' || line.startsWith('prunable ')) {
40      entry.isMissing = true
41    }
42  }
43
44  return entries
45}
46
47// Reads `git status --porcelain=v2 --branch`. Git prints `# branch.ab` only
48// for an upstream that still exists, so an upstream line without it is one
49// deleted on the remote.
50export const parseStatus = (stdout: string): Status => {
51  const status: Status = { branch: null, hasUpstream: false, ab: null, changed: 0 }
52
53  for (const line of lines(stdout)) {
54    if (line.startsWith('# branch.head ')) {
55      const head = line.slice('# branch.head '.length)
56      status.branch = head === '(detached)' ? null : head
57    } else if (line.startsWith('# branch.upstream ')) {
58      status.hasUpstream = true
59    } else if (line.startsWith('# branch.ab ')) {
60      const [plus = '+0', minus = '-0'] = line.slice('# branch.ab '.length).split(' ')
61      status.ab = { ahead: Number(plus.slice(1)) || 0, behind: Number(minus.slice(1)) || 0 }
62    } else if (line !== '' && !line.startsWith('#')) {
63      status.changed += 1
64    }
65  }
66
67  return status
68}
69
70const comparable = (path: string) => path.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase()
71
72const isInside = (parent: string, child: string) => {
73  const p = comparable(parent)
74  const c = comparable(child)
75
76  return c === p || c.startsWith(`${p}/`)
77}
78
79// The innermost worktree holding `cwd`: worktrees can sit inside the main one.
80export const findCurrent = (paths: readonly string[], cwd: string): string | null =>
81  paths
82    .filter(path => isInside(path, cwd))
83    .reduce<string | null>((best, path) => (best === null || path.length > best.length ? path : best), null)
84
85export const displayName = (path: string, root: string) => {
86  if (comparable(path) !== comparable(root) && isInside(root, path)) {
87    return path.replaceAll('\\', '/').slice(root.replaceAll('\\', '/').replace(/\/+$/, '').length + 1)
88  }
89
90  return path.replaceAll('\\', '/').replace(/\/+$/, '').split('/').pop() ?? path
91}
92
93// The Claude app keeps a session's worktree under the main one's .claude/worktrees.
94export const isAppManaged = (path: string, root: string) => {
95  const parent = `${root.replaceAll('\\', '/').replace(/\/+$/, '')}/.claude/worktrees`
96
97  return comparable(path) !== comparable(parent) && isInside(parent, path)
98}
99
100export const nativePath =(path: string) => (/^[A-Za-z]:\//.test(path) ? path.replaceAll('/', '\\') : path)
101
102export const relativeTime = (seconds: number, nowMs: number) => {
103  if (seconds <= 0) {
104    return ''
105  }
106
107  const elapsed = Math.max(0, nowMs / 1000 - seconds)
108  const days = elapsed / 86400
109
110  if (elapsed < 60) return '刚刚'
111  if (elapsed < 3600) return `${Math.floor(elapsed / 60)} 分钟前`
112  if (elapsed < 86400) return `${Math.floor(elapsed / 3600)} 小时前`
113  if (days < 30) return `${Math.floor(days)} 天前`
114  if (days < 365) return `${Math.floor(days / 30)} 个月前`
115
116  return `${Math.floor(days / 365)} 年前`
117}
118
119// What deleting would lose, said before the person confirms. Removing only the
120// folder keeps the branch, and with it every commit on it.
121export const deleteWarnings = (row: Worktree, mode: DeleteMode, base: string | null): string[] => {
122  const warnings: string[] = []
123  let losesCommits = false
124
125  if (row.changed > 0) {
126    warnings.push(`${row.changed} 个未提交的文件会丢失`)
127  }
128
129  if (row.branch === null && row.orphaned > 0) {
130    warnings.push(`${row.orphaned} 个提交不在任何分支上`)
131    losesCommits = true
132  }
133
134  if (mode === 'both' && row.branch !== null) {
135    if (row.unpushed > 0) {
136      warnings.push(`${row.unpushed} 个提交未 push`)
137      losesCommits = true
138    }
139
140    if (base !== null && row.ahead > 0) {
141      warnings.push(`${row.ahead} 个提交未合并进 ${base}`)
142      losesCommits = true
143    }
144  }
145
146  if (losesCommits) {
147    warnings.push('删除后这些提交只能靠 reflog 找回')
148  }
149
150  if (row.isLocked) {
151    warnings.push('worktree 已锁定,会强制删除')
152  }
153
154  return warnings
155}
156
types/index.d.ts 65 lines
1export type Worktree = {
2  /** As git prints it: forward slashes. */
3  path: string
4  /** Relative to the main worktree when inside it, else the folder's name. */
5  name: string
6  /** Null while HEAD is detached. */
7  branch: string | null
8  /** Short commit hash of HEAD. */
9  head: string
10  isMain: boolean
11  /** The session runs inside it. */
12  isCurrent: boolean
13  /** Made by the Claude app, under the main worktree's .claude/worktrees. */
14  isAppManaged: boolean
15  isLocked: boolean
16  /** Its folder is gone; `git worktree prune` clears it. */
17  isMissing: boolean
18  /** Paths staged, modified, untracked or unmerged. */
19  changed: number
20  /** Ahead of a live upstream; with none, commits on no remote branch. */
21  unpushed: number
22  /** Detached only: commits on no branch and no remote branch. */
23  orphaned: number
24  /** Against the base branch. */
25  ahead: number
26  behind: number
27  /** The branch holds nothing the base lacks. */
28  isMerged: boolean
29  /** Its upstream was deleted on the remote. */
30  isGone: boolean
31  /** Unix seconds of the last commit; 0 when unknown. */
32  lastCommitAt: number
33}
34
35export type Snapshot = {
36  /** The main worktree's path. */
37  root: string
38  /** The main worktree's branch, which the others are compared with. */
39  base: string | null
40  worktrees: Worktree[]
41  /** Milliseconds since the epoch. */
42  scannedAt: number
43  error: string | null
44}
45
46export type DeleteMode = 'dir' | 'both'
47
48export type Deleting =
49  | { path: string; step: 'choose' }
50  | { path: string; step: 'confirm'; mode: DeleteMode }
51
52export type Details = { path: string; text: string }
53
54declare module 'claude-code' {
55  interface PluginState {
56    'wt-panel': {
57      snapshot: Snapshot | null
58      /** The row whose changes are shown. */
59      expanded: Details | null
60      /** The row a delete is being chosen or confirmed for. */
61      deleting: Deleting | null
62    }
63  }
64}
65