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 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(["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(["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(["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(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(["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(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(["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() }) }, ) }