SLOPSHOPPER

filemap

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.

newpaneguardcommandtoastprompt
v0.1.0MITupdated 2026-10-16vdvoorder/filemap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filemap
│ ┃ File map ✕ › fix the failing auth test and add an audit log call │ ┃ The file map could not be drawn: undefined │ ┃ is not an object (evaluating 'key of ● filemap: Drawing the file map failed: undefined is not an object (e │ ┃ s.recent') ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /filemap │ ⎿ filemap: File map of : 0 files. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · File map
The file map could not be drawn: undefined is not an object (evaluating 'key of s.recent')
README

filemap

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.

filemap during a rename across 13 files

What the map shows

  • Each source or text file is a rectangle, grouped by folder. Its area follows the square root of its number of lines: a file of 400 lines gets twice the area of a file of 100 lines, not four times, so the long JSON files of a Power BI report crowd out the short TMDL files of its semantic model less.
  • The map holds files of these types: .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .sh, .bash, .zsh, .fish, .py, .ps1, .psm1, .cs, .csx, .rs, .go, .java, .zig, .swift, .c, .h, .cpp, .cc, .hpp, .tmdl, .dax, .md, .mdx, .css, .scss, .html, .json, .toml, .yaml, .yml, .sql, .pbir, .pbism and .pbip. It leaves out empty files, binary files, lock files, minified .js files and the folders node_modules, .venv, venv and \_\_pycache\_\_.
  • A file the agent read turns grey, one step lighter than an untouched file.
  • A changed file gets a blue band. The band's height is the share of the file's lines that changed since you opened the pane. Git counts the lines, so a change that is undone disappears again, and a change made by a shell command or another program counts too.
  • A folder too small to show its files is one box. Its blue band covers the share of its area that the bands of its changed files would cover.
  • Orange marks the files that changed in the last 5 minutes.
  • The header names the busiest folders of the last 5 minutes and counts the changed and read files.
  • The list under the map shows the latest files the agent read or changed, of any type, with the lines added and removed.

Mouse

The map reads the same without a mouse. With one:

  • Hover over a rectangle to see the file or folder and its change in the row under the map.
  • Click a file to keep it in that row, and double-click it to open it.
  • Click a folder to zoom in. Click the top row or "up" to zoom out.
  • Click a row of the list to open its file.

Install it

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>

Settings

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

  • Windows: a script (.py, .js, .ps1, .sh, .cmd, .bat or .vbs) or a .reg file opens in the app that opens .txt files. Another type that Windows runs, such as .exe, .msi or .lnk, does not open, and a toast says so.
  • macOS: a .py, .command or .tool file opens in the text editor that 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.
  • Linux: a file with the execute bit, or a .jar, .jnlp, .desktop, .deb, .rpm, .snap, .flatpakref, .flatpakrepo, .AppImage or .run file, counts as a file that runs. A text file among them, such as an executable .sh script or a .desktop file, opens in the app that 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.

  • The pane sits beside the conversation only in the fullscreen layout, in a terminal of 110 columns or more. In a narrower terminal, or outside the fullscreen layout, it opens above the prompt as wide as the terminal.
  • Claude Code keeps 24 columns for the conversation and 1 for the edge of the pane. A terminal narrower than the width plus 25 columns gives a narrower pane. The pane gets its width back when you widen the terminal.
  • A width you drag the edge of a docked pane to wins over this setting, also in later sessions. Claude Code keeps one such width for the docked panes of every plugin.
  • When the pane is narrower than this setting, the row under the map says why whenever it names no file or folder.
  • Above about 130 columns the map can show fewer rows: the text of the map has a limit of 100,000 characters.

Platforms

  • Windows: the tests pass on Claude Code 2.1.301 with git 2.55, and the pane has been used in live sessions.
  • Linux: the tests pass on Claude Code 2.1.301 with git 2.55 on Arch Linux, and there the pane draws, names the file under the pointer, and zooms in and out on a click.
  • macOS: the openers are covered by unit tests only.
  • The workflow in .github runs the tests on Windows, Linux and macOS with Claude Code 2.1.296.
  • On Linux, a file type with no default app opens nothing and shows no message.

Limits

  • File names show their Latin, Greek, Cyrillic, Armenian and Georgian letters. Letters of other scripts, emoji and invisible characters show as ? in the pane, and the file still opens.
  • A repository whose file list passes 4 MiB gets no map, and a message says why.
  • To count the changed lines, the mod keeps an uncompressed copy of each changed or untracked file in its temporary folder until the session ends. A file over 32 MiB is left out of the count and gets no copy.
  • Where an organization manages Claude Code, the mod does not hear when a background task ends or when a git command outside the agent moves the index or HEAD. The map then shows such a change up to 30 seconds later.

What it leaves alone

  • Git itself writes nothing into the repository's .git folder for the mod: the mod builds its trees in a temporary folder of its own and points Git LFS there too. A clean filter that your git config sets up still runs when the mod reads a file.
  • It never takes git's index lock, so a git command of the agent never fails because of the mod.
  • It deletes its temporary folder when the session ends. On Linux and macOS only you can open that folder, also in a shared /tmp.

Contributing

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.

Source 7 files
hooks/register.tsx 1068 lines
1import { 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}
1068
hooks/map.ts 107 lines
1import 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}
107
hooks/map-client.tsx 117 lines
1import 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
117
hooks/screen.ts 625 lines
1import 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}
625
hooks/shell.ts 409 lines
1import { 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}
409
hooks/track.ts 607 lines
1import 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}
607
types/index.d.ts 34 lines
1export 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