| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 |
- import z from "zod"
- import { Filesystem } from "../util/filesystem"
- import path from "path"
- import { $ } from "bun"
- import { Storage } from "../storage/storage"
- import { Log } from "../util/log"
- import { Flag } from "@/flag/flag"
- import { Session } from "../session"
- import { work } from "../util/queue"
- import { fn } from "@opencode-ai/util/fn"
- import { BusEvent } from "@/bus/bus-event"
- import { iife } from "@/util/iife"
- import { GlobalBus } from "@/bus/global"
- export namespace Project {
- const log = Log.create({ service: "project" })
- export const Info = z
- .object({
- id: z.string(),
- worktree: z.string(),
- vcs: z.literal("git").optional(),
- name: z.string().optional(),
- icon: z
- .object({
- url: z.string().optional(),
- color: z.string().optional(),
- })
- .optional(),
- time: z.object({
- created: z.number(),
- updated: z.number(),
- initialized: z.number().optional(),
- }),
- })
- .meta({
- ref: "Project",
- })
- export type Info = z.infer<typeof Info>
- export const Event = {
- Updated: BusEvent.define("project.updated", Info),
- }
- export async function fromDirectory(directory: string) {
- log.info("fromDirectory", { directory })
- const { id, worktree, vcs } = await iife(async () => {
- const matches = Filesystem.up({ targets: [".git"], start: directory })
- const git = await matches.next().then((x) => x.value)
- await matches.return()
- if (git) {
- let worktree = path.dirname(git)
- let id = await Bun.file(path.join(git, "opencode"))
- .text()
- .then((x) => x.trim())
- .catch(() => {})
- if (!id) {
- const roots = await $`git rev-list --max-parents=0 --all`
- .quiet()
- .nothrow()
- .cwd(worktree)
- .text()
- .then((x) =>
- x
- .split("\n")
- .filter(Boolean)
- .map((x) => x.trim())
- .toSorted(),
- )
- id = roots[0]
- if (id) Bun.file(path.join(git, "opencode")).write(id)
- }
- if (!id)
- return {
- id: "global",
- worktree,
- vcs: "git",
- }
- worktree = await $`git rev-parse --show-toplevel`
- .quiet()
- .nothrow()
- .cwd(worktree)
- .text()
- .then((x) => path.resolve(worktree, x.trim()))
- return { id, worktree, vcs: "git" }
- }
- return {
- id: "global",
- worktree: "/",
- vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
- }
- })
- let existing = await Storage.read<Info>(["project", id]).catch(() => undefined)
- if (!existing) {
- existing = {
- id,
- worktree,
- vcs: vcs as Info["vcs"],
- time: {
- created: Date.now(),
- updated: Date.now(),
- },
- }
- if (id !== "global") {
- await migrateFromGlobal(id, worktree)
- }
- }
- discover(existing)
- const result: Info = {
- ...existing,
- worktree,
- vcs: vcs as Info["vcs"],
- time: {
- ...existing.time,
- updated: Date.now(),
- },
- }
- await Storage.write<Info>(["project", id], result)
- GlobalBus.emit("event", {
- payload: {
- type: Event.Updated.type,
- properties: result,
- },
- })
- return result
- }
- export async function discover(input: Info) {
- if (input.vcs !== "git") return
- if (input.icon) return
- const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
- for await (const match of glob.scan({
- cwd: input.worktree,
- absolute: true,
- onlyFiles: true,
- followSymlinks: false,
- dot: false,
- })) {
- const file = Bun.file(match)
- const buffer = await file.arrayBuffer()
- const base64 = Buffer.from(buffer).toString("base64")
- const mime = file.type || "image/png"
- const url = `data:${mime};base64,${base64}`
- await update({
- projectID: input.id,
- icon: {
- url,
- },
- })
- return
- }
- }
- async function migrateFromGlobal(newProjectID: string, worktree: string) {
- const globalProject = await Storage.read<Info>(["project", "global"]).catch(() => undefined)
- if (!globalProject) return
- const globalSessions = await Storage.list(["session", "global"]).catch(() => [])
- if (globalSessions.length === 0) return
- log.info("migrating sessions from global", { newProjectID, worktree, count: globalSessions.length })
- await work(10, globalSessions, async (key) => {
- const sessionID = key[key.length - 1]
- const session = await Storage.read<Session.Info>(key).catch(() => undefined)
- if (!session) return
- if (session.directory && session.directory !== worktree) return
- session.projectID = newProjectID
- log.info("migrating session", { sessionID, from: "global", to: newProjectID })
- await Storage.write(["session", newProjectID, sessionID], session)
- await Storage.remove(key)
- }).catch((error) => {
- log.error("failed to migrate sessions from global to project", { error, projectId: newProjectID })
- })
- }
- export async function setInitialized(projectID: string) {
- await Storage.update<Info>(["project", projectID], (draft) => {
- draft.time.initialized = Date.now()
- })
- }
- export async function list() {
- const keys = await Storage.list(["project"])
- return await Promise.all(keys.map((x) => Storage.read<Info>(x)))
- }
- export const update = fn(
- z.object({
- projectID: z.string(),
- name: z.string().optional(),
- icon: Info.shape.icon.optional(),
- }),
- async (input) => {
- return await Storage.update<Info>(["project", input.projectID], (draft) => {
- if (input.name !== undefined) draft.name = input.name
- if (input.icon !== undefined) draft.icon = input.icon
- draft.time.updated = Date.now()
- })
- },
- )
- }
|