index.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import { z } from "zod"
  2. import { Bus } from "../bus"
  3. import { $ } from "bun"
  4. import { createPatch } from "diff"
  5. import path from "path"
  6. import * as git from "isomorphic-git"
  7. import { App } from "../app/app"
  8. import fs from "fs"
  9. import { Log } from "../util/log"
  10. export namespace File {
  11. const log = Log.create({ service: "file" })
  12. export const Info = z
  13. .object({
  14. path: z.string(),
  15. added: z.number().int(),
  16. removed: z.number().int(),
  17. status: z.enum(["added", "deleted", "modified"]),
  18. })
  19. .openapi({
  20. ref: "File",
  21. })
  22. export type Info = z.infer<typeof Info>
  23. export const Node = z
  24. .object({
  25. name: z.string(),
  26. path: z.string(),
  27. type: z.enum(["file", "directory"]),
  28. })
  29. .openapi({
  30. ref: "FileNode",
  31. })
  32. export type Node = z.infer<typeof Node>
  33. export const Event = {
  34. Edited: Bus.event(
  35. "file.edited",
  36. z.object({
  37. file: z.string(),
  38. }),
  39. ),
  40. }
  41. export async function status() {
  42. const app = App.info()
  43. if (!app.git) return []
  44. const diffOutput = await $`git diff --numstat HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
  45. const changedFiles: Info[] = []
  46. if (diffOutput.trim()) {
  47. const lines = diffOutput.trim().split("\n")
  48. for (const line of lines) {
  49. const [added, removed, filepath] = line.split("\t")
  50. changedFiles.push({
  51. path: filepath,
  52. added: added === "-" ? 0 : parseInt(added, 10),
  53. removed: removed === "-" ? 0 : parseInt(removed, 10),
  54. status: "modified",
  55. })
  56. }
  57. }
  58. const untrackedOutput = await $`git ls-files --others --exclude-standard`.cwd(app.path.cwd).quiet().nothrow().text()
  59. if (untrackedOutput.trim()) {
  60. const untrackedFiles = untrackedOutput.trim().split("\n")
  61. for (const filepath of untrackedFiles) {
  62. try {
  63. const content = await Bun.file(path.join(app.path.root, filepath)).text()
  64. const lines = content.split("\n").length
  65. changedFiles.push({
  66. path: filepath,
  67. added: lines,
  68. removed: 0,
  69. status: "added",
  70. })
  71. } catch {
  72. continue
  73. }
  74. }
  75. }
  76. // Get deleted files
  77. const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
  78. if (deletedOutput.trim()) {
  79. const deletedFiles = deletedOutput.trim().split("\n")
  80. for (const filepath of deletedFiles) {
  81. changedFiles.push({
  82. path: filepath,
  83. added: 0,
  84. removed: 0, // Could get original line count but would require another git command
  85. status: "deleted",
  86. })
  87. }
  88. }
  89. return changedFiles.map((x) => ({
  90. ...x,
  91. path: path.relative(app.path.cwd, path.join(app.path.root, x.path)),
  92. }))
  93. }
  94. export async function read(file: string) {
  95. using _ = log.time("read", { file })
  96. const app = App.info()
  97. const full = path.join(app.path.cwd, file)
  98. const content = await Bun.file(full)
  99. .text()
  100. .catch(() => "")
  101. .then((x) => x.trim())
  102. if (app.git) {
  103. const rel = path.relative(app.path.root, full)
  104. const diff = await git.status({
  105. fs,
  106. dir: app.path.root,
  107. filepath: rel,
  108. })
  109. if (diff !== "unmodified") {
  110. const original = await $`git show HEAD:${rel}`.cwd(app.path.root).quiet().nothrow().text()
  111. const patch = createPatch(file, original, content, "old", "new", {
  112. context: Infinity,
  113. })
  114. return { type: "patch", content: patch }
  115. }
  116. }
  117. return { type: "raw", content }
  118. }
  119. export async function list(dir?: string) {
  120. const ignore = [".git", ".DS_Store"]
  121. const app = App.info()
  122. const resolved = dir ? path.join(app.path.cwd, dir) : app.path.cwd
  123. const nodes: Node[] = []
  124. for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true })) {
  125. if (ignore.includes(entry.name)) continue
  126. const fullPath = path.join(resolved, entry.name)
  127. const relativePath = path.relative(app.path.cwd, fullPath)
  128. nodes.push({
  129. name: entry.name,
  130. path: relativePath,
  131. type: entry.isDirectory() ? "directory" : "file",
  132. })
  133. }
  134. return nodes.sort((a, b) => {
  135. if (a.type !== b.type) {
  136. return a.type === "directory" ? -1 : 1
  137. }
  138. return a.name.localeCompare(b.name)
  139. })
  140. }
  141. }