321 lines
8.8 KiB
TypeScript
321 lines
8.8 KiB
TypeScript
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 gitignore = Bun.file(path.join(Instance.worktree, ".gitignore"))
|
|
if (await gitignore.exists()) {
|
|
const ig = ignore().add(await gitignore.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 }) {
|
|
log.info("search", { query: input.query })
|
|
const limit = input.limit ?? 100
|
|
const result = await state().then((x) => x.files())
|
|
if (!input.query) return result.dirs.toSorted().slice(0, limit)
|
|
const items = [...result.files, ...result.dirs]
|
|
const sorted = fuzzysort.go(input.query, items, { limit: limit }).map((r) => r.target)
|
|
log.info("search", { query: input.query, results: sorted.length })
|
|
return sorted
|
|
}
|
|
}
|