index.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. import z from "zod"
  2. import { Bus } from "../bus"
  3. import { $ } from "bun"
  4. import type { BunFile } from "bun"
  5. import { formatPatch, structuredPatch } from "diff"
  6. import path from "path"
  7. import fs from "fs"
  8. import ignore from "ignore"
  9. import { Log } from "../util/log"
  10. import { Instance } from "../project/instance"
  11. import { Ripgrep } from "./ripgrep"
  12. import fuzzysort from "fuzzysort"
  13. export namespace File {
  14. const log = Log.create({ service: "file" })
  15. export const Info = z
  16. .object({
  17. path: z.string(),
  18. added: z.number().int(),
  19. removed: z.number().int(),
  20. status: z.enum(["added", "deleted", "modified"]),
  21. })
  22. .meta({
  23. ref: "File",
  24. })
  25. export type Info = z.infer<typeof Info>
  26. export const Node = z
  27. .object({
  28. name: z.string(),
  29. path: z.string(),
  30. absolute: z.string(),
  31. type: z.enum(["file", "directory"]),
  32. ignored: z.boolean(),
  33. })
  34. .meta({
  35. ref: "FileNode",
  36. })
  37. export type Node = z.infer<typeof Node>
  38. export const Content = z
  39. .object({
  40. type: z.literal("text"),
  41. content: z.string(),
  42. diff: z.string().optional(),
  43. patch: z
  44. .object({
  45. oldFileName: z.string(),
  46. newFileName: z.string(),
  47. oldHeader: z.string().optional(),
  48. newHeader: z.string().optional(),
  49. hunks: z.array(
  50. z.object({
  51. oldStart: z.number(),
  52. oldLines: z.number(),
  53. newStart: z.number(),
  54. newLines: z.number(),
  55. lines: z.array(z.string()),
  56. }),
  57. ),
  58. index: z.string().optional(),
  59. })
  60. .optional(),
  61. encoding: z.literal("base64").optional(),
  62. mimeType: z.string().optional(),
  63. })
  64. .meta({
  65. ref: "FileContent",
  66. })
  67. export type Content = z.infer<typeof Content>
  68. async function shouldEncode(file: BunFile): Promise<boolean> {
  69. const type = file.type?.toLowerCase()
  70. if (!type) return false
  71. if (type.startsWith("text/")) return false
  72. if (type.includes("charset=")) return false
  73. const parts = type.split("/", 2)
  74. const top = parts[0]
  75. const rest = parts[1] ?? ""
  76. const sub = rest.split(";", 1)[0]
  77. const tops = ["image", "audio", "video", "font", "model", "multipart"]
  78. if (tops.includes(top)) return true
  79. if (type === "application/octet-stream") return true
  80. const bins = [
  81. "zip",
  82. "gzip",
  83. "bzip",
  84. "compressed",
  85. "binary",
  86. "stream",
  87. "pdf",
  88. "msword",
  89. "powerpoint",
  90. "excel",
  91. "ogg",
  92. "exe",
  93. "dmg",
  94. "iso",
  95. "rar",
  96. ]
  97. if (bins.some((mark) => sub.includes(mark))) return true
  98. return false
  99. }
  100. export const Event = {
  101. Edited: Bus.event(
  102. "file.edited",
  103. z.object({
  104. file: z.string(),
  105. }),
  106. ),
  107. }
  108. const state = Instance.state(async () => {
  109. type Entry = { files: string[]; dirs: string[] }
  110. let cache: Entry = { files: [], dirs: [] }
  111. let fetching = false
  112. const fn = async (result: Entry) => {
  113. fetching = true
  114. const set = new Set<string>()
  115. for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
  116. result.files.push(file)
  117. let current = file
  118. while (true) {
  119. const dir = path.dirname(current)
  120. if (dir === ".") break
  121. if (dir === current) break
  122. current = dir
  123. if (set.has(dir)) continue
  124. set.add(dir)
  125. result.dirs.push(dir + "/")
  126. }
  127. }
  128. cache = result
  129. fetching = false
  130. }
  131. fn(cache)
  132. return {
  133. async files() {
  134. if (!fetching) {
  135. fn({
  136. files: [],
  137. dirs: [],
  138. })
  139. }
  140. return cache
  141. },
  142. }
  143. })
  144. export function init() {
  145. state()
  146. }
  147. export async function status() {
  148. const project = Instance.project
  149. if (project.vcs !== "git") return []
  150. const diffOutput = await $`git diff --numstat HEAD`.cwd(Instance.directory).quiet().nothrow().text()
  151. const changedFiles: Info[] = []
  152. if (diffOutput.trim()) {
  153. const lines = diffOutput.trim().split("\n")
  154. for (const line of lines) {
  155. const [added, removed, filepath] = line.split("\t")
  156. changedFiles.push({
  157. path: filepath,
  158. added: added === "-" ? 0 : parseInt(added, 10),
  159. removed: removed === "-" ? 0 : parseInt(removed, 10),
  160. status: "modified",
  161. })
  162. }
  163. }
  164. const untrackedOutput = await $`git ls-files --others --exclude-standard`
  165. .cwd(Instance.directory)
  166. .quiet()
  167. .nothrow()
  168. .text()
  169. if (untrackedOutput.trim()) {
  170. const untrackedFiles = untrackedOutput.trim().split("\n")
  171. for (const filepath of untrackedFiles) {
  172. try {
  173. const content = await Bun.file(path.join(Instance.directory, filepath)).text()
  174. const lines = content.split("\n").length
  175. changedFiles.push({
  176. path: filepath,
  177. added: lines,
  178. removed: 0,
  179. status: "added",
  180. })
  181. } catch {
  182. continue
  183. }
  184. }
  185. }
  186. // Get deleted files
  187. const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
  188. .cwd(Instance.directory)
  189. .quiet()
  190. .nothrow()
  191. .text()
  192. if (deletedOutput.trim()) {
  193. const deletedFiles = deletedOutput.trim().split("\n")
  194. for (const filepath of deletedFiles) {
  195. changedFiles.push({
  196. path: filepath,
  197. added: 0,
  198. removed: 0, // Could get original line count but would require another git command
  199. status: "deleted",
  200. })
  201. }
  202. }
  203. return changedFiles.map((x) => ({
  204. ...x,
  205. path: path.relative(Instance.directory, x.path),
  206. }))
  207. }
  208. export async function read(file: string): Promise<Content> {
  209. using _ = log.time("read", { file })
  210. const project = Instance.project
  211. const full = path.join(Instance.directory, file)
  212. const bunFile = Bun.file(full)
  213. if (!(await bunFile.exists())) {
  214. return { type: "text", content: "" }
  215. }
  216. const encode = await shouldEncode(bunFile)
  217. if (encode) {
  218. const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
  219. const content = Buffer.from(buffer).toString("base64")
  220. const mimeType = bunFile.type || "application/octet-stream"
  221. return { type: "text", content, mimeType, encoding: "base64" }
  222. }
  223. const content = await bunFile
  224. .text()
  225. .catch(() => "")
  226. .then((x) => x.trim())
  227. if (project.vcs === "git") {
  228. let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
  229. if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
  230. if (diff.trim()) {
  231. const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
  232. const patch = structuredPatch(file, file, original, content, "old", "new", {
  233. context: Infinity,
  234. ignoreWhitespace: true,
  235. })
  236. const diff = formatPatch(patch)
  237. return { type: "text", content, patch, diff }
  238. }
  239. }
  240. return { type: "text", content }
  241. }
  242. export async function list(dir?: string) {
  243. const exclude = [".git", ".DS_Store"]
  244. const project = Instance.project
  245. let ignored = (_: string) => false
  246. if (project.vcs === "git") {
  247. const ig = ignore()
  248. const gitignore = Bun.file(path.join(Instance.worktree, ".gitignore"))
  249. if (await gitignore.exists()) {
  250. ig.add(await gitignore.text())
  251. }
  252. const ignoreFile = Bun.file(path.join(Instance.worktree, ".ignore"))
  253. if (await ignoreFile.exists()) {
  254. ig.add(await ignoreFile.text())
  255. }
  256. ignored = ig.ignores.bind(ig)
  257. }
  258. const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
  259. const nodes: Node[] = []
  260. for (const entry of await fs.promises.readdir(resolved, {
  261. withFileTypes: true,
  262. })) {
  263. if (exclude.includes(entry.name)) continue
  264. const fullPath = path.join(resolved, entry.name)
  265. const relativePath = path.relative(Instance.directory, fullPath)
  266. const type = entry.isDirectory() ? "directory" : "file"
  267. nodes.push({
  268. name: entry.name,
  269. path: relativePath,
  270. absolute: fullPath,
  271. type,
  272. ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
  273. })
  274. }
  275. return nodes.sort((a, b) => {
  276. if (a.type !== b.type) {
  277. return a.type === "directory" ? -1 : 1
  278. }
  279. return a.name.localeCompare(b.name)
  280. })
  281. }
  282. export async function search(input: { query: string; limit?: number; dirs?: boolean }) {
  283. log.info("search", { query: input.query })
  284. const limit = input.limit ?? 100
  285. const result = await state().then((x) => x.files())
  286. if (!input.query)
  287. return input.dirs !== false ? result.dirs.toSorted().slice(0, limit) : result.files.slice(0, limit)
  288. const items = input.dirs !== false ? [...result.files, ...result.dirs] : result.files
  289. const sorted = fuzzysort.go(input.query, items, { limit: limit }).map((r) => r.target)
  290. log.info("search", { query: input.query, results: sorted.length })
  291. return sorted
  292. }
  293. }