project.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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. if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) 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?.url) return
  126. const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
  127. const matches = await Array.fromAsync(
  128. glob.scan({
  129. cwd: input.worktree,
  130. absolute: true,
  131. onlyFiles: true,
  132. followSymlinks: false,
  133. dot: false,
  134. }),
  135. )
  136. const shortest = matches.sort((a, b) => a.length - b.length)[0]
  137. if (!shortest) return
  138. const file = Bun.file(shortest)
  139. const buffer = await file.arrayBuffer()
  140. const base64 = Buffer.from(buffer).toString("base64")
  141. const mime = file.type || "image/png"
  142. const url = `data:${mime};base64,${base64}`
  143. await update({
  144. projectID: input.id,
  145. icon: {
  146. url,
  147. },
  148. })
  149. return
  150. }
  151. async function migrateFromGlobal(newProjectID: string, worktree: string) {
  152. const globalProject = await Storage.read<Info>(["project", "global"]).catch(() => undefined)
  153. if (!globalProject) return
  154. const globalSessions = await Storage.list(["session", "global"]).catch(() => [])
  155. if (globalSessions.length === 0) return
  156. log.info("migrating sessions from global", { newProjectID, worktree, count: globalSessions.length })
  157. await work(10, globalSessions, async (key) => {
  158. const sessionID = key[key.length - 1]
  159. const session = await Storage.read<Session.Info>(key).catch(() => undefined)
  160. if (!session) return
  161. if (session.directory && session.directory !== worktree) return
  162. session.projectID = newProjectID
  163. log.info("migrating session", { sessionID, from: "global", to: newProjectID })
  164. await Storage.write(["session", newProjectID, sessionID], session)
  165. await Storage.remove(key)
  166. }).catch((error) => {
  167. log.error("failed to migrate sessions from global to project", { error, projectId: newProjectID })
  168. })
  169. }
  170. export async function setInitialized(projectID: string) {
  171. await Storage.update<Info>(["project", projectID], (draft) => {
  172. draft.time.initialized = Date.now()
  173. })
  174. }
  175. export async function list() {
  176. const keys = await Storage.list(["project"])
  177. return await Promise.all(keys.map((x) => Storage.read<Info>(x)))
  178. }
  179. export const update = fn(
  180. z.object({
  181. projectID: z.string(),
  182. name: z.string().optional(),
  183. icon: Info.shape.icon.optional(),
  184. }),
  185. async (input) => {
  186. const result = await Storage.update<Info>(["project", input.projectID], (draft) => {
  187. if (input.name !== undefined) draft.name = input.name
  188. if (input.icon !== undefined) {
  189. draft.icon = {
  190. ...draft.icon,
  191. }
  192. if (input.icon.url !== undefined) draft.icon.url = input.icon.url
  193. if (input.icon.color !== undefined) draft.icon.color = input.icon.color
  194. }
  195. draft.time.updated = Date.now()
  196. })
  197. GlobalBus.emit("event", {
  198. payload: {
  199. type: Event.Updated.type,
  200. properties: result,
  201. },
  202. })
  203. return result
  204. },
  205. )
  206. }