2025-09-02 06:20:08 -05:00

173 lines
4.9 KiB
TypeScript

import { z } from "zod"
import { Bus } from "../bus"
import { $ } from "bun"
import { createPatch } from "diff"
import path from "path"
import fs from "fs"
import ignore from "ignore"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
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"]),
})
.openapi({
ref: "File",
})
export type Info = z.infer<typeof Info>
export const Node = z
.object({
name: z.string(),
path: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.openapi({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Event = {
Edited: Bus.event(
"file.edited",
z.object({
file: z.string(),
}),
),
}
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.worktree, 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, path.join(Instance.worktree, x.path)),
}))
}
export async function read(file: string) {
using _ = log.time("read", { file })
const project = Instance.project
const full = path.join(Instance.directory, file)
const content = await Bun.file(full)
.text()
.catch(() => "")
.then((x) => x.trim())
if (project.vcs === "git") {
const rel = path.relative(Instance.worktree, full)
const diff = await $`git diff ${rel}`.cwd(Instance.worktree).quiet().nothrow().text()
if (diff.trim()) {
const original = await $`git show HEAD:${rel}`.cwd(Instance.worktree).quiet().nothrow().text()
const patch = createPatch(file, original, content, "old", "new", {
context: Infinity,
})
return { type: "patch", content: patch }
}
}
return { type: "raw", 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,
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)
})
}
}