| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- import z from "zod"
- import { Bus } from "../bus"
- import { $ } from "bun"
- import type { BunFile } from "bun"
- import { formatPatch, structuredPatch } from "diff"
- import path from "path"
- import fs from "fs"
- import ignore from "ignore"
- import { Log } from "../util/log"
- import { Instance } from "../project/instance"
- import { Ripgrep } from "./ripgrep"
- import fuzzysort from "fuzzysort"
- export namespace File {
- const log = Log.create({ service: "file" })
- export const Info = z
- .object({
- path: z.string(),
- added: z.number().int(),
- removed: z.number().int(),
- status: z.enum(["added", "deleted", "modified"]),
- })
- .meta({
- ref: "File",
- })
- export type Info = z.infer<typeof Info>
- export const Node = z
- .object({
- name: z.string(),
- path: z.string(),
- absolute: z.string(),
- type: z.enum(["file", "directory"]),
- ignored: z.boolean(),
- })
- .meta({
- ref: "FileNode",
- })
- export type Node = z.infer<typeof Node>
- export const Content = z
- .object({
- type: z.literal("text"),
- content: z.string(),
- diff: z.string().optional(),
- patch: z
- .object({
- oldFileName: z.string(),
- newFileName: z.string(),
- oldHeader: z.string().optional(),
- newHeader: z.string().optional(),
- hunks: z.array(
- z.object({
- oldStart: z.number(),
- oldLines: z.number(),
- newStart: z.number(),
- newLines: z.number(),
- lines: z.array(z.string()),
- }),
- ),
- index: z.string().optional(),
- })
- .optional(),
- encoding: z.literal("base64").optional(),
- mimeType: z.string().optional(),
- })
- .meta({
- ref: "FileContent",
- })
- export type Content = z.infer<typeof Content>
- async function shouldEncode(file: BunFile): Promise<boolean> {
- const type = file.type?.toLowerCase()
- if (!type) return false
- if (type.startsWith("text/")) return false
- if (type.includes("charset=")) return false
- const parts = type.split("/", 2)
- const top = parts[0]
- const rest = parts[1] ?? ""
- const sub = rest.split(";", 1)[0]
- const tops = ["image", "audio", "video", "font", "model", "multipart"]
- if (tops.includes(top)) return true
- if (type === "application/octet-stream") return true
- const bins = [
- "zip",
- "gzip",
- "bzip",
- "compressed",
- "binary",
- "stream",
- "pdf",
- "msword",
- "powerpoint",
- "excel",
- "ogg",
- "exe",
- "dmg",
- "iso",
- "rar",
- ]
- if (bins.some((mark) => sub.includes(mark))) return true
- return false
- }
- export const Event = {
- Edited: Bus.event(
- "file.edited",
- z.object({
- file: z.string(),
- }),
- ),
- }
- const state = Instance.state(async () => {
- type Entry = { files: string[]; dirs: string[] }
- let cache: Entry = { files: [], dirs: [] }
- let fetching = false
- const fn = async (result: Entry) => {
- fetching = true
- const set = new Set<string>()
- for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
- result.files.push(file)
- let current = file
- while (true) {
- const dir = path.dirname(current)
- if (dir === ".") break
- if (dir === current) break
- current = dir
- if (set.has(dir)) continue
- set.add(dir)
- result.dirs.push(dir + "/")
- }
- }
- cache = result
- fetching = false
- }
- fn(cache)
- return {
- async files() {
- if (!fetching) {
- fn({
- files: [],
- dirs: [],
- })
- }
- return cache
- },
- }
- })
- export function init() {
- state()
- }
- export async function status() {
- const project = Instance.project
- if (project.vcs !== "git") return []
- const diffOutput = await $`git diff --numstat HEAD`.cwd(Instance.directory).quiet().nothrow().text()
- const changedFiles: Info[] = []
- if (diffOutput.trim()) {
- const lines = diffOutput.trim().split("\n")
- for (const line of lines) {
- const [added, removed, filepath] = line.split("\t")
- changedFiles.push({
- path: filepath,
- added: added === "-" ? 0 : parseInt(added, 10),
- removed: removed === "-" ? 0 : parseInt(removed, 10),
- status: "modified",
- })
- }
- }
- const untrackedOutput = await $`git ls-files --others --exclude-standard`
- .cwd(Instance.directory)
- .quiet()
- .nothrow()
- .text()
- if (untrackedOutput.trim()) {
- const untrackedFiles = untrackedOutput.trim().split("\n")
- for (const filepath of untrackedFiles) {
- try {
- const content = await Bun.file(path.join(Instance.directory, filepath)).text()
- const lines = content.split("\n").length
- changedFiles.push({
- path: filepath,
- added: lines,
- removed: 0,
- status: "added",
- })
- } catch {
- continue
- }
- }
- }
- // Get deleted files
- const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
- .cwd(Instance.directory)
- .quiet()
- .nothrow()
- .text()
- if (deletedOutput.trim()) {
- const deletedFiles = deletedOutput.trim().split("\n")
- for (const filepath of deletedFiles) {
- changedFiles.push({
- path: filepath,
- added: 0,
- removed: 0, // Could get original line count but would require another git command
- status: "deleted",
- })
- }
- }
- return changedFiles.map((x) => ({
- ...x,
- path: path.relative(Instance.directory, x.path),
- }))
- }
- export async function read(file: string): Promise<Content> {
- using _ = log.time("read", { file })
- const project = Instance.project
- const full = path.join(Instance.directory, file)
- const bunFile = Bun.file(full)
- if (!(await bunFile.exists())) {
- return { type: "text", content: "" }
- }
- const encode = await shouldEncode(bunFile)
- if (encode) {
- const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
- const content = Buffer.from(buffer).toString("base64")
- const mimeType = bunFile.type || "application/octet-stream"
- return { type: "text", content, mimeType, encoding: "base64" }
- }
- const content = await bunFile
- .text()
- .catch(() => "")
- .then((x) => x.trim())
- if (project.vcs === "git") {
- let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
- if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
- if (diff.trim()) {
- const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
- const patch = structuredPatch(file, file, original, content, "old", "new", {
- context: Infinity,
- ignoreWhitespace: true,
- })
- const diff = formatPatch(patch)
- return { type: "text", content, patch, diff }
- }
- }
- return { type: "text", content }
- }
- export async function list(dir?: string) {
- const exclude = [".git", ".DS_Store"]
- const project = Instance.project
- let ignored = (_: string) => false
- if (project.vcs === "git") {
- const ig = ignore()
- const gitignore = Bun.file(path.join(Instance.worktree, ".gitignore"))
- if (await gitignore.exists()) {
- ig.add(await gitignore.text())
- }
- const ignoreFile = Bun.file(path.join(Instance.worktree, ".ignore"))
- if (await ignoreFile.exists()) {
- ig.add(await ignoreFile.text())
- }
- ignored = ig.ignores.bind(ig)
- }
- const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
- const nodes: Node[] = []
- for (const entry of await fs.promises.readdir(resolved, {
- withFileTypes: true,
- })) {
- if (exclude.includes(entry.name)) continue
- const fullPath = path.join(resolved, entry.name)
- const relativePath = path.relative(Instance.directory, fullPath)
- const type = entry.isDirectory() ? "directory" : "file"
- nodes.push({
- name: entry.name,
- path: relativePath,
- absolute: fullPath,
- type,
- ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
- })
- }
- return nodes.sort((a, b) => {
- if (a.type !== b.type) {
- return a.type === "directory" ? -1 : 1
- }
- return a.name.localeCompare(b.name)
- })
- }
- export async function search(input: { query: string; limit?: number; dirs?: boolean }) {
- log.info("search", { query: input.query })
- const limit = input.limit ?? 100
- const result = await state().then((x) => x.files())
- if (!input.query)
- return input.dirs !== false ? result.dirs.toSorted().slice(0, limit) : result.files.slice(0, limit)
- const items = input.dirs !== false ? [...result.files, ...result.dirs] : result.files
- const sorted = fuzzysort.go(input.query, items, { limit: limit }).map((r) => r.target)
- log.info("search", { query: input.query, results: sorted.length })
- return sorted
- }
- }
|