Draws a git repository as a treemap in a side pane and marks the files that Claude reads and the lines that change while the pane is open.

A Claude Code mod that draws a git repository as a treemap in a side pane, so you can see at a glance which part of the codebase the agent is working on.

The map reads the same without a mouse. With one:
Type this at the prompt of Claude Code in a terminal, version 2.1.296 or later, with git 2.31 or later:
/plugin install filemap --marketplace vdvoorder/filemap
Answer y to add the marketplace, then pick a scope.
Then type /filemap to map the current repository, or /filemap <folder> for another one.
To load the mod from a folder instead, as when you work on it:
claude --plugin-dir <path to this repository>
"Open files with" (opener) names one program that opens every file: a command on PATH, such as code, or the full path of a program. On macOS it can also be the name of an app, such as Visual Studio Code, which the mod opens with open -a. When the setting is empty, each file opens in the default app of its type. On Windows, .ts belongs to Media Player until you pick another default app or fill in this setting.
A file that the system would run instead of showing does not go to the default app of its type:
open -t picks. Another type that macOS runs, such as .app, .pkg or an executable file without an extension, as gradlew, does not open, and a toast says so.xdg-mime query default text/plain names. Any other does not open, and a toast says so.When this setting names a program, that program opens these files too.
"Pane width" (paneWidth) is the number of columns of the pane beside the conversation, from 40 to 200, and 64 by default. A new value takes effect the next time you type /filemap.
? in the pane, and the file still opens.Run the tests with claude plugin test .. The pre-commit hook in .githooks runs the checks and needs Node 22.18 or later; turn it on with git config core.hooksPath .githooks.
hooks/register.tsx 1068 lines1import { atom, type ConfigValue, type EngineInterface, type ProcessRunInit, read, type Register, type Shaped, type Timer, type ToolCallResult, update } from 'claude-code'
2
3import type { Changes, MapFile, RepoMap, Session, Theme } from '../types'
4import { fitMap } from './map'
5import type { MapMessage, MapProps } from './map-client'
6import { hasFolder, legendOf, listOf, mapDrawing, paneFrame, plainText, type Span, zoomOut } from './screen'
7import { shellReads } from './shell'
8import {
9 absolute,
10 alternatesOf,
11 batchesOf,
12 countsOf,
13 type Counted,
14 diffArgs,
15 EMPTY_SESSION,
16 fold,
17 folderOf,
18 foldersOutside,
19 GIT_ENV,
20 gitArgv,
21 hashArgs,
22 INDEX_INFO_ARGS,
23 indexInfo,
24 isExtensionless,
25 isRooted,
26 isShell,
27 isText,
28 LIST_ARGS,
29 looseObjectOf,
30 moveFolders,
31 needsListing,
32 nestedIn,
33 newlyChanged,
34 nextExpiry,
35 noteChanges,
36 noteTouches,
37 objectsOf,
38 openerOf,
39 type Os,
40 parseDiff,
41 parseFileList,
42 parseModules,
43 parseStatus,
44 parseTop,
45 pathsStdin,
46 readTreeArgs,
47 runsWhenOpened,
48 screenInput,
49 sessionArgs,
50 setsReadTime,
51 staleObjects,
52 STATE_SHAPE,
53 STATUS_ARGS,
54 type Temp,
55 tempOf,
56 toKey,
57 TOP_ARGS,
58 touchesFromInput,
59 touchesFromResult,
60 type ToolTouch,
61 trackedArgs,
62 treesOf,
63 UPDATE_INDEX_ARGS,
64 useWindows,
65 WRITE_TREE_ARGS,
66} from './track'
67
68const PANE = 'filemap'
69const NOTICE = 'Run /filemap <folder> to draw a git repository here.'
70const NOT_COUNTED = ' Changes are not counted: git could not describe the working tree.'
71const NO_MAP: RepoMap = { root: '', repo: 'no repository', commonDir: '', files: [], version: 0 }
72const OPEN_TIMEOUT_MS = 10_000
73const UNAME_TIMEOUT_MS = 3_000
74const DOUBLE_MS = 450
75const TOAST_MS = 4_000
76const TOAST_MS_PER_CHARACTER = 50
77const GIT_TIMEOUT_MS = 30_000
78const COUNT_AFTER_MS = 30_000
79const RETRY_AFTER_MS = 5 * 60_000
80const INDEX_LIMIT = 20
81const WORKTREE_LIMIT = 200
82const REMOVE_TIMEOUT_MS = 20_000
83const PRIVATE_TIMEOUT_MS = 10_000
84const PRUNE_LIMIT = 16 * 2 ** 20
85const ARGUMENT_LIMIT = 64 * 2 ** 10
86const END_TIMEOUT_MS = 1_000
87const STALE_AFTER_MS = 7 * 24 * 60 * 60_000
88const REFOCUS_MS = 2_000
89const GIT_DIR_DEPTH = 64
90const PANE_COLUMNS = 64
91const CONVERSATION_COLUMNS = 24
92const EDGE_COLUMNS = 1
93const TASK_ENDED = '<task-notification>'
94const ENDED = 'the session ended'
95const STATUS_FILE = 'status'
96const OURS = /^(status|(command|job)-\d+\.index(\.lock)?|(objects|counts)-[0-9a-f]{8}|lfs)$/
97const SESSION_FOLDER = /^filemap-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
98const REMOVE_ON_WINDOWS = 'foreach ($p in $env:FILEMAP_REMOVE.Split([char]10)) { Remove-Item -LiteralPath $p -Recurse -Force -ErrorAction SilentlyContinue }'
99const LOG_LIMIT = 4096
100const REASON_LIMIT = 200
101const WORKTREE_TOOLS = new Set(['EnterWorktree', 'ExitWorktree'])
102const OS_NAMES: Record<Os, string> = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' }
103const FILE_LIMIT = 32 * 1024 * 1024
104const COUNTING = 'Counting the changed lines'
105const LISTING = 'Listing the files again'
106const RESOLVING = 'Resolving the folders of the touched files'
107const TIMING = 'Timing the next drawing of the pane'
108const TAKING = 'Taking the baseline'
109const READING_THEME = 'Reading the theme'
110const CLEANING = 'Deleting the folders of ended sessions'
111const NOTING = 'Counting the files of a tool call'
112const TOASTED_STEPS = new Set([COUNTING, LISTING, TAKING])
113const MAP = { plugin: 'filemap', key: 'map' } as const
114const SESSION = { plugin: 'filemap', key: 'session' } as const
115const CHANGES = { plugin: 'filemap', key: 'changes' } as const
116const STALE = { plugin: 'filemap', key: 'isStale' } as const
117const ROOT = { plugin: 'filemap', key: 'root' } as const
118const PAUSED = { plugin: 'filemap', key: 'isGitPaused' } as const
119const THEME = { plugin: 'filemap', key: 'theme' } as const
120const ZOOM = { plugin: 'filemap', key: 'zoom' } as const
121const EPOCH = { plugin: 'filemap', key: 'epoch' } as const
122const SHAPE = { shape: STATE_SHAPE }
123const map = atom(MAP, null, SHAPE)
124const session = atom(SESSION, EMPTY_SESSION, SHAPE)
125const changes = atom(CHANGES, null, SHAPE)
126const isStale = atom(STALE, false, SHAPE)
127const rootFolder = atom(ROOT, '', SHAPE)
128const isGitPaused = atom(PAUSED, false, SHAPE)
129const theme = atom(THEME, 'dark', SHAPE)
130const zoom = atom(ZOOM, '', SHAPE)
131const epoch = atom(EPOCH, 0, SHAPE)
132
133let platform: Promise<Os> | null = null
134let program = ''
135let paneColumns = PANE_COLUMNS
136let lastClick = { path: '', at: 0 }
137let lastJob: Promise<unknown> = Promise.resolve()
138let rootPrefix: string | undefined
139let seenRoot: string | undefined
140let countTimer: Timer | null = null
141let windowTimer: { at: number; timer: Timer } | null = null
142let generation = 0
143let focusedAt = -Infinity
144const asked = new Map<string, number>()
145const begun = new Map<string, number>()
146const realFolders = new Map<string, string>()
147const removed = new Set<string>()
148const inUse = new Set<string>()
149const stale = new Map<string, number>()
150const written: Written[] = []
151const failing = new Set<string>()
152let nestedPrefixes: string[] = []
153
154type Top = { root: string; commonDir: string }
155type Since = { generation: number; epoch: number }
156type Written = { path: string; before?: string | null }
157type Before = { named: ToolTouch[]; isGrepOfOneFile: boolean; texts: Map<string, string | null>; cwd?: string }
158
159function osName($: EngineInterface): Promise<Os> {
160 platform ??= (async (): Promise<Os> => {
161 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
162 try {
163 return (await $.process.run(['uname', '-s'], { timeoutMs: UNAME_TIMEOUT_MS })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
164 } catch {
165 return 'linux'
166 }
167 })().then(
168 os => {
169 useWindows(os === 'win32')
170 return os
171 },
172 error => {
173 platform = null
174 throw error
175 },
176 )
177 return platform
178}
179
180function inOrder<T>(job: () => Promise<T>): Promise<T> {
181 const ran = lastJob.then(job)
182 lastJob = ran.catch(() => undefined)
183 return ran
184}
185
186function reasonOf(error: unknown): string {
187 return error instanceof Error ? error.message : String(error)
188}
189
190function logFailure($: EngineInterface, step: string, error: unknown): void {
191 try {
192 $.ui.log(`${step} failed: ${reasonOf(error)}`.slice(0, LOG_LIMIT), { to: 'debug' })
193 } catch {
194 return
195 }
196}
197
198function failed($: EngineInterface, step: string, error: unknown): void {
199 logFailure($, step, error)
200 if (!TOASTED_STEPS.has(step) || failing.has(step)) return
201 failing.add(step)
202 try {
203 $.ui.toast(plainText(`${step} failed: ${reasonOf(error)}`.replace(/\s+/g, ' ')).slice(0, REASON_LIMIT))
204 } catch {
205 return
206 }
207}
208
209function later($: EngineInterface, step: string, job: () => Promise<void>): void {
210 asked.set(step, (asked.get(step) ?? 0) + 1)
211 $.clock.after(0, () => {
212 inOrder(async () => {
213 if (begun.get(step) === asked.get(step)) return
214 begun.set(step, asked.get(step) ?? 0)
215 await osName($)
216 await job()
217 failing.delete(step)
218 }).catch(error => failed($, step, error))
219 })
220}
221
222async function detach($: EngineInterface, step: string, job: () => Promise<void>): Promise<void> {
223 $.clock.after(0, () => void job().catch(error => logFailure($, step, error)))
224}
225
226function shaped<T>(value: T): Shaped<T> {
227 return { shape: STATE_SHAPE, value }
228}
229
230function ofThisShape<T>(stored: Shaped<T> | undefined): T | undefined {
231 return stored?.shape === STATE_SHAPE ? stored.value : undefined
232}
233
234function unquote(text: string): string {
235 return text.length > 1 && (text.startsWith('"') || text.startsWith("'")) && text.endsWith(text.charAt(0)) ? text.slice(1, -1) : text
236}
237
238function resolveFolder(folder: string, cwd: string, home: string, os: Os): string {
239 const ends = os === 'win32' ? /[\\/]+$/ : /\/+$/
240 if (home !== '' && (os === 'win32' ? /^~([\\/]|$)/ : /^~(\/|$)/).test(folder)) return `${home.replace(ends, '')}${folder.slice(1)}`
241 return (os === 'win32' ? /^([A-Za-z]:[\\/]|[\\/])/ : /^\//).test(folder) ? folder : `${cwd.replace(ends, '')}/${folder}`
242}
243
244async function homeOf($: EngineInterface): Promise<string> {
245 if ((await osName($)) === 'win32') return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
246 return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
247}
248
249function lastSegment(path: string): string {
250 return path.split(/[\\/]/).filter(part => part !== '').pop() ?? path
251}
252
253async function changeSession($: EngineInterface, change: (cur: Session) => Session): Promise<void> {
254 for (;;) {
255 const stored = await $.state.get(SESSION)
256 const cur = ofThisShape(stored.value) ?? EMPTY_SESSION
257 const next = change(cur)
258 if (next === cur || (await $.state.set(SESSION, shaped(next), { ifVersion: stored.version })).isSet) return
259 }
260}
261
262function inRealFolder(path: string): string {
263 const folder = folderOf(path)
264 const real = realFolders.get(fold(folder))
265 return real === undefined ? path : real + path.slice(folder.length)
266}
267
268function isUnplaced(path: string): boolean {
269 const key = fold(path)
270 const folder = folderOf(key)
271 const isInside = rootPrefix !== undefined && key.startsWith(rootPrefix)
272 return rootPrefix !== '' && folder !== '' && !isInside && !realFolders.has(folder)
273}
274
275async function locate($: EngineInterface, touches: ToolTouch[], from?: string): Promise<ToolTouch[]> {
276 if (touches.length === 0) return touches
277 await osName($)
278 const cwd = touches.every(one => isRooted(one.path)) ? '' : (from ?? (await $.session.cwd()))
279 const isHomeNamed = touches.some(one => one.path.startsWith('~'))
280 const home = isHomeNamed ? await homeOf($) : ''
281 return touches.map(one => ({ ...one, path: inRealFolder(absolute(one.path, cwd, home)) }))
282}
283
284async function nestedRootOf($: EngineInterface, rootLength: number, folder: string): Promise<string> {
285 for (let inside = folder; inside.length > rootLength; inside = folderOf(inside)) if (await $.fs.exists(`${inside}/.git`)) return inside
286 return ''
287}
288
289async function mayBeElsewhere($: EngineInterface, path: string): Promise<boolean> {
290 const key = fold(path)
291 if (rootPrefix === undefined || rootPrefix === '') return false
292 if (!key.startsWith(rootPrefix) || nestedPrefixes.some(folder => key.startsWith(folder))) return true
293 return (await nestedRootOf($, rootPrefix.length - 1, folderOf(path)).catch(() => '')) !== ''
294}
295
296async function knowRoot($: EngineInterface): Promise<void> {
297 if (rootPrefix !== undefined) return
298 await osName($)
299 const root = await read($, rootFolder)
300 const held = await read($, changes)
301 if (rootPrefix !== undefined) return
302 const prefix = root === '' ? '' : `${toKey(root, root)}/`
303 rootPrefix = prefix
304 if (held !== null && held.root === root) nestedPrefixes = held.nested.map(folder => prefix + fold(folder))
305}
306
307async function textsBefore($: EngineInterface, touches: ToolTouch[]): Promise<Map<string, string | null>> {
308 const texts = new Map<string, string | null>()
309 if (touches.some(touch => touch.kind === 'edit')) await knowRoot($)
310 for (const one of touches.filter(touch => touch.kind === 'edit')) {
311 if (!(await mayBeElsewhere($, one.path))) continue
312 const text = await $.fs.read(one.path).catch(() => $.fs.exists(one.path).then(isThere => (isThere ? undefined : null), () => undefined))
313 if (text !== undefined) texts.set(one.path, text)
314 }
315 return texts
316}
317
318async function inputTouches($: EngineInterface, e: Record<string, unknown>): Promise<{ touches: ToolTouch[]; isGrepOfOneFile: boolean }> {
319 const touches = await locate($, touchesFromInput(e))
320 const searched = e.tool === 'Grep' ? touches[0]?.path : undefined
321 if (searched === undefined) return { touches, isGrepOfOneFile: false }
322 const isGrepOfOneFile = await $.fs.stat(searched).then(
323 stat => stat.kind === 'file',
324 () => false,
325 )
326 return { touches: isGrepOfOneFile ? touches : [], isGrepOfOneFile }
327}
328
329function git($: EngineInterface, root: string, args: string[], init: ProcessRunInit = {}) {
330 return $.process.run(gitArgv(root, args), { timeoutMs: GIT_TIMEOUT_MS, ...init, env: { ...init.env, ...GIT_ENV } })
331}
332
333function withStderr(stderr: string): string {
334 const line = firstLine(stderr)
335 return line === '' ? '' : `: ${line}`
336}
337
338async function runGit($: EngineInterface, root: string, args: string[], init?: ProcessRunInit): Promise<string> {
339 const name = `git ${args.find((arg, i) => arg !== '-c' && args[i - 1] !== '-c') ?? ''}`
340 const run = await git($, root, args, init)
341 if (run.exitCode !== 0) throw new Error(`${name} ended with exit code ${run.exitCode}${withStderr(run.stderr)}`)
342 if (run.isStdoutTruncated) throw new Error(`the output of ${name} is longer than 4 MiB`)
343 return run.stdout
344}
345
346async function listFiles($: EngineInterface, root: string): Promise<MapFile[]> {
347 const run = await git($, root, LIST_ARGS)
348 const isEmpty = run.exitCode === 1 && run.stdout === ''
349 if (run.exitCode !== 0 && !isEmpty) throw new Error(`git could not list the files of ${root} (exit code ${run.exitCode})${withStderr(run.stderr)}`)
350 if (run.isStdoutTruncated) throw new Error(`git could not list the files of ${root} (the list is longer than 4 MiB)`)
351 return parseFileList(run.stdout)
352}
353
354async function repoOf($: EngineInterface, folder: string): Promise<Top | string> {
355 const run = await git($, folder, TOP_ARGS)
356 if (run.exitCode === 0) return parseTop(run.stdout)
357 if (/not a git repository/i.test(run.stderr)) return ''
358 const said = run.stderr.split(/\r?\n/).map(line => line.trim()).filter(line => line !== '').join(' ')
359 return said === '' ? `git rev-parse ended with exit code ${run.exitCode}` : said
360}
361
362async function topOf($: EngineInterface, folder: string): Promise<Top | null> {
363 const found = await repoOf($, folder)
364 return typeof found === 'string' ? null : found
365}
366
367async function tempFolder($: EngineInterface): Promise<string> {
368 const folder = (await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? '/tmp'
369 return folder.replace(/[\\/]+$/, '')
370}
371
372async function sessionFolder($: EngineInterface, id?: string): Promise<string> {
373 return `${await tempFolder($)}/filemap-${id ?? (await $.session.id())}`
374}
375
376function stopIfEnded(folder: string): void {
377 if (removed.has(folder)) throw new Error(ENDED)
378}
379
380async function writeLive($: EngineInterface, folder: string, path: string, text: string): Promise<void> {
381 stopIfEnded(folder)
382 await $.fs.write(path, text)
383}
384
385async function makePrivate($: EngineInterface, folder: string): Promise<void> {
386 for (const argv of [['mkdir', '-p', '-m', '700', '--', folder], ['chmod', '700', '--', folder]]) {
387 stopIfEnded(folder)
388 const run = await $.process.run(argv, { timeoutMs: PRIVATE_TIMEOUT_MS })
389 if (run.exitCode !== 0) throw new Error(`could not make ${folder} private: ${argv[0]} ended with exit code ${run.exitCode}${withStderr(run.stderr)}`)
390 }
391}
392
393async function prepare($: EngineInterface, top: Top, isFollowing: boolean): Promise<Temp> {
394 const temp = tempOf(await sessionFolder($), top.commonDir)
395 if (!isFollowing) removed.delete(temp.folder)
396 if ((await osName($)) !== 'win32') await makePrivate($, temp.folder)
397 await writeLive($, temp.folder, `${temp.folder}/${STATUS_FILE}`, 'open')
398 await writeLive($, temp.folder, `${temp.objects}/info/alternates`, alternatesOf(`${top.commonDir}/objects`))
399 return temp
400}
401
402async function remove($: EngineInterface, folders: string[], timeoutMs: number): Promise<void> {
403 const run =
404 (await osName($)) === 'win32'
405 ? await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', REMOVE_ON_WINDOWS], { env: { FILEMAP_REMOVE: folders.join('\n') }, timeoutMs })
406 : await removeInBatches($, folders, timeoutMs)
407 if (run.exitCode !== 0) throw new Error(`could not delete ${folders.join(', ')}: exit code ${run.exitCode}${withStderr(run.stderr)}`)
408}
409
410async function removeInBatches($: EngineInterface, folders: string[], timeoutMs: number): Promise<{ exitCode: number; stderr: string }> {
411 for (const batch of batchesOf(folders, ARGUMENT_LIMIT)) {
412 const run = await $.process.run(['rm', '-rf', '--', ...batch], { timeoutMs })
413 if (run.exitCode !== 0) return run
414 }
415 return { exitCode: 0, stderr: '' }
416}
417
418async function isOver($: EngineInterface, folder: string, now: number): Promise<boolean> {
419 const inside = await $.fs.list(folder)
420 if (!inside.every(one => OURS.test(one.name))) return false
421 if (!inside.some(one => one.name === STATUS_FILE)) return true
422 return (await $.fs.read(`${folder}/${STATUS_FILE}`)) === 'ended' || now - Math.max(...inside.map(one => one.mtimeMs)) > STALE_AFTER_MS
423}
424
425async function sweep($: EngineInterface): Promise<void> {
426 const temp = await tempFolder($)
427 const own = `filemap-${await $.session.id()}`
428 const now = await $.clock.now()
429 const over: string[] = []
430 for (const entry of await $.fs.list(temp)) if (entry.kind === 'dir' && SESSION_FOLDER.test(entry.name) && entry.name !== own && (await isOver($, `${temp}/${entry.name}`, now).catch(() => false))) over.push(`${temp}/${entry.name}`)
431 if (over.length > 0) await remove($, over, REMOVE_TIMEOUT_MS)
432}
433
434async function endFolder($: EngineInterface, id: string, isGoingOn: boolean): Promise<void> {
435 const folder = await sessionFolder($, id)
436 if (!(await $.fs.exists(folder))) return
437 await $.fs.write(`${folder}/${STATUS_FILE}`, 'ended')
438 if (isGoingOn) return later($, CLEANING, () => sweep($))
439 removed.add(folder)
440 await remove($, [folder], END_TIMEOUT_MS)
441}
442
443async function freeIndex($: EngineInterface, folder: string, user: 'command' | 'job'): Promise<string> {
444 for (let n = 0; n < INDEX_LIMIT; n++) {
445 const index = `${folder}/${user}-${n}.index`
446 if ((await $.fs.exists(`${index}.lock`)) || inUse.has(index)) continue
447 inUse.add(index)
448 return index
449 }
450 throw new Error(`every index file of the mod in ${folder} is locked`)
451}
452
453async function worktreesIn($: EngineInterface, top: Top): Promise<string[]> {
454 const folder = `${absolute(top.commonDir, '')}/worktrees`
455 const ids = await $.fs.list(folder).catch(() => [])
456 const gitDirs = await Promise.all(ids.filter(one => one.kind === 'dir').slice(0, WORKTREE_LIMIT).map(one => $.fs.read(`${folder}/${one.name}/gitdir`).then(text => absolute(text.trim(), `${folder}/${one.name}`), () => '')))
457 return nestedIn(top.root, gitDirs.filter(one => one !== '').map(folderOf))
458}
459
460async function modulesIn($: EngineInterface, top: Top): Promise<string[]> {
461 return parseModules(await $.fs.read(`${top.root}/.gitmodules`).catch(() => ''))
462}
463
464async function withinLimit($: EngineInterface, root: string, paths: string[]): Promise<string[]> {
465 const sizes = await Promise.all(paths.map(path => $.fs.stat(`${root}/${path}`).then(stat => (stat.kind === 'file' ? stat.size : 0), () => 0)))
466 return paths.filter((_, i) => (sizes[i] ?? 0) <= FILE_LIMIT)
467}
468
469async function treeOfWorkingTree($: EngineInterface, top: Top, temp: Temp, user: 'command' | 'job', kept: Map<string, string | null>): Promise<{ tree: string; nested: string[] }> {
470 const objects = { GIT_OBJECT_DIRECTORY: temp.objects }
471 const config = sessionArgs(temp)
472 const step = async (args: string[], init: ProcessRunInit) => {
473 stopIfEnded(temp.folder)
474 return runGit($, top.root, [...config, ...args], init)
475 }
476 const list = parseStatus(await step(STATUS_ARGS, { env: objects }))
477 const nested = [...new Set([...list.nested, ...(await worktreesIn($, top)), ...(await modulesIn($, top))])]
478 const paths = await withinLimit($, top.root, list.paths.filter(path => !kept.has(fold(path))))
479 const index = await freeIndex($, temp.folder, user)
480 const env = { ...objects, GIT_INDEX_FILE: index }
481 try {
482 await step(readTreeArgs(list.head), { env })
483 if (paths.length > 0) await step(UPDATE_INDEX_ARGS, { env, stdin: pathsStdin(paths) })
484 const listed = new Set(list.paths.map(fold))
485 const unlisted = [...kept.keys()].filter(path => !listed.has(path))
486 const clean = unlisted.length === 0 ? [] : (await step(trackedArgs(unlisted), { env })).split('\0').filter(path => path !== '')
487 const entries: string[] = []
488 for (const path of [...list.paths.filter(one => kept.has(fold(one))), ...clean]) {
489 const before = kept.get(fold(path)) ?? null
490 if (before !== null) entries.push(indexInfo(path, (await step(hashArgs(path), { env: objects, stdin: before })).trim(), list.head))
491 else if (list.head !== '') entries.push(indexInfo(path, null, list.head))
492 }
493 if (entries.length > 0) await step(INDEX_INFO_ARGS, { env, stdin: entries.join('') })
494 return { tree: (await step(WRITE_TREE_ARGS, { env })).trim(), nested }
495 } finally {
496 await freeAgain($, temp.folder, index).catch(error => logFailure($, 'Freeing the index file of the mod', error))
497 inUse.delete(index)
498 }
499}
500
501async function freeAgain($: EngineInterface, folder: string, index: string): Promise<void> {
502 if (!removed.has(folder) && (await $.fs.exists(`${index}.lock`))) await remove($, [`${index}.lock`], REMOVE_TIMEOUT_MS)
503 if (!removed.has(folder)) await $.fs.write(index, '')
504}
505
506async function treeOfCounts($: EngineInterface, loaded: RepoMap, before: Changes, counts: Temp): Promise<{ tree: string; nested: string[] }> {
507 const alternates = `${counts.objects}/info/alternates`
508 if (!(await $.fs.exists(alternates))) await writeLive($, counts.folder, alternates, alternatesOf(before.objects))
509 return treeOfWorkingTree($, loaded, counts, 'job', new Map()).catch(async (error: unknown) => {
510 stale.clear()
511 if (!removed.has(counts.folder)) await remove($, [folderOf(counts.objects)], REMOVE_TIMEOUT_MS).catch(failure => logFailure($, 'Deleting the objects of a failed count', failure))
512 throw error
513 })
514}
515
516async function clearCounts($: EngineInterface, counts: Temp): Promise<void> {
517 const inside = await $.fs.list(counts.objects).catch(() => [])
518 if (inside.every(one => one.name === 'info')) return
519 stale.clear()
520 await remove($, [folderOf(counts.objects)], REMOVE_TIMEOUT_MS)
521}
522
523async function prune($: EngineInterface, counts: Temp, before: Counted, after: Counted): Promise<void> {
524 for (const id of staleObjects(before, after)) {
525 const path = looseObjectOf(counts.objects, id)
526 await $.fs.stat(path).then(
527 stat => stale.set(path, stat.size),
528 () => undefined,
529 )
530 }
531 for (const id of objectsOf(after)) stale.delete(looseObjectOf(counts.objects, id))
532 const lfs = `${counts.lfs}/objects`
533 const hasLfs = await $.fs.exists(lfs)
534 if (!hasLfs && [...stale.values()].reduce((sum, size) => sum + size, 0) < PRUNE_LIMIT) return
535 await remove($, [...stale.keys(), ...(hasLfs ? [lfs] : [])], REMOVE_TIMEOUT_MS)
536 stale.clear()
537}
538
539async function load($: EngineInterface, top: Top, user: 'command' | 'job', kept: Map<string, string | null>, from?: RepoMap, since?: Since): Promise<{ files: MapFile[]; isCounted: boolean }> {
540 await $.state.set(STALE, shaped(false))
541 const files = await listFiles($, top.root).catch(async error => {
542 await $.state.set(STALE, shaped(true))
543 throw error
544 })
545 const taken = await prepare($, top, since !== undefined)
546 .then(async temp => ({ ...temp, ...(await treeOfWorkingTree($, top, temp, user, kept)) }))
547 .catch((error: unknown) => {
548 if (reasonOf(error) !== ENDED) logFailure($, TAKING, error)
549 return null
550 })
551 const isLive = since === undefined || (await read($, epoch)) === since.epoch
552 let isStored = false
553 await update($, map, cur => {
554 isStored = isLive && (since === undefined || since.generation === generation) && (from === undefined || cur?.version === from.version)
555 return isStored ? { root: top.root, repo: lastSegment(top.root), commonDir: top.commonDir, files, version: (cur?.version ?? 0) + 1 } : cur
556 })
557 if (isStored) {
558 const prefix = `${toKey(top.root, top.root)}/`
559 nestedPrefixes = (taken?.nested ?? []).map(folder => prefix + fold(folder))
560 await keepZoom($, files)
561 await $.state.set(CHANGES, shaped(taken === null ? null : { root: top.root, baseline: taken.tree, tree: taken.tree, files: {}, trees: [], folder: taken.folder, objects: taken.objects, nested: taken.nested }))
562 await $.state.set(ROOT, shaped(top.root))
563 if (rootPrefix !== prefix) {
564 rootPrefix = prefix
565 later($, RESOLVING, () => resolveFolders($))
566 }
567 if (taken !== null) countLater($)
568 }
569 return { files, isCounted: taken !== null }
570}
571
572async function relist($: EngineInterface, isOnlyWhenStale: boolean): Promise<void> {
573 if (isOnlyWhenStale && ((await read($, isGitPaused)) || !(await read($, isStale)))) return
574 const root = await read($, rootFolder)
575 if (root === '') return
576 await $.state.set(STALE, shaped(false))
577 const files = await listFiles($, root).catch(async error => {
578 await $.state.set(STALE, shaped(true))
579 throw error
580 })
581 let isStored = false
582 await update($, map, cur => {
583 isStored = cur !== null && cur.root === root
584 return cur === null || !isStored ? cur : { ...cur, files, version: cur.version + 1 }
585 })
586 if (isStored) await keepZoom($, files)
587}
588
589async function keepZoom($: EngineInterface, files: MapFile[]): Promise<void> {
590 if (!hasFolder(files, await read($, zoom))) await $.state.set(ZOOM, shaped(''))
591}
592
593async function retake($: EngineInterface): Promise<void> {
594 const loaded = await read($, map)
595 if (loaded !== null && !(await read($, isGitPaused))) await load($, loaded, 'job', new Map(), loaded)
596}
597
598async function gitDirOf($: EngineInterface, cwd: string): Promise<string> {
599 await osName($)
600 for (let dir = absolute(cwd, ''), depth = 0; dir !== '' && depth < GIT_DIR_DEPTH; dir = folderOf(dir), depth++) {
601 const stat = await $.fs.stat(`${dir}/.git`).catch(() => null)
602 if (stat?.kind === 'dir') return `${dir}/.git`
603 if (stat?.kind !== 'file') continue
604 const named = /^gitdir:\s*(.+)$/m.exec(await $.fs.read(`${dir}/.git`))?.[1]?.trim()
605 return named === undefined ? '' : absolute(named, dir)
606 }
607 return ''
608}
609
610async function refocus($: EngineInterface): Promise<void> {
611 const now = await $.clock.now()
612 if (now - focusedAt < REFOCUS_MS) return
613 focusedAt = now
614 later($, COUNTING, () => count($))
615}
616
617function countLater($: EngineInterface, afterMs = COUNT_AFTER_MS): void {
618 countTimer?.cancel()
619 countTimer = $.clock.after(afterMs, () => {
620 countTimer = null
621 later($, COUNTING, () => count($))
622 later($, LISTING, () => relist($, true))
623 })
624}
625
626async function worktreeOfWrite($: EngineInterface, loaded: RepoMap, path: string | undefined): Promise<Top | null> {
627 const root = toKey(loaded.root, loaded.root)
628 const folder = path === undefined ? '' : folderOf(absolute(path, ''))
629 if (folder === '') return null
630 if (!fold(folder).startsWith(`${root}/`) && fold(folder) !== root) return topOf($, folder)
631 const inside = await nestedRootOf($, root.length, folder)
632 return inside === '' ? null : topOf($, inside)
633}
634
635async function follow($: EngineInterface, loaded: RepoMap, wrote: Written[], since: Since): Promise<boolean> {
636 const root = await $.session.root()
637 const hasMoved = seenRoot !== undefined && toKey(root, '') !== toKey(seenRoot, '')
638 const target = hasMoved ? await topOf($, root) : await worktreeOfWrite($, loaded, wrote.at(-1)?.path)
639 if (target === null || toKey(target.commonDir, '') !== toKey(loaded.commonDir, '') || toKey(target.root, '') === toKey(loaded.root, '')) {
640 seenRoot = root
641 return false
642 }
643 const prefix = `${toKey(target.root, '')}/`
644 const kept = new Map<string, string | null>()
645 for (const one of wrote.toReversed()) {
646 const key = toKey(one.path, '')
647 if (!key.startsWith(prefix)) continue
648 if (one.before === undefined) kept.delete(key.slice(prefix.length))
649 else kept.set(key.slice(prefix.length), one.before)
650 }
651 await load($, target, 'job', kept, loaded, since)
652 seenRoot = root
653 return true
654}
655
656async function count($: EngineInterface): Promise<void> {
657 countTimer?.cancel()
658 countTimer = null
659 await countOnce($).catch((error: unknown) => {
660 if (reasonOf(error) === ENDED) return
661 countLater($, RETRY_AFTER_MS)
662 throw error
663 })
664}
665
666async function countOnce($: EngineInterface): Promise<void> {
667 const mine = generation
668 const wrote = written.splice(0)
669 if (await read($, isGitPaused)) return
670 const loaded = await read($, map)
671 if (loaded === null) return
672 const since = { generation: mine, epoch: await read($, epoch) }
673 if (await follow($, loaded, wrote, since)) {
674 later($, COUNTING, () => count($))
675 return
676 }
677 const held = await $.state.get(CHANGES)
678 const before = ofThisShape(held.value) ?? null
679 if (before === null || before.root !== loaded.root || removed.has(before.folder)) return
680 const counts = countsOf(before.folder, loaded.commonDir)
681 if (before.tree === before.baseline) await clearCounts($, counts).catch(error => logFailure($, 'Deleting the versions that the counts of an older baseline left', error))
682 const { tree, nested } = await treeOfCounts($, loaded, before, counts)
683 stopIfEnded(before.folder)
684 const diff = tree === before.tree ? null : await runGit($, before.root, diffArgs(before.baseline, tree), { env: { GIT_OBJECT_DIRECTORY: counts.objects } })
685 const after = diff === null ? before : { files: parseDiff(diff), trees: [tree, ...treesOf(diff)] }
686 if (mine !== generation || (await read($, epoch)) !== since.epoch) return
687 const isSame = tree === before.tree && nested.join('\0') === before.nested.join('\0')
688 if (!isSame && !(await $.state.set(CHANGES, shaped({ ...before, tree, files: after.files, trees: after.trees, nested }), { ifVersion: held.version })).isSet) return
689 nestedPrefixes = nested.map(folder => `${toKey(loaded.root, loaded.root)}/${fold(folder)}`)
690 const found = newlyChanged(before.files, after.files)
691 if (found.length > 0) {
692 const now = await $.clock.now()
693 await changeSession($, cur => (mine === generation ? noteChanges(cur, before.root, found, now) : cur))
694 await $.state.set(STALE, shaped(true))
695 later($, TIMING, () => timeWindow($))
696 if (needsListing(loaded.files, after.files, found)) later($, LISTING, () => relist($, false))
697 }
698 if (diff !== null && !removed.has(before.folder)) await prune($, counts, before, after).catch(error => logFailure($, 'Deleting the old versions of the counted files', error))
699 countLater($)
700}
701
702async function timeWindow($: EngineInterface): Promise<void> {
703 const now = await $.clock.now()
704 const end = nextExpiry(await read($, session), now)
705 if (end === null || (windowTimer !== null && windowTimer.at <= end)) return
706 windowTimer?.timer.cancel()
707 windowTimer = {
708 at: end,
709 timer: $.clock.after(end - now, () => {
710 windowTimer = null
711 $.ui.invalidate('ui.render')
712 later($, TIMING, () => timeWindow($))
713 }),
714 }
715}
716
717async function resolveFolders($: EngineInterface): Promise<void> {
718 const root = await read($, rootFolder)
719 rootPrefix = root === '' ? '' : `${toKey(root, root)}/`
720 if (root === '') return
721 const moves: Array<[string, string]> = []
722 for (const folder of foldersOutside(await read($, session), root)) {
723 const key = fold(folder)
724 if (realFolders.has(key)) continue
725 const real = await $.fs.stat(folder, { resolve: true }).then(
726 stat => (stat.realPath === undefined ? folder : absolute(stat.realPath, '')),
727 () => folder,
728 )
729 realFolders.set(key, real)
730 if (fold(real) !== key) moves.push([key, real])
731 }
732 if (moves.length > 0) await changeSession($, cur => moveFolders(cur, moves))
733}
734
735async function noteCall($: EngineInterface, tool: string, touches: ToolTouch[], isMain: boolean, mine: number, texts: Map<string, string | null>): Promise<void> {
736 const now = await $.clock.now()
737 let isNoted = false
738 await changeSession($, cur => {
739 isNoted = mine === generation
740 return isNoted ? noteTouches(cur, touches, '', now, setsReadTime(tool)) : cur
741 })
742 if (!isNoted) return
743 const edits = touches.filter(one => one.kind === 'edit')
744 if (edits.length > 0) {
745 if (isMain) written.push(...edits.map(one => (texts.has(one.path) ? { path: one.path, before: texts.get(one.path) ?? null } : { path: one.path })))
746 await $.state.set(STALE, shaped(true))
747 later($, COUNTING, () => count($))
748 }
749 if (touches.some(one => isUnplaced(one.path))) later($, RESOLVING, () => resolveFolders($))
750 if (edits.length > 0 || setsReadTime(tool)) later($, TIMING, () => timeWindow($))
751}
752
753async function shellTouches($: EngineInterface, e: Record<string, unknown>, ran: ToolCallResult, cwd: string | undefined): Promise<ToolTouch[]> {
754 const printed = ran.isError !== true && typeof ran.result === 'object' && ran.result !== null ? (ran.result as { stdout?: unknown }).stdout : ''
755 const reads = shellReads(String(e.tool), typeof e.command === 'string' ? e.command : '', typeof printed === 'string' ? printed : '', ran.isError !== true)
756 const named = await locate($, reads.map((path): ToolTouch => ({ path, kind: 'read' })), cwd)
757 const isFile = await Promise.all(named.map(one => $.fs.stat(one.path).then(stat => stat.kind === 'file', () => false)))
758 return named.filter((_, i) => isFile[i])
759}
760
761async function afterCall($: EngineInterface, e: Record<string, unknown>, ran: ToolCallResult, before: Before, mine: number): Promise<void> {
762 if (ran.deny !== undefined) return
763 const tool = String(e.tool)
764 const touches = isShell(tool) ? await shellTouches($, e, ran, before.cwd) : ran.isError === true ? [] : [...before.named, ...(await locate($, touchesFromResult(e, ran.result, before.isGrepOfOneFile)))]
765 if (touches.length > 0) await noteCall($, tool, touches, e.agentId === undefined, mine, before.texts)
766 if ((isShell(tool) && ran.isReadOnly !== true) || (WORKTREE_TOOLS.has(tool) && ran.isError !== true)) later($, COUNTING, () => count($))
767}
768
769function toTheme(value: ConfigValue | undefined): Theme {
770 return String(value).startsWith('light') ? 'light' : 'dark'
771}
772
773async function readTheme($: EngineInterface): Promise<Theme> {
774 return toTheme((await $.config.list()).find(one => one.key === 'theme')?.value)
775}
776
777async function followTheme($: EngineInterface): Promise<void> {
778 if ((await read($, rootFolder)) === '' || (await read($, isGitPaused))) return
779 const row = await readTheme($)
780 if (row !== (await read($, theme))) await $.state.set(THEME, shaped(row))
781}
782
783function firstLine(text: string): string {
784 return text.split(/\r?\n/).find(line => line.trim() !== '')?.trim() ?? ''
785}
786
787function toastReadable($: EngineInterface, text: string): void {
788 const shown = plainText(text).slice(0, REASON_LIMIT)
789 $.ui.toast(shown, { timeoutMs: Math.max(TOAST_MS, shown.length * TOAST_MS_PER_CHARACTER) })
790}
791
792async function isExecutable($: EngineInterface, file: string): Promise<boolean> {
793 return $.process.run(['test', '-x', file], { timeoutMs: OPEN_TIMEOUT_MS }).then(
794 run => run.exitCode === 0,
795 () => true,
796 )
797}
798
799async function openFile($: EngineInterface, root: string, path: string): Promise<void> {
800 const file = `${root.replace(/\/+$/, '')}/${path}`
801 const os = await osName($)
802 if (!(await $.fs.exists(file).catch(() => true))) {
803 toastReadable($, `${path} is not on disk.`)
804 return
805 }
806 const pathext = os === 'win32' ? ((await $.env.get('PATHEXT')) ?? '') : ''
807 const runs = program === '' && (runsWhenOpened(os, path, pathext) || ((os === 'linux' || (os === 'darwin' && isExtensionless(path))) && (await isExecutable($, file))))
808 if (runs && !isText(path)) {
809 toastReadable($, `${path} was not opened: ${OS_NAMES[os]} would run it. See "Open files with" in /config.`)
810 return
811 }
812 const opener = openerOf(os, program, file, runs)
813 const reason = await $.process.run(opener.argv, { timeoutMs: OPEN_TIMEOUT_MS, ...(opener.env === undefined ? {} : { env: opener.env }) }).then(
814 run => (run.exitCode === 0 ? null : `exit code ${run.exitCode}${withStderr(run.stderr)}`),
815 reasonOf,
816 )
817 if (reason !== null) toastReadable($, `Could not open ${path} with ${program !== '' ? program : runs ? 'the default text editor' : 'its default app'}: ${reason}`)
818}
819
820function parseMessage(data: unknown): MapMessage | null {
821 if (typeof data !== 'object' || data === null) return null
822 const { kind, path } = data as { kind?: unknown; path?: unknown }
823 if (kind === 'up') return { kind: 'up' }
824 return kind === 'click' && typeof path === 'string' ? { kind: 'click', path } : null
825}
826
827async function answer($: EngineInterface, message: MapMessage): Promise<void> {
828 const loaded = await read($, map)
829 if (loaded === null) return
830 const now = await $.clock.now()
831 const isFile = message.kind === 'click' && loaded.files.some(file => file.p === message.path)
832 const isDouble = isFile && lastClick.path === message.path && now - lastClick.at < DOUBLE_MS
833 lastClick = { path: isFile && !isDouble ? message.path : '', at: now }
834 if (isFile) {
835 if (isDouble) await openFile($, loaded.root, message.path)
836 return
837 }
838 const shown = await read($, zoom)
839 const next = message.kind === 'up' ? zoomOut(loaded.files, hasFolder(loaded.files, shown) ? shown : '') : message.path
840 const isFolder = message.kind === 'up' || (next !== '' && hasFolder(loaded.files, next))
841 if (isFolder && next !== shown) await $.state.set(ZOOM, shaped(next))
842}
843
844function narrowerLine(placement: 'dock' | 'inline', columns: number, conversation: number | undefined): string {
845 if (placement !== 'dock' || columns >= paneColumns) return ''
846 if (conversation === undefined || conversation <= CONVERSATION_COLUMNS) return `Widen the terminal to ${paneColumns + CONVERSATION_COLUMNS + EDGE_COLUMNS} columns, or lower "Pane width" ${paneColumns} in /config.`
847 const dragged = `Claude Code uses the width you dragged a pane's edge to, not "Pane width" ${paneColumns}. Drag the edge to change it.`
848 return dragged.length <= columns ? dragged : `A dragged width replaces "Pane width" ${paneColumns}.`
849}
850
851function coloursOf(span: Span): { color?: string; backgroundColor?: string } {
852 return { ...(span.ink === undefined ? {} : { color: span.ink }), ...(span.fill === undefined ? {} : { backgroundColor: span.fill }) }
853}
854
855export const register: Register = (on, options) => {
856 program = typeof options?.opener === 'string' ? options.opener.trim() : ''
857 paneColumns = typeof options?.paneWidth === 'number' ? Math.round(options.paneWidth) : PANE_COLUMNS
858 on('session.start', async ($, e, next) => {
859 await $.command.register({
860 name: PANE,
861 description: 'Draw a git repository as a map in a side pane that marks the files Claude reads and the lines that change.',
862 argumentHint: '[folder]',
863 immediate: true,
864 })
865 if ((await read($, map)) !== null) {
866 await update($, epoch, n => n + 1)
867 later($, COUNTING, () => count($))
868 later($, TIMING, () => timeWindow($))
869 }
870 return next(e)
871 })
872
873 on('command.run', { command: PANE }, async ($, e) => {
874 const os = await osName($)
875 const cwd = await $.session.cwd()
876 const typed = unquote(e.args.trim())
877 const shownRoot = await read($, rootFolder)
878 const folder = typed === '' ? shownRoot || cwd : resolveFolder(typed, cwd, typed.startsWith('~') ? await homeOf($) : '', os)
879 try {
880 const top = await repoOf($, folder)
881 if (top === '') return { text: `${folder} is not inside a git repository, so there is no map to draw.` }
882 if (typeof top === 'string') return { text: `Git could not read ${folder} as a repository: ${top}` }
883 seenRoot = await $.session.root()
884 const loaded = await load($, top, 'command', new Map())
885 later($, CLEANING, () => sweep($))
886 if (toKey(top.root, '') !== toKey(shownRoot, '') && (await read($, zoom)) !== '') await $.state.set(ZOOM, shaped(''))
887 const row = await readTheme($).catch((error): Theme => {
888 logFailure($, READING_THEME, error)
889 return 'dark'
890 })
891 await $.state.set(THEME, shaped(row))
892 await $.state.set(PAUSED, shaped(false))
893 const opened = await $.ui.open({ id: PANE, title: 'File map', columns: paneColumns })
894 const counts = loaded.isCounted ? '' : NOT_COUNTED
895 const waits = opened.isPlaced ? '' : ` The pane is not drawn yet: ${opened.reason}`
896 return { text: `File map of ${top.root}: ${loaded.files.length} files.${counts}${waits}` }
897 } catch (error) {
898 return { text: `The file map of ${folder} could not be drawn: ${reasonOf(error)}` }
899 }
900 })
901
902 on('tool.call', async ($, e, next) => {
903 const mine = generation
904 const isShellCall = isShell(String(e.tool))
905 const before: Before = { named: [], isGrepOfOneFile: false, texts: new Map() }
906 try {
907 if (isShellCall) before.cwd = await $.session.cwd()
908 const input = await inputTouches($, e)
909 before.named = input.touches
910 before.isGrepOfOneFile = input.isGrepOfOneFile
911 if (e.agentId === undefined) before.texts = await textsBefore($, before.named)
912 } catch (error) {
913 logFailure($, 'Naming the files of a tool call', error)
914 }
915 const ran = await next(e)
916 const note = () => afterCall($, e, ran, before, mine)
917 await (isShellCall ? detach($, NOTING, note) : note()).catch(error => logFailure($, NOTING, error))
918 return ran
919 })
920
921 on('turn.start', ($, e, next) => {
922 later($, READING_THEME, () => followTheme($))
923 if (countTimer === null || failing.has(COUNTING)) later($, COUNTING, () => count($))
924 if (windowTimer === null) later($, TIMING, () => timeWindow($))
925 return next(e)
926 })
927
928 on('turn.complete', ($, e, next) => {
929 if (e.agentId === undefined) later($, LISTING, () => relist($, true))
930 return next(e)
931 })
932
933 on('session.end', async ($, e, next) => {
934 const isGoingOn = e.reason === 'clear' || e.reason === 'resume'
935 if (isGoingOn) {
936 generation += 1
937 written.length = 0
938 await $.state.set(SESSION, shaped(EMPTY_SESSION))
939 await $.state.set(CHANGES, shaped(null))
940 await update($, epoch, n => n + 1)
941 later($, TAKING, () => retake($))
942 }
943 await endFolder($, e.sessionId, isGoingOn).catch(error => logFailure($, 'Deleting the folder of the session', error))
944 return next(e)
945 })
946
947 on('ui.close', { id: PANE, origin: { kind: 'person' } }, async ($, e, next) => {
948 const closed = await next(e)
949 if (closed.deny === undefined) await $.state.set(PAUSED, shaped(true)).catch(error => logFailure($, 'Pausing the git work', error))
950 return closed
951 })
952
953 on('config.set', { key: 'theme' }, async ($, e, next) => {
954 const changed = await next(e)
955 if (changed.deny === undefined) await $.state.set(THEME, shaped(toTheme(changed.value))).catch(error => logFailure($, 'Storing the theme', error))
956 return changed
957 })
958
959 on('prompt.submit', ($, e, next) => {
960 if (e.origin.kind === 'task-notification' || e.text.includes(TASK_ENDED)) later($, COUNTING, () => count($))
961 return next(e)
962 })
963
964 on('prompt.attachment', ($, e, next) => {
965 if (e.text.includes(TASK_ENDED)) later($, COUNTING, () => count($))
966 return next(e)
967 })
968
969 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
970 const focused = await next(e)
971 await refocus($).catch(error => logFailure($, 'Counting when the pane gets the focus', error))
972 return focused
973 })
974
975 on('classic.SessionStart', async ($, e, next) => {
976 const started = await next(e)
977 const gitDir = await gitDirOf($, e.cwd).catch(() => '')
978 return gitDir === '' ? started : { ...started, watchPaths: [...(started.watchPaths ?? []), `${gitDir}/index`, `${gitDir}/HEAD`] }
979 })
980
981 on('classic.FileChanged', async ($, e, next) => {
982 const changed = await next(e)
983 if (/[\\/](index|HEAD)$/.test(e.file_path)) later($, COUNTING, () => count($))
984 return changed
985 })
986
987 on('ui.message', { requestId: PANE, element: 'map' }, async ($, e, next) => {
988 const message = parseMessage(e.data)
989 if (message !== null) await answer($, message).catch(error => logFailure($, 'Answering a click on the map', error))
990 return next(e)
991 })
992
993 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
994 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
995 const { Text } = $.ui.resolve(e)
996 return <Text>The file map draws in the terminal and in the desktop app.</Text>
997 }
998 const { Box, Button, Client, Text } = $.ui.resolve(e)
999 try {
1000 await osName($)
1001 const held = await read($, map)
1002 const loaded = held ?? NO_MAP
1003 const shown = await read($, zoom)
1004 const view = {
1005 columns: Math.max(1, Math.floor(e.props.bodyColumns)),
1006 rows: Math.max(1, Math.floor(e.props.scroll?.bodyRows ?? (e.viewport?.rows ?? 24) - 4)),
1007 zoom: hasFolder(loaded.files, shown) ? shown : '',
1008 }
1009 const idle = e.surface === 'terminal' ? narrowerLine(e.props.placement, view.columns, e.viewport?.columns) : ''
1010 const input = { ...screenInput(loaded, await read($, session), await read($, changes), view, await read($, theme), await $.clock.now(), held === null ? NOTICE : undefined), idle }
1011 const frame = paneFrame(input)
1012 const props = fitMap(rows => mapDrawing(input, rows), frame.mapRows)
1013 const list = listOf(input, frame)
1014 const regionRows = props === null ? frame.mapRows + 2 : 1 + props.rows.length + (props.rows.length > 0 ? 1 : 0)
1015 const drawSpan = (span: Span) => <Text {...coloursOf(span)}>{span.text}</Text>
1016 const tooLong = `The map is not drawn: ${view.zoom === '' ? 'this repository' : 'this folder'} has too many long file paths to draw at this width. Make the pane narrower${view.zoom === '' ? '' : ', or press up'}.`
1017 return (
1018 <Box flexDirection="column">
1019 {props === null ? (
1020 <Box key="map" height={regionRows}>
1021 <Text>{tooLong}</Text>
1022 </Box>
1023 ) : (
1024 <Client key="map" module="./map-client.tsx" props={props satisfies MapProps} width={view.columns} height={regionRows} />
1025 )}
1026 {frame.listRows > 0 ? (
1027 <Box key="list" flexDirection="column" height={frame.listRows}>
1028 {list.rows.map(row => (
1029 <Box flexDirection="row">
1030 <Text backgroundColor={row.swatch}>{' '}</Text>
1031 <Text> </Text>
1032 <Button key={`open:${row.path}`} label={row.label} plain dimColor onPress={() => void openFile($, loaded.root, row.path).catch(error => logFailure($, 'Opening a file of the list', error))} />
1033 <Text wrap="truncate" {...coloursOf(row.rest)}>
1034 {row.rest.text}
1035 </Text>
1036 </Box>
1037 ))}
1038 {list.outside === null ? null : (
1039 <Text wrap="truncate" {...coloursOf(list.outside)}>
1040 {list.outside.text}
1041 </Text>
1042 )}
1043 </Box>
1044 ) : null}
1045 {frame.hasLegend ? <Text wrap="truncate">{legendOf(input).map(drawSpan)}</Text> : null}
1046 {frame.hasControls ? (
1047 <Box key="controls" flexDirection="row" gap={1}>
1048 <Button
1049 key="refresh"
1050 label="refresh"
1051 onPress={() => {
1052 inOrder(() => relist($, false)).catch(error => logFailure($, LISTING, error))
1053 later($, COUNTING, () => count($))
1054 }}
1055 />
1056 {view.zoom === '' ? null : <Button key="up" label="up" onPress={() => void answer($, { kind: 'up' }).catch(error => logFailure($, 'Showing the folder around the map', error))} />}
1057 </Box>
1058 ) : null}
1059 </Box>
1060 )
1061 } catch (error) {
1062 logFailure($, 'Drawing the file map', error)
1063 const reason = plainText(reasonOf(error).replace(/\s+/g, ' ')).slice(0, REASON_LIMIT)
1064 return <Text>{`The file map could not be drawn: ${reason}`}</Text>
1065 }
1066 })
1067}
1068hooks/map.ts 107 lines1import type { MapProps, MapRun } from './map-client'
2import { DEFAULT, type MapDrawing, type TileFacts } from './screen'
3
4const SPACE = 0x20
5const ANY = -2
6
7export const TREE_BUDGET = 96_000
8export const PROPS_BUDGET = 90_000
9
10type Palette = { colours: string[]; indexOf: Map<number, number> }
11
12function colourIndex(palette: Palette, colour: number): number {
13 if (colour === DEFAULT || colour === ANY) return -1
14 let index = palette.indexOf.get(colour)
15 if (index === undefined) {
16 index = palette.colours.length
17 palette.colours.push(`#${colour.toString(16).padStart(6, '0')}`)
18 palette.indexOf.set(colour, index)
19 }
20 return index
21}
22
23function rankOf(tiles: TileFacts[], index: number): number {
24 const tile = tiles[index]
25 if (tile === undefined) return -1
26 return (tile.kind === 'file' ? 1000 : 0) + tile.depth
27}
28
29function ownersOf(drawing: MapDrawing, row: number): number[] {
30 const { columns, owner, pixelRows, tiles } = drawing
31 const pairs: number[] = []
32 for (let x = 0; x < columns; x++) {
33 const upper = 2 * row < pixelRows ? (owner[2 * row * columns + x] ?? -1) : -1
34 const lower = 2 * row + 1 < pixelRows ? (owner[(2 * row + 1) * columns + x] ?? -1) : -1
35 const tile = rankOf(tiles, lower) > rankOf(tiles, upper) ? lower : upper
36 const last = pairs.length - 2
37 if (last >= 0 && pairs[last] === tile) pairs[last + 1] = (pairs[last + 1] ?? 0) + 1
38 else pairs.push(tile, 1)
39 }
40 return pairs
41}
42
43function runsOf(drawing: MapDrawing, row: number, palette: Palette): MapRun[] {
44 const { columns, grid } = drawing
45 const runs: MapRun[] = []
46 let text = ''
47 let ink = ANY
48 let fill = DEFAULT
49 for (let x = 0; x < columns; x++) {
50 const at = (row * columns + x) * 3
51 const glyph = grid[at] ?? SPACE
52 const cellInk = glyph === SPACE ? ANY : (grid[at + 1] ?? DEFAULT)
53 const cellFill = grid[at + 2] ?? DEFAULT
54 const joins = text !== '' && cellFill === fill && (cellInk === ANY || ink === ANY || cellInk === ink)
55 if (text !== '' && !joins) {
56 runs.push([text, colourIndex(palette, ink), colourIndex(palette, fill)])
57 text = ''
58 }
59 if (text === '') {
60 ink = cellInk
61 fill = cellFill
62 } else if (ink === ANY) ink = cellInk
63 text += String.fromCodePoint(glyph)
64 }
65 if (text !== '') runs.push([text, colourIndex(palette, ink), colourIndex(palette, fill)])
66 return runs
67}
68
69export function treeCost(props: MapProps): number {
70 let cost = 300 + 2 * (props.columns + 80)
71 for (const runs of props.rows) {
72 cost += 60
73 for (const [text, ink, fill] of runs) cost += text.length + (ink >= 0 && fill >= 0 ? 90 : fill >= 0 ? 72 : ink >= 0 ? 62 : 3)
74 }
75 return cost
76}
77
78export function mapProps(drawing: MapDrawing): MapProps {
79 const palette: Palette = { colours: [], indexOf: new Map() }
80 const rows = Array.from({ length: drawing.rows }, (_, row) => runsOf(drawing, row, palette))
81 return {
82 columns: drawing.columns,
83 top: drawing.top,
84 summary: drawing.summary,
85 upHint: drawing.upHint,
86 idle: drawing.idle,
87 palette: palette.colours,
88 rows,
89 owners: Array.from({ length: drawing.rows }, (_, row) => ownersOf(drawing, row)),
90 tiles: drawing.tiles.map(({ path, hover }) => ({ path, hover })),
91 }
92}
93
94function overrun(props: MapProps): number {
95 return Math.max(treeCost(props) / TREE_BUDGET, JSON.stringify(props).length / PROPS_BUDGET)
96}
97
98export function fitMap(layout: (rows: number) => MapDrawing, rows: number): MapProps | null {
99 let props = mapProps(layout(rows))
100 for (let over = overrun(props); over > 1; over = overrun(props)) {
101 if (rows <= 1) return null
102 rows = Math.max(1, Math.min(rows - 1, Math.floor(rows / over)))
103 props = mapProps(layout(rows))
104 }
105 return props
106}
107hooks/map-client.tsx 117 lines1import type { ClientElements, ClientModule, ClientPointerEvent } from 'claude-code'
2
3export type MapTile = { path: string; hover: string }
4export type MapRun = [text: string, ink: number, fill: number]
5export type MapProps = {
6 columns: number
7 top: string
8 summary: string
9 upHint: string
10 idle: string
11 palette: string[]
12 rows: MapRun[][]
13 owners: number[][]
14 tiles: MapTile[]
15}
16export type MapMessage = { kind: 'click'; path: string } | { kind: 'up' }
17type Target = number | 'top'
18type Local = { x: number; y: number; isInside: boolean; press: string | null; chosen: string | null }
19
20const START: Local = { x: -1, y: -1, isInside: false, press: null, chosen: null }
21const TOP = '\u0000top'
22
23function targetAt(props: MapProps, x: number, y: number): Target | null {
24 if (x < 0 || x >= props.columns) return null
25 if (y === 0) return props.upHint === '' ? null : 'top'
26 const pairs = props.owners[y - 1]
27 if (pairs === undefined) return null
28 let left = x
29 for (let i = 0; i + 1 < pairs.length; i += 2) {
30 const count = pairs[i + 1] ?? 0
31 if (left < count) {
32 const tile = pairs[i] ?? -1
33 return tile >= 0 && tile < props.tiles.length ? tile : null
34 }
35 left -= count
36 }
37 return null
38}
39
40function idOf(props: MapProps, target: Target | null): string | null {
41 if (target === 'top') return TOP
42 return target === null ? null : (props.tiles[target]?.path ?? null)
43}
44
45function hoverOf(props: MapProps, local: Local): Target | null {
46 return local.isInside ? targetAt(props, local.x, local.y) : null
47}
48
49function hoverLine(props: MapProps, local: Local): string {
50 const target = hoverOf(props, local)
51 if (target === 'top') return props.upHint
52 if (target !== null) return props.tiles[target]?.hover ?? ''
53 return props.tiles.find(tile => tile.path === local.chosen)?.hover ?? ''
54}
55
56function messageOf(id: string): MapMessage {
57 return id === TOP ? { kind: 'up' } : { kind: 'click', path: id }
58}
59
60function step(props: MapProps, local: Local, event: ClientPointerEvent): { next: Local; message: MapMessage | null } {
61 const rows = props.rows.length + 2
62 const isInside = event.x >= 0 && event.y >= 0 && event.x < props.columns && event.y < rows
63 const under = isInside ? idOf(props, targetAt(props, event.x, event.y)) : null
64 switch (event.type) {
65 case 'leave':
66 return { next: { ...local, isInside: false }, message: null }
67 case 'enter':
68 case 'move':
69 return { next: { ...local, x: event.x, y: event.y, isInside, chosen: under !== null && under !== local.chosen ? null : local.chosen }, message: null }
70 case 'down': {
71 const isMain = event.button === undefined || event.button === 'left'
72 return { next: { ...local, x: event.x, y: event.y, isInside, press: isMain ? under : null }, message: null }
73 }
74 case 'up': {
75 const isClick = local.press !== null && under === local.press
76 const chosen = isClick && under !== TOP ? under : local.chosen
77 return { next: { x: event.x, y: event.y, isInside, press: null, chosen }, message: isClick && under !== null ? messageOf(under) : null }
78 }
79 }
80}
81
82function drawRun(Text: ClientElements['Text'], palette: string[], [text, ink, fill]: MapRun) {
83 if (ink < 0 && fill < 0) return text
84 const style = {
85 ...(ink >= 0 ? { color: palette[ink] ?? '' } : {}),
86 ...(fill >= 0 ? { backgroundColor: palette[fill] ?? '' } : {}),
87 }
88 return <Text {...style}>{text}</Text>
89}
90
91const MapClient: ClientModule<MapProps, Local> = (props, surface) => {
92 const { Box, Text } = surface.elements
93 let current = surface.state ?? START
94 surface.onPointer(event => {
95 const { next, message } = step(props, current, event)
96 const isChanged = next.press !== current.press || next.chosen !== current.chosen || hoverLine(props, next) !== hoverLine(props, current)
97 current = next
98 if (isChanged) surface.setState(next)
99 if (message) surface.post(message)
100 })
101 const isUp = props.upHint !== ''
102 return (
103 <Box flexDirection="column">
104 <Text wrap="truncate">
105 {isUp ? <Text underline>{props.top}</Text> : props.top}
106 {props.summary}
107 </Text>
108 {props.rows.map(runs => (
109 <Text wrap="truncate">{runs.map(run => drawRun(Text, props.palette, run))}</Text>
110 ))}
111 {props.rows.length > 0 ? <Text wrap="truncate">{hoverLine(props, current) || props.idle || ' '}</Text> : null}
112 </Box>
113 )
114}
115
116export default MapClient
117hooks/screen.ts 625 lines1import type { FileChange, MapFile, Theme } from '../types'
2import { fold, type Mark, type RecentRow, type ScreenInput } from './track'
3
4export type TileFacts = { path: string; kind: 'file' | 'folder'; depth: number; hover: string }
5export type MapDrawing = {
6 columns: number
7 rows: number
8 grid: Uint32Array
9 owner: Int32Array
10 pixelRows: number
11 tiles: TileFacts[]
12 top: string
13 summary: string
14 upHint: string
15 idle: string
16}
17
18export const DEFAULT = 0x01000000
19const SPACE = 0x20
20const UPPER_HALF = 0x2580
21const LOWER_HALF = 0x2584
22const MIN_LINES = 20
23const MIN_FILE_AREA = 16
24const MIN_SIDE = 2
25const MIN_STEP = MIN_SIDE + 1
26const FLOOR_ROUNDS = 12
27const FRAME_WIDE = 2
28const FRAME_HIGH = 3.5
29const FOLDER = 0
30const NEUTRAL = 1
31const READ = 2
32const CHANGED = 3
33const NOW = 4
34const MIN_FILL = 2
35
36type Palette = { fill: number[]; ink: number[]; quiet: number }
37
38const PALETTES: Record<Theme, Palette> = {
39 dark: {
40 fill: [0x2a2722, 0x37342d, 0x454238, 0x4f7bab, 0xdc7236],
41 ink: [0xb8b1a2, 0xb8b1a2, 0xece6d8, 0xece6d8, 0xece6d8],
42 quiet: 0xb8b1a2,
43 },
44 light: {
45 fill: [0xe8e2d4, 0xd3ccbc, 0xbeb6a5, 0x6a90bc, 0xd8692e],
46 ink: [0x56585c, 0x56585c, 0x26282c, 0xfaf8f2, 0xfaf8f2],
47 quiet: 0x56585c,
48 },
49}
50
51type LegendItem = { fill: number; word: string }
52
53const LEGEND_LEAD = 'files '
54const LIST_WIDTH = 64
55const LIST_LIMIT = 160
56const LIST_MIN_COLUMNS = 20
57const LIST_MIN_ROWS = 8
58const NO_FILES = 'No file of a mapped type in this repository.'
59const LAYOUT_LIMIT = 8
60
61type Entry = { name: string; path: string; weight: number; lines: number; key: string; children: Entry[] | null }
62type Dir = { name: string; dirs: Map<string, Dir>; files: MapFile[] }
63type Row = { count: number; stacked: boolean }
64type Crowding = { leaves: number; isCrowded: boolean }
65
66type Tile = {
67 entry: Entry
68 value: number
69 tiles: Tile[] | null
70 x0: number
71 y0: number
72 x1: number
73 y1: number
74}
75
76export type MapLabel = { column: number; row: number; text: string }
77
78export type MapRect = {
79 kind: 'folder' | 'file' | 'box'
80 name: string
81 path: string
82 depth: number
83 keys: string[]
84 lines: number
85 weight: number
86 x0: number
87 y0: number
88 x1: number
89 y1: number
90 label: MapLabel | null
91}
92
93export type MapLayout = {
94 width: number
95 height: number
96 rects: MapRect[]
97 owner: Int32Array
98 byKey: Map<string, number>
99 linesOf: Map<string, number>
100}
101
102export type PaneFrame = { mapRows: number; listRows: number; hasLegend: boolean; hasControls: boolean }
103export type Span = { text: string; ink?: string; fill?: string }
104export type ListRow = { path: string; swatch: string; label: string; rest: Span }
105
106const layouts = new Map<string, MapLayout>()
107const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
108const join = (folder: string, name: string) => (folder === '' ? name : `${folder}/${name}`)
109const parentOf = (path: string) => path.slice(0, Math.max(0, path.lastIndexOf('/')))
110const hex = (colour: number) => `#${colour.toString(16).padStart(6, '0')}`
111const weightOf = (lines: number) => Math.sqrt(Math.max(lines, MIN_LINES))
112
113const SHOWN = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Script=Armenian}\p{Script=Georgian}\p{Script=Common}]/u
114const HIDDEN = /[\p{C}\p{M}\p{Zl}\p{Zp}\p{Emoji_Presentation} ᄀ-ᅟ⺀-가-힣豈-︐-︙︰--⦆¢-₩]/u
115
116export function plainText(text: string): string {
117 let out = ''
118 for (const ch of text.normalize('NFC')) out += ch.length === 1 && SHOWN.test(ch) && !HIDDEN.test(ch) ? ch : '?'
119 return out
120}
121
122export function hasFolder(files: MapFile[], folder: string): boolean {
123 return folder === '' || files.some(file => file.p.startsWith(`${folder}/`))
124}
125
126function holdsOneFolder(files: MapFile[], folder: string): boolean {
127 let only: string | undefined
128 for (const file of files) {
129 if (!file.p.startsWith(`${folder}/`)) continue
130 const rest = file.p.slice(folder.length + 1)
131 const cut = rest.indexOf('/')
132 if (cut < 0 || (only !== undefined && only !== rest.slice(0, cut))) return false
133 only = rest.slice(0, cut)
134 }
135 return only !== undefined
136}
137
138export function zoomOut(files: MapFile[], folder: string): string {
139 let parent = parentOf(folder)
140 while (parent !== '' && holdsOneFolder(files, parent)) parent = parentOf(parent)
141 return parent
142}
143
144function onlyDir(dir: Dir): Dir | undefined {
145 if (dir.files.length > 0 || dir.dirs.size !== 1) return undefined
146 for (const only of dir.dirs.values()) return only
147}
148
149function toEntry(dir: Dir, path: string, isRoot: boolean): Entry {
150 let name = dir.name
151 for (let only = isRoot ? undefined : onlyDir(dir); only; only = onlyDir(dir)) {
152 dir = only
153 name += '/' + dir.name
154 path += '/' + dir.name
155 }
156 const children = [...dir.dirs.values()].map(d => toEntry(d, join(path, d.name), false))
157 for (const f of dir.files) children.push({ name: baseName(f.p), path: f.p, weight: weightOf(f.lines), lines: f.lines, key: fold(f.p), children: null })
158 let weight = 0
159 let lines = 0
160 for (const c of children) {
161 weight += c.weight
162 lines += c.lines
163 }
164 return { name, path, weight, lines, key: '', children }
165}
166
167function toTree(files: MapFile[], zoom: string): Entry {
168 const root: Dir = { name: '', dirs: new Map(), files: [] }
169 const depth = zoom === '' ? 0 : zoom.split('/').length
170 for (const f of files) {
171 if (zoom !== '' && !f.p.startsWith(`${zoom}/`)) continue
172 let dir = root
173 for (const part of f.p.split('/').slice(depth, -1)) {
174 let next = dir.dirs.get(part)
175 if (!next) {
176 next = { name: part, dirs: new Map(), files: [] }
177 dir.dirs.set(part, next)
178 }
179 dir = next
180 }
181 dir.files.push(f)
182 }
183 return toEntry(root, zoom, true)
184}
185
186function framed(content: number, floor: number): number {
187 const perPixel = floor / MIN_FILE_AREA
188 const side = Math.sqrt(content / perPixel)
189 return (side + FRAME_WIDE) * (side + FRAME_HIGH) * perPixel
190}
191
192function valueOf(entry: Entry, closed: Set<Entry>, floor: number, isRoot: boolean): number {
193 if (!entry.children || closed.has(entry)) return Math.max(entry.weight, floor)
194 let content = 0
195 for (const c of entry.children) content += valueOf(c, closed, floor, false)
196 return isRoot ? content : framed(content, floor)
197}
198
199function toTile(entry: Entry, closed: Set<Entry>, floor: number, isRoot: boolean): Tile {
200 const tile: Tile = { entry, value: Math.max(entry.weight, floor), tiles: null, x0: 0, y0: 0, x1: 0, y1: 0 }
201 if (!entry.children || closed.has(entry)) return tile
202 const tiles = entry.children.map(c => toTile(c, closed, floor, false))
203 tiles.sort((a, b) => b.value - a.value || (a.entry.name < b.entry.name ? -1 : a.entry.name > b.entry.name ? 1 : 0))
204 tile.tiles = tiles
205 let content = 0
206 for (const t of tiles) content += t.value
207 tile.value = isRoot ? content : framed(content, floor)
208 return tile
209}
210
211function floorOf(root: Entry, closed: Set<Entry>, area: number): number {
212 let floor = (MIN_FILE_AREA * root.weight) / area
213 for (let round = 0; round < FLOOR_ROUNDS; round++) floor = (MIN_FILE_AREA * valueOf(root, closed, floor, true)) / area
214 return floor
215}
216
217function squarify(values: number[], width: number, height: number): Row[] {
218 const rows: Row[] = []
219 let rest = 0
220 for (const v of values) rest += v
221 let row: Row | null = null
222 let side = 0
223 let perValue = 0
224 let sum = 0
225 let min = 0
226 let max = 0
227 let ratio = 0
228 const worst = (total: number, least: number, most: number) => {
229 const thickness = (total * perValue) / side
230 return Math.max((thickness * thickness) / (least * perValue), (most * perValue) / (thickness * thickness))
231 }
232 for (const v of values) {
233 if (row) {
234 const next = side > 0 ? worst(sum + v, Math.min(min, v), Math.max(max, v)) : 0
235 if (next <= ratio) {
236 row.count++
237 sum += v
238 min = Math.min(min, v)
239 max = Math.max(max, v)
240 ratio = next
241 continue
242 }
243 if (row.stacked) width -= (sum * perValue) / side
244 else height -= (sum * perValue) / side
245 rest -= sum
246 }
247 row = { count: 1, stacked: width >= height }
248 rows.push(row)
249 side = row.stacked ? height : width
250 perValue = (width * height) / rest
251 sum = min = max = v
252 ratio = side > 0 ? worst(v, v, v) : 0
253 }
254 return rows
255}
256
257function spread(members: Tile[], start: number, end: number, set: (tile: Tile, from: number, to: number) => void): void {
258 const least = members.length * MIN_STEP <= end - start ? MIN_STEP : 0
259 let sum = 0
260 for (const t of members) sum += t.value
261 let to = end
262 for (const t of [...members].reverse()) {
263 const from = t === members[0] ? start : to - Math.max(least, ((to - start) * t.value) / sum)
264 set(t, from, to)
265 sum -= t.value
266 to = from
267 }
268}
269
270function setRect(tile: Tile, x0: number, y0: number, x1: number, y1: number): void {
271 tile.x0 = Math.round(x0)
272 tile.y0 = Math.round(y0)
273 tile.x1 = Math.round(x1) - 1
274 tile.y1 = Math.round(y1) - 1
275}
276
277function place(tile: Tile, isRoot: boolean): void {
278 const tiles = tile.tiles
279 if (!tiles) return
280 const band = isRoot ? 0 : tile.y0 % 2 === 0 ? 2 : 3
281 const pad = isRoot ? 0 : 1
282 let left = tile.x0 + pad
283 let top = tile.y0 + band
284 const right = Math.max(left, tile.x1 - pad + 1)
285 const bottom = Math.max(top, tile.y1 - pad + 1)
286 const rows = squarify(tiles.map(t => t.value), right - left, bottom - top)
287 const steps = rows.map(row => ({ row, wideAfter: 0, highAfter: 0 }))
288 let wide = 0
289 let high = 0
290 for (const step of [...steps].reverse()) {
291 step.wideAfter = wide
292 step.highAfter = high
293 const along = step.row.count * MIN_STEP
294 wide = step.row.stacked ? wide + MIN_STEP : Math.max(wide, along)
295 high = step.row.stacked ? Math.max(high, along) : high + MIN_STEP
296 }
297 const thickness = (room: number, share: number, after: number) =>
298 room - after >= MIN_STEP ? Math.min(room - after, Math.max(MIN_STEP, room * share)) : room * share
299 let rest = 0
300 for (const t of tiles) rest += t.value
301 let first = 0
302 steps.forEach((step, r) => {
303 const members = tiles.slice(first, first + step.row.count)
304 const isLastRow = r === steps.length - 1
305 let sum = 0
306 for (const t of members) sum += t.value
307 if (step.row.stacked) {
308 const x = left
309 const edge = isLastRow ? right : x + thickness(right - x, sum / rest, step.wideAfter)
310 spread(members, top, bottom, (t, from, to) => setRect(t, x, from, edge, to))
311 left = edge
312 } else {
313 const y = top
314 const edge = isLastRow ? bottom : y + thickness(bottom - y, sum / rest, step.highAfter)
315 spread(members, left, right, (t, from, to) => setRect(t, from, y, to, edge))
316 top = edge
317 }
318 rest -= sum
319 first += step.row.count
320 })
321 for (const t of tiles) place(t, false)
322}
323
324function findCrowded(tile: Tile, isRoot: boolean, innermost: Entry[]): Crowding {
325 if (!tile.tiles) return { leaves: 1, isCrowded: false }
326 let leaves = 0
327 let hasCrowdedInside = false
328 let hasThinLeaf = false
329 for (const t of tile.tiles) {
330 const inside = findCrowded(t, false, innermost)
331 leaves += inside.leaves
332 if (inside.isCrowded) hasCrowdedInside = true
333 if (!t.tiles && (t.x1 - t.x0 < MIN_SIDE || t.y1 - t.y0 < MIN_SIDE)) hasThinLeaf = true
334 }
335 if (isRoot) return { leaves, isCrowded: hasCrowdedInside }
336 const area = Math.max(0, tile.x1 - tile.x0) * Math.max(0, tile.y1 - tile.y0)
337 const isCrowded = hasThinLeaf || area < MIN_FILE_AREA * leaves
338 if (isCrowded && !hasCrowdedInside) innermost.push(tile.entry)
339 return { leaves, isCrowded: isCrowded || hasCrowdedInside }
340}
341
342function makePlan(root: Entry, width: number, height: number): Tile {
343 const closed = new Set<Entry>()
344 for (;;) {
345 const top = toTile(root, closed, floorOf(root, closed, width * height), true)
346 top.x1 = width
347 top.y1 = height
348 place(top, true)
349 const innermost: Entry[] = []
350 findCrowded(top, true, innermost)
351 if (!innermost.length) return top
352 for (const e of innermost) closed.add(e)
353 }
354}
355
356function leavesUnder(entry: Entry, out: Entry[]): Entry[] {
357 if (!entry.children) out.push(entry)
358 else for (const c of entry.children) leavesUnder(c, out)
359 return out
360}
361
362function collect(tile: Tile, depth: number, layout: MapLayout): void {
363 const x1 = Math.min(tile.x1, layout.width)
364 const y1 = Math.min(tile.y1, layout.height)
365 if (depth > 0) {
366 if (x1 <= tile.x0 || y1 <= tile.y0) return
367 const index = layout.rects.length
368 const kind = tile.tiles ? 'folder' : tile.entry.children ? 'box' : 'file'
369 const leaves = leavesUnder(tile.entry, [])
370 const { name, path, lines, weight } = tile.entry
371 layout.rects.push({ kind, name, path, depth, keys: leaves.map(leaf => leaf.key), lines, weight, x0: tile.x0, y0: tile.y0, x1, y1, label: null })
372 if (kind !== 'folder') {
373 for (const leaf of leaves) {
374 layout.byKey.set(leaf.key, index)
375 layout.linesOf.set(leaf.key, leaf.lines)
376 }
377 }
378 for (let y = tile.y0; y < y1; y++) layout.owner.fill(index, y * layout.width + tile.x0, y * layout.width + x1)
379 }
380 if (tile.tiles) for (const t of tile.tiles) collect(t, depth + 1, layout)
381}
382
383function labelOf(rect: MapRect): MapLabel | null {
384 const row = Math.ceil(rect.y0 / 2)
385 if (2 * row + 2 > rect.y1) return null
386 const name = plainText(rect.name)
387 const texts =
388 rect.kind === 'file'
389 ? [name, name.replace(/\.[^.]+$/, '')]
390 : [name, baseName(name)].map(t => (rect.kind === 'box' ? t + '/' : t))
391 const width = rect.x1 - rect.x0
392 const text = texts.find(t => t && t.length <= width)
393 return text ? { column: text.length < width ? rect.x0 + 1 : rect.x0, row, text } : null
394}
395
396export function paneFrame(input: ScreenInput): PaneFrame {
397 const hasControls = input.rows > 1
398 const rest = Math.max(0, input.rows - (hasControls ? 1 : 0))
399 const mapRowsOf = (regionRows: number) => (regionRows >= 3 ? regionRows - 2 : 0)
400 if (input.notice !== undefined || input.columns < LIST_MIN_COLUMNS || rest < LIST_MIN_ROWS) return { mapRows: mapRowsOf(rest), listRows: 0, hasLegend: false, hasControls }
401 const listRows = Math.floor(rest / 4)
402 return { mapRows: mapRowsOf(rest - listRows - 1), listRows, hasLegend: true, hasControls }
403}
404
405function layoutFor(input: ScreenInput, rows: number): MapLayout {
406 const key = `${input.filesVersion} ${input.columns} ${rows} ${input.zoom}`
407 const held = layouts.get(key)
408 if (held) return held
409 const width = input.columns
410 const height = 2 * rows
411 const layout: MapLayout = { width, height, rects: [], owner: new Int32Array(width * height).fill(-1), byKey: new Map(), linesOf: new Map() }
412 collect(makePlan(toTree(input.files, input.zoom), width, height), 0, layout)
413 for (const rect of layout.rects) rect.label = labelOf(rect)
414 layouts.set(key, layout)
415 for (const old of layouts.keys()) {
416 if (layouts.size <= LAYOUT_LIMIT) break
417 layouts.delete(old)
418 }
419 return layout
420}
421
422export function layoutOf(input: ScreenInput, rows: number): MapLayout | null {
423 return input.notice !== undefined || input.files.length === 0 || rows <= 0 || input.columns <= 0 ? null : layoutFor(input, rows)
424}
425
426function changedTop(rect: MapRect, share: number): number {
427 const filled = Math.max(MIN_FILL, Math.round(share * (rect.y1 - rect.y0)))
428 return Math.max(rect.y0, Math.floor((rect.y1 - filled) / 2) * 2)
429}
430
431function writeText(grid: Uint32Array, columns: number, row: number, column: number, text: string, ink: number): void {
432 for (let i = 0; i < text.length && column + i < columns; i++) {
433 const at = (row * columns + column + i) * 3
434 grid[at] = text.charCodeAt(i)
435 grid[at + 1] = ink
436 }
437}
438
439function drawMap(grid: Uint32Array, input: ScreenInput, layout: MapLayout, palette: Palette): void {
440 const { rects, width, owner } = layout
441 const changed = rects.map(() => 0)
442 const isRead = rects.map(() => false)
443 const isNow = rects.map(() => false)
444 for (const [key, mark] of Object.entries(input.marks)) {
445 const index = layout.byKey.get(key)
446 if (index === undefined) continue
447 const lines = layout.linesOf.get(key) ?? 0
448 changed[index] = (changed[index] ?? 0) + weightOf(lines) * Math.min(1, mark.changed / Math.max(1, lines))
449 if (mark.isRead) isRead[index] = true
450 if (mark.isNow) isNow[index] = true
451 }
452 const looks = rects.map((rect, i) => {
453 const share = (changed[i] ?? 0) / rect.weight
454 const base = rect.kind !== 'folder' && isRead[i] ? READ : rect.kind === 'file' ? NEUTRAL : FOLDER
455 const fill = isNow[i] ? NOW : CHANGED
456 const top = share > 0 ? changedTop(rect, share) : rect.y1
457 const baseColor = base === FOLDER && rect.depth % 2 === 0 ? DEFAULT : (palette.fill[base] ?? DEFAULT)
458 return { base, fill, top, baseColor, fillColor: palette.fill[fill] ?? DEFAULT }
459 })
460 const colorAt = (pixel: number) => {
461 const look = looks[owner[pixel] ?? -1]
462 if (look === undefined) return DEFAULT
463 return Math.floor(pixel / width) >= look.top ? look.fillColor : look.baseColor
464 }
465 for (let row = 0; 2 * row < layout.height; row++) {
466 for (let x = 0; x < width; x++) {
467 const upper = colorAt(2 * row * width + x)
468 const lower = colorAt((2 * row + 1) * width + x)
469 const at = (row * width + x) * 3
470 if (upper === lower) grid[at + 2] = upper
471 else if (upper === DEFAULT) {
472 grid[at] = LOWER_HALF
473 grid[at + 1] = lower
474 } else {
475 grid[at] = UPPER_HALF
476 grid[at + 1] = upper
477 grid[at + 2] = lower
478 }
479 }
480 }
481 rects.forEach((rect, i) => {
482 const look = looks[i]
483 if (!rect.label || look === undefined) return
484 const under = 2 * rect.label.row >= look.top ? look.fill : look.base
485 writeText(grid, width, rect.label.row, rect.label.column, rect.label.text, palette.ink[under] ?? DEFAULT)
486 })
487}
488
489const countText = (change: FileChange | null) => (change === null ? 'read' : change.isBinary ? 'binary' : `+${change.added} -${change.deleted}`)
490
491function folderStatus(keys: string[], marks: Record<string, Mark>): string {
492 let changed = 0
493 let read = 0
494 for (const key of keys) {
495 const mark = marks[key]
496 if (mark?.change) changed++
497 else if (mark?.isRead) read++
498 }
499 const parts = [`${keys.length} ${keys.length === 1 ? 'file' : 'files'}`]
500 if (changed > 0) parts.push(`${changed} changed`)
501 if (read > 0) parts.push(`${read} read`)
502 return parts.join(', ')
503}
504
505function hoverText(path: string, name: string, status: string, width: number): string {
506 const tail = status === '' ? '' : ` ${status}`
507 if (path.length + tail.length <= width) return path + tail
508 const room = width - tail.length
509 if (room > name.length) return `~${path.slice(path.length - room + 1)}${tail}`
510 return path.length <= width ? path : `~${path.slice(path.length - width + 1)}`
511}
512
513function tilesOf(input: ScreenInput, layout: MapLayout): TileFacts[] {
514 return layout.rects.map(rect => {
515 const isFile = rect.kind === 'file'
516 const mark = input.marks[rect.keys[0] ?? '']
517 const status = isFile ? (mark === undefined ? '' : countText(mark.change)) : folderStatus(rect.keys, input.marks)
518 const end = isFile ? '' : '/'
519 const hover = hoverText(plainText(rect.path) + end, plainText(rect.name) + end, status, input.columns)
520 return { path: rect.path, kind: isFile ? 'file' : 'folder', depth: rect.depth, hover }
521 })
522}
523
524function headerOf(input: ScreenInput): Pick<MapDrawing, 'top' | 'summary' | 'upHint'> {
525 const { columns, zoom } = input
526 const place = plainText(zoom === '' ? input.repo : `${input.repo}/${zoom}`)
527 const top = place.length <= columns ? place : zoom === '' ? place.slice(0, columns) : `~${place.slice(place.length - columns + 1)}`
528 let summary = ''
529 const fits = (more: string) => top.length + summary.length + more.length <= columns
530 for (const [i, folder] of input.busy.entries()) {
531 const more = (i === 0 ? ' now: ' : ', ') + plainText(folder)
532 if (!fits(more)) break
533 summary += more
534 }
535 const counts = ` ${input.changedCount} changed, ${input.readCount} read`
536 if (fits(counts)) summary += counts
537 const back = zoomOut(input.files, zoom)
538 const upHint = zoom === '' ? '' : plainText(`Click to show ${back === '' ? input.repo : back} again.`).slice(0, columns)
539 return { top, summary, upHint }
540}
541
542function drawNotice(grid: Uint32Array, columns: number, rows: number, notice: string): void {
543 let row = 0
544 let line = ''
545 for (const word of plainText(notice.replace(/\s+/g, ' ')).split(' ')) {
546 let rest = word
547 while (rest && row < rows) {
548 const joined = line ? line + ' ' + rest : rest
549 if (joined.length <= columns) {
550 line = joined
551 rest = ''
552 } else if (line) {
553 writeText(grid, columns, row++, 0, line, DEFAULT)
554 line = ''
555 } else {
556 writeText(grid, columns, row++, 0, rest.slice(0, columns), DEFAULT)
557 rest = rest.slice(columns)
558 }
559 }
560 }
561 if (line && row < rows) writeText(grid, columns, row, 0, line, DEFAULT)
562}
563
564export function mapDrawing(input: ScreenInput, rows: number): MapDrawing {
565 const { columns } = input
566 const grid = new Uint32Array(columns * rows * 3)
567 for (let at = 0; at < grid.length; at += 3) {
568 grid[at] = SPACE
569 grid[at + 1] = DEFAULT
570 grid[at + 2] = DEFAULT
571 }
572 const owner = new Int32Array(columns * 2 * rows).fill(-1)
573 const drawing: MapDrawing = { columns, rows, grid, owner, pixelRows: 2 * rows, tiles: [], idle: input.idle ?? '', ...headerOf(input) }
574 const notice = input.notice ?? (input.files.length === 0 ? NO_FILES : undefined)
575 if (notice !== undefined) {
576 drawNotice(grid, columns, rows, notice)
577 return drawing
578 }
579 const layout = layoutOf(input, rows)
580 if (layout === null) return drawing
581 drawMap(grid, input, layout, PALETTES[input.theme])
582 return { ...drawing, owner: layout.owner, tiles: tilesOf(input, layout) }
583}
584
585function listRow(recent: RecentRow, width: number, palette: Palette): ListRow {
586 const label = plainText(recent.label)
587 const tail = countText(recent.change)
588 const room = Math.max(0, width - 3 - tail.length - 2)
589 const shown = label.length <= room ? label : room > 0 ? '~' + label.slice(label.length - room + 1) : ''
590 const gap = Math.max(3, width - tail.length) - 3 - shown.length
591 return {
592 path: recent.label,
593 swatch: hex(palette.fill[recent.change === null ? READ : recent.isNow ? NOW : CHANGED] ?? DEFAULT),
594 label: shown,
595 rest: { text: ' '.repeat(gap) + tail, ink: hex(palette.quiet) },
596 }
597}
598
599export function listOf(input: ScreenInput, frame: PaneFrame): { rows: ListRow[]; outside: Span | null } {
600 const palette = PALETTES[input.theme]
601 const shown = input.recent.slice(0, input.outside > 0 ? Math.max(0, frame.listRows - 1) : frame.listRows)
602 const count = `+${input.outside} ${input.outside === 1 ? 'file' : 'files'} outside this repo`
603 const widest = shown.reduce((most, recent) => Math.max(most, 3 + plainText(recent.label).length + 2 + countText(recent.change).length), LIST_WIDTH)
604 const width = Math.min(input.columns, LIST_LIMIT, widest)
605 return {
606 rows: shown.map(recent => listRow(recent, width, palette)),
607 outside: input.outside > 0 && frame.listRows > 0 ? { text: ` ${count}`, ink: hex(palette.quiet) } : null,
608 }
609}
610
611export function legendOf(input: ScreenInput): Span[] {
612 const palette = PALETTES[input.theme]
613 const widthOf = (items: LegendItem[]) => items.reduce((sum, item) => sum + 2 + 1 + item.word.length + 2, LEGEND_LEAD.length - 2)
614 let items: LegendItem[] = [
615 { fill: READ, word: 'read' },
616 { fill: CHANGED, word: 'changed' },
617 { fill: NOW, word: `last ${input.windowMinutes} min` },
618 ]
619 while (items.length > 1 && widthOf(items) > input.columns) items = items.slice(0, -1)
620 const quiet = hex(palette.quiet)
621 const spans: Span[] = [{ text: LEGEND_LEAD, ink: quiet }]
622 items.forEach((item, i) => spans.push({ text: ' ', fill: hex(palette.fill[item.fill] ?? DEFAULT) }, { text: ` ${item.word}${i < items.length - 1 ? ' ' : ''}`, ink: quiet }))
623 return spans
624}
625hooks/shell.ts 409 lines1import { isOnWindows } from './track'
2
3const READERS = new Set(['cat', 'head', 'tail', 'bat', 'less', 'more', 'nl', 'wc', 'sort', 'uniq', 'cut', 'diff', 'cmp', 'sed', 'awk', 'jq', 'rg', 'grep', 'egrep', 'fgrep'])
4const PATTERN_FIRST = new Set(['sed', 'awk', 'jq', 'rg', 'grep', 'egrep', 'fgrep'])
5const GREP_FLAGS = { values: 'efmdDABC', lists: 'lL', isRecursive: (letters: string) => /[rR]/.test(letters) }
6const SEARCH_FLAGS: Record<string, typeof GREP_FLAGS> = { grep: GREP_FLAGS, egrep: GREP_FLAGS, fgrep: GREP_FLAGS, rg: { values: 'efEmjgdtTABCMr', lists: 'l', isRecursive: () => false } }
7const PREFIXES = new Set(['env', 'sudo', 'time', 'command', 'nohup', 'exec', 'builtin'])
8const KEYWORDS = new Set(['if', 'then', 'elif', 'else', 'while', 'until', 'do', '!', 'fi', 'done', 'esac'])
9const MOVES = new Set(['cd', 'set-location', 'sl'])
10const PUSHES = new Set(['pushd', 'push-location'])
11const POPS = new Set(['popd', 'pop-location'])
12const CONTENT_READERS = new Set(['get-content', 'gc', 'cat', 'type'])
13const STRING_SEARCHERS = new Set(['select-string', 'sls'])
14const CMDLET_ALIASES = new Set(['diff', 'sort'])
15const FILE_LISTERS = new Set(['get-childitem', 'gci', 'dir', 'ls', 'get-item', 'gi'])
16const PASSES = new Set(['head', 'tail', 'sort', 'uniq', 'where-object', 'where', '?', 'sort-object', 'select-object', 'select'])
17const QUIET = new Set(['', 'cd', 'set-location', 'sl', 'push-location', 'pop-location', 'true', 'false', ':', 'test', '[', '[[', 'export', 'unset', 'out-null', 'out-file', 'set-content', 'add-content'])
18const VALUE_FLAGS = /^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|-regexp|-file|-context|-after-context|-before-context)$/
19const PATTERN_FLAGS = /^-(e|f|-regexp|-file)$/
20const LONG_PATTERN = /^--(regexp|file)(=|$)/
21const LONG_LIST = /^--(files-with-matches|files-without-match)$/
22const LONG_RECURSIVE = /^--(dereference-)?recursive$/
23const PATH_PARAMETERS = new Set(['path', 'literalpath', 'pspath', 'lp'])
24const VALUE_PARAMETERS = new Set(['pattern', 'totalcount', 'head', 'first', 'tail', 'last', 'readcount', 'encoding', 'delimiter', 'stream', 'filter', 'include', 'exclude', 'credential', 'context', 'culture'])
25const WRITE_REDIRECT = /^(\d*|&)>>?$|^\d*>&$|^<<<?$/
26const ATTACHED_REDIRECT = /^(\d*|&)>|^<</
27const STDOUT_REDIRECT = /^(1|&|\*)?>/
28const LINE_LIMIT = 400
29const READ_LIMIT = 60
30const BLOCK_OPENERS = new Set(['if', 'while', 'until', 'for', 'case', 'select'])
31const BLOCK_CLOSERS = new Set(['fi', 'done', 'esac'])
32const RANKS = ['surely', 'ifDone', 'perhaps'] as const
33
34type Found = { paths: string[]; operands: string[]; isSearch: boolean; isListing: boolean }
35type Ran = (typeof RANKS)[number]
36type Joint = 'and' | 'or' | 'pipe' | 'list' | 'background'
37type Segment = { words: string[]; ran: Ran; isPiped: boolean; isShown: boolean; enters: number; leaves: number; isBroken: boolean; isBackground: boolean; isTail: boolean }
38type Outer = { words: string[]; word: string; isPiped: boolean; isOpen: boolean; quote: string }
39type Group = { kind: string; ran: Ran; joint: Ran; from: number; inner: number; isShell: boolean; isExited: boolean; outer?: Outer }
40type Body = { end: string; isTabbed: boolean }
41
42function worse(a: Ran, b: Ran): Ran {
43 return RANKS.indexOf(a) > RANKS.indexOf(b) ? a : b
44}
45
46function delimiterAt(command: string, at: number): Body {
47 let i = at + 2
48 const isTabbed = command[i] === '-'
49 if (isTabbed) i++
50 while (command[i] === ' ' || command[i] === '\t') i++
51 let end = ''
52 let quote = ''
53 for (; i < command.length; i++) {
54 const ch = command[i] ?? ''
55 if (quote === '' && /[\s;&|<>()]/.test(ch)) break
56 if (quote === '' && (ch === '"' || ch === "'")) quote = ch
57 else if (ch === quote) quote = ''
58 else if (quote === '' && ch === '\\') end += command[++i] ?? ''
59 else end += ch
60 }
61 return { end, isTabbed }
62}
63
64function afterBodies(command: string, from: number, bodies: Body[]): number {
65 let at = from
66 for (const { end, isTabbed } of bodies) {
67 while (at < command.length) {
68 const stop = command.indexOf('\n', at + 1)
69 const next = stop === -1 ? command.length : stop
70 const line = command.slice(at + 1, next).replace(/\r$/, '')
71 at = next
72 if ((isTabbed ? line.replace(/^\t+/, '') : line) === end) break
73 }
74 }
75 return at
76}
77
78function closingParen(command: string, from: number): number {
79 let depth = 0
80 for (let i = from; i < command.length; i++) {
81 if (command[i] === '(') depth++
82 else if (command[i] === ')' && --depth === 0) return i
83 }
84 return command.length
85}
86
87function isFunctionHead(words: string[]): boolean {
88 return (words.length === 1 && /^[\w.:-]+$/.test(words[0] ?? '')) || (words.length === 2 && words[0] === 'function')
89}
90
91function endsShell(words: string[], isPowerShell: boolean): boolean {
92 const [head = '', ...rest] = withoutRedirects(words).words
93 return isPowerShell ? /^(exit|return)$/i.test(head) : head === 'exit' || (head === 'exec' && rest.length > 0)
94}
95
96function segmentsOf(command: string, isPowerShell: boolean): Segment[] {
97 const escape = isPowerShell ? '`' : '\\'
98 const out: Segment[] = []
99 const groups: Group[] = []
100 const bodies: Body[] = []
101 let joint: Ran = 'surely'
102 let words: string[] = []
103 let word = ''
104 let quote = ''
105 let isWord = false
106 let isOpen = false
107 let isPiped = false
108 let isBroken = false
109 let isFunctionNext = false
110 let isExited = false
111 let closedFrom: number | undefined
112 let enters = 0
113 let leaves = 0
114 const top = () => groups.at(-1)?.ran ?? 'surely'
115 const hasExited = () => groups.findLast(group => group.isShell)?.isExited ?? isExited
116 const endWord = () => {
117 const text = word
118 const isBody = isFunctionNext || (words.length === 2 && words[0] === 'function')
119 const isCommand = words.every(one => KEYWORDS.has(one))
120 const hasWord = isWord
121 word = ''
122 isWord = false
123 if (!hasWord) return
124 if (!isPowerShell && text === '{' && (isBody || isCommand)) {
125 open('{', isBody)
126 isFunctionNext = false
127 } else if (!isPowerShell && text === '}' && isCommand) close('{')
128 else {
129 words.push(text)
130 isFunctionNext = false
131 }
132 }
133 const endSegment = (next: Ran, kind?: Joint) => {
134 endWord()
135 const head = isPowerShell ? '' : (words[0] ?? '')
136 const lead = words.findIndex(one => !KEYWORDS.has(one))
137 const isEmpty = words.length === 0
138 if (BLOCK_CLOSERS.has(head) && groups.at(-1)?.kind === 'block') groups.pop()
139 if (!isEmpty) out.push({ words, ran: hasExited() ? 'perhaps' : worse(top(), joint), isPiped, isShown: kind !== 'pipe', enters, leaves, isBroken, isBackground: kind === 'background', isTail: false })
140 if (!isEmpty && !isPiped && kind !== 'pipe' && kind !== 'background' && endsShell(words.slice(lead === -1 ? words.length : lead), isPowerShell)) {
141 const shell = groups.findLast(group => group.isShell)
142 if (shell === undefined) isExited = true
143 else shell.isExited = true
144 }
145 if (isEmpty && kind === 'pipe') for (const segment of out.slice(closedFrom ?? out.length)) segment.isShown = false
146 if (!isEmpty || kind !== undefined) closedFrom = undefined
147 if (!isEmpty) isFunctionNext = !isPowerShell && words.length === 2 && words[0] === 'function'
148 for (const one of isPowerShell ? [] : words.slice(0, lead === -1 ? words.length : lead + 1)) {
149 if (BLOCK_OPENERS.has(one)) groups.push({ kind: 'block', ran: 'perhaps', joint, from: out.length, inner: out.length, isShell: false, isExited: false })
150 }
151 if (!isEmpty) [isPiped, isBroken, enters, leaves] = [false, false, 0, 0]
152 isBroken ||= kind === 'or' || ((kind === 'list' || kind === 'background') && !(isEmpty && isOpen))
153 isPiped ||= kind === 'pipe'
154 joint = isEmpty && isOpen ? worse(joint, next) : next
155 isOpen = kind === 'and' || kind === 'or' || kind === 'pipe' || (isEmpty && isOpen)
156 words = []
157 }
158 const open = (kind: string, isBody: boolean, outerQuote = '') => {
159 if (!isPowerShell && kind === '(' && /[$<>]$/.test(word)) {
160 groups.push({ kind, ran: worse(top(), joint), joint, from: out.length, inner: out.length, isShell: true, isExited: hasExited(), outer: { words, word, isPiped, isOpen, quote: outerQuote } })
161 words = []
162 word = ''
163 isWord = false
164 isPiped = false
165 isOpen = false
166 joint = 'surely'
167 enters++
168 return
169 }
170 const from = out.length
171 endSegment(joint)
172 const isFunctionBody = isBody || (kind === '(' && isFunctionNext)
173 isFunctionNext = false
174 const owner = out.length > from ? out.at(-1)?.words : undefined
175 const isShell = isPowerShell ? /^(function|filter)$/i.test(owner?.[0] ?? '') : kind === '(' || isFunctionBody
176 groups.push({ kind, ran: isFunctionBody ? 'perhaps' : worse(top(), joint), joint, from, inner: out.length, isShell, isExited: hasExited() })
177 joint = 'surely'
178 if (kind === '(' && !isPowerShell) enters++
179 }
180 const close = (kind: string) => {
181 endSegment(joint)
182 const group = groups.at(-1)
183 if (group?.kind !== kind) return
184 groups.pop()
185 joint = group.joint
186 isOpen = false
187 closedFrom = group.from
188 isFunctionNext = !isPowerShell && kind === '(' && out.length === group.inner && group.from < group.inner && isFunctionHead(out[group.from]?.words ?? [])
189 if (kind === '(' && !isPowerShell) [enters, leaves] = enters > 0 ? [enters - 1, leaves] : [enters, leaves + 1]
190 if (group.outer === undefined) return
191 for (const segment of out.slice(group.inner)) segment.isShown = false
192 words = group.outer.words
193 word = group.outer.word
194 isWord = true
195 isPiped = group.outer.isPiped
196 isOpen = group.outer.isOpen
197 quote = group.outer.quote
198 closedFrom = undefined
199 }
200 for (let i = 0; i < command.length; i++) {
201 const ch = command[i] ?? ''
202 const next = command[i + 1] ?? ''
203 const isEscaped = next !== '' && ch === escape && (quote === '' || (quote === '"' && (isPowerShell || '$`"\\\n'.includes(next))))
204 if (isEscaped) {
205 word += command[++i]
206 isWord = true
207 } else if (quote === "'" && isPowerShell && ch === "'" && next === "'") word += command[++i]
208 else if (!isPowerShell && quote === '"' && ch === '$' && next === '(' && command[i + 2] !== '(') {
209 word += ch
210 quote = ''
211 open(command[++i] ?? '', false, '"')
212 } else if (quote !== '' && ch === quote) quote = ''
213 else if (quote !== '') word += ch
214 else if (isPowerShell && ch === '@' && (next === "'" || next === '"') && /^[ \t]*\r?\n/.test(command.slice(i + 2))) {
215 const start = command.indexOf('\n', i) + 1
216 const end = command.indexOf(`\n${next}@`, start - 1)
217 word += command.slice(start, end === -1 ? command.length : end)
218 isWord = true
219 i = end === -1 ? command.length : end + 2
220 } else if (ch === '"' || ch === "'") {
221 quote = ch
222 isWord = true
223 } else if (ch === '#' && !isWord) {
224 while (i + 1 < command.length && command[i + 1] !== '\n') i++
225 } else if (ch === ' ' || ch === '\t' || ch === '\r' || (isPowerShell && ch === ',')) endWord()
226 else if (!isPowerShell && ch === '(' && next === '(') {
227 const end = closingParen(command, i)
228 word += command.slice(i, end + 1)
229 isWord = true
230 i = end
231 } else if (ch === '(' || (isPowerShell && ch === '{')) open(ch, ch === '{')
232 else if (ch === ')') close('(')
233 else if (isPowerShell && ch === '}') close('{')
234 else if ((ch === '|' || ch === '&') && next === ch) {
235 i++
236 endSegment(ch === '|' ? 'perhaps' : 'ifDone', ch === '|' ? 'or' : 'and')
237 } else if (ch === '|') endSegment(joint, 'pipe')
238 else if (ch === '\n' || ch === ';' || (isPowerShell && ch === '&')) {
239 endSegment('surely', 'list')
240 if (ch === '\n' && bodies.length > 0) i = afterBodies(command, i, bodies.splice(0))
241 } else if (ch === '&' && command[i - 1] !== '>' && next !== '>') endSegment('surely', 'background')
242 else {
243 if (!isPowerShell && ch === '<' && next === '<' && command[i + 2] !== '<' && command[i - 1] !== '<') bodies.push(delimiterAt(command, i))
244 word += ch
245 isWord = true
246 }
247 }
248 endSegment('surely', 'list')
249 let isTail = true
250 for (const segment of [...out].reverse()) {
251 if (segment.isBackground) isTail = false
252 segment.isTail = isTail
253 if (segment.isBroken) isTail = false
254 }
255 return out
256}
257
258function isAbsolutePath(path: string): boolean {
259 return (isOnWindows() ? /^([A-Za-z]:[\\/]|[\\/]|~)/ : /^[/~]/).test(path)
260}
261
262function joined(base: string | null, path: string): string | null {
263 if (isAbsolutePath(path) || base === '') return path
264 return base === null ? null : `${base}/${path}`
265}
266
267function isPathLike(word: string): boolean {
268 return word !== '' && word !== '-' && !/[*?<>|$`{}]/.test(word) && !word.includes('://')
269}
270
271function comparable(path: string): string {
272 return isOnWindows() ? path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(?=\/|$)/, '$1:').toLowerCase() : path
273}
274
275function isUnder(name: string, operand: string): boolean {
276 const wild = operand.search(/[*?[{$`~]/)
277 const named = comparable(name)
278 if (wild !== -1) return named.startsWith(comparable(operand.slice(0, wild).replace(/[^\\/]*$/, '')))
279 const folder = comparable(operand).replace(/[\\/]+$/, '')
280 const isMounted = isOnWindows() && /^\/[^/]/.test(folder) && /^[a-z]:\//.test(named)
281 return named === folder || named.startsWith(`${folder}/`) || named.startsWith(`${folder}\\`) || (isMounted && `${named}/`.includes(`${folder.replace(/^\/[^/]*/, '')}/`))
282}
283
284function withoutRedirects(words: string[]): { words: string[]; inputs: string[] } {
285 const kept: string[] = []
286 const inputs: string[] = []
287 for (let i = 0; i < words.length; i++) {
288 const word = words[i] ?? ''
289 if (word === '<') inputs.push(words[++i] ?? '')
290 else if (WRITE_REDIRECT.test(word)) i++
291 else if (/^<[^<]/.test(word)) inputs.push(word.slice(1))
292 else if (!ATTACHED_REDIRECT.test(word)) kept.push(word)
293 }
294 return { words: kept, inputs }
295}
296
297function headOf(words: string[]): { head: string; args: string[] } {
298 let at = 0
299 while (at < words.length && (PREFIXES.has(words[at] ?? '') || KEYWORDS.has(words[at] ?? '') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[at] ?? ''))) at++
300 const name = (words[at] ?? '').split(/[\\/]/).pop() ?? ''
301 return { head: name.toLowerCase().replace(/\.exe$/, ''), args: words.slice(at + 1) }
302}
303
304function shortFlags(arg: string, values: string): { letters: string; takesNext: boolean } {
305 const at = [...arg.slice(1)].findIndex(letter => values.includes(letter))
306 return at === -1 ? { letters: arg.slice(1), takesNext: false } : { letters: arg.slice(1, at + 2), takesNext: at + 2 === arg.length }
307}
308
309function toolReads(head: string, args: string[], isFed: boolean, found: Found): void {
310 const options = args.includes('--') ? args.slice(0, args.indexOf('--')) : args
311 if ((head === 'sed' && options.some(arg => /^(-i|--in-place)/.test(arg))) || (head === 'rg' && options.includes('--files'))) return
312 const search = SEARCH_FLAGS[head]
313 const flagOf = (arg: string) =>
314 search === undefined || arg.startsWith('--') ? { letters: '', takesNext: VALUE_FLAGS.test(arg) || ((head === 'head' || head === 'tail') && /^-[nc]$/.test(arg)) } : shortFlags(arg, search.values)
315 const isFlag = (arg: string) => arg.startsWith('-') && arg !== '-'
316 const isGiven = (arg: string) => (search === undefined ? PATTERN_FLAGS.test(arg) : LONG_PATTERN.test(arg) || /[ef]/.test(flagOf(arg).letters))
317 let isPatternNext = PATTERN_FIRST.has(head) && !options.some(arg => isFlag(arg) && isGiven(arg))
318 let isLiteral = false
319 let isRecursive = false
320 for (let i = 0; i < args.length; i++) {
321 const arg = args[i] ?? ''
322 if (arg === '--' && !isLiteral) isLiteral = true
323 else if (isFlag(arg) && !isLiteral) {
324 const { letters, takesNext } = flagOf(arg)
325 if (takesNext) i++
326 found.isListing ||= search !== undefined && (LONG_LIST.test(arg) || [...letters].some(letter => search.lists.includes(letter)))
327 isRecursive ||= search !== undefined && (LONG_RECURSIVE.test(arg) || search.isRecursive(letters))
328 } else if (isPatternNext) isPatternNext = false
329 else {
330 found.operands.push(arg)
331 if (isPathLike(arg)) found.paths.push(arg)
332 }
333 }
334 found.isSearch = search !== undefined && !(isFed && found.operands.length === 0 && !isRecursive)
335}
336
337function cmdletReads(head: string, args: string[], isText: boolean, found: Found): void {
338 let isPatternNext = STRING_SEARCHERS.has(head) && !args.some(arg => /^-pattern(:|$)/i.test(arg))
339 let isNamed = false
340 for (let i = 0; i < args.length; i++) {
341 const arg = args[i] ?? ''
342 const [, name, attached] = /^-([A-Za-z]+)(?::(.*))?$/.exec(arg) ?? []
343 const key = name?.toLowerCase() ?? ''
344 const value = name === undefined ? arg : (attached ?? (PATH_PARAMETERS.has(key) || VALUE_PARAMETERS.has(key) ? args[++i] : undefined))
345 if (name === undefined && isPatternNext) isPatternNext = false
346 else if ((name === undefined || PATH_PARAMETERS.has(key)) && value !== undefined) {
347 isNamed = true
348 if (isPathLike(value)) found.paths.push(value)
349 }
350 }
351 found.isSearch = STRING_SEARCHERS.has(head) && (isNamed || !isText)
352}
353
354function printedPaths(stdout: string, isListing: boolean, operands: string[] | null): string[] {
355 const printed: string[] = []
356 for (const line of stdout.split(/\r?\n/).slice(0, LINE_LIMIT)) {
357 const named = /^((?:[A-Za-z]:)?[^:\0]+?):\d+[:-]/.exec(line)?.[1] ?? (isListing ? line : /^((?:[A-Za-z]:)?[^:\0]+?):/.exec(line)?.[1])
358 if (named !== undefined && named === named.trim() && isPathLike(named) && (operands === null || operands.some(operand => isUnder(named, operand)))) printed.push(named)
359 }
360 return printed
361}
362
363export function shellReads(tool: string, command: string, stdout: string, hasSucceeded = true): string[] {
364 const isPowerShell = tool === 'PowerShell'
365 const paths: Array<string | null> = []
366 const outer: Array<string | null> = []
367 const pushed: Array<string | null> = []
368 let base: string | null = ''
369 let previous: string | null = null
370 let printBase: string | null | undefined
371 let operands: string[] | null = []
372 let isListing = false
373 let isFiles = false
374 let isFromSearch = false
375 let isMixed = false
376 for (const { words: all, ran, isPiped, isShown, enters, leaves, isTail } of segmentsOf(command, isPowerShell)) {
377 for (let n = 0; n < leaves; n++) base = outer.pop() ?? base
378 for (let n = 0; n < enters; n++) outer.push(base)
379 const { words, inputs } = withoutRedirects(all)
380 const { head, args } = headOf(words)
381 const isProgram = isPowerShell && READERS.has(head) && !CONTENT_READERS.has(head)
382 const hasRun = (ran === 'surely' || (ran === 'ifDone' && hasSucceeded && isTail)) && (!isProgram || (hasSucceeded && isTail))
383 const target = args.find(arg => !/^-./.test(arg))
384 const found: Found = { paths: inputs.filter(isPathLike), operands: [], isSearch: false, isListing: false }
385 const isText = isPiped && !isFiles
386 isFiles = isPiped ? isFiles && PASSES.has(head) : FILE_LISTERS.has(head)
387 const place: string | null = target === undefined || target === '-' || !isPathLike(target) ? null : joined(base, target)
388 if (MOVES.has(head)) [base, previous] = hasRun ? [target === '-' ? previous : place, base] : [null, null]
389 else if (PUSHES.has(head)) {
390 pushed.push(hasRun ? base : null)
391 base = hasRun ? place : null
392 } else if (POPS.has(head)) base = hasRun ? (pushed.pop() ?? null) : null
393 else if (isPowerShell && (CONTENT_READERS.has(head) || STRING_SEARCHERS.has(head))) cmdletReads(head, args, isText, found)
394 else if (READERS.has(head) && !(isPowerShell && CMDLET_ALIASES.has(head))) toolReads(head, args, isPiped || inputs.length > 0 || all.some(word => word.startsWith('<<')), found)
395 isFromSearch = (hasRun && found.isSearch) || (isPiped && isFromSearch && PASSES.has(head))
396 if (isShown && !all.some(word => STDOUT_REDIRECT.test(word)) && !isFromSearch && (!QUIET.has(head) || (head === 'cd' && target === '-'))) isMixed = true
397 if (!hasRun) continue
398 paths.push(...found.paths.map(path => joined(base, path)))
399 if (found.isSearch) {
400 printBase = printBase === undefined || printBase === base ? base : null
401 operands = operands === null || found.operands.length === 0 ? null : [...operands, ...found.operands]
402 }
403 isListing ||= found.isListing
404 }
405 const at = isMixed ? undefined : printBase
406 const printed = at === undefined ? [] : printedPaths(stdout, isListing, operands).map(path => joined(at, path))
407 return [...new Set([...paths, ...printed])].filter(path => path !== null).slice(0, READ_LIMIT)
408}
409hooks/track.ts 607 lines1import type { Changes, FileChange, MapFile, RepoMap, Session, Theme } from '../types'
2
3export type ToolTouch = { path: string; kind: 'edit' | 'read' }
4export type Mark = { changed: number; change: FileChange | null; isRead: boolean; isNow: boolean }
5export type RecentRow = { label: string; change: FileChange | null; isNow: boolean }
6export type ScreenInput = {
7 repo: string
8 files: MapFile[]
9 filesVersion: number
10 marks: Record<string, Mark>
11 recent: RecentRow[]
12 outside: number
13 busy: string[]
14 changedCount: number
15 readCount: number
16 windowMinutes: number
17 zoom: string
18 columns: number
19 rows: number
20 theme: Theme
21 notice?: string
22 idle?: string
23}
24
25export type Os = 'win32' | 'darwin' | 'linux'
26export type Opener = { argv: string[]; env?: Record<string, string> }
27
28export const EMPTY_SESSION: Session = { reads: {}, readAt: {}, changedAt: {}, labels: {}, recent: [] }
29export const STATE_SHAPE = 'filemap-5'
30export const WINDOW_MS = 5 * 60_000
31const RECENT_LIMIT = 200
32const ROW_LIMIT = 50
33const BUSY_LIMIT = 3
34const BUSY_DEPTH = 2
35const TOP_LEVEL = 'top level'
36const POWERSHELL = ['powershell', '-NoProfile', '-NonInteractive', '-Command']
37const INVOKE = 'Invoke-Item -LiteralPath $env:FILEMAP_OPEN'
38const START = [
39 'try { $c = Get-Command -Name $env:FILEMAP_PROGRAM -CommandType Application -ErrorAction Stop | Where-Object Extension | Select-Object -First 1',
40 "$isScript = $c.Path -match '\\.(cmd|bat)$'",
41 "if ($isScript -and $env:FILEMAP_OPEN.Contains('%')) { [Console]::Error.WriteLine($c.Name + ' runs through cmd.exe, which changes a % in the path; name the .exe of the program in /config'); exit 2 }",
42 `Start-Process -FilePath $c.Path -ArgumentList ('"' + $env:FILEMAP_OPEN + '"') -WindowStyle $(if ($isScript) { 'Hidden' } else { 'Normal' }) -ErrorAction Stop } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }`,
43].join('; ')
44const AS_TEXT = [
45 `if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { try { Start-Process -FilePath notepad.exe -ArgumentList ('"' + $env:FILEMAP_OPEN + '"') -ErrorAction Stop; exit 0 } catch { throw $_.Exception.Message } }`,
46 [
47 "Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class FilemapText {",
48 '[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] struct Info { public int Size; public uint Mask; public IntPtr Window; public string Verb; public string File; public string Parameters; public string Directory; public int Show; public IntPtr Instance; public IntPtr IdList; public string Class; public IntPtr ClassKey; public uint HotKey; public IntPtr Icon; public IntPtr Process; }',
49 '[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern bool ShellExecuteExW(ref Info info);',
50 'const uint ClassName = 0x1, NoAsync = 0x100, NoUi = 0x400;',
51 "public static int Open(string file, string type) { var info = new Info { Mask = ClassName | NoAsync | NoUi, File = file, Class = type, Show = 1 }; info.Size = Marshal.SizeOf(info); return ShellExecuteExW(ref info) ? 0 : Marshal.GetLastWin32Error(); } }'",
52 ].join(' '),
53 "$code = [FilemapText]::Open($env:FILEMAP_OPEN, '.txt')",
54 'if ($code -ne 0) { [Console]::Error.WriteLine((New-Object ComponentModel.Win32Exception $code).Message); exit 1 }',
55].join('; ')
56const DEFAULT_APP = 'if command -v gio >/dev/null; then exec gio open "$1"; else exec xdg-open "$1"; fi </dev/null >/dev/null 2>&1'
57const PROGRAM = 'command -v "$1" >/dev/null 2>&1 || { echo "$1 is not a program on PATH" >&2; exit 127; }; exec setsid -f "$1" "$2" </dev/null >/dev/null 2>&1'
58const AS_TEXT_ON_LINUX = [
59 'app=$(xdg-mime query default text/plain 2>/dev/null)',
60 '[ -n "$app" ] || { echo "xdg-mime names no app for text/plain" >&2; exit 3; }',
61 'if command -v gtk-launch >/dev/null 2>&1; then exec setsid -f gtk-launch "$app" "$1" </dev/null >/dev/null 2>&1; fi',
62 'for d in "${XDG_DATA_HOME:-$HOME/.local/share}" $(printf %s "${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" | tr : " "); do [ -f "$d/applications/$app" ] && exec setsid -f gio launch "$d/applications/$app" "$1" </dev/null >/dev/null 2>&1; done',
63 'echo "found no way to start $app" >&2; exit 3',
64].join('; ')
65const PROGRAM_ON_MAC = [
66 'if { [ -f "$1" ] && [ -x "$1" ]; } || { case "$1" in */*) false ;; *) command -v "$1" >/dev/null 2>&1 ;; esac; }; then "$1" "$2" </dev/null >/dev/null 2>&1 & exit 0; fi',
67 'exec open -a "$1" "$2"',
68].join('; ')
69
70let windows = false
71
72export function useWindows(on: boolean): void {
73 windows = on
74}
75
76export function isOnWindows(): boolean {
77 return windows
78}
79
80export function fold(path: string): string {
81 return windows ? path.toLowerCase() : path
82}
83
84export function isRooted(path: string): boolean {
85 return windows ? /^[a-zA-Z]:[\\/]/.test(path) : path.startsWith('/')
86}
87
88export function absolute(path: string, cwd: string, home = ''): string {
89 const slashed = (text: string) => (windows ? text.replace(/\\/g, '/') : text)
90 const base = slashed(cwd)
91 let given = windows ? slashed(path).replace(/^\/\/\?\//, '') : path
92 if (home !== '' && /^~(\/|$)/.test(given)) given = slashed(home) + given.slice(1)
93 if (windows && /^[a-zA-Z]:/.test(base)) given = given.replace(/^\/([a-zA-Z])(\/|$)/, '$1:/')
94 const full = (windows ? /^([a-zA-Z]:)?\// : /^\//).test(given) ? given : `${base}/${given}`
95 const drive = windows ? (/^[a-zA-Z]:/.exec(full)?.[0] ?? '') : ''
96 const segments: string[] = []
97 for (const segment of full.slice(drive.length).split('/')) {
98 if (segment === '..') segments.pop()
99 else if (segment !== '' && segment !== '.') segments.push(segment)
100 }
101 return `${drive}/${segments.join('/')}`
102}
103
104export function toKey(path: string, cwd: string): string {
105 return fold(absolute(path, cwd))
106}
107
108function touch(path: unknown, kind: ToolTouch['kind']): ToolTouch[] {
109 return typeof path === 'string' && path !== '' ? [{ path, kind }] : []
110}
111
112export function touchesFromInput(e: Record<string, unknown>): ToolTouch[] {
113 switch (e.tool) {
114 case 'Read':
115 return touch(e.file_path, 'read')
116 case 'Grep':
117 return touch(e.path, 'read')
118 case 'Edit':
119 case 'Write':
120 return touch(e.file_path, 'edit')
121 case 'NotebookEdit':
122 return touch(e.notebook_path, 'edit')
123 default:
124 return []
125 }
126}
127
128const NUMBERED_LINE = /^((?:[a-zA-Z]:)?[^:\n]+):\d+:/gm
129const PLAIN_LINE = /^((?:[a-zA-Z]:)?[^:\n]+):/gm
130const COUNT_LINE = /^((?:[a-zA-Z]:)?[^:\n]+):\d+$/gm
131
132function isContextLine(found: string, paths: Set<string>, mark: RegExp): boolean {
133 for (const dash of found.matchAll(mark)) if (paths.has(found.slice(0, dash.index))) return true
134 return false
135}
136
137function pathsInContent(content: string, isNumbered: boolean): string[] {
138 const found = new Set<string>()
139 for (const [, path = ''] of content.matchAll(isNumbered ? NUMBERED_LINE : PLAIN_LINE)) found.add(path)
140 return [...found].filter(path => !isContextLine(path, found, isNumbered ? /-\d+-/g : /-/g))
141}
142
143export function touchesFromResult(e: Record<string, unknown>, result: unknown, isGrepOfOneFile = false): ToolTouch[] {
144 if (e.tool !== 'Grep' || typeof result !== 'object' || result === null) return []
145 const { filenames, mode, content } = result as Record<string, unknown>
146 const paths: string[] = []
147 if (Array.isArray(filenames)) for (const name of filenames) if (typeof name === 'string') paths.push(name)
148 if (typeof content === 'string' && !isGrepOfOneFile) {
149 if (mode === 'content') paths.push(...pathsInContent(content, e['-n'] !== false))
150 if (mode === 'count') for (const [, path = ''] of content.matchAll(COUNT_LINE)) paths.push(path)
151 }
152 return [...new Set(paths)].flatMap(path => touch(path, 'read'))
153}
154
155export function isShell(tool: string): boolean {
156 return tool === 'Bash' || tool === 'PowerShell'
157}
158
159export function setsReadTime(tool: string): boolean {
160 return tool !== 'Grep'
161}
162
163function toFront(s: Session, named: Map<string, string>): Pick<Session, 'labels' | 'recent'> {
164 const recent = [...named.keys(), ...s.recent.filter(key => !named.has(key))].slice(0, RECENT_LIMIT)
165 const labels = Object.fromEntries(recent.map(key => [key, s.labels[key] ?? named.get(key) ?? key]))
166 return { labels, recent }
167}
168
169export function noteTouches(s: Session, touches: ToolTouch[], cwd: string, now: number, isTimed = true): Session {
170 if (touches.length === 0) return s
171 const reads = { ...s.reads }
172 const readAt = { ...s.readAt }
173 const changedAt = { ...s.changedAt }
174 const named = new Map<string, string>()
175 const counted = new Set<string>()
176 for (const { path, kind } of touches) {
177 const label = absolute(path, cwd)
178 const key = fold(label)
179 if (!named.has(key)) named.set(key, label)
180 if (counted.has(`${kind} ${key}`)) continue
181 counted.add(`${kind} ${key}`)
182 if (kind === 'edit') changedAt[key] = now
183 else {
184 reads[key] = (reads[key] ?? 0) + 1
185 if (isTimed) readAt[key] = now
186 }
187 }
188 return { ...s, reads, readAt, changedAt, ...toFront(s, named) }
189}
190
191export function noteChanges(s: Session, root: string, paths: string[], now: number): Session {
192 if (paths.length === 0) return s
193 const named = new Map(paths.map(path => absolute(path, root)).map((label): [string, string] => [fold(label), label]))
194 const changedAt = { ...s.changedAt }
195 for (const key of named.keys()) changedAt[key] = now
196 return { ...s, changedAt, ...toFront(s, named) }
197}
198
199export function nextExpiry(s: Session, now: number): number | null {
200 let next: number | null = null
201 for (const at of [...Object.values(s.readAt), ...Object.values(s.changedAt)]) {
202 const end = at + WINDOW_MS
203 if (end > now && (next === null || end < next)) next = end
204 }
205 return next
206}
207
208export function folderOf(path: string): string {
209 const folder = path.slice(0, path.lastIndexOf('/'))
210 return /^([a-zA-Z]:)?$/.test(folder) ? '' : folder
211}
212
213export function foldersOutside(s: Session, root: string): string[] {
214 const prefix = `${toKey(root, root)}/`
215 const folders = new Map<string, string>()
216 for (const key of s.recent) {
217 const folder = folderOf(key)
218 if (folder !== '' && !key.startsWith(prefix) && !folders.has(folder)) folders.set(folder, folderOf(s.labels[key] ?? key))
219 }
220 return [...folders.values()]
221}
222
223export function moveFolders(s: Session, moves: Array<[from: string, to: string]>): Session {
224 const moved = (label: string): string => {
225 const key = fold(label)
226 for (const [from, to] of moves) if (key.startsWith(`${from}/`)) return to + label.slice(from.length)
227 return label
228 }
229 const keyOf = (key: string) => fold(moved(key))
230 const reads: Record<string, number> = {}
231 for (const [key, count] of Object.entries(s.reads)) reads[keyOf(key)] = (reads[keyOf(key)] ?? 0) + count
232 const latest = (times: Record<string, number>) => {
233 const out: Record<string, number> = {}
234 for (const [key, at] of Object.entries(times)) out[keyOf(key)] = Math.max(out[keyOf(key)] ?? at, at)
235 return out
236 }
237 return {
238 reads,
239 readAt: latest(s.readAt),
240 changedAt: latest(s.changedAt),
241 labels: Object.fromEntries(Object.entries(s.labels).map(([key, label]) => [keyOf(key), moved(label)])),
242 recent: [...new Set(s.recent.map(keyOf))],
243 }
244}
245
246const INCLUDED = new Set([
247 '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts',
248 '.sh', '.bash', '.zsh', '.fish',
249 '.py', '.ps1', '.psm1', '.cs', '.csx', '.rs', '.go', '.java', '.zig', '.swift',
250 '.c', '.h', '.cpp', '.cc', '.hpp', '.tmdl', '.dax',
251 '.md', '.mdx',
252 '.css', '.scss', '.html', '.json', '.toml', '.yaml', '.yml', '.sql', '.pbir', '.pbism', '.pbip',
253])
254const SKIP_NAMES = new Set([
255 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'uv.lock', 'poetry.lock', 'Cargo.lock',
256])
257const NEVER_SOURCE = new Set(['node_modules', '.venv', 'venv', '__pycache__'])
258const RUNS_ON: Record<Os, string> = {
259 win32: [
260 '.ade;.adp;.apk;.app;.appcontent-ms;.application;.appref-ms;.appx;.asp;.aspx;.asx;.bas;.bat;.bgi;.cab;.cdxml;.cer;.chm;.cmd;.cnt;.com;.cpl;.crt;.csh;.der;.diagcab;.exe;.fxp;.gadget;.grp;.hlp;.hpj;.hta;.htc;.img;.inf;.ins;.iso;.isp;.its;.jar;.jnlp;.js;.jse;.ksh',
261 '.library-ms;.lnk;.mad;.maf;.mag;.mam;.maq;.mar;.mas;.mat;.mau;.mav;.maw;.mcf;.mda;.mdb;.mde;.mdt;.mdw;.mdz;.msc;.mht;.mhtml;.msh;.msh1;.msh2;.mshxml;.msh1xml;.msh2xml;.msi;.msp;.mst;.msu;.ops;.osd;.pcd;.pif;.pl;.plg;.prf;.prg;.printerexport',
262 '.ps1;.ps1xml;.ps2;.ps2xml;.psc1;.psc2;.psd1;.psdm1;.pssc;.pst;.py;.pyc;.pyo;.pyw;.pyz;.pyzw;.reg;.scf;.scr;.sct;.search-ms;.settingcontent-ms;.shb;.shs;.theme;.tmp;.udl;.url;.vb;.vbe;.vbp;.vbs;.vhd;.vhdx;.vsmacros;.vsw;.webpnp;.website;.ws;.wsb;.wsc;.wsf;.wsh;.xbap;.xll;.xnk',
263 '.sh;.bash;.rb;.rbw;.ahk;.au3;.a3x;.psm1;.appxbundle;.msix;.msixbundle;.appinstaller;.vsix;.ppkg;.searchconnector-ms;.xla;.xlam;.ppam;.accde;.accdb;.rdp;.themepack;.deskthemepack',
264 ].join(';'),
265 darwin: '.command;.tool;.terminal;.app;.pkg;.mpkg;.dmg;.workflow;.action;.prefpane;.saver;.mobileconfig;.shortcut;.webloc;.inetloc;.fileloc;.jar;.jnlp;.py;.pyw;.pyc;.pyo',
266 linux: '.jar;.jnlp;.desktop;.deb;.rpm;.snap;.flatpakref;.flatpakrepo;.appimage;.run',
267}
268const TEXT = new Set([
269 ...INCLUDED,
270 ...'.bat;.cmd;.pyw;.vbs;.vb;.vbp;.bas;.ws;.wsf;.wsc;.wsh;.sct;.hta;.htc;.asp;.aspx;.pl;.rb;.rbw;.ahk;.au3;.tcl;.csh;.ksh;.msh;.msh1;.msh2;.ps2;.psd1;.psdm1;.pssc;.command;.tool'.split(';'),
271 ...'.ps1xml;.ps2xml;.psc1;.psc2;.cdxml;.mshxml;.msh1xml;.msh2xml;.msc;.application;.appinstaller;.jnlp;.asx;.osd;.wsb;.library-ms;.search-ms;.searchconnector-ms;.settingcontent-ms;.appcontent-ms'.split(';'),
272 ...'.reg;.inf;.ins;.isp;.url;.website;.scf;.theme;.udl;.appref-ms;.cnt;.hpj;.prf;.plg;.mht;.mhtml;.desktop'.split(';'),
273])
274
275function isIncluded(path: string): boolean {
276 const folders = path.split('/')
277 const name = folders.pop() ?? ''
278 if (SKIP_NAMES.has(name) || name.endsWith('.min.js') || folders.some(folder => NEVER_SOURCE.has(folder))) return false
279 const dot = name.lastIndexOf('.')
280 return dot > 0 && INCLUDED.has(name.slice(dot).toLowerCase())
281}
282
283export function isExtensionless(path: string): boolean {
284 return (path.split('/').pop() ?? '').lastIndexOf('.') <= 0
285}
286
287function extensionOf(path: string): string {
288 const name = path.split('/').pop() ?? ''
289 const dot = name.lastIndexOf('.')
290 return dot < 0 ? '' : name.slice(dot).toLowerCase()
291}
292
293export function runsWhenOpened(os: Os, path: string, pathext: string): boolean {
294 const extension = extensionOf(path)
295 return extension !== '' && `${pathext};${RUNS_ON[os]}`.toLowerCase().split(';').includes(extension)
296}
297
298export function isText(path: string): boolean {
299 return TEXT.has(extensionOf(path))
300}
301
302export function gitArgv(root: string, args: string[]): string[] {
303 return ['git', '--no-optional-locks', '-C', root, ...args]
304}
305
306export const GIT_ENV = { GIT_OPTIONAL_LOCKS: '0' }
307
308export const LIST_ARGS = ['grep', '--no-color', '--no-recurse-submodules', '-I', '-c', '--untracked', '-z', '-e', '']
309
310export function parseFileList(stdout: string): MapFile[] {
311 const files: MapFile[] = []
312 for (const [, p = '', lines = ''] of stdout.matchAll(/([^\0]+)\0(\d+)\n/g)) if (isIncluded(p)) files.push({ p, lines: Number(lines) })
313 return files.sort((a, b) => (a.p < b.p ? -1 : a.p > b.p ? 1 : 0))
314}
315
316export const TOP_ARGS = ['rev-parse', '--show-toplevel', '--path-format=absolute', '--git-common-dir']
317
318export function parseTop(stdout: string): { root: string; commonDir: string } {
319 const [root = '', commonDir = ''] = stdout.split('\n').map(line => line.trim())
320 return { root, commonDir }
321}
322
323export const STATUS_ARGS = ['status', '--porcelain=v2', '--branch', '--no-ahead-behind', '--untracked-files=all', '--no-renames', '-z']
324
325export function readTreeArgs(head: string): string[] {
326 return ['read-tree', head === '' ? '--empty' : head]
327}
328
329export const UPDATE_INDEX_ARGS = ['update-index', '--add', '--remove', '-z', '--stdin']
330export const INDEX_INFO_ARGS = ['update-index', '-z', '--index-info']
331export const WRITE_TREE_ARGS = ['write-tree']
332
333export function hashArgs(path: string): string[] {
334 return ['hash-object', '-w', '--stdin', `--path=${path}`]
335}
336
337export function indexInfo(path: string, blob: string | null, head: string): string {
338 return blob === null ? `0 ${'0'.repeat(head.length)}\t${path}\0` : `100644 ${blob}\t${path}\0`
339}
340
341export function trackedArgs(paths: string[]): string[] {
342 return ['ls-files', '-z', '--', ...paths.map(path => `:(literal${windows ? ',icase' : ''})${path}`)]
343}
344
345export function hashOf(text: string): string {
346 let h = 0x811c9dc5
347 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193) >>> 0
348 return h.toString(16).padStart(8, '0')
349}
350
351export type Temp = { folder: string; objects: string; lfs: string }
352
353export function tempOf(folder: string, commonDir: string): Temp {
354 return { folder, objects: `${folder}/objects-${hashOf(toKey(commonDir, ''))}`, lfs: `${folder}/lfs` }
355}
356
357export function countsOf(folder: string, commonDir: string): Temp {
358 const counts = `${folder}/counts-${hashOf(toKey(commonDir, ''))}`
359 return { folder, objects: `${counts}/objects`, lfs: `${counts}/lfs` }
360}
361
362export function alternatesOf(objects: string): string {
363 return `${absolute(objects, '')}\n`
364}
365
366export function looseObjectOf(objects: string, id: string): string {
367 return `${objects}/${id.slice(0, 2)}/${id.slice(2)}`
368}
369
370export type Counted = { files: Record<string, FileChange>; trees: string[] }
371
372export function objectsOf(counted: Counted): string[] {
373 return [...new Set([...Object.values(counted.files).map(change => change.blob), ...counted.trees])].filter(id => !/^0*$/.test(id))
374}
375
376export function staleObjects(before: Counted, after: Counted): string[] {
377 const kept = new Set(objectsOf(after))
378 return objectsOf(before).filter(id => !kept.has(id))
379}
380
381const SESSION_CONFIG = ['core.looseCompression=0', 'core.safecrlf=false', 'core.splitIndex=false', 'core.bigFileThreshold=4095m']
382
383export function sessionArgs(temp: Temp): string[] {
384 return [`lfs.storage=${temp.lfs}`, `core.hooksPath=${temp.folder}/hooks`, ...SESSION_CONFIG].flatMap(setting => ['-c', setting])
385}
386
387export function batchesOf(paths: string[], limit: number): string[][] {
388 const batches: string[][] = []
389 let size = limit
390 for (const path of paths) {
391 if (size + path.length + 1 > limit) {
392 batches.push([])
393 size = 0
394 }
395 batches.at(-1)?.push(path)
396 size += path.length + 1
397 }
398 return batches
399}
400
401export function needsListing(files: MapFile[], changed: Record<string, FileChange>, found: string[]): boolean {
402 const listed = new Set(files.map(file => file.p))
403 return found.some(path => {
404 const change = changed[path]
405 if (change === undefined || /^0+$/.test(change.blob)) return listed.has(path)
406 return !listed.has(path) && !change.isBinary && isIncluded(path)
407 })
408}
409
410export function pathsStdin(paths: string[]): string {
411 return paths.map(path => `${path}\0`).join('')
412}
413
414export function diffArgs(from: string, to: string): string[] {
415 return ['diff-tree', '-r', '-t', '-z', '--no-renames', '--raw', '--numstat', from, to]
416}
417
418export type StatusList = { head: string; paths: string[]; nested: string[] }
419
420const FIELDS_BEFORE_PATH: Record<string, number> = { '1': 8, u: 10 }
421const TREE_MODE = '040000'
422
423export function parseStatus(stdout: string): StatusList {
424 const list: StatusList = { head: '', paths: [], nested: [] }
425 for (const entry of stdout.split('\0')) {
426 const fields = entry.split(' ')
427 const [type = '', state = '', sub = ''] = fields
428 if (type === '#' && state === 'branch.oid' && sub !== '(initial)') list.head = sub
429 if (type === '?') (entry.endsWith('/') ? list.nested : list.paths).push(entry.slice(2))
430 const before = FIELDS_BEFORE_PATH[type]
431 const path = before === undefined ? undefined : fields.slice(before).join(' ')
432 if (path !== undefined && sub.startsWith('S')) list.nested.push(`${path}/`)
433 else if (path !== undefined) list.paths.push(path)
434 }
435 return { head: list.head, paths: [...new Set(list.paths)], nested: list.nested }
436}
437
438function configValue(text: string): string {
439 let value = ''
440 let spaces = ''
441 let isQuoted = false
442 for (let i = 0; i < text.length; i++) {
443 const ch = text.charAt(i)
444 if (!isQuoted && (ch === '#' || ch === ';')) break
445 if (!isQuoted && /\s/.test(ch)) {
446 if (value !== '') spaces += ' '
447 continue
448 }
449 value += spaces
450 spaces = ''
451 if (ch === '"') isQuoted = !isQuoted
452 else if (ch !== '\\') value += ch
453 else {
454 const next = text.charAt(++i)
455 value += next === 'n' ? '\n' : next === 't' ? '\t' : next === 'b' ? '\b' : next
456 }
457 }
458 return value
459}
460
461export function parseModules(text: string): string[] {
462 const folders: string[] = []
463 let isModule = false
464 for (const line of text.split(/\r?\n/)) {
465 const header = /^\s*\[\s*([^\s\]"]+)\s*(?:"(?:[^"\\]|\\.)*"\s*)?\]/.exec(line)
466 if (header !== null) isModule = (header[1] ?? '').split('.')[0]?.toLowerCase() === 'submodule'
467 const setting = isModule ? /^\s*path\s*(?:=(.*))?$/i.exec(line.slice(header?.[0].length ?? 0)) : null
468 const path = configValue(setting?.[1] ?? '').replace(/\/+$/, '')
469 if (path !== '') folders.push(`${path}/`)
470 }
471 return folders
472}
473
474export function nestedIn(root: string, roots: string[]): string[] {
475 const prefix = `${toKey(root, '')}/`
476 return roots
477 .map(one => absolute(one, ''))
478 .filter(one => fold(one).startsWith(prefix))
479 .map(one => `${one.slice(prefix.length)}/`)
480 .sort()
481}
482
483function isTreeEntry(token: string): boolean {
484 const [from = '', to = ''] = token.slice(1).split(' ')
485 return from === TREE_MODE || to === TREE_MODE
486}
487
488export function treesOf(stdout: string): string[] {
489 const tokens = stdout.split('\0')
490 const trees: string[] = []
491 for (let i = 0; i < tokens.length; i++) {
492 const token = tokens[i] ?? ''
493 if (!token.startsWith(':')) continue
494 const id = token.split(' ')[3] ?? ''
495 if (isTreeEntry(token) && !/^0*$/.test(id)) trees.push(id)
496 i++
497 }
498 return trees
499}
500
501export function parseDiff(stdout: string): Record<string, FileChange> {
502 const tokens = stdout.split('\0')
503 const blobs = new Map<string, string>()
504 const files: Array<[string, FileChange]> = []
505 for (let i = 0; i < tokens.length; i++) {
506 const token = tokens[i] ?? ''
507 if (token.startsWith(':')) {
508 if (!isTreeEntry(token)) blobs.set(tokens[i + 1] ?? '', token.split(' ')[3] ?? '')
509 i++
510 continue
511 }
512 const numstat = /^(\d+|-)\t(\d+|-)\t([\s\S]+)$/.exec(token)
513 if (numstat === null) continue
514 const [, added = '', deleted = '', path = ''] = numstat
515 const isBinary = added === '-'
516 if (!isBinary && added === '0' && deleted === '0') continue
517 files.push([path, { added: isBinary ? 0 : Number(added), deleted: isBinary ? 0 : Number(deleted), isBinary, blob: blobs.get(path) ?? '' }])
518 }
519 return Object.fromEntries(files)
520}
521
522export function newlyChanged(before: Record<string, FileChange>, after: Record<string, FileChange>): string[] {
523 const paths = Object.keys(after).filter(path => !Object.hasOwn(before, path) || before[path]?.blob !== after[path]?.blob)
524 for (const path of Object.keys(before)) if (!Object.hasOwn(after, path)) paths.push(path)
525 return paths.sort()
526}
527
528const changedLines = (change: FileChange, lines: number) => (change.isBinary ? lines : Math.max(change.added, change.deleted))
529
530export function openerOf(os: Os, program: string, file: string, asText: boolean): Opener {
531 if (os === 'win32') {
532 const env = { FILEMAP_OPEN: file.replace(/\//g, '\\') }
533 return program === '' ? { argv: [...POWERSHELL, asText ? AS_TEXT : INVOKE], env } : { argv: [...POWERSHELL, START], env: { ...env, FILEMAP_PROGRAM: program } }
534 }
535 if (os === 'darwin') return { argv: program === '' ? ['open', ...(asText ? ['-t'] : []), file] : ['sh', '-c', PROGRAM_ON_MAC, 'sh', program, file] }
536 if (program !== '') return { argv: ['sh', '-c', PROGRAM, 'sh', program, file] }
537 return { argv: asText ? ['sh', '-c', AS_TEXT_ON_LINUX, 'sh', file] : ['setsid', '-f', 'sh', '-c', DEFAULT_APP, 'sh', file] }
538}
539
540export function screenInput(
541 map: RepoMap,
542 s: Session,
543 changes: Changes | null,
544 view: { columns: number; rows: number; zoom?: string },
545 theme: Theme,
546 now: number,
547 notice?: string,
548): ScreenInput {
549 const root = toKey(map.root, map.root)
550 const prefix = root.endsWith('/') ? root : `${root}/`
551 const depth = prefix.split('/').length - 1
552 const counted = changes !== null && changes.root === map.root ? Object.entries(changes.files) : []
553 const nested = changes !== null && changes.root === map.root ? changes.nested.map(folder => prefix + fold(folder)) : []
554 const inRoot = (key: string) => map.root !== '' && key.startsWith(prefix) && !nested.some(folder => key.startsWith(folder))
555 const onMap = new Map(map.files.map(file => [fold(file.p), file]))
556 const changed = new Map(counted.map(([path, change]) => [fold(path), change]))
557 const isNow = (at: number | undefined) => at !== undefined && now - at < WINDOW_MS
558 const isRead = (key: string) => Object.hasOwn(s.reads, key)
559 const spelled = (key: string) => onMap.get(key.slice(prefix.length))?.p ?? (s.labels[key] ?? key).split('/').slice(depth).join('/')
560 const marks: Record<string, Mark> = {}
561 for (const file of map.files) {
562 const path = fold(file.p)
563 const key = prefix + path
564 const change = changed.get(path)
565 if (change === undefined && !isRead(key)) continue
566 marks[path] = {
567 changed: change === undefined ? 0 : changedLines(change, file.lines),
568 change: change ?? null,
569 isRead: isRead(key),
570 isNow: change !== undefined && isNow(s.changedAt[key]),
571 }
572 }
573 const recent: RecentRow[] = []
574 for (const key of s.recent) {
575 if (recent.length === ROW_LIMIT) break
576 const change = inRoot(key) ? (changed.get(key.slice(prefix.length)) ?? null) : null
577 if (inRoot(key) && (change !== null || isRead(key))) recent.push({ label: spelled(key), change, isNow: change !== null && isNow(s.changedAt[key]) })
578 }
579 const touched = new Set([...Object.keys(s.reads), ...Object.keys(s.changedAt)])
580 const busy = new Map<string, { name: string; files: number; last: number }>()
581 for (const key of new Set([...Object.keys(s.readAt), ...Object.keys(s.changedAt)])) {
582 const last = Math.max(s.readAt[key] ?? -Infinity, s.changedAt[key] ?? -Infinity)
583 if (!inRoot(key) || !isNow(last)) continue
584 const name = spelled(key).split('/').slice(0, -1).slice(0, BUSY_DEPTH).join('/') || TOP_LEVEL
585 const held = busy.get(fold(name)) ?? { name, files: 0, last }
586 busy.set(fold(name), { name: held.name, files: held.files + 1, last: Math.max(held.last, last) })
587 }
588 const busiest = [...busy.values()].sort((a, b) => b.files - a.files || b.last - a.last || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
589 return {
590 repo: map.repo,
591 files: map.files,
592 filesVersion: map.version,
593 marks,
594 recent,
595 outside: [...touched].filter(key => !inRoot(key)).length,
596 busy: busiest.slice(0, BUSY_LIMIT).map(folder => folder.name),
597 changedCount: changed.size,
598 readCount: Object.keys(s.reads).filter(key => inRoot(key) && !changed.has(key.slice(prefix.length))).length,
599 windowMinutes: WINDOW_MS / 60_000,
600 zoom: view.zoom ?? '',
601 columns: view.columns,
602 rows: view.rows,
603 theme,
604 ...(notice === undefined ? {} : { notice }),
605 }
606}
607types/index.d.ts 34 lines1export type MapFile = { p: string; lines: number }
2
3export type RepoMap = { root: string; repo: string; commonDir: string; files: MapFile[]; version: number }
4
5export type Session = {
6 reads: Record<string, number>
7 readAt: Record<string, number>
8 changedAt: Record<string, number>
9 labels: Record<string, string>
10 recent: string[]
11}
12
13export type FileChange = { added: number; deleted: number; isBinary: boolean; blob: string }
14
15export type Changes = { root: string; baseline: string; tree: string; files: Record<string, FileChange>; trees: string[]; folder: string; objects: string; nested: string[] }
16
17export type Theme = 'dark' | 'light'
18
19declare module 'claude-code' {
20 interface PluginState {
21 filemap: {
22 map: Shaped<RepoMap | null>
23 root: Shaped<string>
24 session: Shaped<Session>
25 changes: Shaped<Changes | null>
26 isStale: Shaped<boolean>
27 isGitPaused: Shaped<boolean>
28 theme: Shaped<Theme>
29 zoom: Shaped<string>
30 epoch: Shaped<number>
31 }
32 }
33}
34