SLOPSHOPPER

file-explorer

VS Code style file tree of the session's working directory in a pane

newpanecommandtoaststatusprocess
★ 1v?MITupdated 2026-10-04IT-BAER/claude-forge/mods/file-explorer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-explorer
│ ┃ file-explorer ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ▣ client module ./clear-selection.tsx ↻ ⊟ ⏺ Read(src/auth.ts) │ ┃ ⌕ : Search files ⎿ Read 6 lines │ ┃ {} ▣ client module ./file-name.tsx ⏺ Update(src/auth.ts) │ ┃ ≡ ▣ client module ./file-name.tsx ⎿ Added 2 lines, removed 1 line │ ┃ M↓ ▣ client module ./file-name.tsx ⏺ Bash(bun test) │ ┃ ▣ client module ./clear-selection.tsx ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /files │ ⎿ file-explorer: Files pane closed. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · file-explorer
▣ client module ./clear-selection.tsx ↻ ⊟ ⌕ : Search files {} ▣ client module ./file-name.tsx ≡ ▣ client module ./file-name.tsx M↓ ▣ client module ./file-name.tsx ▣ client module ./clear-selection.tsx
README

file-explorer

A VS Code style file explorer for Claude Code. It opens a pane with the file tree of your session's working directory, previews files, and lets you edit and save them without leaving the session.

<img src="screenshots/file-tree.png" alt="File tree of a project folder" width="330">

The pane on a project folder. Folders come first, ignored entries such as node_modules are dimmed, and the search box, refresh and collapse buttons sit at the top.

Install

Pick one way. All three need a new Claude Code session afterwards.

1. Plugin marketplace (recommended)

Run this in Claude Code:

/plugin marketplace add IT-BAER/claude-forge
/plugin install file-explorer@claude-forge

Or from a normal terminal:

claude plugin marketplace add IT-BAER/claude-forge
claude plugin install file-explorer@claude-forge

2. Copy the folder

git clone https://github.com/IT-BAER/claude-forge.git
mkdir -p ~/.claude/skills
rm -rf ~/.claude/skills/file-explorer
cp -r claude-forge/mods/file-explorer ~/.claude/skills/file-explorer

PowerShell:

git clone https://github.com/IT-BAER/claude-forge.git
Remove-Item "$HOME\.claude\skills\file-explorer" -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item claude-forge\mods\file-explorer "$HOME\.claude\skills\file-explorer" -Recurse

Claude Code loads any plugin folder inside ~/.claude/skills/ at the next session start. The first command line removes an older copy, so run the pair again to update. Without it, a second copy ends up inside the first.

3. Try it for one session

claude --plugin-dir path/to/claude-forge/mods/file-explorer

Use it

Type /files to open the pane. Type it again to close it.

CommandWhat it does
/filesOpen or close the pane
/files onOpen the pane at every session start
/files offStop opening it at session start
/files <path>Show another folder instead of the working directory
/files resetGo back to the working directory

Requirements

  • Claude Code 2.1.x with the mod API, in the desktop app (Code tab) or the terminal.
  • Windows. The preview, save and image scripts run with powershell.exe.
  • git on the PATH for git status colours (optional).

Features

File tree

  • Folders open and close with a click. Git status shows as a coloured letter: M modified, U untracked, A added, R renamed, D deleted. Ignored files are dimmed.
  • The search box matches file and folder names in all subfolders, also collapsed ones. Each result shows its parent folder. It skips .git and node_modules and stops at 20,000 entries.
  • Hover the icons at the top right: refresh the tree, or collapse all folders.
  • Click a file to select it. Double-click opens the preview. Ctrl+click adds an @path mention to your prompt.
  • Click the … at the end of a row for a popup menu (a click outside the pane hides it): Open, Add to prompt, Rename, Duplicate, Delete (to the Recycle Bin, after a confirm), Copy path, Copy relative path, Reveal in File Explorer. Folders also offer New file and New folder.

<img src="screenshots/context-menu.png" alt="Row menu" width="260">

The … menu of a row. Open and Add to prompt come first, then Rename, Duplicate and Delete, then the copy and reveal actions.

Preview

  • Markdown files render with headings, tables, links and code blocks. A Source button shows the raw text with syntax colours.
  • PNG, JPG, GIF and BMP images (up to 20 MiB) are scaled to fit the pane.
  • Text files up to 2 MiB open. Binary files show a short notice instead.

Editor

<img src="screenshots/file-editor.png" alt="Editor with syntax colours" width="560">

Editing CHANGELOG.md. The status line shows the caret position, encoding and line ending. Headings, links and inline code have their own colours, and wrapped list lines keep an indent guide.

Press Edit in the preview. Changes stay a draft until you press Save or Ctrl+S. Back asks before it discards a draft. If the file changed on disk in the meantime, the save is refused and your draft stays.

  • In Claude Desktop the editor draws text in a monospace font on the pane's cell grid, so a click puts the caret exactly on the letter you clicked.
  • Encoding (UTF-8, UTF-8 with BOM, UTF-16) and line endings are kept as they were.
  • Syntax colours for TypeScript, JavaScript, C-like languages, Python, PowerShell, shell, JSON, YAML, TOML, SQL, CSS, HTML and Markdown (with fenced code in its own language).
  • Soft wrap is on for Markdown and text files, off for code. The Wrap button switches it.
  • The bracket next to the caret and its partner are highlighted. Leading indentation shows guides, trailing spaces and tabs show as dim marks.
  • Files over 30,000 characters, with mixed line endings or with control characters open read-only.
KeysAction
Ctrl+SSave
Ctrl+Z, Ctrl+YUndo, redo
Ctrl+A, Ctrl+C, Ctrl+X, Ctrl+VSelect all, copy, cut, paste
Ctrl+Left, Ctrl+RightMove by word
Ctrl+Backspace, Ctrl+DeleteDelete a word
Ctrl+D, Ctrl+K, Ctrl+LDuplicate, delete, select the line
Ctrl+/Comment or uncomment the selected lines
Tab, Shift+TabIndent, outdent the selected lines
HomeFirst non-blank character, then column 0
Double-click, triple-clickSelect a word, a line

Find and replace

Ctrl+F finds as you type and marks every match. Ctrl+H adds a replace field. The bar shows in the status line under the prompt.

KeysAction
Enter, Shift+EnterNext, previous match
TabSwitch between the find and replace field (Ctrl+H)
Enter in the replace fieldReplace this match, go to the next
Ctrl+EnterReplace all matches (one undo step)
Ctrl+F or Ctrl+H againClose the bar

Matching ignores case.

Settings

None are needed. An optional theme.json next to .claude-plugin/ tunes the look, for example { "radius": 4, "bgHover": "#262729", "bgPicked": "#343638", "rowH": 26.5, "searchCells": 22 }. The pane picks up changes within a few seconds.

Editor keys: cellW is the CSS width of one cell (default 7.92, measured on Claude Desktop at default zoom), codeFont the font size, codeBase the text baseline. "editorMono": false falls back to the proportional font, where clicks are less exact.

Limits

  • The tree shows the first 800 rows of the current view.
  • Edits are limited to 30,000 characters per file.
  • The pane needs Claude Code's mod API, which changes between releases. After an upgrade the mod can need an update.

License

MIT, see the repository root.

