| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- import z from "zod"
- import fs from "fs/promises"
- 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"
- import { existsSync } from "fs"
- 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(),
- }),
- sandboxes: z.array(z.string()),
- })
- .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, sandbox, 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 sandbox = path.dirname(git)
- const gitBinary = Bun.which("git")
- // cached id calculation
- let id = await Bun.file(path.join(git, "opencode"))
- .text()
- .then((x) => x.trim())
- .catch(() => undefined)
- if (!gitBinary) {
- return {
- id: id ?? "global",
- worktree: sandbox,
- sandbox: sandbox,
- vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
- }
- }
- // generate id from root commit
- if (!id) {
- const roots = await $`git rev-list --max-parents=0 --all`
- .quiet()
- .nothrow()
- .cwd(sandbox)
- .text()
- .then((x) =>
- x
- .split("\n")
- .filter(Boolean)
- .map((x) => x.trim())
- .toSorted(),
- )
- .catch(() => undefined)
- if (!roots) {
- return {
- id: "global",
- worktree: sandbox,
- sandbox: sandbox,
- vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
- }
- }
- id = roots[0]
- if (id) {
- void Bun.file(path.join(git, "opencode"))
- .write(id)
- .catch(() => undefined)
- }
- }
- if (!id) {
- return {
- id: "global",
- worktree: sandbox,
- sandbox: sandbox,
- vcs: "git",
- }
- }
- const top = await $`git rev-parse --show-toplevel`
- .quiet()
- .nothrow()
- .cwd(sandbox)
- .text()
- .then((x) => path.resolve(sandbox, x.trim()))
- .catch(() => undefined)
- if (!top) {
- return {
- id,
- sandbox,
- worktree: sandbox,
- vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
- }
- }
- sandbox = top
- const worktree = await $`git rev-parse --git-common-dir`
- .quiet()
- .nothrow()
- .cwd(sandbox)
- .text()
- .then((x) => {
- const dirname = path.dirname(x.trim())
- if (dirname === ".") return sandbox
- return dirname
- })
- .catch(() => undefined)
- if (!worktree) {
- return {
- id,
- sandbox,
- worktree: sandbox,
- vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
- }
- }
- return {
- id,
- sandbox,
- worktree,
- vcs: "git",
- }
- }
- return {
- id: "global",
- worktree: "/",
- sandbox: "/",
- 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"],
- sandboxes: [],
- time: {
- created: Date.now(),
- updated: Date.now(),
- },
- }
- if (id !== "global") {
- await migrateFromGlobal(id, worktree)
- }
- }
- // migrate old projects before sandboxes
- if (!existing.sandboxes) existing.sandboxes = []
- if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
- const result: Info = {
- ...existing,
- worktree,
- vcs: vcs as Info["vcs"],
- time: {
- ...existing.time,
- updated: Date.now(),
- },
- }
- if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox)
- result.sandboxes = result.sandboxes.filter((x) => existsSync(x))
- await Storage.write<Info>(["project", id], result)
- GlobalBus.emit("event", {
- payload: {
- type: Event.Updated.type,
- properties: result,
- },
- })
- return { project: result, sandbox }
- }
- export async function discover(input: Info) {
- if (input.vcs !== "git") return
- if (input.icon?.url) return
- const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
- const matches = await Array.fromAsync(
- glob.scan({
- cwd: input.worktree,
- absolute: true,
- onlyFiles: true,
- followSymlinks: false,
- dot: false,
- }),
- )
- const shortest = matches.sort((a, b) => a.length - b.length)[0]
- if (!shortest) return
- const file = Bun.file(shortest)
- 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"])
- const projects = await Promise.all(keys.map((x) => Storage.read<Info>(x)))
- return projects.map((project) => ({
- ...project,
- sandboxes: project.sandboxes?.filter((x) => existsSync(x)),
- }))
- }
- export const update = fn(
- z.object({
- projectID: z.string(),
- name: z.string().optional(),
- icon: Info.shape.icon.optional(),
- }),
- async (input) => {
- const result = await Storage.update<Info>(["project", input.projectID], (draft) => {
- if (input.name !== undefined) draft.name = input.name
- if (input.icon !== undefined) {
- draft.icon = {
- ...draft.icon,
- }
- if (input.icon.url !== undefined) draft.icon.url = input.icon.url
- if (input.icon.color !== undefined) draft.icon.color = input.icon.color
- }
- draft.time.updated = Date.now()
- })
- GlobalBus.emit("event", {
- payload: {
- type: Event.Updated.type,
- properties: result,
- },
- })
- return result
- },
- )
- export async function sandboxes(projectID: string) {
- const project = await Storage.read<Info>(["project", projectID]).catch(() => undefined)
- if (!project?.sandboxes) return []
- const valid: string[] = []
- for (const dir of project.sandboxes) {
- const stat = await fs.stat(dir).catch(() => undefined)
- if (stat?.isDirectory()) valid.push(dir)
- }
- return valid
- }
- export async function removeSandbox(projectID: string, directory: string) {
- const result = await Storage.update<Info>(["project", projectID], (draft) => {
- const sandboxes = draft.sandboxes ?? []
- draft.sandboxes = sandboxes.filter((sandbox) => sandbox !== directory)
- draft.time.updated = Date.now()
- })
- GlobalBus.emit("event", {
- payload: {
- type: Event.Updated.type,
- properties: result,
- },
- })
- return result
- }
- }
|