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

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.
Pick one way. All three need a new Claude Code session afterwards.
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
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.
claude --plugin-dir path/to/claude-forge/mods/file-explorer
Type /files to open the pane. Type it again to close it.
| Command | What it does |
|---|---|
/files | Open or close the pane |
/files on | Open the pane at every session start |
/files off | Stop opening it at session start |
/files <path> | Show another folder instead of the working directory |
/files reset | Go back to the working directory |
powershell.exe.git on the PATH for git status colours (optional)..git and node_modules and stops at 20,000 entries.@path mention to your prompt.<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.
<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.
| Keys | Action |
|---|---|
| Ctrl+S | Save |
| Ctrl+Z, Ctrl+Y | Undo, redo |
| Ctrl+A, Ctrl+C, Ctrl+X, Ctrl+V | Select all, copy, cut, paste |
| Ctrl+Left, Ctrl+Right | Move by word |
| Ctrl+Backspace, Ctrl+Delete | Delete a word |
| Ctrl+D, Ctrl+K, Ctrl+L | Duplicate, delete, select the line |
| Ctrl+/ | Comment or uncomment the selected lines |
| Tab, Shift+Tab | Indent, outdent the selected lines |
| Home | First non-blank character, then column 0 |
| Double-click, triple-click | Select a word, a line |
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.
| Keys | Action |
|---|---|
| Enter, Shift+Enter | Next, previous match |
| Tab | Switch between the find and replace field (Ctrl+H) |
| Enter in the replace field | Replace this match, go to the next |
| Ctrl+Enter | Replace all matches (one undo step) |
| Ctrl+F or Ctrl+H again | Close the bar |
Matching ignores case.
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.
MIT, see the repository root.
hooks/register.tsx 1133 lines1import { 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 === '&' ? '&' : c === '<' ? '<' : '>')
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}
1133hooks/editing.ts 63 lines1import 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}
63hooks/editor.tsx 729 lines1import 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
729hooks/preview-navigation.tsx 21 lines1import 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
21hooks/menu-item.tsx 24 lines1import 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
24hooks/clear-selection.tsx 14 lines1import 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
14hooks/file-name.tsx 76 lines1import 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
76types/index.d.ts 57 lines1export 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