project.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import z from "zod"
  2. import { Filesystem } from "../util/filesystem"
  3. import path from "path"
  4. import { $ } from "bun"
  5. import { Storage } from "../storage/storage"
  6. import { Log } from "../util/log"
  7. import { Flag } from "@/flag/flag"
  8. import { Session } from "../session"
  9. import { work } from "../util/queue"
  10. import { fn } from "@opencode-ai/util/fn"
  11. import { BusEvent } from "@/bus/bus-event"
  12. import { iife } from "@/util/iife"
  13. import { GlobalBus } from "@/bus/global"
  14. export namespace Project {
  15. const log = Log.create({ service: "project" })
  16. export const Info = z
  17. .object({
  18. id: z.string(),
  19. worktree: z.string(),
  20. vcs: z.literal("git").optional(),
  21. name: z.string().optional(),
  22. icon: z
  23. .object({
  24. url: z.string().optional(),
  25. color: z.string().optional(),
  26. })
  27. .optional(),
  28. time: z.object({
  29. created: z.number(),
  30. updated: z.number(),
  31. initialized: z.number().optional(),
  32. }),
  33. })
  34. .meta({
  35. ref: "Project",
  36. })
  37. export type Info = z.infer<typeof Info>
  38. export const Event = {
  39. Updated: BusEvent.define("project.updated", Info),
  40. }
  41. export async function fromDirectory(directory: string) {
  42. log.info("fromDirectory", { directory })
  43. const { id, worktree, vcs } = await iife(async () => {
  44. const matches = Filesystem.up({ targets: [".git"], start: directory })
  45. const git = await matches.next().then((x) => x.value)
  46. await matches.return()
  47. if (git) {
  48. let worktree = path.dirname(git)
  49. let id = await Bun.file(path.join(git, "opencode"))
  50. .text()
  51. .then((x) => x.trim())
  52. .catch(() => {})
  53. if (!id) {
  54. const roots = await $`git rev-list --max-parents=0 --all`
  55. .quiet()
  56. .nothrow()
  57. .cwd(worktree)
  58. .text()
  59. .then((x) =>
  60. x
  61. .split("\n")
  62. .filter(Boolean)
  63. .map((x) => x.trim())
  64. .toSorted(),
  65. )
  66. id = roots[0]
  67. if (id) Bun.file(path.join(git, "opencode")).write(id)
  68. }
  69. if (!id)
  70. return {
  71. id: "global",
  72. worktree,
  73. vcs: "git",
  74. }
  75. worktree = await $`git rev-parse --show-toplevel`
  76. .quiet()
  77. .nothrow()
  78. .cwd(worktree)
  79. .text()
  80. .then((x) => path.resolve(worktree, x.trim()))
  81. return { id, worktree, vcs: "git" }
  82. }
  83. return {
  84. id: "global",
  85. worktree: "/",
  86. vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
  87. }
  88. })
  89. let existing = await Storage.read<Info>(["project", id]).catch(() => undefined)
  90. if (!existing) {
  91. existing = {
  92. id,
  93. worktree,
  94. vcs: vcs as Info["vcs"],
  95. time: {
  96. created: Date.now(),
  97. updated: Date.now(),
  98. },
  99. }
  100. if (id !== "global") {
  101. await migrateFromGlobal(id, worktree)
  102. }
  103. }
  104. discover(existing)
  105. const result: Info = {
  106. ...existing,
  107. worktree,
  108. vcs: vcs as Info["vcs"],
  109. time: {
  110. ...existing.time,
  111. updated: Date.now(),
  112. },
  113. }
  114. await Storage.write<Info>(["project", id], result)
  115. GlobalBus.emit("event", {
  116. payload: {
  117. type: Event.Updated.type,
  118. properties: result,
  119. },
  120. })
  121. return result
  122. }
  123. export async function discover(input: Info) {
  124. if (input.vcs !== "git") return
  125. if (input.icon) return
  126. const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
  127. for await (const match of glob.scan({
  128. cwd: input.worktree,
  129. absolute: true,
  130. onlyFiles: true,
  131. followSymlinks: false,
  132. dot: false,
  133. })) {
  134. const file = Bun.file(match)
  135. const buffer = await file.arrayBuffer()
  136. const base64 = Buffer.from(buffer).toString("base64")
  137. const mime = file.type || "image/png"
  138. const url = `data:${mime};base64,${base64}`
  139. await update({
  140. projectID: input.id,
  141. icon: {
  142. url,
  143. },
  144. })
  145. return
  146. }
  147. }
  148. async function migrateFromGlobal(newProjectID: string, worktree: string) {
  149. const globalProject = await Storage.read<Info>(["project", "global"]).catch(() => undefined)
  150. if (!globalProject) return
  151. const globalSessions = await Storage.list(["session", "global"]).catch(() => [])
  152. if (globalSessions.length === 0) return
  153. log.info("migrating sessions from global", { newProjectID, worktree, count: globalSessions.length })
  154. await work(10, globalSessions, async (key) => {
  155. const sessionID = key[key.length - 1]
  156. const session = await Storage.read<Session.Info>(key).catch(() => undefined)
  157. if (!session) return
  158. if (session.directory && session.directory !== worktree) return
  159. session.projectID = newProjectID
  160. log.info("migrating session", { sessionID, from: "global", to: newProjectID })
  161. await Storage.write(["session", newProjectID, sessionID], session)
  162. await Storage.remove(key)
  163. }).catch((error) => {
  164. log.error("failed to migrate sessions from global to project", { error, projectId: newProjectID })
  165. })
  166. }
  167. export async function setInitialized(projectID: string) {
  168. await Storage.update<Info>(["project", projectID], (draft) => {
  169. draft.time.initialized = Date.now()
  170. })
  171. }
  172. export async function list() {
  173. const keys = await Storage.list(["project"])
  174. return await Promise.all(keys.map((x) => Storage.read<Info>(x)))
  175. }
  176. export const update = fn(
  177. z.object({
  178. projectID: z.string(),
  179. name: z.string().optional(),
  180. icon: Info.shape.icon.optional(),
  181. }),
  182. async (input) => {
  183. return await Storage.update<Info>(["project", input.projectID], (draft) => {
  184. if (input.name !== undefined) draft.name = input.name
  185. if (input.icon !== undefined) draft.icon = input.icon
  186. draft.time.updated = Date.now()
  187. })
  188. },
  189. )
  190. }