project.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. import z from "zod"
  2. import fs from "fs/promises"
  3. import { Filesystem } from "../util/filesystem"
  4. import path from "path"
  5. import { $ } from "bun"
  6. import { Storage } from "../storage/storage"
  7. import { Log } from "../util/log"
  8. import { Flag } from "@/flag/flag"
  9. import { Session } from "../session"
  10. import { work } from "../util/queue"
  11. import { fn } from "@opencode-ai/util/fn"
  12. import { BusEvent } from "@/bus/bus-event"
  13. import { iife } from "@/util/iife"
  14. import { GlobalBus } from "@/bus/global"
  15. import { existsSync } from "fs"
  16. export namespace Project {
  17. const log = Log.create({ service: "project" })
  18. export const Info = z
  19. .object({
  20. id: z.string(),
  21. worktree: z.string(),
  22. vcs: z.literal("git").optional(),
  23. name: z.string().optional(),
  24. icon: z
  25. .object({
  26. url: z.string().optional(),
  27. color: z.string().optional(),
  28. })
  29. .optional(),
  30. time: z.object({
  31. created: z.number(),
  32. updated: z.number(),
  33. initialized: z.number().optional(),
  34. }),
  35. sandboxes: z.array(z.string()),
  36. })
  37. .meta({
  38. ref: "Project",
  39. })
  40. export type Info = z.infer<typeof Info>
  41. export const Event = {
  42. Updated: BusEvent.define("project.updated", Info),
  43. }
  44. export async function fromDirectory(directory: string) {
  45. log.info("fromDirectory", { directory })
  46. const { id, sandbox, worktree, vcs } = await iife(async () => {
  47. const matches = Filesystem.up({ targets: [".git"], start: directory })
  48. const git = await matches.next().then((x) => x.value)
  49. await matches.return()
  50. if (git) {
  51. let sandbox = path.dirname(git)
  52. const gitBinary = Bun.which("git")
  53. // cached id calculation
  54. let id = await Bun.file(path.join(git, "opencode"))
  55. .text()
  56. .then((x) => x.trim())
  57. .catch(() => undefined)
  58. if (!gitBinary) {
  59. return {
  60. id: id ?? "global",
  61. worktree: sandbox,
  62. sandbox: sandbox,
  63. vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
  64. }
  65. }
  66. // generate id from root commit
  67. if (!id) {
  68. const roots = await $`git rev-list --max-parents=0 --all`
  69. .quiet()
  70. .nothrow()
  71. .cwd(sandbox)
  72. .text()
  73. .then((x) =>
  74. x
  75. .split("\n")
  76. .filter(Boolean)
  77. .map((x) => x.trim())
  78. .toSorted(),
  79. )
  80. .catch(() => undefined)
  81. if (!roots) {
  82. return {
  83. id: "global",
  84. worktree: sandbox,
  85. sandbox: sandbox,
  86. vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
  87. }
  88. }
  89. id = roots[0]
  90. if (id) {
  91. void Bun.file(path.join(git, "opencode"))
  92. .write(id)
  93. .catch(() => undefined)
  94. }
  95. }
  96. if (!id) {
  97. return {
  98. id: "global",
  99. worktree: sandbox,
  100. sandbox: sandbox,
  101. vcs: "git",
  102. }
  103. }
  104. const top = await $`git rev-parse --show-toplevel`
  105. .quiet()
  106. .nothrow()
  107. .cwd(sandbox)
  108. .text()
  109. .then((x) => path.resolve(sandbox, x.trim()))
  110. .catch(() => undefined)
  111. if (!top) {
  112. return {
  113. id,
  114. sandbox,
  115. worktree: sandbox,
  116. vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
  117. }
  118. }
  119. sandbox = top
  120. const worktree = await $`git rev-parse --git-common-dir`
  121. .quiet()
  122. .nothrow()
  123. .cwd(sandbox)
  124. .text()
  125. .then((x) => {
  126. const dirname = path.dirname(x.trim())
  127. if (dirname === ".") return sandbox
  128. return dirname
  129. })
  130. .catch(() => undefined)
  131. if (!worktree) {
  132. return {
  133. id,
  134. sandbox,
  135. worktree: sandbox,
  136. vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
  137. }
  138. }
  139. return {
  140. id,
  141. sandbox,
  142. worktree,
  143. vcs: "git",
  144. }
  145. }
  146. return {
  147. id: "global",
  148. worktree: "/",
  149. sandbox: "/",
  150. vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
  151. }
  152. })
  153. let existing = await Storage.read<Info>(["project", id]).catch(() => undefined)
  154. if (!existing) {
  155. existing = {
  156. id,
  157. worktree,
  158. vcs: vcs as Info["vcs"],
  159. sandboxes: [],
  160. time: {
  161. created: Date.now(),
  162. updated: Date.now(),
  163. },
  164. }
  165. if (id !== "global") {
  166. await migrateFromGlobal(id, worktree)
  167. }
  168. }
  169. // migrate old projects before sandboxes
  170. if (!existing.sandboxes) existing.sandboxes = []
  171. if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
  172. const result: Info = {
  173. ...existing,
  174. worktree,
  175. vcs: vcs as Info["vcs"],
  176. time: {
  177. ...existing.time,
  178. updated: Date.now(),
  179. },
  180. }
  181. if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox)
  182. result.sandboxes = result.sandboxes.filter((x) => existsSync(x))
  183. await Storage.write<Info>(["project", id], result)
  184. GlobalBus.emit("event", {
  185. payload: {
  186. type: Event.Updated.type,
  187. properties: result,
  188. },
  189. })
  190. return { project: result, sandbox }
  191. }
  192. export async function discover(input: Info) {
  193. if (input.vcs !== "git") return
  194. if (input.icon?.url) return
  195. const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
  196. const matches = await Array.fromAsync(
  197. glob.scan({
  198. cwd: input.worktree,
  199. absolute: true,
  200. onlyFiles: true,
  201. followSymlinks: false,
  202. dot: false,
  203. }),
  204. )
  205. const shortest = matches.sort((a, b) => a.length - b.length)[0]
  206. if (!shortest) return
  207. const file = Bun.file(shortest)
  208. const buffer = await file.arrayBuffer()
  209. const base64 = Buffer.from(buffer).toString("base64")
  210. const mime = file.type || "image/png"
  211. const url = `data:${mime};base64,${base64}`
  212. await update({
  213. projectID: input.id,
  214. icon: {
  215. url,
  216. },
  217. })
  218. return
  219. }
  220. async function migrateFromGlobal(newProjectID: string, worktree: string) {
  221. const globalProject = await Storage.read<Info>(["project", "global"]).catch(() => undefined)
  222. if (!globalProject) return
  223. const globalSessions = await Storage.list(["session", "global"]).catch(() => [])
  224. if (globalSessions.length === 0) return
  225. log.info("migrating sessions from global", { newProjectID, worktree, count: globalSessions.length })
  226. await work(10, globalSessions, async (key) => {
  227. const sessionID = key[key.length - 1]
  228. const session = await Storage.read<Session.Info>(key).catch(() => undefined)
  229. if (!session) return
  230. if (session.directory && session.directory !== worktree) return
  231. session.projectID = newProjectID
  232. log.info("migrating session", { sessionID, from: "global", to: newProjectID })
  233. await Storage.write(["session", newProjectID, sessionID], session)
  234. await Storage.remove(key)
  235. }).catch((error) => {
  236. log.error("failed to migrate sessions from global to project", { error, projectId: newProjectID })
  237. })
  238. }
  239. export async function setInitialized(projectID: string) {
  240. await Storage.update<Info>(["project", projectID], (draft) => {
  241. draft.time.initialized = Date.now()
  242. })
  243. }
  244. export async function list() {
  245. const keys = await Storage.list(["project"])
  246. const projects = await Promise.all(keys.map((x) => Storage.read<Info>(x)))
  247. return projects.map((project) => ({
  248. ...project,
  249. sandboxes: project.sandboxes?.filter((x) => existsSync(x)),
  250. }))
  251. }
  252. export const update = fn(
  253. z.object({
  254. projectID: z.string(),
  255. name: z.string().optional(),
  256. icon: Info.shape.icon.optional(),
  257. }),
  258. async (input) => {
  259. const result = await Storage.update<Info>(["project", input.projectID], (draft) => {
  260. if (input.name !== undefined) draft.name = input.name
  261. if (input.icon !== undefined) {
  262. draft.icon = {
  263. ...draft.icon,
  264. }
  265. if (input.icon.url !== undefined) draft.icon.url = input.icon.url
  266. if (input.icon.color !== undefined) draft.icon.color = input.icon.color
  267. }
  268. draft.time.updated = Date.now()
  269. })
  270. GlobalBus.emit("event", {
  271. payload: {
  272. type: Event.Updated.type,
  273. properties: result,
  274. },
  275. })
  276. return result
  277. },
  278. )
  279. export async function sandboxes(projectID: string) {
  280. const project = await Storage.read<Info>(["project", projectID]).catch(() => undefined)
  281. if (!project?.sandboxes) return []
  282. const valid: string[] = []
  283. for (const dir of project.sandboxes) {
  284. const stat = await fs.stat(dir).catch(() => undefined)
  285. if (stat?.isDirectory()) valid.push(dir)
  286. }
  287. return valid
  288. }
  289. export async function removeSandbox(projectID: string, directory: string) {
  290. const result = await Storage.update<Info>(["project", projectID], (draft) => {
  291. const sandboxes = draft.sandboxes ?? []
  292. draft.sandboxes = sandboxes.filter((sandbox) => sandbox !== directory)
  293. draft.time.updated = Date.now()
  294. })
  295. GlobalBus.emit("event", {
  296. payload: {
  297. type: Event.Updated.type,
  298. properties: result,
  299. },
  300. })
  301. return result
  302. }
  303. }