Source 8 files
hooks/register.tsx 1133 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Entry, Preview, Tree } from '../types'
5import { editorProps, markdownChunks, sourceChunks } from './editing'
6import { COLORS, HEAD, highlight, layout } from './editor'
7
8const cleanError = (error: unknown) => String((error as Error)?.message ?? error).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').slice(0, 1000)
9async function requestBack($: any, discard = false): Promise<boolean> {
10  let closed = false
11  await update($, tree, (cur: Tree) => {
12    closed = false
13    const p = cur.preview
14    if (!p) return cur
15    if (p.busy) return cur
16    if (p.dirty && !discard) return { ...cur, preview: { ...p, confirmBack: true } }
17    closed = true
18    return { ...cur, picked: '', preview: undefined }
19  })
20  return closed
21}
22
23async function savePreview($: any, request: number, goBack = false) {
24  let snapshot: Preview | undefined
25  await update($, tree, (cur: Tree) => {
26    snapshot = undefined
27    const p = cur.preview
28    if (!p || p.request !== request || !p.editable || p.busy) return cur
29    snapshot = { ...p }
30    return { ...cur, preview: { ...p, busy: true, error: '' } }
31  })
32  if (!snapshot) return
33  const p = snapshot as Preview
34  try {
35    const cur: Tree = await read($, tree)
36    const [root, file] = await Promise.all([$.fs.stat(cur.root, { resolve: true }), $.fs.stat(p.path, { resolve: true })])
37    if (!root.realPath || !file.realPath || file.kind !== 'file' || norm(file.realPath) !== norm(p.realPath ?? '') || !norm(file.realPath).startsWith(norm(root.realPath) + '/')) throw new Error('File location changed or is outside the explorer root.')
38    const result = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-File', `${$.plugin.root}/hooks/write-file.ps1`, '-FilePath', file.realPath, '-RootPath', root.realPath], {
39      stdin: JSON.stringify({ hash: p.hash, text: p.draft, encoding: p.encoding, eol: p.eol }), timeoutMs: 10000,
40    })
41    if (result.exitCode !== 0) throw new Error('Save could not finish. Your draft is kept; check the file on disk.')
42    const data = JSON.parse(result.stdout)
43    if (data.ok !== true || typeof data.hash !== 'string' || !/^[a-f0-9]{64}$/.test(data.hash)) throw new Error(typeof data.message === 'string' ? data.message : 'Invalid save response. Your draft is kept.')
44    await update($, tree, (saved: Tree) => saved.preview?.request === request ? { ...saved, preview: {
45      ...saved.preview, hash: data.hash, savedText: p.draft, dirty: saved.preview.draft !== p.draft,
46      busy: false, error: '', notice: 'Saved',
47    } } : saved)
48    if (goBack) await requestBack($)
49  } catch (error) {
50    await update($, tree, (cur: Tree) => cur.preview?.request === request ? { ...cur, preview: { ...cur.preview, busy: false, error: cleanError(error) } } : cur)
51  }
52}
53
54async function editorMessage($: any, e: any) {
55  const data = e.data
56  if (data?.geo) {
57    const g = data.geo
58    if ([g.request, g.columns, g.left, g.limit].every(Number.isSafeInteger) && g.columns >= 8 && g.columns <= 1000 && g.left >= 0 && g.limit >= 0) {
59      const prior = editorGeo.get(g.request)
60      if (!prior || prior.columns !== g.columns || prior.left !== g.left || prior.limit !== g.limit) { editorGeo.set(g.request, g); $.ui.invalidate('ui.render') }
61    }
62    return {}
63  }
64  if (data?.view) {
65    const v = data.view
66    if (![v.request, v.row, v.line, v.col, v.selected, v.total].every(Number.isSafeInteger) || v.row < 0 || v.line < 0 || v.col < 0 || v.selected < 0 || v.total < 1) return {}
67    await update($, tree, (cur: Tree) => {
68      const p = cur.preview
69      if (!p?.editing || p.request !== v.request) return cur
70      return { ...cur, preview: { ...p, editorRow: v.row, editorLine: v.line, editorCol: v.col, editorSelected: v.selected, editorTotal: v.total,
71        ...(Number.isSafeInteger(v.cursor) && Number.isSafeInteger(v.anchor) && v.cursor >= 0 && v.anchor >= 0 ? { editorCaret: v.cursor, editorAnchor: v.anchor } : {}) } }
72    })
73    $.clock.after(80, () => $.ui.scroll({ to: { key: 'editor-caret' }, in: PANE }).catch(() => {}))
74    return {}
75  }
76  if (data?.find) {
77    const f = data.find
78    if (!Number.isSafeInteger(f.request) || typeof f.text !== 'string' || f.text.length > 400) return {}
79    if (f.request !== (await current($)).preview?.request) return {}
80    findShown = !!f.text
81    $.ui.status(f.text.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ') || undefined)
82    return {}
83  }
84  if (!data || typeof data.instance !== 'string' || !data.instance || data.instance.length > 100 || !Number.isSafeInteger(data.seq) || data.seq <= 0 || !Number.isSafeInteger(data.request)) return {}
85  if (!Number.isSafeInteger(data.rows) || data.rows < 8 || data.rows > 40) return {}
86  if (typeof data.text !== 'string' || data.text.length > 30000 || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(data.text)) return {}
87  if (!['change', 'save', 'copy', 'cut', 'paste'].includes(data.action)) return {}
88  if (!Number.isSafeInteger(data.from) || !Number.isSafeInteger(data.to) || data.from < 0 || data.to < data.from || data.to > data.text.length) return {}
89  let accepted = false
90  await update($, tree, (cur: Tree) => {
91    accepted = false
92    const p = cur.preview
93    if (!p || p.request !== data.request || !p.editable || !p.editing || (p.editorInstance === data.instance && data.seq <= (p.editorSeq ?? 0))) return cur
94    accepted = true
95    return { ...cur, preview: { ...p, draft: data.text, content: data.text, dirty: data.text !== p.savedText,
96      editorInstance: data.instance, editorSeq: data.seq, editorCursor: data.to } }
97  })
98  if (!accepted) return {}
99  if (data.action === 'save') await savePreview($, data.request)
100  else if (data.action !== 'change') {
101    await update($, tree, (cur: Tree) => cur.preview && cur.preview.request === data.request ? { ...cur, preview: { ...cur.preview, busy: true } } : cur)
102    try {
103      let inserted = ''
104      if (data.action === 'paste') {
105        const result = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $text=Get-Clipboard -Raw; [Console]::Out.Write((@{text=$text}|ConvertTo-Json -Compress))'], { timeoutMs: 5000 })
106        if (result.exitCode !== 0) throw new Error('Clipboard could not be read.')
107        const clipboard = JSON.parse(result.stdout)
108        if (clipboard.text !== null && typeof clipboard.text !== 'string') throw new Error('Clipboard does not contain text.')
109        inserted = (clipboard.text ?? '').replace(/\r\n?/g, '\n')
110      } else {
111        const copied = await $.ui.copy({ text: data.text.slice(data.from, data.to), surface: e.surface })
112        if (!copied.isCopied) throw new Error('Clipboard could not be updated.')
113      }
114      const text = data.action === 'copy' ? data.text : data.text.slice(0, data.from) + inserted + data.text.slice(data.to)
115      if (text.length > 30000 || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(text)) throw new Error('Pasted text exceeds the editor limit or contains unsupported control characters.')
116      await update($, tree, (cur: Tree) => {
117        const p = cur.preview
118        if (!p || p.request !== data.request) return cur
119        if (p.editorSeq !== data.seq || p.editorInstance !== data.instance) return { ...cur, preview: { ...p, busy: false, error: 'Buffer changed during the clipboard operation. Try again.' } }
120        return { ...cur, preview: { ...p, draft: text, content: text, dirty: text !== p.savedText, busy: false,
121          editorRefresh: (p.editorRefresh ?? 0) + (data.action === 'copy' ? 0 : 1), editorCursor: data.from + inserted.length } }
122      })
123    } catch (error) {
124      await update($, tree, (cur: Tree) => cur.preview && cur.preview.request === data.request ? { ...cur, preview: { ...cur.preview, busy: false, error: cleanError(error) } } : cur)
125    }
126  }
127  const cur: Tree = await read($, tree)
128  return cur.preview && cur.preview.request === data.request ? { props: editorFor(cur.preview, data.rows) } : {}
129}
130
131const PANE = 'file-explorer'
132const MAX_ROWS = 800
133const clickAcks = new Map<string, { instance: string; seq: number }>()
134let lastFileClick: { path: string; root: string } | undefined
135let selectionActive = false
136let findShown = false
137// Last pane geometry seen, so the editor's props agree between a pane render and an editor message reply.
138const xmlText = (value: string) => value.replace(/[&<>]/g, c => c === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;')
139// One editor row as an SVG: every character sits at its own cell, so pointer cell N is character N whatever the font.
140function rowSvg(text: string, kinds: Uint8Array | undefined, from: number, left: number, cells: number, sel?: [number, number], caret?: number): string {
141  const w = cells * T.cellW
142  const h = T.cellPx
143  const runs: string[] = []
144  if (sel && sel[1] > from + left && sel[0] < text.length + 1) {
145    const a = Math.max(0, sel[0] - from - left), b = Math.min(cells, sel[1] - from - left)
146    if (b > a) runs.push(`<rect x="${+(a * T.cellW).toFixed(2)}" y="0" width="${+((b - a) * T.cellW).toFixed(2)}" height="${h}" fill="#264f78"/>`)
147  }
148  let x: number[] = []
149  let chars = ''
150  let color = ''
151  let bold = false
152  const flush = () => {
153    if (chars) runs.push(`<text x="${x.map(v => +v.toFixed(2)).join(' ')}" fill="${color}"${bold ? ' font-weight="700"' : ''}>${xmlText(chars)}</text>`)
154    x = []; chars = ''
155  }
156  const line = text.slice(from + left, from + left + cells)
157  for (let i = 0; i < line.length; i++) {
158    const c = line[i] === '\t' ? ' ' : line[i]!
159    if (c === ' ') continue
160    const kind = kinds?.[from + left + i] ?? 0
161    const nextColor = COLORS[kind] ?? '#d4d4d4'
162    const nextBold = kind === HEAD
163    if (nextColor !== color || nextBold !== bold) { flush(); color = nextColor; bold = nextBold }
164    x.push(i * T.cellW)
165    chars += c
166  }
167  flush()
168  if (caret !== undefined && caret >= from + left && caret - from - left <= cells) runs.push(`<rect x="${+((caret - from - left) * T.cellW).toFixed(2)}" y="1" width="1.6" height="${h - 2}" fill="#aeafad"/>`)
169  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><g font-family="Cascadia Mono,Consolas,ui-monospace,monospace" font-size="${T.codeFont}">${runs.join('').replace(/<text /g, `<text y="${(h * T.codeBase).toFixed(2)}" `)}</g></svg>`
170}
171const editorGeo = new Map<number, { columns: number; left: number; limit: number }>()
172const paneView = { offset: 0, body: 35, content: 0, fit: 1, mono: false }
173const editorFor = (p: Preview, rows: number) => {
174  const total = p.editorTotal ?? p.draft?.split('\n').length ?? 0
175  const top = Math.max(0, paneView.offset - Math.max(0, paneView.content - total - 2))
176  return { ...editorProps(p, rows, paneView.fit, top, Math.max(8, paneView.body)), mono: paneView.mono }
177}
178const EDITOR_WINDOW = 120
179const ENCODINGS: Record<string, string> = { utf8: 'UTF-8', utf8bom: 'UTF-8 BOM', utf16le: 'UTF-16 LE', utf16be: 'UTF-16 BE' }
180const EMPTY: Tree = {
181  root: '',
182  dirs: {},
183  open: [],
184  filter: '',
185  picked: '',
186  git: {},
187  reveal: { dir: '', n: 0 },
188  pin: '',
189  tick: 0,
190}
191const tree = atom({ plugin: 'file-explorer', key: 'tree' } as const, EMPTY)
192const current = async ($: any): Promise<Tree> => ({ ...EMPTY, ...(await read($, tree)) })
193
194async function clearSelection($: any) {
195  lastFileClick = undefined
196  selectionActive = false
197  const c = await current($)
198  if (c.picked || c.menu) await update($, tree, (cur: Tree) => ({ ...cur, picked: '', ...NO_MENU }))
199}
200
201async function backToFiles($: any, discard = false) {
202  if (await requestBack($, discard)) {
203    lastFileClick = undefined
204    clickAcks.clear()
205    selectionActive = false
206  }
207}
208
209const GIT_COLOR: Record<string, string> = {
210  M: '#d29922',
211  U: '#3fb950',
212  A: '#3fb950',
213  R: '#3fb950',
214  D: '#f85149',
215}
216const GIT_RANK = ['D', 'M', 'R', 'A', 'U']
217
218// Tunable at runtime from theme.json beside the plugin manifest; see loadTheme.
219const T = {
220  rowH: 23,
221  // CSS px of one character cell vertically; converts rowH into the cell offsets Box top and bottom take.
222  cellPx: 19.2,
223  // Editor text characters per pointer cell on the desktop (measured: cell 60 past the gutter lands on character 79-80).
224  textFit: 4 / 3,
225  // Desktop editor rows drawn with Code (code font, one character per cell); false falls back to Text and textFit.
226  editorMono: true,
227  // CSS px of one pointer cell across (measured: 9.9 device px at 125 %), the monospace font size and its baseline in the row.
228  cellW: 7.92,
229  codeFont: 13,
230  codeBase: 0.74,
231  radius: 4,
232  bgHover: '#161b22',
233  bgPicked: '#09182e',
234  menuBg: '#262624',
235  menuBorder: '#3d3d3a',
236  menuHover: '#30302e',
237  guide: '#30363d',
238  chevron: '#8b949e',
239  folder: '#dcb67a',
240  chevCells: 2,
241  iconCells: 3,
242  guideCells: 2,
243  padX: 1,
244  searchCells: 20,
245  bg: '#111111',
246  searchMode: 'row',
247  searchTop: -2,
248  searchRight: 6,
249  overlay: true,
250  auto: true,
251  hitCells: 160,
252  nameNormal: '#c9d1d9',
253  nameIgnored: '#6e7681',
254  dimOpacity: 0.5,
255  animMs: 30,
256  animStep: 1,
257  // Rows at the reveal front drawn dim: a short fade-in trail.
258  fadeRows: 3,
259  wideCells: 48,
260  chevStroke: 1.1,
261  pin: '',
262}
263let themeText = ''
264let themeFailed = false
265let themeStatus = 'theme: not loaded yet'
266async function loadTheme($: any): Promise<boolean> {
267  try {
268    const text: string = await $.fs.read(`${$.plugin.root}/theme.json`)
269    themeStatus = `theme ok (${$.plugin.root})`
270    if (text === themeText) return false
271    themeText = text
272    const file = JSON.parse(text)
273    const { git, ...rest } = file
274    Object.assign(T, rest)
275    if (git) Object.assign(GIT_COLOR, git)
276    return true
277  } catch (err) {
278    themeStatus = `theme error: ${String((err as Error)?.message ?? err)} root=${$.plugin.root}`
279    themeFailed = true
280    return false
281  }
282}
283
284const svg = (body: string, dim = false) =>
285  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 ${T.rowH}" width="16" height="${T.rowH}"><g transform="translate(0 ${(T.rowH - 16) / 2})"${dim ? ` opacity="${T.dimOpacity}"` : ''}>${body}</g></svg>`
286const guideSvg = () =>
287  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 ${T.rowH}" width="16" height="${T.rowH}"><path d="M8 0V${T.rowH}" stroke="${T.guide}" stroke-width="1"/></svg>`
288// One 8px-wide end of a rounded row highlight; the straight middle is a plain Box.
289const capSvg = (color: string, left: boolean) => {
290  const h = T.rowH
291  const r = T.radius
292  const path = left
293    ? `M8 0H${r}A${r} ${r} 0 0 0 0 ${r}V${h - r}A${r} ${r} 0 0 0 ${r} ${h}H8Z`
294    : `M0 0H${8 - r}A${r} ${r} 0 0 1 8 ${r}V${h - r}A${r} ${r} 0 0 1 ${8 - r} ${h}H0Z`
295  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 ${h}" width="8" height="${h}"><path d="${path}" fill="${color}"/></svg>`
296}
297const chevronSvg = (open: boolean) =>
298  svg(
299    `<path d="${open ? 'M3.5 6l4.5 4.5L12.5 6' : 'M6 3.5L10.5 8L6 12.5'}" fill="none" stroke="${T.chevron}" stroke-width="${T.chevStroke}" stroke-linecap="round" stroke-linejoin="round"/>`,
300  )
301const folderSvg = (open: boolean, dim = false) =>
302  svg(
303    open
304      ? `<path d="M1.5 4a1 1 0 0 1 1-1h3.2l1.3 1.5h6.5a1 1 0 0 1 1 1V6H4.2a1 1 0 0 0-.95.7L1.5 12z" fill="#b8975f"/><path d="M3.3 6.8a.8.8 0 0 1 .75-.55H14.6a.7.7 0 0 1 .67.9l-1.5 5.2a1 1 0 0 1-.96.7H2.6a.6.6 0 0 1-.57-.8z" fill="${T.folder}"/>`
305      : `<path d="M1.5 4a1 1 0 0 1 1-1h3.2l1.3 1.5h6.5a1 1 0 0 1 1 1v6.5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z" fill="${T.folder}"/>`,
306    dim,
307  )
308const fileSvg = (color: string, dim = false) =>
309  svg(
310    `<path d="M4 1.5h5l3.5 3.5v9a.5.5 0 0 1-.5.5H4a.5.5 0 0 1-.5-.5v-12a.5.5 0 0 1 .5-.5z" fill="none" stroke="${color}" stroke-width="1.2" stroke-linejoin="round"/><path d="M9 1.7V5h3.3" fill="none" stroke="${color}" stroke-width="1.2" stroke-linejoin="round"/><rect x="5.6" y="8.6" width="4.8" height="1.6" rx=".8" fill="${color}"/><rect x="5.6" y="11.2" width="3" height="1.2" rx=".6" fill="${color}" opacity=".55"/>`,
311    dim,
312  )
313
314type Icon = { glyph: string; color: string }
315const BY_EXT: Record<string, Icon> = {
316  ts: { glyph: 'TS', color: '#519aba' },
317  tsx: { glyph: 'TS', color: '#519aba' },
318  js: { glyph: 'JS', color: '#cbcb41' },
319  jsx: { glyph: 'JS', color: '#cbcb41' },
320  mjs: { glyph: 'JS', color: '#cbcb41' },
321  cjs: { glyph: 'JS', color: '#cbcb41' },
322  json: { glyph: '{}', color: '#cbcb41' },
323  jsonl: { glyph: '{}', color: '#cbcb41' },
324  md: { glyph: 'M↓', color: '#519aba' },
325  py: { glyph: 'Py', color: '#ffbc03' },
326  ps1: { glyph: '>_', color: '#519aba' },
327  sh: { glyph: '$', color: '#89e051' },
328  bash: { glyph: '$', color: '#89e051' },
329  yml: { glyph: 'Y', color: '#a074c4' },
330  yaml: { glyph: 'Y', color: '#a074c4' },
331  toml: { glyph: 'T', color: '#a074c4' },
332  html: { glyph: '<>', color: '#e37933' },
333  css: { glyph: '#', color: '#519aba' },
334  png: { glyph: '▣', color: '#a074c4' },
335  jpg: { glyph: '▣', color: '#a074c4' },
336  jpeg: { glyph: '▣', color: '#a074c4' },
337  gif: { glyph: '▣', color: '#a074c4' },
338  svg: { glyph: '▣', color: '#e37933' },
339  lock: { glyph: '≡', color: '#8a8a8a' },
340  log: { glyph: '≡', color: '#8a8a8a' },
341  txt: { glyph: '≡', color: '#d4d7d6' },
342  sql: { glyph: 'DB', color: '#dad8d8' },
343  csv: { glyph: '▦', color: '#89e051' },
344  xml: { glyph: '<>', color: '#e37933' },
345  ini: { glyph: '⚙', color: '#8a8a8a' },
346  cfg: { glyph: '⚙', color: '#8a8a8a' },
347  env: { glyph: '⚙', color: '#cbcb41' },
348  zip: { glyph: '▤', color: '#cbcb41' },
349}
350const BY_NAME: Record<string, Icon> = {
351  '.gitignore': { glyph: '◆', color: '#f54d27' },
352  '.gitattributes': { glyph: '◆', color: '#f54d27' },
353  'package.json': { glyph: '{}', color: '#89e051' },
354  'claude.md': { glyph: 'M↓', color: '#e37933' },
355  license: { glyph: '§', color: '#cbcb41' },
356}
357const FILE_DEFAULT: Icon = { glyph: '≡', color: '#8a8a8a' }
358
359const iconOf = (name: string, kind: Entry['kind']): Icon => {
360  if (kind === 'dir') return { glyph: '■', color: T.folder }
361  const lower = name.toLowerCase()
362  const byName = BY_NAME[lower]
363  if (byName) return byName
364  const dot = lower.lastIndexOf('.')
365  return (dot >= 0 && BY_EXT[lower.slice(dot + 1)]) || FILE_DEFAULT
366}
367
368const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
369const join = (dir: string, name: string) => (/[\\/]$/.test(dir) ? dir + name : `${dir}/${name}`)
370const baseName = (p: string) => p.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? p
371const relPath = (root: string, p: string) => p.slice(root.replace(/[\\/]+$/, '').length + 1)
372const mention = (rel: string) => `@${/\s/.test(rel) ? `"${rel}"` : rel} `
373
374let previewRequest = 0
375const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp'])
376const BINARY_EXT = new Set(['webp', 'avif', 'ico', 'pdf', 'zip', 'gz', '7z', 'exe', 'dll', 'wasm', 'glb', 'psd', 'mp4', 'mp3', 'woff', 'woff2'])
377
378async function previewFile($: any, path: string, root: string) {
379  const request = ++previewRequest
380  const preview: Preview = { path, request, kind: 'loading', content: '', markdown: /\.(md|markdown)$/i.test(path), rendered: true, notice: '' }
381  await update($, tree, (cur: Tree) => ({ ...cur, picked: path, preview }))
382  try {
383    const [folder, file] = await Promise.all([$.fs.stat(root, { resolve: true }), $.fs.stat(path, { resolve: true })])
384    if (!folder.realPath || !file.realPath || !norm(file.realPath).startsWith(norm(folder.realPath) + '/')) throw new Error('File is outside the explorer root.')
385    if (file.kind !== 'file') throw new Error('This entry is not a regular file.')
386    const ext = path.split('.').pop()?.toLowerCase() ?? ''
387    if (IMAGE_EXT.has(ext)) {
388      if (file.size > 20 * 1024 * 1024) {
389        preview.kind = 'unsupported'
390        preview.content = 'Image exceeds the preview size limit (20 MiB).'
391      } else {
392        const result = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-File', `${$.plugin.root}/hooks/preview-image.ps1`, '-ImagePath', file.realPath], { timeoutMs: 10000 })
393        if (result.exitCode !== 0) throw new Error('Image could not be decoded for preview.')
394        const svg = result.stdout.trim()
395        if (!svg.startsWith('<svg ') || svg.length > 131072) throw new Error('Image preview exceeds the renderer limit.')
396        preview.kind = 'image'
397        preview.content = svg
398        preview.notice = 'Image preview · scaled to fit'
399      }
400    } else if (BINARY_EXT.has(ext) || file.size > 2 * 1024 * 1024) {
401      preview.kind = 'unsupported'
402      preview.content = BINARY_EXT.has(ext) ? 'Preview is not available for this file type.' : 'File exceeds the text preview size limit (2 MiB).'
403    } else {
404      const result = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-File', `${$.plugin.root}/hooks/read-preview.ps1`, '-FilePath', file.realPath, '-RootPath', folder.realPath], { timeoutMs: 10000 })
405      if (result.exitCode !== 0) throw new Error('File could not be read for preview.')
406      const data = JSON.parse(result.stdout)
407      if (!['text', 'unsupported'].includes(data.kind) || typeof data.content !== 'string' || data.content.length > 30000) throw new Error('Invalid text preview response.')
408      preview.kind = data.kind
409      preview.content = data.content
410      preview.realPath = file.realPath
411      if (data.kind === 'text' && data.editable === true) {
412        if (typeof data.draft !== 'string' || data.draft !== data.content || !/^[a-f0-9]{64}$/.test(data.hash) || !['utf8', 'utf8bom', 'utf16le', 'utf16be'].includes(data.encoding) || !['lf', 'crlf', 'cr'].includes(data.eol)) throw new Error('Invalid editable file response.')
413        Object.assign(preview, { editable: true, draft: data.draft, savedText: data.draft, hash: data.hash, encoding: data.encoding, eol: data.eol, dirty: false, editing: false })
414      }
415      preview.notice = data.kind === 'text' ? (typeof data.notice === 'string' && data.notice ? data.notice : data.truncated ? 'Preview truncated · showing the beginning of the file' : preview.editable ? 'Preview · Edit to make changes' : 'Read-only preview') : ''
416    }
417  } catch (err) {
418    preview.kind = 'error'
419    preview.content = `Cannot preview file: ${String((err as Error)?.message ?? err)}`.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').slice(0, 1000)
420  }
421  await update($, tree, (cur: Tree) => cur.preview?.request === request && cur.root === root ? { ...cur, preview } : cur)
422}
423
424const sorted = (entries: Entry[]) =>
425  [...entries].sort((a, b) =>
426    a.kind === b.kind
427      ? a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
428      : a.kind === 'dir'
429        ? -1
430        : 1,
431  )
432
433type Row = { path: string; name: string; depth: number; kind: Entry['kind']; isOpen: boolean; fresh: boolean; where?: string }
434
435// Every entry below the root, crawled breadth-first on the first search so collapsed folders match too.
436let searchIndex: { root: string; hits: { path: string; name: string; kind: Entry['kind'] }[] } | undefined
437const SEARCH_SKIP = new Set(['node_modules'])
438const SEARCH_CAP = 20000
439
440async function buildSearchIndex($: any, root: string) {
441  const index = { root, hits: [] as { path: string; name: string; kind: Entry['kind'] }[] }
442  searchIndex = index
443  const queue = [root]
444  let listed = 0
445  while (queue.length && index.hits.length < SEARCH_CAP && searchIndex === index) {
446    const dir = queue.shift()!
447    let entries: Entry[]
448    try { entries = await $.fs.list(dir) } catch { continue }
449    for (const e of entries) {
450      if (HIDDEN.has(e.name) || SEARCH_SKIP.has(e.name)) continue
451      const path = join(dir, e.name)
452      index.hits.push({ path, name: e.name, kind: e.kind })
453      if (e.kind === 'dir') queue.push(path)
454    }
455    if (++listed % 25 === 0) await update($, tree, (cur: Tree) => ({ ...cur, tick: cur.tick + 1 }))
456  }
457  if (searchIndex === index) await update($, tree, (cur: Tree) => ({ ...cur, tick: cur.tick + 1 }))
458}
459
460const flatten = (t: Tree): Row[] => {
461  const rows: Row[] = []
462  const needle = t.filter.trim().toLowerCase()
463  if (needle && searchIndex?.root === t.root) {
464    const rootLength = t.root.replace(/[\\/]+$/, '').length + 1
465    for (const h of searchIndex.hits) {
466      if (!h.name.toLowerCase().includes(needle)) continue
467      const where = h.path.slice(rootLength, h.path.length - h.name.length - 1)
468      rows.push({ path: h.path, name: h.name, depth: 0, kind: h.kind, isOpen: false, fresh: false, where })
469    }
470    return rows
471  }
472  const walk = (dir: string, depth: number) => {
473    const kids = t.dirs[dir] ?? []
474    const limit = !needle && t.reveal.dir === dir ? t.reveal.n : kids.length
475    const fadeFrom = t.reveal.dir === dir ? limit - T.fadeRows : Infinity
476    kids.slice(0, limit).forEach((e, i) => {
477      const path = join(dir, e.name)
478      const isOpen = e.kind === 'dir' && t.open.includes(path)
479      if (!needle || e.name.toLowerCase().includes(needle)) {
480        rows.push({ path, name: e.name, depth: needle ? 0 : depth, kind: e.kind, isOpen, fresh: i >= fadeFrom })
481      }
482      if (isOpen) walk(path, depth + 1)
483    })
484  }
485  walk(t.root, 0)
486  return rows
487}
488
489// VS Code's default files.exclude.
490const HIDDEN = new Set(['.git', '.svn', '.hg', 'CVS', '.DS_Store', 'Thumbs.db'])
491
492async function load($: any, dir: string) {
493  const entries = await $.fs.list(dir)
494  const slim = sorted(
495    entries.filter((e: Entry) => !HIDDEN.has(e.name)).map((e: Entry) => ({ name: e.name, kind: e.kind })),
496  )
497  await update($, tree, (t: Tree) => ({ ...t, dirs: { ...t.dirs, [dir]: slim } }))
498}
499
500// Maps normalized absolute path to a status letter; folders carry their strongest child status.
501
502async function gitStatus($: any, root: string): Promise<Record<string, string>> {
503  try {
504    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root })
505    if (top.exitCode !== 0) return {}
506    const base = norm(top.stdout.trim())
507    const st = await $.process.run(['git', 'status', '--porcelain=v1', '-z', '--ignored'], { cwd: root })
508    if (st.exitCode !== 0) return {}
509    const out: Record<string, string> = {}
510    const put = (key: string, letter: string) => {
511      if (letter === 'I') {
512        out[key] = 'I'
513        return
514      }
515      const have = out[key]
516      if (!have || GIT_RANK.indexOf(letter) < GIT_RANK.indexOf(have)) out[key] = letter
517    }
518    const parts: string[] = st.stdout.split('\0')
519    for (let i = 0; i < parts.length; i++) {
520      const line = parts[i]
521      if (line.length < 4) continue
522      const xy = line.slice(0, 2)
523      const file = line.slice(3)
524      if (xy[0] === 'R' || xy[0] === 'C') i++
525      const letter =
526        xy === '!!'
527          ? 'I'
528          : xy === '??'
529            ? 'U'
530            : xy.includes('D')
531              ? 'D'
532              : xy.includes('R')
533                ? 'R'
534                : xy.includes('A')
535                  ? 'A'
536                  : 'M'
537      let key = `${base}/${file}`.toLowerCase().replace(/\/+$/, '')
538      put(key, letter)
539      if (letter === 'I') continue
540      while (key.length > base.length) {
541        key = key.slice(0, key.lastIndexOf('/'))
542        if (key.length > base.length) put(key, letter)
543      }
544    }
545    return out
546  } catch {
547    return {}
548  }
549}
550
551// Refreshes run one after another; at most one more waits behind the running one.
552let tail: Promise<void> = Promise.resolve()
553let pending = 0
554
555let lastRoot = ''
556
557async function refreshNow($: any) {
558  await loadTheme($)
559  const t: Tree = await current($)
560  const root: string = t.pin || T.pin || (await $.session.root())
561  if (t.root !== root && (t.preview?.dirty || t.preview?.busy)) return
562  lastRoot = root
563  searchIndex = undefined
564  if (t.filter.trim()) buildSearchIndex($, root).catch(() => {})
565  const dirs = Array.from(new Set([root, ...t.open]))
566  for (const d of dirs) {
567    try {
568      await load($, d)
569    } catch (err) {
570      $.ui.toast(`file-explorer: cannot list ${d}: ${String((err as Error)?.message ?? err)}`)
571      if (d !== root) {
572        await update($, tree, (cur: Tree) => ({ ...cur, open: cur.open.filter(p => p !== d) }))
573      }
574    }
575  }
576  await update($, tree, (cur: Tree) => cur.root !== root && (cur.preview?.dirty || cur.preview?.busy) ? cur : ({ ...cur, root, preview: cur.root === root ? cur.preview : undefined }))
577  const git = await gitStatus($, root)
578  await update($, tree, (cur: Tree) => ({ ...cur, git }))
579}
580
581function refresh($: any) {
582  if (pending >= 2) return tail
583  pending++
584  tail = tail
585    .then(() => refreshNow($))
586    .catch(err => $.ui.toast(`file-explorer: refresh failed: ${String((err as Error)?.message ?? err)}`))
587    .finally(() => {
588      pending--
589    })
590  return tail
591}
592
593const NO_MENU = { menu: undefined, menuMode: undefined, menuText: undefined }
594const MENU_W = 28
595
596// Runs file-op.ps1 on a path inside the explorer root and reports a refusal as a toast.
597async function fileOp($: any, op: string, path: string, name = '') {
598  try {
599    const cur: Tree = await read($, tree)
600    const [root, item] = await Promise.all([$.fs.stat(cur.root, { resolve: true }), $.fs.stat(path, { resolve: true })])
601    const inside = (p?: string) => !!p && !!root.realPath && (norm(p) + '/').startsWith(norm(root.realPath) + '/')
602    if (!inside(item.realPath) || (norm(item.realPath) === norm(root.realPath) && !op.startsWith('new'))) throw new Error('Item is outside the explorer root.')
603    const result = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-File', `${$.plugin.root}/hooks/file-op.ps1`, '-Op', op, '-Path', item.realPath, '-RootPath', root.realPath, ...(name ? ['-Name', name] : [])], { timeoutMs: 15000 })
604    if (result.exitCode !== 0) throw new Error('File operation could not finish.')
605    const data = JSON.parse(result.stdout)
606    if (data.ok !== true) throw new Error(typeof data.message === 'string' && data.message ? data.message : 'File operation failed.')
607    await refresh($)
608  } catch (error) {
609    $.ui.toast(cleanError(error))
610  }
611}
612
613type MenuItemDef = { id: string; label: string; run: () => unknown }
614
615// The items of the open row menu: the pane draws them and the item message runs one by id.
616function menuGroups($: any, t: Tree, row: Row, surface: string): MenuItemDef[][] {
617  const isDir = row.kind === 'dir'
618  const close = () => update($, tree, (cur: Tree) => ({ ...cur, ...NO_MENU }))
619  const closing = (id: string, label: string, run: () => unknown): MenuItemDef => ({ id, label, run: async () => { await close(); await run() } })
620  const staying = (id: string, label: string, patch: Partial<Tree>): MenuItemDef => ({ id, label, run: () => update($, tree, (cur: Tree) => ({ ...cur, menu: row.path, ...patch })) })
621  if (t.menuMode === 'delete') return [[closing('delete-confirm', 'Delete', () => fileOp($, 'delete', row.path)), { id: 'cancel', label: 'Cancel', run: close }]]
622  if (t.menuMode) return []
623  const rel = relPath(t.root, row.path)
624  return [
625    [
626      ...(isDir
627        ? [staying('new-file', 'New file', { menuMode: 'newfile', menuText: '' }), staying('new-folder', 'New folder', { menuMode: 'newfolder', menuText: '' })]
628        : [closing('open', 'Open', () => previewFile($, row.path, t.root))]),
629      closing('add-prompt', 'Add to prompt', () => $.prompt.fill({ text: mention(rel), mode: 'append' })),
630    ],
631    [
632      staying('rename', 'Rename', { menuMode: 'rename', menuText: row.name }),
633      closing('duplicate', 'Duplicate', () => fileOp($, 'duplicate', row.path)),
634      staying('delete', 'Delete', { menuMode: 'delete' }),
635    ],
636    [
637      closing('copy-path', 'Copy path', () => $.ui.copy({ text: row.path, surface })),
638      closing('copy-relative', 'Copy relative path', () => $.ui.copy({ text: rel, surface })),
639      closing('reveal', 'Reveal in File Explorer', () => $.process.run(['explorer.exe', `/select,${row.path.replace(/\//g, '\\')}`], { timeoutMs: 5000 })),
640    ],
641  ]
642}
643
644async function toggleDir($: any, path: string, isOpen: boolean) {
645  lastFileClick = undefined
646  selectionActive = true
647  await update($, tree, (cur: Tree) => ({
648    ...cur,
649    picked: path,
650    ...NO_MENU,
651    open: isOpen ? cur.open.filter(p => p !== path) : [...cur.open, path],
652    reveal: isOpen ? cur.reveal : { dir: path, n: 0 },
653  }))
654  if (isOpen) return
655  await load($, path)
656  const total = (await current($)).dirs[path]?.length ?? 0
657  let n = 0
658  const timer = $.clock.every(T.animMs, async () => {
659    n += T.animStep
660    const done = n >= total
661    if (done) timer.cancel()
662    await update($, tree, (cur: Tree) => ({
663      ...cur,
664      reveal: done ? { dir: '', n: 0 } : { dir: path, n },
665    }))
666  })
667}
668
669// iterate.json {remaining, prompt}: each reload queues one prompt while remaining > 0.
670async function continueIteration($: any) {
671  const file = `${$.plugin.root}/iterate.json`
672  try {
673    const cfg = JSON.parse(await $.fs.read(file))
674    if (!(cfg.remaining > 0) || typeof cfg.prompt !== 'string') return
675    await $.fs.write(file, JSON.stringify({ ...cfg, remaining: cfg.remaining - 1 }))
676    void $.prompt.submit({ text: cfg.prompt })
677  } catch {
678    // no iterate.json: nothing to continue
679  }
680}
681
682let paneOpen = false
683let paneFocused = false
684// The menu path hidden because the pane lost the keyboard while it was open.
685let blurredMenu: string | undefined
686
687async function openPane($: any) {
688  await refresh($)
689  const opened = await $.ui.open({ id: PANE, title: 'Files' })
690  paneOpen = !!opened.isPlaced
691  if (!opened.isPlaced) $.ui.toast(`Files pane not drawn: ${opened.reason}`)
692}
693
694async function closePane($: any) {
695  const p = (await current($)).preview
696  if (p?.dirty || p?.busy) { await requestBack($); return false }
697  await $.ui.close({ id: PANE })
698  paneOpen = false
699  return true
700}
701
702// "auto" in theme.json: open the pane at every session start.
703async function setAuto($: any, auto: boolean) {
704  const file = `${$.plugin.root}/theme.json`
705  const cfg = JSON.parse(await $.fs.read(file))
706  await $.fs.write(file, JSON.stringify({ ...cfg, auto }, null, 2))
707  T.auto = auto
708}
709
710export const register: Register = on => {
711  on('session.start', async ($, e, next) => {
712    await $.command.register({ name: 'files', description: 'Toggle the files tree pane; on/off sets the start-up default' })
713    await loadTheme($)
714    await continueIteration($)
715    if (T.auto !== false) await openPane($)
716    $.clock.every(1500, async () => {
717      const changed = await loadTheme($)
718      // Session state can start empty (after /clear) without a new session.start.
719      if (changed || (T.pin && T.pin !== lastRoot) || !(await current($)).root) await refresh($)
720      if (!changed) return
721      await update($, tree, (cur: Tree) => ({ ...cur, tick: cur.tick + 1 }))
722    })
723    return next(e)
724  })
725
726  on('ui.close', async ($, e, next) => {
727    if ((e as any).id === PANE || (e as any).requestId === PANE) {
728      const p = (await current($)).preview
729      if (e.origin.kind !== 'unload' && (p?.dirty || p?.busy)) { await requestBack($); return { value: undefined } }
730      paneOpen = false
731      selectionActive = false
732      lastFileClick = undefined
733      clickAcks.clear()
734    }
735    return next(e)
736  })
737
738  // /files toggles the pane, /files on|off also sets the start-up default, /files <path> pins a root.
739  on('command.run', { command: 'files' }, async ($, e) => {
740    const arg = e.args.trim().replace(/^["']|["']$/g, '')
741    if (arg === 'on' || arg === 'off') {
742      await setAuto($, arg === 'on')
743      if (arg === 'on') await openPane($)
744      else if (!(await closePane($))) return { text: 'Auto-open disabled. Save or discard the draft before closing Files.' }
745      return { text: arg === 'on' ? 'Files pane on: opens at every session start.' : 'Files pane off: stays closed at session start. /files opens it.' }
746    }
747    if (arg) {
748      const p = (await current($)).preview
749      if (p?.dirty || p?.busy) { await requestBack($); return { text: 'Save or discard the current draft before changing the files root.' } }
750      const pin = arg === 'reset' ? '' : arg
751      await update($, tree, (cur: Tree) => ({ ...cur, pin, open: [], dirs: {}, picked: '', preview: undefined }))
752      await openPane($)
753      return { text: 'Files pane opened.' }
754    }
755    if (paneOpen) {
756      const closed = await closePane($)
757      return { text: closed ? 'Files pane closed.' : 'Save or discard the current draft before closing Files.' }
758    }
759    await openPane($)
760    return { text: 'Files pane opened.' }
761  })
762
763  on('turn.complete', async ($, e, next) => {
764    await refresh($)
765    return next(e)
766  })
767
768  on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
769    if (!(await current($)).preview && e.origin.kind === 'person' && (e.plugin !== 'file-explorer' || !/^(n:|menu-)/.test(e.element ?? ''))) await clearSelection($)
770    return next(e)
771  })
772
773  // The pane scrolls the editor natively; the editor only draws a window of lines, centred on the scroll position.
774  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
775    paneView.offset = e.offset; paneView.body = e.bodyRows; paneView.content = e.contentRows
776    const p = (await current($)).preview
777    if (p?.editing && p.draft !== undefined) {
778      const total = p.editorTotal ?? p.draft.split('\n').length
779      const first = Math.max(0, e.offset - Math.max(0, e.contentRows - total - 2))
780      const win = Math.max(0, Math.min(total - EDITOR_WINDOW, first - Math.floor((EDITOR_WINDOW - e.bodyRows) / 2)))
781      if (Math.abs(win - (p.editorWin ?? 0)) > 15) update($, tree, (cur: Tree) => cur.preview?.request === p.request ? { ...cur, preview: { ...cur.preview, editorWin: win } } : cur).catch(() => {})
782    }
783    return next(e)
784  })
785
786  on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
787    if (e.element === 'preview-navigation' && e.module === 'hooks/preview-navigation.tsx' && (e.data as { back?: unknown } | null)?.back === true) { await backToFiles($); return {} }
788    if (e.element === 'file-editor' && e.module === 'hooks/editor.tsx') return editorMessage($, e)
789    if (e.module === 'hooks/clear-selection.tsx' && ['tree-empty', 'tree-title'].includes(e.element) && (e.data as { clear?: unknown } | null)?.clear === true) {
790      await clearSelection($)
791      return {}
792    }
793    if (e.module === 'hooks/menu-item.tsx') {
794      const id = (e.data as { id?: unknown } | null)?.id
795      if (typeof id !== 'string' || e.element !== `m:${id}`) return {}
796      const t = await current($)
797      const row = t.menu && !t.preview ? flatten(t).slice(0, MAX_ROWS).find(r => r.path === t.menu) : undefined
798      const item = row && menuGroups($, t, row, e.surface).flat().find(i => i.id === id)
799      if (item) await item.run()
800      return {}
801    }
802    const data = e.data as { path?: unknown; instance?: unknown; clicks?: unknown; toggle?: unknown; menu?: unknown } | null
803    if (!data || typeof data.path !== 'string' || e.element !== `n:${data.path}` || e.module !== 'hooks/file-name.tsx') return {}
804    if (data.menu === true) {
805      const t = await current($)
806      const path = data.path
807      if (t.preview || !flatten(t).slice(0, MAX_ROWS).some(r => r.path === path)) return {}
808      const shown = t.menu === path && blurredMenu !== path
809      blurredMenu = undefined
810      lastFileClick = undefined
811      await update($, tree, (cur: Tree) => shown ? { ...cur, ...NO_MENU } : { ...cur, ...NO_MENU, menu: path })
812      return {}
813    }
814    if (data.toggle === true) {
815      const t = await current($)
816      const row = t.preview ? undefined : flatten(t).slice(0, MAX_ROWS).find(r => r.path === data.path)
817      if (row?.kind === 'dir') await toggleDir($, row.path, row.isOpen)
818      return {}
819    }
820    if (typeof data.instance !== 'string' || data.instance.length > 100 || !Array.isArray(data.clicks) || !data.clicks.length || data.clicks.length > 32) return {}
821    if (!data.clicks.every(click => click && Number.isSafeInteger(click.seq) && click.seq > 0 && typeof click.ctrl === 'boolean' && typeof click.canDouble === 'boolean')) return {}
822    const t = await current($)
823    if (t.preview || !flatten(t).slice(0, MAX_ROWS).some(row => row.path === data.path && row.kind !== 'dir')) return {}
824    const prior = clickAcks.get(data.path)
825    let seq = prior?.instance === data.instance ? prior.seq : 0
826    const pending = data.clicks.filter(click => click.seq > seq)
827    if (!pending.every((click, i) => click.seq === seq + i + 1)) return {}
828    let preview = false
829    let mentions = 0
830    for (const click of pending) {
831      if (click.ctrl) { mentions++; lastFileClick = undefined }
832      else if (click.canDouble && lastFileClick?.path === data.path && lastFileClick.root === t.root) {
833        preview = true
834        lastFileClick = undefined
835      } else lastFileClick = { path: data.path, root: t.root }
836      seq = click.seq
837    }
838    const ack = { instance: data.instance, seq }
839    clickAcks.set(data.path, ack)
840    if (pending.length) {
841      selectionActive = true
842      await update($, tree, (cur: Tree) => ({ ...cur, picked: data.path as string, ...NO_MENU }))
843    }
844    for (let i = 0; i < mentions; i++) await $.prompt.fill({ text: mention(relPath(t.root, data.path)), mode: 'append' })
845    if (preview) await previewFile($, data.path, t.root)
846    return {}
847  })
848
849  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
850    if (e.surface !== 'desktop' && e.surface !== 'terminal') return $.ui.resolve(e).Text({ children: 'Files pane is available in Claude Desktop and the terminal.' })
851    const elements = $.ui.resolve(e)
852    const { Box, Text, Button, Input, Code, Markdown, Client } = elements
853    const Svg = 'Svg' in elements ? elements.Svg : undefined
854    try {
855      await loadTheme($)
856      if (!e.props.isFocused) { selectionActive = false; lastFileClick = undefined }
857      const t: Tree = await current($)
858      // No blur event: the pane losing the keyboard (a click outside) hides the open menu. Drawing only, no state write.
859      if (paneFocused && !e.props.isFocused && t.menu) blurredMenu = t.menu
860      paneFocused = e.props.isFocused
861      paneView.offset = e.props.scroll.offset; paneView.body = e.props.scroll.bodyRows; paneView.mono = e.surface === 'desktop' && !!T.editorMono; paneView.fit = e.surface === 'desktop' && !paneView.mono ? T.textFit : 1
862      if (findShown && !t.preview?.editing) { findShown = false; $.ui.status(undefined) }
863      if (t.preview) {
864        const p = t.preview
865        const rel =p.path.slice(t.root.replace(/[\\/]+$/, '').length + 1)
866        return (
867          <Box flexDirection="column" paddingX={1} gap={1}>
868            <Box flexDirection="row" justifyContent="space-between">
869              <Client key="preview-navigation" module="./preview-navigation.tsx" props={{}} />
870              {p.editable && <Box flexDirection="row" gap={1}>
871                {p.editing && <Button key="preview-wrap" label={editorProps(p).wrap ? 'Wrap: on' : 'Wrap: off'} variant="secondary" onPress={() => update($, tree, (cur: Tree) => cur.preview ? { ...cur, preview: { ...cur.preview, editorWrap: !editorProps(cur.preview).wrap } } : cur)} />}
872                <Button key="preview-edit" label="Edit" variant={p.editing ? 'primary' : 'secondary'} onPress={() => update($, tree, (cur: Tree) => ({ ...cur, preview: cur.preview ? { ...cur.preview, editing: true, confirmBack: false } : undefined }))} />
873                <Button key="preview-save" label={p.busy ? 'Saving…' : 'Save'} variant="primary" onPress={() => savePreview($, p.request)} />
874              </Box>}
875            </Box>
876            <Text bold wrap="wrap">{rel}{p.dirty ? ' • Unsaved' : ''}</Text>
877            {p.confirmBack && <Box flexDirection="column" gap={1}>
878              <Text>Save changes before returning to Files?</Text>
879              <Box flexDirection="row" gap={1}>
880                <Button key="preview-save-back" label="Save and return" variant="primary" onPress={async () => { await savePreview($, p.request); const latest = (await current($)).preview; if (latest?.request === p.request && !latest.dirty && !latest.busy && !latest.error) await backToFiles($) }} />
881                <Button key="preview-discard" label="Discard changes" onPress={() => backToFiles($, true)} />
882                <Button key="preview-keep-editing" label="Keep editing" onPress={() => update($, tree, (cur: Tree) => ({ ...cur, preview: cur.preview ? { ...cur.preview, confirmBack: false } : undefined }))} />
883              </Box>
884            </Box>}
885            {p.error && <Text color="red" wrap="wrap">{p.error}</Text>}
886            {p.kind === 'text' && (
887              <Box flexDirection="row" gap={1}>
888                {p.markdown && <Button key="preview-rendered" label="Preview" plain onPress={() => update($, tree, (cur: Tree) => ({ ...cur, preview: cur.preview ? { ...cur.preview, rendered: true, editing: false } : undefined }))} />}
889                <Button key="preview-source" label="Source" plain onPress={() => update($, tree, (cur: Tree) => ({ ...cur, preview: cur.preview ? { ...cur.preview, rendered: false, editing: false } : undefined }))} />
890              </Box>
891            )}
892            {p.kind === 'loading' ? <Text dimColor>Loading preview…</Text>
893              : p.kind === 'image' && Svg ? <Svg source={p.content} alt={`Preview of ${rel}`} />
894              : p.kind === 'image' ? <Text dimColor>Image preview is available in Claude Desktop.</Text>
895              : p.kind === 'text' && p.editing ? <Box flexDirection="column">
896                <Text dimColor>{`Ln ${(p.editorLine ?? 0) + 1}, Col ${(p.editorCol ?? 0) + 1}${p.editorSelected ? ` (${p.editorSelected} selected)` : ''} · ${ENCODINGS[p.encoding ?? ''] ?? p.encoding ?? ''} · ${(p.eol ?? 'lf').toUpperCase()} · Ctrl+S Save · Ctrl+Z Undo · Ctrl+Y Redo · Ctrl+V Paste`}</Text>
897                {(() => {
898                  const props = editorFor(p, Math.max(8, Math.min(40, e.props.scroll.bodyRows - 8)))
899                  const client = <Client key="file-editor" module="./editor.tsx" width="100%" props={props} />
900                  const geo = props.mono && Svg ? editorGeo.get(p.request) : undefined
901                  if (!geo) return <Box flexDirection="column" position="relative">
902                    <Box key="editor-caret" position="absolute" top={p.editorRow ?? 0} height={1} width={1} />
903                    {client}
904                  </Box>
905                  // Monospace mode: the text is Code rows here, the Client lies on top for keys, pointer, caret and highlights.
906                  const text = props.text
907                  const gutter = Math.max(4, String(text.split('\n').length).length + 2)
908                  const cells = Math.max(8, geo.columns - gutter)
909                  const vrows = layout(text, geo.limit)
910                  const from = Math.max(0, props.top - 20)
911                  const to = Math.min(vrows.length, props.top + props.span + 20)
912                  const caretLine = vrows[Math.min(vrows.length - 1, p.editorRow ?? 0)]?.line
913                  const kinds = highlight(text, props.ext)
914                  const sel: [number, number] | undefined = p.editorCaret !== undefined && p.editorAnchor !== undefined && p.editorCaret !== p.editorAnchor
915                    ? [Math.min(p.editorCaret, p.editorAnchor), Math.max(p.editorCaret, p.editorAnchor)] : undefined
916                  return <Box flexDirection="column" position="relative" height={vrows.length}>
917                    {from > 0 && <Box key="code-top" height={from} flexShrink={0} />}
918                    {vrows.slice(from, to).map((r, i) => {
919                      const end = text[r.end - 1] === '\n' ? r.end - 1 : r.end
920                      return <Box key={`code-row:${from + i}`} height={1} flexShrink={0} overflow="hidden" flexDirection="row" backgroundColor={r.line === caretLine ? '#151b23' : undefined}>
921                        <Box width={gutter} flexShrink={0} />
922                        <Box flexGrow={1} height={1} overflow="hidden">{Svg && (text.slice(r.start, end).trim() || from + i === p.editorRow || sel) ? <Svg source={rowSvg(text.slice(0, end), kinds, r.start, geo.left, cells, sel, from + i === p.editorRow ? p.editorCaret : undefined)} alt="editor row" width={Math.round(cells * T.cellW)} height={T.cellPx} /> : null}</Box>
923                      </Box>
924                    })}
925                    <Box key="editor-caret" position="absolute" top={p.editorRow ?? 0} height={1} width={1} />
926                    <Box position="absolute" top={0} left={0} width="100%" height={vrows.length}>{client}</Box>
927                  </Box>
928                })()}
929              </Box>
930              : p.kind === 'text' ? (p.content ? (p.markdown && p.rendered ? <Box flexDirection="column">{markdownChunks(p.content).map((part, index) => <Box key={`markdown:${index}`}>{part.code ? <Code source={part.text} language={part.code} /> : <Markdown text={part.text} />}</Box>)}</Box> : <Box flexDirection="column">{sourceChunks(p.content).map((part, index) => <Box key={`source:${index}`}><Code source={part.text} path={p.path} startLine={part.line} /></Box>)}</Box>) : <Text dimColor>Empty file.</Text>)
931              : <Text color={p.kind === 'error' ? 'red' : undefined}>{p.content}</Text>}
932            {p.notice && <Text dimColor>{p.notice}</Text>}
933          </Box>
934        )
935      }
936      const rows = flatten(t)
937      const shown = rows.slice(0, MAX_ROWS)
938
939      const setFilter = (v: string) => {
940        lastFileClick = undefined
941        if (v.trim() && searchIndex?.root !== t.root) buildSearchIndex($, t.root).catch(() => {})
942        return update($, tree, (cur: Tree) => ({ ...cur, filter: v, picked: '' }))
943      }
944
945      const footer =
946        (rows.length > MAX_ROWS ? `Showing ${MAX_ROWS} of ${rows.length}` : `${rows.length} items`) + ` | ${themeStatus}`
947
948      const rootKey = norm(t.root)
949      const isIgnored = (path: string) => {
950        let key = norm(path)
951        while (key.length > rootKey.length) {
952          if (t.git[key] === 'I') return true
953          key = key.slice(0, key.lastIndexOf('/'))
954        }
955        return false
956      }
957
958      // The highlight ends are in-flow cells; an absolute layer over the row would paint over its icon.
959      // alignSelf flex-start pins the cell to the row's top: the desktop draws it zero-high, and centred it starts half a row low.
960      const rowCap = (left: boolean, isPicked: boolean) => {
961        if (!Svg) return <Box width={T.padX} />
962        const color = isPicked ? T.bgPicked : T.bgHover
963        return (
964          <Box width={1} position="relative" alignSelf="flex-start">
965            <Svg source={`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 ${T.rowH}" width="8" height="${T.rowH}"/>`} alt="" width={8} height={T.rowH} />
966            <Box position="absolute" top={0} left={0} display={isPicked ? 'flex' : 'none'} hover={isPicked ? undefined : { display: 'flex' }}>
967              <Svg source={capSvg(color, left)} alt={isPicked ? 'row-cap-picked' : 'row-cap'} width={8} height={T.rowH} />
968            </Box>
969          </Box>
970        )
971      }
972      // Drawn last in the rows container, so no row paints over it. Rows are rowH px tall and Box offsets are whole cells of cellPx px.
973      const menuAt = t.menu && t.menu !== blurredMenu ? shown.findIndex(r => r.path === t.menu) : -1
974      const menuPanel = (row: Row, idx: number) => {
975        const mode = t.menuMode
976        const edit = mode === 'rename' || mode === 'newfile' || mode === 'newfolder'
977        const groups = menuGroups($, t, row, e.surface)
978        const lines = edit ? 1 : mode === 'delete' ? 3 : groups.flat().length + groups.length - 1
979        const cells = (rows: number) => Math.round((rows * T.rowH) / T.cellPx)
980        const { offset = 0, bodyRows = 30 } = (e.props as any).scroll ?? {}
981        // Beside its row, ending just left of the "…" (right cap cell + 2 trigger cells); flipped up near the bottom.
982        const top = 4 + cells(idx) - offset + lines + 2 > bodyRows ? Math.max(0, cells(idx + 1) - lines - 2) : cells(idx)
983        return (
984          <Box key="menu-panel" position="absolute" top={top} right={3} width={MENU_W} flexDirection="column" backgroundColor={T.menuBg} borderStyle="round" borderColor={T.menuBorder} paddingX={1}>
985            {edit && (
986              <Input key="menu-input" label={mode === 'newfile' ? 'New file ' : mode === 'newfolder' ? 'New folder ' : ''} value={t.menuText ?? ''} onInput={(v: string) => update($, tree, (cur: Tree) => ({ ...cur, menuText: v }))}
987                onSubmit={async (v: string) => {
988                  await update($, tree, (cur: Tree) => ({ ...cur, ...NO_MENU, open: mode === 'rename' || cur.open.includes(row.path) ? cur.open : [...cur.open, row.path] }))
989                  await fileOp($, mode as string, row.path, v)
990                }} />
991            )}
992            {mode === 'delete' && <Text>Move to Recycle Bin?</Text>}
993            {groups.map((group, g) => (
994              <Box key={`menu-group-${g}`} flexDirection="column">
995                {/* The desktop draws ─ 1.15 cells wide; a wrapping Box would move the rule to the top of its cell. */}
996                {g > 0 && <Text color={T.menuBorder}>{'─'.repeat(Math.floor((MENU_W - 4) / (e.surface === 'desktop' ? 1.15 : 1)))}</Text>}
997                {group.map(item => (
998                  <Box key={`menu-row-${item.id}`} hover={{ backgroundColor: T.menuHover }}>
999                    <Client key={`m:${item.id}`} module="./menu-item.tsx" width="100%" height={1} props={{ id: item.id, label: item.label }} />
1000                  </Box>
1001                ))}
1002              </Box>
1003            ))}
1004          </Box>
1005        )
1006      }
1007
1008      return (
1009        <Box flexDirection="column" minHeight="100%">
1010          {(() => {
1011            const wide = ((e as { bodyColumns?: number }).bodyColumns ?? 0) >= T.wideCells
1012            const search = (
1013              <Input
1014                key="filter"
1015                label="⌕ "
1016                placeholder="Search files"
1017                value={t.filter}
1018                onInput={setFilter}
1019                onSubmit={setFilter}
1020              />
1021            )
1022            // A keyed Box is the hover scope: the label is hidden until the pointer is over its button.
1023            const tip = (id: string, label: string, button: unknown) => (
1024              <Box key={`tip-${id}`} position="relative">
1025                {button}
1026                <Box position="absolute" top={1} right={0} display="none" hover={{ display: 'flex' }} backgroundColor="#1f2428" paddingX={1}>
1027                  <Text>{label}</Text>
1028                </Box>
1029              </Box>
1030            )
1031            const actions = (
1032              <Box flexDirection="row" gap={1}>
1033                {tip('refresh', 'Refresh', <Button key="refresh" label="↻" plain dimColor onPress={async () => { await clearSelection($); await refresh($) }} />)}
1034                {tip('collapse', 'Collapse all folders', <Button
1035                  key="collapse"
1036                  label="⊟"
1037                  plain
1038                  dimColor
1039                  onPress={() => { lastFileClick = undefined; return update($, tree, (cur: Tree) => ({ ...cur, open: [], picked: '' })) }}
1040                />)}
1041              </Box>
1042            )
1043            if (T.searchMode === 'abs') {
1044              return (
1045                <Box flexDirection="column">
1046                  <Box position="absolute" top={T.searchTop} right={T.searchRight} width={T.searchCells}>
1047                    {search}
1048                  </Box>
1049                  <Box flexDirection="row" justifyContent="space-between" paddingX={T.padX}>
1050                    <Client key="tree-title" module="./clear-selection.tsx" flexGrow={1} height={1}
1051                      props={{ text: `▾ ${baseName(t.root || '...').toUpperCase()}`, bold: true, padX: 0 }} />
1052                    {actions}
1053                  </Box>
1054                </Box>
1055              )
1056            }
1057            return (
1058              <Box flexDirection="column" paddingTop={1}>
1059                <Box flexDirection="row" justifyContent="space-between" paddingX={T.padX}>
1060                    <Client key="tree-title" module="./clear-selection.tsx" flexGrow={1} height={1}
1061                      props={{ text: `▾ ${baseName(t.root || '...').toUpperCase()}`, bold: true, padX: 0 }} />
1062                  <Box flexDirection="row" gap={1}>
1063                    {wide && <Box width={T.searchCells}>{search}</Box>}
1064                    {actions}
1065                  </Box>
1066                </Box>
1067                {!wide && search}
1068              </Box>
1069            )
1070          })()}
1071          <Box position="relative" flexDirection="column">
1072          {shown.map(row => {
1073            const icon = iconOf(row.name, row.kind)
1074            const letter = t.git[norm(row.path)]
1075            const status = letter === 'I' ? undefined : letter
1076            const isDir = row.kind === 'dir'
1077            const isPicked = selectionActive && t.picked === row.path
1078            const isDim = isIgnored(row.path) || row.fresh
1079            return (
1080              <Box key={row.path} flexDirection="row" alignItems="center">
1081                {rowCap(true, isPicked)}
1082                <Box flexGrow={1} flexDirection="row" alignItems="center" backgroundColor={isPicked ? T.bgPicked : undefined} hover={isPicked ? undefined : { backgroundColor: T.bgHover }}>
1083                {Array.from({ length: row.depth }, (_, i) => (
1084                  <Box key={`g${i}`} width={T.guideCells}>
1085                    {Svg ? <Svg source={guideSvg()} alt="" width={16} height={T.rowH} /> : <Text dimColor>│ </Text>}
1086                  </Box>
1087                ))}
1088                <Box width={T.chevCells}>
1089                  {!isDir ? (
1090                    <Text> </Text>
1091                  ) : Svg ? (
1092                    <Svg source={chevronSvg(row.isOpen)} alt="chevron" width={16} height={T.rowH} />
1093                  ) : (
1094                    <Text dimColor>{row.isOpen ? '⌄' : '›'}</Text>
1095                  )}
1096                </Box>
1097                <Box width={T.iconCells}>
1098                  {Svg ? (
1099                    <Svg
1100                      source={isDir ? folderSvg(row.isOpen, isDim) : fileSvg(icon.color, isDim)}
1101                      alt={isDir ? 'folder' : 'file'}
1102                      width={16}
1103                      height={T.rowH}
1104                    />
1105                  ) : (
1106                    <Text color={icon.color} bold>
1107                      {icon.glyph}
1108                    </Text>
1109                  )}
1110                </Box>
1111                <Box flexGrow={1} flexDirection="row">
1112                  {/* The Client also draws the status and the "…" trigger: a desktop Button can spend its first click on focus, and a Client per trigger floods the page. */}
1113                  <Client key={`n:${row.path}`} module="./file-name.tsx" width="100%" height={1}
1114                    props={{ path: row.path, name: row.where ? `${row.name}  ${row.where}` : row.name, dim: isDim, picked: t.picked, ack: clickAcks.get(row.path) ?? null, dir: isDir, menu: true,
1115                      status: status ? { text: isDir ? '●' : status, color: GIT_COLOR[status] ?? '' } : null }} />
1116                </Box>
1117                </Box>
1118                {rowCap(false, isPicked)}
1119              </Box>
1120            )
1121          })}
1122          {menuAt >= 0 && menuPanel(shown[menuAt]!, menuAt)}
1123          </Box>
1124          <Client key="tree-empty" module="./clear-selection.tsx" width="100%" flexGrow={1}
1125            props={{ text: footer, bold: false, padX: 1 }} />
1126        </Box>
1127      )
1128    } catch (err) {
1129      return <Text color="red">file-explorer error: {String((err as Error)?.message ?? err)}</Text>
1130    }
1131  })
1132}
1133
hooks/editing.ts 63 lines
1import type { Preview } from '../types'
2import type { EditorProps } from './editor'
3
4const PROSE_EXT = ['md', 'markdown', 'txt', 'text']
5
6export function editorProps(p: Preview, rows = 20, fit = 1, top = 0, span = 40): EditorProps {
7  const ext = (/\.([^./\\]+)$/.exec(p.path)?.[1] ?? '').toLowerCase()
8  return { request: p.request, text: p.draft ?? '', instance: p.editorInstance ?? '', seq: p.editorSeq ?? 0,
9    refresh: p.editorRefresh ?? 0, cursor: p.editorCursor ?? 0, busy: !!p.busy, rows, fit, top, span, win: p.editorWin ?? 0,
10    wrap: p.editorWrap ?? PROSE_EXT.includes(ext), ext }
11}
12
13export function sourceChunks(text: string): { text: string; line: number }[] {
14  const chunks: { text: string; line: number }[] = []
15  let line = 1
16  while (text.length) {
17    let length = Math.min(9000, text.length)
18    if (length < text.length) {
19      const newline = text.lastIndexOf('\n', length - 1)
20      if (newline >= 0) length = newline + 1
21      if (/[\ud800-\udbff]/.test(text[length - 1] ?? '')) length--
22    }
23    const part = text.slice(0, length)
24    chunks.push({ text: part, line })
25    line += (part.match(/\n/g) ?? []).length
26    text = text.slice(length)
27  }
28  return chunks
29}
30
31export function markdownChunks(text: string): { text: string; code?: string }[] {
32  if (text.length <= 10000) return [{ text }]
33  const result: { text: string; code?: string }[] = []
34  let pending = ''
35  let block = ''
36  let fence = ''
37  let language = ''
38  const flush = () => { if (pending) { result.push({ text: pending }); pending = '' } }
39  const add = (part: string) => {
40    if (part.length > 9000) { flush(); for (const chunk of sourceChunks(part)) result.push({ text: chunk.text, code: 'text' }); return }
41    if (pending.length + part.length > 9000) flush()
42    pending += part
43  }
44  for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
45    const marker = /^\s*(`{3,}|~{3,})([^\s]*)/.exec(line)
46    if (!fence && marker) { if (block) { add(block); block = '' } fence = marker[1]!; language = marker[2] ?? '' }
47    else if (fence && marker && marker[1]![0] === fence[0] && marker[1]!.length >= fence.length) {
48      block += line
49      if (block.length > 9000) {
50        flush()
51        const contents = block.slice(block.indexOf('\n') + 1, block.lastIndexOf(line))
52        for (const chunk of sourceChunks(contents)) result.push({ text: chunk.text, code: language || 'text' })
53      } else add(block)
54      block = ''; fence = ''; continue
55    }
56    block += line
57    if (!fence && !line.trim()) { add(block); block = '' }
58  }
59  if (block) add(block)
60  flush()
61  return result
62}
63
hooks/editor.tsx 729 lines
1import type { ClientModule } from 'claude-code'
2
3export type EditorProps = {
4  request: number; text: string; instance: string; seq: number; refresh: number; cursor: number;
5  busy: boolean; rows: number; ext: string; fit: number; win: number; wrap: boolean; top: number; span: number
6  mono?: boolean
7}
8type Snapshot = { text: string; cursor: number; anchor: number }
9type Find = { mode: 0 | 1 | 2; q: string; r: string; field: 0 | 1 }
10type State = Snapshot & {
11  request: number; instance: string; seq: number; refresh: number; limit: number; find: Find; findView: string;
12  left: number; py: number; view?: string; geo?: string; win: number; winProp: number; seen: number; undo: Snapshot[]; redo: Snapshot[]; drag: boolean; locked: number
13  clicks: { t: number; x: number; y: number; n: number } | null; unit: 'char' | 'word' | 'line'; base: [number, number]
14  edit: { kind: string; t: number; at: number } | null
15}
16
17const SLASHES = ['c', 'cc', 'cpp', 'h', 'hpp', 'cs', 'java', 'js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'go', 'rs', 'swift', 'kt', 'php', 'scala', 'dart']
18const HASHES = ['py', 'sh', 'bash', 'zsh', 'ps1', 'psm1', 'psd1', 'yml', 'yaml', 'toml', 'rb', 'r', 'pl', 'conf', 'env', 'gitignore', 'dockerfile']
19function commentMarks(ext: string): [string, string] {
20  if (SLASHES.includes(ext)) return ['//', '']
21  if (HASHES.includes(ext)) return ['#', '']
22  if (['sql', 'lua', 'hs'].includes(ext)) return ['--', '']
23  if (['ini', 'cfg'].includes(ext)) return [';', '']
24  if (['css', 'scss'].includes(ext)) return ['/*', '*/']
25  if (['html', 'htm', 'xml', 'md', 'svg', 'vue'].includes(ext)) return ['<!--', '-->']
26  return ['#', '']
27}
28
29const WINDOW = 120, BUDGET = 70000
30export const PLAIN = 0, KW = 1, CTL = 2, STR = 3, COM = 4, NUM = 5, TYPE = 6, FN = 7, PROP = 8, HEAD = 9, CODE = 10
31const FAINT = '#4b5260', MATCH = '#3a3f4b', FOUND = '#515c6a'
32export const COLORS: (string | undefined)[] =[undefined, '#569cd6', '#c586c0', '#ce9178', '#6a9955', '#b5cea8', '#4ec9b0', '#dcdcaa', '#9cdcfe', '#569cd6', '#ce9178']
33
34type Lang = {
35  line?: string[]; block?: [string, string][]; quotes?: string; triple?: boolean
36  kw?: Set<string>; ctl?: Set<string>; types?: Set<string>
37  ci?: boolean; vars?: boolean; fn?: boolean; dash?: boolean; verbNoun?: boolean; hex?: boolean; at?: boolean; keyed?: ':' | '='
38}
39const words = (list: string) => new Set(list.split(' '))
40const CLIKE: Lang = { line: ['//'], block: [['/*', '*/']], quotes: '"\'`', fn: true,
41  kw: words('const let var function class interface enum extends implements new this super typeof instanceof in of as import from export default async await static public private protected readonly void null undefined true false delete keyof namespace declare abstract int long short byte char float double bool string struct fn mut impl trait use mod pub func package go defer chan using internal sealed override virtual nil self'),
42  ctl: words('if else for while do switch case break continue return throw try catch finally yield match goto select foreach'),
43  types: words('number boolean any unknown never symbol bigint Array Promise Record Map Set Date Error String Number Boolean Object') }
44const JSONL: Lang = { quotes: '"', keyed: ':', kw: words('true false null') }
45const PY: Lang = { line: ['#'], quotes: '"\'', triple: true, fn: true,
46  kw: words('def class import from as lambda None True False self cls in is not and or with pass global nonlocal async await del'),
47  ctl: words('if elif else for while break continue return raise try except finally yield assert match case') }
48const PS1: Lang = { line: ['#'], block: [['<#', '#>']], quotes: '"\'', vars: true, ci: true, dash: true, verbNoun: true,
49  kw: words('function param begin process end class using filter workflow in switch'),
50  ctl: words('if elseif else foreach for while do until break continue return throw try catch finally exit trap') }
51const SH: Lang = { line: ['#'], quotes: '"\'', vars: true,
52  kw: words('function export local readonly alias source in do done then fi esac declare'),
53  ctl: words('if elif else for while until case return exit break continue') }
54const YML: Lang = { line: ['#'], quotes: '"\'', keyed: ':', dash: true, kw: words('true false null yes no on off True False') }
55const TOML: Lang = { line: ['#', ';'], quotes: '"\'', keyed: '=', dash: true, kw: words('true false') }
56const SQL: Lang = { line: ['--'], block: [['/*', '*/']], quotes: '\'"', ci: true, fn: true,
57  kw: words('select from where and or not null insert into values update set delete create table alter drop index join left right inner outer on as group by order having limit distinct union all case when then else end is in like between exists primary key foreign references default int varchar text begin commit rollback') }
58const CSS: Lang = { block: [['/*', '*/']], quotes: '"\'', keyed: ':', dash: true, hex: true, at: true, kw: words('important') }
59const LANGS = new Map<string, Lang | 'md' | 'html'>()
60for (const e of 'js jsx mjs cjs ts tsx cs java go rs c cc cpp h hpp kt swift dart php scala'.split(' ')) LANGS.set(e, CLIKE)
61for (const [list, lang] of [['json jsonc', JSONL], ['py', PY], ['ps1 psm1 psd1', PS1], ['sh bash zsh', SH], ['yml yaml', YML], ['toml ini cfg conf env', TOML], ['sql', SQL], ['css scss', CSS],
62  ['md markdown', 'md'], ['html htm xml svg vue', 'html']] as const) for (const e of list.split(' ')) LANGS.set(e, lang)
63const FENCE_EXT: Record<string, string> = { typescript: 'ts', javascript: 'js', powershell: 'ps1', bash: 'sh', shell: 'sh', python: 'py', yaml: 'yml', csharp: 'cs', rust: 'rs', golang: 'go', 'c++': 'cpp', xml: 'html' }
64
65const IDENT = /[A-Za-z_$][\w$]*/y
66const IDENT_DASH = /[A-Za-z_$][\w$-]*/y
67const NUMBER = /0[xX][\da-fA-F_]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?[A-Za-z%]*/y
68const VAR = /\$\{?[\w:?]+\}?/y
69const HEX = /#[\da-fA-F]{3,8}\b/y
70
71function sticky(re: RegExp, text: string, at: number) {
72  re.lastIndex = at
73  return re.exec(text)
74}
75
76function scan(text: string, cfg: Lang, kinds: Uint8Array, from: number, to: number) {
77  const keyRe = cfg.keyed === '=' ? /^[ \t]*=(?!=)/ : /^[ \t]*:(?=\s|$)/
78  const strKeyRe = cfg.keyed === '=' ? /^\s*=/ : /^\s*:/
79  const ident = cfg.dash ? IDENT_DASH : IDENT
80  let i = from
81  outer: while (i < to) {
82    const c = text[i]!
83    for (const [open, close] of cfg.block ?? []) {
84      if (!text.startsWith(open, i)) continue
85      const end = text.indexOf(close, i + open.length)
86      const stop = end < 0 || end + close.length > to ? to : end + close.length
87      kinds.fill(COM, i, stop)
88      i = stop
89      continue outer
90    }
91    for (const marker of cfg.line ?? []) {
92      if (!text.startsWith(marker, i)) continue
93      const nl = text.indexOf('\n', i)
94      const stop = nl < 0 || nl > to ? to : nl
95      kinds.fill(COM, i, stop)
96      i = stop
97      continue outer
98    }
99    if (cfg.quotes?.includes(c)) {
100      const triple = !!cfg.triple && text.startsWith(c.repeat(3), i)
101      const quote = triple ? c.repeat(3) : c
102      let j = i + quote.length
103      while (j < to) {
104        if (text[j] === '\\') { j += 2; continue }
105        if (text.startsWith(quote, j)) { j += quote.length; break }
106        if (!triple && c !== '`' && text[j] === '\n') break
107        j++
108      }
109      j = Math.min(j, to)
110      kinds.fill(cfg.keyed && strKeyRe.test(text.slice(j, j + 40)) ? PROP : STR, i, j)
111      i = j
112      continue
113    }
114    const special = c === '$' && cfg.vars ? sticky(VAR, text, i) : c === '#' && cfg.hex ? sticky(HEX, text, i) : null
115    if (special) {
116      const stop = Math.min(i + special[0].length, to)
117      kinds.fill(c === '$' ? PROP : NUM, i, stop)
118      i = stop
119      continue
120    }
121    if (c === '@' && cfg.at) {
122      const m = sticky(IDENT_DASH, text, i + 1)
123      if (m) { const stop = Math.min(i + 1 + m[0].length, to); kinds.fill(KW, i, stop); i = stop; continue }
124    }
125    if (c >= '0' && c <= '9') {
126      const stop = Math.min(i + sticky(NUMBER, text, i)![0].length, to)
127      kinds.fill(NUM, i, stop)
128      i = stop
129      continue
130    }
131    const m = sticky(ident, text, i)
132    if (m) {
133      const stop = Math.min(i + m[0].length, to)
134      const word = cfg.ci ? m[0].toLowerCase() : m[0]
135      const rest = text.slice(stop, stop + 20)
136      const kind = cfg.ctl?.has(word) ? CTL : cfg.kw?.has(word) ? KW : cfg.types?.has(word) ? TYPE
137        : cfg.keyed && keyRe.test(rest) ? PROP
138        : cfg.verbNoun && /^[A-Za-z]+-[A-Za-z]+$/.test(m[0]) ? FN
139        : cfg.fn && rest[0] === '(' ? FN
140        : cfg.fn && /^[A-Z][a-z]/.test(m[0]) ? TYPE : PLAIN
141      if (kind) kinds.fill(kind, i, stop)
142      i = stop
143      continue
144    }
145    i++
146  }
147}
148
149function htmlScan(text: string, kinds: Uint8Array, base: number) {
150  let i = 0
151  while (i < text.length) {
152    const lt = text.indexOf('<', i)
153    if (lt < 0) break
154    if (text.startsWith('<!--', lt)) {
155      const end = text.indexOf('-->', lt + 4)
156      const stop = end < 0 ? text.length : end + 3
157      kinds.fill(COM, base + lt, base + stop)
158      i = stop
159      continue
160    }
161    const tag = /^<\/?[A-Za-z][\w:.-]*/.exec(text.slice(lt, lt + 80))
162    if (!tag) { i = lt + 1; continue }
163    kinds.fill(TYPE, base + lt + (tag[0][1] === '/' ? 2 : 1), base + lt + tag[0].length)
164    let j = lt + tag[0].length
165    while (j < text.length && text[j] !== '>') {
166      const c = text[j]!
167      if (c === '"' || c === '\'') {
168        const end = text.indexOf(c, j + 1)
169        const stop = end < 0 ? text.length : end + 1
170        kinds.fill(STR, base + j, base + stop)
171        j = stop
172        continue
173      }
174      const attr = /^[A-Za-z_:@][\w:.@-]*/.exec(text.slice(j, j + 60))
175      if (attr) { kinds.fill(PROP, base + j, base + j + attr[0].length); j += attr[0].length; continue }
176      j++
177    }
178    i = j + 1
179  }
180}
181
182function highlightRange(text: string, ext: string, kinds: Uint8Array, from: number, to: number) {
183  const lang = LANGS.get(FENCE_EXT[ext.toLowerCase()] ?? ext.toLowerCase())
184  if (lang === 'html') htmlScan(text.slice(from, to), kinds, from)
185  else if (lang && lang !== 'md') scan(text, lang, kinds, from, to)
186}
187
188function markdownScan(text: string, kinds: Uint8Array) {
189  let pos = 0
190  let fence: { marker: string; lang: string; body: number } | null = null
191  while (pos <= text.length) {
192    const nl = text.indexOf('\n', pos)
193    const end = nl < 0 ? text.length : nl
194    const line = text.slice(pos, end)
195    if (fence) {
196      if (line.trim().startsWith(fence.marker)) {
197        highlightRange(text, fence.lang, kinds, fence.body, pos)
198        kinds.fill(COM, pos, end)
199        fence = null
200      }
201    } else {
202      const open = /^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/.exec(line)
203      if (open) {
204        fence = { marker: open[1]!, lang: open[2] ?? '', body: Math.min(end + 1, text.length) }
205        kinds.fill(COM, pos, end)
206      } else if (/^#{1,6}\s/.test(line)) kinds.fill(HEAD, pos, end)
207      else if (/^\s*>/.test(line)) kinds.fill(COM, pos, end)
208      else {
209        const list = /^\s*(?:[-*+]|\d+[.)])\s/.exec(line)
210        if (list) kinds.fill(KW, pos, pos + list[0].length - 1)
211        for (const m of line.matchAll(/\[[^\]\n]*\]\([^)\n]*\)/g)) kinds.fill(FN, pos + m.index!, pos + m.index! + m[0].length)
212        for (const m of line.matchAll(/`[^`\n]+`/g)) kinds.fill(CODE, pos + m.index!, pos + m.index! + m[0].length)
213      }
214    }
215    if (nl < 0) break
216    pos = nl + 1
217  }
218  if (fence) highlightRange(text, fence.lang, kinds, fence.body, text.length)
219}
220
221let memo: { text: string; ext: string; kinds: Uint8Array } | undefined
222export function highlight(text: string, ext: string): Uint8Array | undefined {
223  const lang = LANGS.get(ext)
224  if (!lang) return undefined
225  if (memo && memo.text === text && memo.ext === ext) return memo.kinds
226  const kinds = new Uint8Array(text.length)
227  try {
228    if (lang === 'md') markdownScan(text, kinds)
229    else highlightRange(text, ext, kinds, 0, text.length)
230  } catch { kinds.fill(PLAIN) }
231  memo = { text, ext, kinds }
232  return kinds
233}
234
235// The bracket under or just before `at` and its partner; brackets inside strings and comments do not count.
236function matchBracket(text: string, at: number, kinds?: Uint8Array): [number, number] | undefined {
237  const skip = (i: number) => kinds !== undefined && (kinds[i] === STR || kinds[i] === COM)
238  for (const i of [at, at - 1]) {
239    const c = text[i]
240    if (i < 0 || !c || skip(i)) continue
241    const open = '([{'.indexOf(c)
242    const close = ')]}'.indexOf(c)
243    if (open < 0 && close < 0) continue
244    const dir = open >= 0 ? 1 : -1
245    const partner = open >= 0 ? ')]}'[open]! : '([{'[close]!
246    let depth = 0
247    for (let j = i; j >= 0 && j < text.length; j += dir) {
248      if (skip(j)) continue
249      if (text[j] === c) depth++
250      else if (text[j] === partner && --depth === 0) return [i, j]
251    }
252  }
253  return undefined
254}
255
256// Start offsets of every case-insensitive, non-overlapping occurrence of q.
257function findAll(text: string, q: string): number[] {
258  if (!q) return []
259  const hay = text.toLowerCase()
260  const needle = q.toLowerCase()
261  const folded = hay.length === text.length && needle.length === q.length
262  const h = folded ? hay : text
263  const n = folded ? needle : q
264  const out: number[] = []
265  for (let i = h.indexOf(n); i >= 0; i = h.indexOf(n, i + n.length)) out.push(i)
266  return out
267}
268
269const kindOf = (c: string) => c === '\n' ? 'n' : /[ \t]/.test(c) ? 's' : /[\p{L}\p{N}_\ud800-\udfff]/u.test(c) ? 'w' : 'p'
270
271function wordRange(text: string, at: number): [number, number] {
272  let i = at
273  if ((i >= text.length || text[i] === '\n') && i > 0 && text[i - 1] !== '\n') i--
274  if (i >= text.length || text[i] === '\n') return [at, at]
275  const kind = kindOf(text[i]!)
276  let from = i
277  let to = i + 1
278  while (from > 0 && kindOf(text[from - 1]!) === kind) from--
279  while (to < text.length && kindOf(text[to]!) === kind) to++
280  return [from, to]
281}
282
283const lineStart = (text: string, at: number) => at <= 0 ? 0 : text.lastIndexOf('\n', at - 1) + 1
284
285// One entry per drawn row. `limit` 0 means one row per line; otherwise rows break after the last space, else hard-cut.
286type VRow = { start: number; end: number; line: number; first: boolean; last: boolean }
287let layoutMemo: { text: string; limit: number; rows: VRow[] } | undefined
288export function layout(text: string, limit: number): VRow[] {
289  if (layoutMemo && layoutMemo.text === text && layoutMemo.limit === limit) return layoutMemo.rows
290  const rows: VRow[] = []
291  let start = 0
292  for (let line = 0; ; line++) {
293    const nl = text.indexOf('\n', start)
294    const stop = nl < 0 ? text.length : nl
295    for (let at = start; ;) {
296      let end = stop
297      if (limit && stop - at > limit) {
298        const space = text.lastIndexOf(' ', at + limit - 1)
299        end = space > at ? space + 1 : at + limit
300        if (space <= at && /[\ud800-\udbff]/.test(text[end - 1]!)) end--
301      }
302      rows.push({ start: at, end, line, first: at === start, last: end === stop })
303      if (end === stop) break
304      at = end
305    }
306    if (nl < 0) break
307    start = nl + 1
308  }
309  layoutMemo = { text, limit, rows }
310  return rows
311}
312
313function lineRange(text: string, at: number): [number, number] {
314  const from = lineStart(text, at)
315  const newline = text.indexOf('\n', at)
316  return [from, newline < 0 ? text.length : newline + 1]
317}
318
319// The frame timer outlives each render's closures, so it calls whichever tick the latest render set.
320const ticks = new WeakMap<object, () => void>()
321
322const Editor: ClientModule<EditorProps, State> = (props, surface) => {
323  const { Box, Text } = surface.elements
324  if (!surface.state) surface.every(60, () => ticks.get(surface)?.())
325  let s: State = surface.state?.request === props.request ? surface.state : { text: props.text, cursor: 0, anchor: 0, request: props.request,
326    instance: crypto.randomUUID(), seq: 0, refresh: props.refresh, limit: -1, find: { mode: 0, q: '', r: '', field: 0 }, findView: '',
327    left: 0, py: 0, win: props.win, winProp: props.win, seen: 0, undo: [], redo: [], drag: false, locked: 0, clicks: null, unit: 'char', base: [0, 0], edit: null }
328  if (surface.state !== s) surface.setState(s)
329  if (props.instance === s.instance && props.seq >= s.locked && !props.busy) s.locked = 0
330  if (props.refresh !== s.refresh) {
331    s.undo.push({ text: s.text, cursor: s.cursor, anchor: s.anchor })
332    s.text = props.text
333    s.cursor = Math.min(props.cursor, s.text.length)
334    s.anchor = s.cursor
335    s.refresh = props.refresh
336    s.edit = null
337  }
338  const height = props.rows
339  const gutter = Math.max(4, String(s.text.split('\n').length).length + 2)
340  const cells = Math.max(8, (surface.columns || 70) - gutter)
341  const width = Math.max(8, Math.floor(cells * props.fit))
342  const limit = props.wrap ? width - 1 : 0
343  const V = () => layout(s.text, limit)
344  // Rows are drawn rows, not lines: with wrap off the two are the same.
345  const point = (position: number) => {
346    const all = V()
347    let lo = 0
348    let hi = all.length - 1
349    while (lo < hi) {
350      const mid = (lo + hi + 1) >> 1
351      if (all[mid]!.start <= position) lo = mid; else hi = mid - 1
352    }
353    return { row: lo, col: position - all[lo]!.start }
354  }
355  const offset = (row: number, col: number) => {
356    const all = V()
357    const r = all[Math.max(0, Math.min(all.length - 1, row))]!
358    return r.start + Math.max(0, Math.min(r.end - r.start - (r.last ? 0 : 1), col))
359  }
360  const redraw = (reveal = true) => {
361    if (reveal) {
362      const p = point(s.cursor)
363      if (limit) s.left = 0
364      else if (p.col < s.left) s.left = p.col
365      else if (p.col >= s.left + width) s.left = p.col - width + 1
366    }
367    surface.setState({ ...s })
368  }
369  const send = (action = 'change') => {
370    const seq = ++s.seq
371    if (action !== 'change') s.locked = seq
372    surface.post({ request: s.request, instance: s.instance, seq, text: s.text, action, rows: props.rows,
373      from: Math.min(s.cursor, s.anchor), to: Math.max(s.cursor, s.anchor) })
374    redraw()
375  }
376  const snapshot = (): Snapshot => ({ text: s.text, cursor: s.cursor, anchor: s.anchor })
377  // A run of typing, or of deletes, at one caret is a single undo step.
378  const replace = (text: string, from = Math.min(s.cursor, s.anchor), to = Math.max(s.cursor, s.anchor), kind = 'other') => {
379    text = text.replace(/\r\n?/g, '\n')
380    if (s.text.length - (to - from) + text.length > 30000 || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(text)) return false
381    const now = Date.now()
382    const last = s.edit
383    const join = kind !== 'other' && last && last.kind === kind && now - last.t < 800 && last.at === s.cursor && s.cursor === s.anchor
384    if (!join) {
385      s.undo.push(snapshot())
386      if (s.undo.length > 100) s.undo.shift()
387    }
388    s.redo = []
389    s.text = s.text.slice(0, from) + text + s.text.slice(to)
390    s.cursor = from + text.length
391    s.anchor = s.cursor
392    s.edit = { kind, t: now, at: s.cursor }
393    send()
394    return true
395  }
396  const mapLines = (fn: (line: string) => string) => {
397    const from = Math.min(s.cursor, s.anchor)
398    const to = Math.max(s.cursor, s.anchor)
399    const bs = lineStart(s.text, from)
400    const newline = s.text.indexOf('\n', to > from && s.text[to - 1] === '\n' ? to - 1 : to)
401    const be = newline < 0 ? s.text.length : newline
402    const block = s.text.slice(bs, be)
403    const text = block.split('\n').map(fn).join('\n')
404    const ranged = s.cursor !== s.anchor
405    const caret = s.cursor
406    if (text === block || !replace(text, bs, be)) return
407    if (ranged) { s.anchor = bs; s.cursor = bs + text.length } else { s.cursor = s.anchor = Math.max(bs, caret + text.length - block.length) }
408    redraw()
409  }
410  const toggleComment = () => {
411    const [open, close] = commentMarks(props.ext)
412    const from = Math.min(s.cursor, s.anchor)
413    const to = Math.max(s.cursor, s.anchor)
414    const first = lineStart(s.text, from)
415    const last = s.text.indexOf('\n', to)
416    const filled = s.text.slice(first, last < 0 ? s.text.length : last).split('\n').filter(l => l.trim())
417    const commented = filled.length > 0 && filled.every(l => l.trimStart().startsWith(open))
418    mapLines(line => {
419      if (!line.trim()) return line
420      const indent = /^[ \t]*/.exec(line)![0]
421      const body = line.slice(indent.length)
422      if (commented) return indent + body.slice(open.length).replace(/^ /, '').replace(close ? new RegExp(` ?${close.replace(/[*]/g, '\\$&')}$`) : /$^/, '')
423      return indent + open + ' ' + body + (close ? ' ' + close : '')
424    })
425  }
426  const move = (next: number, select: boolean) => {
427    s.edit = null
428    s.cursor = Math.max(0, Math.min(s.text.length, next))
429    if (!select) s.anchor = s.cursor
430    redraw()
431  }
432  const previous = (at: number) => at > 1 && /[\udc00-\udfff]/.test(s.text[at - 1]!) ? at - 2 : at - 1
433  const following = (at: number) => /[\ud800-\udbff]/.test(s.text[at] ?? '') ? at + 2 : at + 1
434  const loose = (c: string | undefined) => c !== undefined && (kindOf(c) === 's' || kindOf(c) === 'p')
435  const wordRight = (at: number) => {
436    const t = s.text
437    if (at >= t.length) return at
438    if (t[at] === '\n') return at + 1
439    let i = at
440    while (loose(t[i])) i++
441    if (i >= t.length || t[i] === '\n') return i
442    while (i < t.length && kindOf(t[i]!) === 'w') i++
443    return i
444  }
445  const wordLeft = (at: number) => {
446    const t = s.text
447    if (at <= 0) return 0
448    if (t[at - 1] === '\n') return at - 1
449    let i = at
450    while (loose(t[i - 1])) i--
451    if (i === 0 || t[i - 1] === '\n') return i
452    while (i > 0 && kindOf(t[i - 1]!) === 'w') i--
453    return i
454  }
455  const step = (dir: 1 | -1, inclusive: boolean) => {
456    const all = findAll(s.text, s.find.q)
457    if (!all.length) return
458    const lo = Math.min(s.cursor, s.anchor)
459    const hi = Math.max(s.cursor, s.anchor)
460    const at = dir === 1 ? all.find(h => h >= (inclusive ? lo : hi)) ?? all[0]! : [...all].reverse().find(h => h < lo) ?? all[all.length - 1]!
461    s.anchor = at
462    s.cursor = at + s.find.q.length
463    s.edit = null
464    redraw()
465  }
466  const replaceCurrent = () => {
467    const lo = Math.min(s.cursor, s.anchor)
468    const hi = Math.max(s.cursor, s.anchor)
469    if (s.find.q && s.text.slice(lo, hi).toLowerCase() === s.find.q.toLowerCase() && !replace(s.find.r, lo, hi)) return
470    step(1, false)
471  }
472  const replaceAll = () => {
473    const all = findAll(s.text, s.find.q)
474    if (!all.length) return
475    let out = ''
476    let last = 0
477    for (const h of all) { out += s.text.slice(last, h) + s.find.r; last = h + s.find.q.length }
478    if (replace(out + s.text.slice(last), 0, s.text.length)) { s.cursor = s.anchor = all[0]!; redraw() }
479  }
480  // Escape returns the focus before it reaches a Client, so the bar closes with Ctrl+F or Ctrl+H again.
481  const toggleFind = (mode: 1 | 2) => {
482    const f = s.find
483    const picked = s.text.slice(Math.min(s.cursor, s.anchor), Math.max(s.cursor, s.anchor))
484    s.find = f.mode === mode ? { ...f, mode: 0 } : { ...f, mode, field: 0, q: picked && !picked.includes('\n') ? picked : f.q }
485    redraw(false)
486  }
487  const findKey = (e: { key: string; ctrl?: true; shift?: true; meta?: true }, key: string): boolean => {
488    const f = s.find
489    if (e.ctrl || e.meta) {
490      if (key === 'return') { if (!props.busy && !s.locked) replaceAll(); return true }
491      return false
492    }
493    const edited = (patch: Partial<Find>) => { s.find = { ...f, ...patch }; if (patch.q !== undefined) step(1, true); else redraw(false) }
494    if (key === 'return') {
495      if (e.shift) step(-1, false)
496      else if (f.mode === 2 && f.field === 1) { if (!props.busy && !s.locked) replaceCurrent() }
497      else step(1, false)
498    } else if (key === 'tab') { if (f.mode === 2) edited({ field: f.field ? 0 : 1 }) }
499    else if (key === 'backspace') edited(f.field ? { r: f.r.slice(0, -1) } : { q: f.q.slice(0, -1) })
500    else if ([...e.key].length === 1) edited(f.field ? { r: f.r + e.key } : { q: f.q + e.key })
501    else return false
502    return true
503  }
504  surface.onKey(e => {
505    const key = e.key.toLowerCase()
506    const p = point(s.cursor)
507    const selected = s.cursor !== s.anchor
508    if ((e.ctrl || e.meta) && (key === 'f' || key === 'h')) { toggleFind(key === 'f' ? 1 : 2); return }
509    if (s.find.mode && findKey(e, key)) return
510    if (e.ctrl || e.meta) {
511      if (key === 'a') { s.anchor = 0; s.cursor = s.text.length; redraw(); return }
512      if (key === 'home') { move(0, !!e.shift); return }
513      if (key === 'end') { move(s.text.length, !!e.shift); return }
514      if (key === 'left') { move(wordLeft(s.cursor), !!e.shift); return }
515      if (key === 'right') { move(wordRight(s.cursor), !!e.shift); return }
516      if (key === 'l') { [s.anchor, s.cursor] = lineRange(s.text, s.cursor); s.edit = null; redraw(); return }
517      if (props.busy || s.locked) return
518      if (key === 'backspace' || key === 'delete') {
519        if (selected) replace('')
520        else if (key === 'backspace') replace('', wordLeft(s.cursor), s.cursor)
521        else replace('', s.cursor, wordRight(s.cursor))
522        return
523      }
524      if (key === 'd') {
525        const from = Math.min(s.cursor, s.anchor)
526        const to = Math.max(s.cursor, s.anchor)
527        const start = lineStart(s.text, from)
528        const newline = s.text.indexOf('\n', to > from && s.text[to - 1] === '\n' ? to - 1 : to)
529        const stop = newline < 0 ? s.text.length : newline
530        const block = s.text.slice(start, stop)
531        const caret = s.cursor + block.length + 1
532        if (replace('\n' + block, stop, stop)) { s.cursor = s.anchor = caret; redraw() }
533        return
534      }
535      if (key === 'k') {
536        const [start] = lineRange(s.text, Math.min(s.cursor, s.anchor))
537        const [, end] = lineRange(s.text, Math.max(s.cursor, s.anchor))
538        const last = end >= s.text.length && s.text[end - 1] !== '\n'
539        replace('', last && start > 0 ? start - 1 : start, end)
540        return
541      }
542      if (key === '/' || key === '_') { toggleComment(); return }
543      if (key === 's') { send('save'); return }
544      if (key === 'c' || key === 'x') { if (selected) send(key === 'c' ? 'copy' : 'cut'); return }
545      if (key === 'v') { send('paste'); return }
546      if (key === 'z' || key === 'y') {
547        const redo = key === 'y' || !!e.shift
548        const next = (redo ? s.redo : s.undo).pop()
549        if (next) {
550          (redo ? s.undo : s.redo).push(snapshot())
551          Object.assign(s, next)
552          s.edit = null
553          send()
554        }
555        return
556      }
557      return
558    }
559    if (key === 'left') { move(selected && !e.shift ? Math.min(s.cursor, s.anchor) : previous(s.cursor), !!e.shift); return }
560    if (key === 'right') { move(selected && !e.shift ? Math.max(s.cursor, s.anchor) : following(s.cursor), !!e.shift); return }
561    if (key === 'up' || key === 'down' || key === 'pageup' || key === 'pagedown') {
562      const delta = key === 'up' ? -1 : key === 'down' ? 1 : key === 'pageup' ? -height : height
563      move(offset(p.row + delta, p.col), !!e.shift); return
564    }
565    if (key === 'home' || key === 'end') {
566      const r = V()[p.row]!
567      const indent = r.first ? /^[ \t]*/.exec(s.text.slice(r.start, r.end))![0].length : 0
568      move(offset(p.row, key === 'end' ? Infinity : p.col === indent ? 0 : indent), !!e.shift); return
569    }
570    if (props.busy || s.locked) return
571    if (key === 'return') {
572      const from = Math.min(s.cursor, s.anchor)
573      const before = s.text.slice(lineStart(s.text, from), from)
574      replace('\n' + /^[ \t]*/.exec(before)![0] + (/[{(\[]\s*$/.test(before) ? '  ' : ''))
575    }
576    else if (key === 'tab') {
577      if (e.shift) mapLines(line => line.replace(/^(?: {1,2}|\t)/, ''))
578      else if (selected) mapLines(line => line ? '  ' + line : line)
579      else replace('  ', undefined, undefined, 'type')
580    }
581    else if (key === 'backspace') {
582      if (selected) replace('')
583      else if (s.cursor > 0) replace('', previous(s.cursor), s.cursor, 'delete')
584    } else if (key === 'delete') {
585      if (selected) replace('')
586      else if (s.cursor < s.text.length) replace('', s.cursor, following(s.cursor), 'delete')
587    } else if ([...e.key].length === 1 || e.key.includes('\n')) replace(e.key, undefined, undefined, e.key === '\n' ? 'other' : 'type')
588  })
589  // Past the visible rows the caret keeps stepping toward the pointer, faster the further out it is.
590  ticks.set(surface, () => {
591    const bottom = props.top + props.span - 1
592    const gap = s.py > bottom ? s.py - bottom : s.py < props.top ? s.py - props.top : 0
593    if (!s.drag || !gap) return
594    const p = point(s.cursor)
595    move(offset(p.row + Math.sign(gap) * Math.min(8, 1 + (Math.abs(gap) >> 2)), p.col), true)
596  })
597  const span = (at: number): [number, number] => s.unit === 'word' ? wordRange(s.text, at) : s.unit === 'line' ? lineRange(s.text, at) : [at, at]
598  // The pointer column counts cells; the desktop draws props.fit characters per cell. The caret goes before the character under the cell centre.
599  const place = (e: { x: number; y: number }) => offset(e.y, s.left + Math.max(0, Math.round((e.x - gutter + 0.5) * props.fit - 0.5)))
600  surface.onPointer(e => {
601    if (e.type === 'down' && e.button === 'left') {
602      const at = place(e)
603      const now = Date.now()
604      const last = s.clicks
605      const n = last && now - last.t < 400 && last.y === e.y && Math.abs(last.x - e.x) <= 1 ? Math.min(3, last.n + 1) : 1
606      s.clicks = { t: now, x: e.x, y: e.y, n }
607      s.edit = null
608      s.unit = n === 3 ? 'line' : n === 2 ? 'word' : 'char'
609      if (s.unit === 'char') {
610        s.cursor = at
611        if (!e.shift) s.anchor = s.cursor
612      } else {
613        s.base = span(at)
614        s.anchor = s.base[0]
615        s.cursor = s.base[1]
616      }
617      s.drag = true
618      s.py = e.y
619      redraw()
620    } else if (e.type === 'move' && s.drag && e.button === 'left') {
621      s.py = e.y
622      const at = place({ x: e.x, y: Math.max(props.top, Math.min(props.top + props.span - 1, e.y)) })
623      if (s.unit === 'char') s.cursor = at
624      else {
625        const [from, to] = span(at)
626        if (at >= s.base[0]) { s.anchor = s.base[0]; s.cursor = to } else { s.anchor = s.base[1]; s.cursor = from }
627      }
628      redraw()
629    } else if (e.type === 'up' || e.type === 'leave') s.drag = false
630  })
631  const vrows = V()
632  const start = Math.min(s.cursor, s.anchor)
633  const end = Math.max(s.cursor, s.anchor)
634  const caret = point(s.cursor)
635  const caretLine = vrows[caret.row]!.line
636  const logicalCol = s.cursor - lineStart(s.text, s.cursor)
637  const kinds = highlight(s.text, props.ext)
638  const total = vrows.length
639  const view = `${caret.row}:${caretLine}:${logicalCol}:${s.cursor}:${s.anchor}:${total}`
640  if (s.view !== view) {
641    s.view = view
642    surface.post({ view: { request: s.request, row: caret.row, line: caretLine, col: logicalCol, selected: end - start, total, cursor: s.cursor, anchor: s.anchor } })
643  }
644  if (s.limit !== limit) { s.limit = limit; s.seen = -1 }
645  if (props.win !== s.winProp) { s.winProp = props.win; s.win = props.win }
646  if (s.cursor !== s.seen && (caret.row < s.win + 2 || caret.row >= s.win + WINDOW - 2)) s.win = caret.row - WINDOW / 2
647  s.seen = s.cursor
648  s.win = Math.max(0, Math.min(total - WINDOW, Math.floor(s.win)))
649  const pair = start === end ? matchBracket(s.text, s.cursor, kinds) : undefined
650  const hits = s.find.mode ? findAll(s.text, s.find.q) : []
651  const mask = hits.length ? new Uint8Array(s.text.length) : undefined
652  for (const h of hits) mask!.fill(1, h, h + s.find.q.length)
653  const shown = s.find.q.slice(0, 40)
654  const index = end - start === s.find.q.length ? hits.indexOf(start) : -1
655  const count = !s.find.q ? 'type to search' : hits.length ? `${index >= 0 ? index + 1 : '–'}/${hits.length}` : 'no results'
656  const status = !s.find.mode ? ''
657    : s.find.mode === 1 ? `Find: ${shown}▏ · ${count} · Enter next · Shift+Enter prev · Ctrl+F close`
658    : `Find: ${shown}${s.find.field ? '' : '▏'} → Replace: ${s.find.r.slice(0, 40)}${s.find.field ? '▏' : ''} · ${count} · Tab field · Enter replace · Ctrl+Enter all · Ctrl+H close`
659  if (s.findView !== status) {
660    s.findView = status
661    surface.post({ find: { request: s.request, text: status } })
662  }
663  let size = 0
664  const draw = (row: number) => {
665      const r = vrows[row]!
666      const line = s.text.slice(r.start, r.end)
667      const at = r.start
668      const visible = [...line.slice(s.left, s.left + cells * 2)]
669      let column = s.left
670      const lead = r.first ? / */.exec(line)![0].length : 0
671      const trail = r.last ? / *$/.exec(line)![0].length : 0
672      const spans: { text: string; cursor: boolean; selected: boolean; kind: number; tone: number; match: boolean; found: boolean }[] = []
673      for (const char of visible) {
674        const offset = at + column
675        const cursor = s.cursor === offset && row === caret.row
676        const selected = offset >= start && offset < end
677        const kind = kinds?.[offset] ?? PLAIN
678        const match = !!pair && (offset === pair[0] || offset === pair[1])
679        const found = mask?.[offset] === 1
680        const faint = char === '\t' || (char === ' ' && trail > 0 && column >= line.length - trail)
681        const guide = char === ' ' && lead < line.length && column % 2 === 0 && column + 2 <= lead
682        const tone = faint ? 2 : guide ? 1 : 0
683        column += char.length
684        const text = char === '\t' ? '→' : faint ? '·' : guide ? '│' : char
685        const last = spans[spans.length - 1]
686        if (last && last.cursor === cursor && last.selected === selected && last.kind === kind && last.tone === tone && last.match === match && last.found === found) last.text += text
687        else spans.push({ text, cursor, selected, kind, tone, match, found })
688      }
689      if (props.mono) {
690        // The pane draws this row's text, caret and selection as SVG under the Client; here only find and bracket marks.
691        const marks: { left: number; width: number; color: string }[] = []
692        let x = 0
693        for (const span of spans) {
694          const n = [...span.text].length
695          const color = span.found ? '#515c6aaa' : span.match ? '#3a3f4baa' : ''
696          const last = marks[marks.length - 1]
697          if (color && last?.color === color && last.left + last.width === x) last.width += n
698          else if (color) marks.push({ left: x, width: n, color })
699          x += n
700        }
701        size += 200 + marks.length * 120
702        return <Box key={`line:${row}`} flexShrink={0} height={1} overflow="hidden" flexDirection="row">
703          <Box width={gutter} flexShrink={0}><Text dimColor>{r.first ? `${String(r.line + 1).padStart(gutter - 1)} ` : ' '.repeat(gutter)}</Text></Box>
704          <Box key={`code:${row}`} flexGrow={1} height={1} overflow="hidden" position="relative">
705            {marks.map((m, n) => <Box key={`mark:${n}`} position="absolute" top={0} left={m.left} width={m.width} height={1} backgroundColor={m.color} />)}
706          </Box>
707        </Box>
708      }
709      size += 260 + spans.reduce((n, span) => n + span.text.length + 80, 0)
710      return <Box key={`line:${row}`} flexShrink={0} height={1} overflow="hidden" flexDirection="row" backgroundColor={r.line === caretLine ? '#151b23' : undefined}>
711        <Box width={gutter}><Text dimColor>{r.first ? `${String(r.line + 1).padStart(gutter - 1)} ` : ' '.repeat(gutter)}</Text></Box>
712        <Text wrap="truncate-end">{spans.map((span, n) => <Text key={`span:${n}`} inverse={span.cursor} color={span.tone ? FAINT : COLORS[span.kind]} bold={span.kind === HEAD || span.match} backgroundColor={span.selected ? '#264f78' : span.found ? FOUND : span.match ? MATCH : undefined}>{span.text}</Text>)}{caret.row === row && caret.col === line.length ? <Text inverse> </Text> : ''}</Text>
713      </Box>
714  }
715  const rows: unknown[] = []
716  let to = s.win
717  while (to < Math.min(total, s.win + WINDOW) && size < BUDGET) rows.push(draw(to++))
718  const geo = `${surface.columns}:${s.left}:${limit}`
719  if (props.mono && s.geo !== geo) { s.geo = geo; surface.post({ geo: { request: s.request, columns: surface.columns || 70, left: s.left, limit } }) }
720  // A see-through root still takes the pointer only with a background.
721  return <Box flexDirection="column" position="relative" backgroundColor={props.mono ? '#00000001' : undefined}>
722    {s.win > 0 ? <Box key="pad-top" height={s.win} flexShrink={0} /> : ''}
723    {rows}
724    {to < total ? <Box key="pad-bottom" height={total - to} flexShrink={0} /> : ''}
725  </Box>
726}
727
728export default Editor
729
hooks/preview-navigation.tsx 21 lines
1import type { ClientModule } from 'claude-code'
2
3const PreviewNavigation: ClientModule<Record<string, never>, { down: boolean }> = (_, surface) => {
4  const { Button } = surface.elements
5  const state = surface.state ?? { down: false }
6  if (!surface.state) surface.setState(state)
7  const back = () => surface.post({ back: true })
8  surface.onPointer(e => {
9    if (e.type === 'down' && e.button === 'left') state.down = true
10    if (e.type === 'up' && e.button === 'left') {
11      const down = state.down
12      state.down = false
13      if (down && e.x >= 0 && e.y >= 0 && e.x < surface.columns && e.y < surface.rows) back()
14    }
15  })
16  surface.onKey(e => { if (e.key === 'return' || e.key === ' ') back() })
17  return <Button key="preview-back" label="← Files" variant="secondary" onPress={back} />
18}
19
20export default PreviewNavigation
21
hooks/menu-item.tsx 24 lines
1import type { ClientModule } from 'claude-code'
2
3type Props = { id: string; label: string; dim?: boolean }
4type State = { armed: boolean }
5
6// One row of the row menu: the whole region is the target, so a press anywhere on the row counts.
7const MenuItem: ClientModule<Props, State> = (props, surface) => {
8  const { Text } = surface.elements
9  const state: State = surface.state ?? { armed: false }
10  if (!surface.state) surface.setState(state)
11  surface.onPointer(e => {
12    if (e.type === 'leave') state.armed = false
13    if (e.button !== 'left') return
14    if (e.type === 'down') state.armed = true
15    if (e.type !== 'up') return
16    const armed = state.armed
17    state.armed = false
18    if (armed && e.x >= 0 && e.x < surface.columns && e.y === 0) surface.post({ id: props.id })
19  })
20  return <Text wrap="truncate-end" dimColor={props.dim}>{props.label}</Text>
21}
22
23export default MenuItem
24
hooks/clear-selection.tsx 14 lines
1import type { ClientModule } from 'claude-code'
2
3const ClearSelection: ClientModule<{ text: string; bold: boolean; padX: number }> = (props, surface) => {
4  const { Box, Text } = surface.elements
5  surface.onPointer(e => {
6    if (e.type === 'down' && e.button === 'left') surface.post({ clear: true })
7  })
8  return <Box flexDirection="column" height="100%" paddingX={props.padX}>
9    <Text bold={props.bold} dimColor={!props.bold} wrap="wrap">{props.text}</Text>
10  </Box>
11}
12
13export default ClearSelection
14
hooks/file-name.tsx 76 lines
1import type { ClientModule } from 'claude-code'
2
3type Props = {
4  path: string; name: string; dim: boolean; picked: string; ack: { instance: string; seq: number } | null; dir?: boolean
5  menu?: boolean; status?: { text: string; color: string } | null
6}
7// The "…" trigger: the last cells of the region.
8const MENU_CELLS = 2
9type Click = { seq: number; ctrl: boolean; canDouble: boolean }
10type State = {
11  instance: string
12  seq: number
13  pending: Click[]
14  armed: boolean
15  cancel?: () => void
16  down?: { x: number; y: number; ctrl: boolean }
17}
18
19const FileName: ClientModule<Props, State> = (props, surface) => {
20  const { Text, Box } = surface.elements
21  const state: State = surface.state ?? { instance: crypto.randomUUID(), seq: 0, pending: [], armed: false }
22  if (!surface.state) surface.setState(state)
23  const reset = () => { state.cancel?.(); state.cancel = undefined; state.armed = false }
24  if (props.ack?.instance === state.instance) state.pending = state.pending.filter(click => click.seq > props.ack!.seq)
25  surface.onPointer(ev => {
26    // columns is floor(width / cell): the pixels right of the last whole cell report x === columns.
27    const e = { ...ev, x: Math.min(ev.x, surface.columns - 1) }
28    if (e.type === 'leave') {
29      state.down = undefined
30      reset()
31      return
32    }
33    if (e.type === 'move' && state.down && (Math.abs(e.x - state.down.x) > 1 || e.y !== state.down.y)) {
34      state.down = undefined
35      reset()
36    }
37    if (e.type === 'down' && e.button !== 'left') reset()
38    if (e.type === 'down' && e.button === 'left') {
39      state.down = { x: e.x, y: e.y, ctrl: !!e.ctrl }
40      return
41    }
42    if (e.type !== 'up' || e.button !== 'left') return
43    const down = state.down
44    state.down = undefined
45    if (!down || e.x < 0 || e.y !== 0 || e.x >= surface.columns || Math.abs(e.x - down.x) > 1 || e.y !== down.y) return
46    if (props.menu && down.x >= surface.columns - MENU_CELLS && e.x >= surface.columns - MENU_CELLS) {
47      surface.post({ path: props.path, menu: true })
48      return
49    }
50    if (state.pending.length >= 32) return
51    if (props.dir) {
52      surface.post({ path: props.path, toggle: true })
53      return
54    }
55    const ctrl = down.ctrl || !!e.ctrl
56    const canDouble = !ctrl && state.armed
57    reset()
58    if (!ctrl) {
59      state.armed = true
60      state.cancel = surface.every(500, reset)
61    }
62    state.pending.push({ seq: ++state.seq, ctrl, canDouble })
63    surface.post({ path: props.path, instance: state.instance, clicks: state.pending.map(click => ({ ...click })) })
64  })
65  if (!props.menu && !props.status) return <Text dimColor={props.dim} wrap="truncate-end">{props.name}</Text>
66  return (
67    <Box flexDirection="row" width="100%">
68      <Box flexGrow={1} flexShrink={1}><Text dimColor={props.dim} wrap="truncate-end">{props.name}</Text></Box>
69      {props.status && <Text color={props.status.color} bold>{props.status.text}</Text>}
70      {props.menu && <Box width={MENU_CELLS} justifyContent="flex-end"><Text dimColor>…</Text></Box>}
71    </Box>
72  )
73}
74
75export default FileName
76
types/index.d.ts 57 lines
1export type Entry = { name: string; kind: 'file' | 'dir' | 'other' }
2export type Preview = {
3  path: string
4  request: number
5  kind: 'loading' | 'text' | 'image' | 'error' | 'unsupported'
6  content: string
7  markdown: boolean
8  rendered: boolean
9  notice: string
10  realPath?: string
11  editable?: boolean
12  draft?: string
13  savedText?: string
14  hash?: string
15  encoding?: string
16  eol?: string
17  editing?: boolean
18  dirty?: boolean
19  busy?: boolean
20  confirmBack?: boolean
21  error?: string
22  editorInstance?: string
23  editorSeq?: number
24  editorRefresh?: number
25  editorCursor?: number
26  editorRow?: number
27  editorCol?: number
28  editorSelected?: number
29  editorWin?: number
30  editorLine?: number
31  editorTotal?: number
32  editorCaret?: number
33  editorAnchor?: number
34  editorWrap?: boolean
35}
36export type Tree = {
37  root: string
38  dirs: Record<string, Entry[]>
39  open: string[]
40  filter: string
41  picked: string
42  git: Record<string, string>
43  reveal: { dir: string; n: number }
44  pin: string
45  tick: number
46  menu?: string
47  menuMode?: 'rename' | 'delete' | 'newfile' | 'newfolder'
48  menuText?: string
49  preview?: Preview
50}
51
52declare module 'claude-code' {
53  interface PluginState {
54    'file-explorer': { tree: Tree }
55  }
56}
57