Skip to content

Commit 7e9ab6e

Browse files
committed
fix: keep opened project metadata single-sourced
1 parent ff54e0b commit 7e9ab6e

7 files changed

Lines changed: 252 additions & 82 deletions

File tree

packages/app/src/context/opened-projects.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ export type ConfigProjectEntry = {
1515
icon?: {
1616
color?: string
1717
override?: string
18-
emoji?: string
1918
}
2019
commands?: {
2120
start?: string

packages/opencode/src/config/projects.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { withStatics } from "@/util/schema"
55
export const ProjectIcon = Schema.Struct({
66
override: Schema.optional(Schema.String),
77
color: Schema.optional(Schema.String),
8-
emoji: Schema.optional(Schema.String),
98
})
109

1110
export const ProjectCommands = Schema.Struct({

packages/opencode/src/server/routes/global.ts

Lines changed: 38 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import * as Log from "@opencode-ai/core/util/log"
1515
import { lazy } from "../../util/lazy"
1616
import { Config } from "@/config/config"
1717
import { ConfigProjects } from "@/config/projects"
18+
import { Project } from "@/project/project"
1819
import { errors } from "../error"
1920
import { Event as ServerEvent } from "../event"
2021
import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle"
22+
import { OpenedProjects } from "../shared/opened-projects"
2123

2224
const log = Log.create({ service: "server" })
2325

@@ -219,8 +221,14 @@ export const GlobalRoutes = lazy(() =>
219221
},
220222
}),
221223
async (c) => {
222-
const cfg = await AppRuntime.runPromise(Config.Service.use((svc) => svc.getGlobal()))
223-
return c.json(cfg.projects ?? [])
224+
const projects = await AppRuntime.runPromise(
225+
Effect.gen(function* () {
226+
const config = yield* Config.Service
227+
const project = yield* Project.Service
228+
return yield* OpenedProjects.list(config, project)
229+
}),
230+
)
231+
return c.json(projects)
224232
},
225233
)
226234
.post(
@@ -244,17 +252,13 @@ export const GlobalRoutes = lazy(() =>
244252
async (c) => {
245253
const input = c.req.valid("json")
246254
const next = await AppRuntime.runPromise(
247-
Config.Service.use((svc) =>
248-
Effect.gen(function* () {
249-
const cfg = yield* svc.getGlobal()
250-
const projects = cfg.projects ?? []
251-
if (projects.some((project) => project.worktree === input.worktree)) return projects
252-
const next = [{ worktree: input.worktree }, ...projects]
253-
yield* svc.updateGlobal({ ...cfg, projects: next })
254-
yield* Effect.sync(emitOpenedProjectsUpdated)
255-
return next
256-
}),
257-
),
255+
Effect.gen(function* () {
256+
const config = yield* Config.Service
257+
const project = yield* Project.Service
258+
const next = yield* OpenedProjects.open(config, project, input.worktree)
259+
yield* Effect.sync(emitOpenedProjectsUpdated)
260+
return next
261+
}),
258262
)
259263
return c.json(next)
260264
},
@@ -280,15 +284,13 @@ export const GlobalRoutes = lazy(() =>
280284
async (c) => {
281285
const input = c.req.valid("json")
282286
const next = await AppRuntime.runPromise(
283-
Config.Service.use((svc) =>
284-
Effect.gen(function* () {
285-
const cfg = yield* svc.getGlobal()
286-
const next = (cfg.projects ?? []).filter((project) => project.worktree !== input.worktree)
287-
yield* svc.updateGlobal({ ...cfg, projects: next })
288-
yield* Effect.sync(emitOpenedProjectsUpdated)
289-
return next
290-
}),
291-
),
287+
Effect.gen(function* () {
288+
const config = yield* Config.Service
289+
const project = yield* Project.Service
290+
const next = yield* OpenedProjects.close(config, project, input.worktree)
291+
yield* Effect.sync(emitOpenedProjectsUpdated)
292+
return next
293+
}),
292294
)
293295
return c.json(next)
294296
},
@@ -319,7 +321,6 @@ export const GlobalRoutes = lazy(() =>
319321
.object({
320322
color: z.string().optional(),
321323
override: z.string().optional(),
322-
emoji: z.string().optional(),
323324
})
324325
.optional(),
325326
commands: z
@@ -332,24 +333,13 @@ export const GlobalRoutes = lazy(() =>
332333
async (c) => {
333334
const input = c.req.valid("json")
334335
const next = await AppRuntime.runPromise(
335-
Config.Service.use((svc) =>
336-
Effect.gen(function* () {
337-
const cfg = yield* svc.getGlobal()
338-
const projects = cfg.projects ?? []
339-
const next = projects.map((project) => {
340-
if (project.worktree !== input.worktree) return project
341-
return {
342-
...project,
343-
...(input.name !== undefined ? { name: input.name } : {}),
344-
...(input.icon !== undefined ? { icon: { ...project.icon, ...input.icon } } : {}),
345-
...(input.commands !== undefined ? { commands: { ...project.commands, ...input.commands } } : {}),
346-
}
347-
})
348-
yield* svc.updateGlobal({ ...cfg, projects: next })
349-
yield* Effect.sync(emitOpenedProjectsUpdated)
350-
return next
351-
}),
352-
),
336+
Effect.gen(function* () {
337+
const config = yield* Config.Service
338+
const project = yield* Project.Service
339+
const next = yield* OpenedProjects.meta(config, project, input)
340+
yield* Effect.sync(emitOpenedProjectsUpdated)
341+
return next
342+
}),
353343
)
354344
return c.json(next)
355345
},
@@ -375,14 +365,13 @@ export const GlobalRoutes = lazy(() =>
375365
async (c) => {
376366
const input = c.req.valid("json")
377367
const next = await AppRuntime.runPromise(
378-
Config.Service.use((svc) =>
379-
Effect.gen(function* () {
380-
const cfg = yield* svc.getGlobal()
381-
yield* svc.updateGlobal({ ...cfg, projects: input.projects })
382-
yield* Effect.sync(emitOpenedProjectsUpdated)
383-
return input.projects
384-
}),
385-
),
368+
Effect.gen(function* () {
369+
const config = yield* Config.Service
370+
const project = yield* Project.Service
371+
const next = yield* OpenedProjects.reorder(config, project, input.projects)
372+
yield* Effect.sync(emitOpenedProjectsUpdated)
373+
return next
374+
}),
386375
)
387376
return c.json(next)
388377
},

packages/opencode/src/server/routes/instance/httpapi/groups/global.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ const OpenedMetaPayload = Schema.Struct({
1616
Schema.Struct({
1717
color: Schema.optional(Schema.String),
1818
override: Schema.optional(Schema.String),
19-
emoji: Schema.optional(Schema.String),
2019
}),
2120
),
2221
commands: Schema.optional(

packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { Config } from "@/config/config"
22
import type { Project as ConfigProject } from "@/config/projects"
3+
import { Project } from "@/project/project"
34
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
45
import { EffectBridge } from "@/effect/bridge"
56
import { Bus } from "@/bus"
67
import { Installation } from "@/installation"
78
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
89
import { Event as ServerEvent } from "@/server/event"
10+
import { OpenedProjects } from "@/server/shared/opened-projects"
911
import { InstallationVersion } from "@opencode-ai/core/installation/version"
1012
import * as Log from "@opencode-ai/core/util/log"
1113
import { Effect, Queue, Schema } from "effect"
@@ -82,6 +84,7 @@ function eventResponse() {
8284
export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handlers) =>
8385
Effect.gen(function* () {
8486
const config = yield* Config.Service
87+
const projects = yield* Project.Service
8588
const installation = yield* Installation.Service
8689
const bridge = yield* EffectBridge.make()
8790

@@ -169,26 +172,17 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
169172
})
170173

171174
const openedList = Effect.fn("GlobalHttpApi.openedList")(function* () {
172-
const cfg = yield* config.getGlobal()
173-
return cfg.projects ?? []
175+
return yield* OpenedProjects.list(config, projects)
174176
})
175177

176178
const openedOpen = Effect.fn("GlobalHttpApi.openedOpen")(function* (ctx: { payload: { worktree: string } }) {
177-
const { worktree } = ctx.payload
178-
const cfg = yield* config.getGlobal()
179-
const projects = cfg.projects ?? []
180-
if (projects.some((p) => p.worktree === worktree)) return projects
181-
const next = [{ worktree }, ...projects]
182-
yield* config.updateGlobal({ ...cfg, projects: next })
179+
const next = yield* OpenedProjects.open(config, projects, ctx.payload.worktree)
183180
yield* Effect.sync(emitOpenedProjectsUpdated)
184181
return next
185182
})
186183

187184
const openedClose = Effect.fn("GlobalHttpApi.openedClose")(function* (ctx: { payload: { worktree: string } }) {
188-
const { worktree } = ctx.payload
189-
const cfg = yield* config.getGlobal()
190-
const next = (cfg.projects ?? []).filter((p) => p.worktree !== worktree)
191-
yield* config.updateGlobal({ ...cfg, projects: next })
185+
const next = yield* OpenedProjects.close(config, projects, ctx.payload.worktree)
192186
yield* Effect.sync(emitOpenedProjectsUpdated)
193187
return next
194188
})
@@ -197,32 +191,21 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
197191
payload: {
198192
worktree: string
199193
name?: string
200-
icon?: { color?: string; override?: string; emoji?: string }
194+
icon?: { color?: string; override?: string }
201195
commands?: { start?: string }
202196
}
203197
}) {
204-
const { worktree, ...patch } = ctx.payload
205-
const cfg = yield* config.getGlobal()
206-
const projects = cfg.projects ?? []
207-
const idx = projects.findIndex((p) => p.worktree === worktree)
208-
if (idx === -1) return projects
209-
const existing = projects[idx]
210-
const updated = {
211-
...existing,
212-
...(patch.name !== undefined ? { name: patch.name } : {}),
213-
...(patch.commands !== undefined ? { commands: { ...existing.commands, ...patch.commands } } : {}),
214-
...(patch.icon !== undefined ? { icon: { ...existing.icon, ...patch.icon } } : {}),
215-
}
216-
const next = projects.map((p, i) => (i === idx ? updated : p))
217-
yield* config.updateGlobal({ ...cfg, projects: next })
198+
const next = yield* OpenedProjects.meta(config, projects, ctx.payload)
218199
yield* Effect.sync(emitOpenedProjectsUpdated)
219200
return next
220201
})
221202

222203
const openedReorder = Effect.fn("GlobalHttpApi.openedReorder")(function* (ctx) {
223-
const cfg = yield* config.getGlobal()
224-
const next = (ctx as { payload: { projects: ConfigProject[] } }).payload.projects
225-
yield* config.updateGlobal({ ...cfg, projects: next })
204+
const next = yield* OpenedProjects.reorder(
205+
config,
206+
projects,
207+
(ctx as { payload: { projects: ConfigProject[] } }).payload.projects,
208+
)
226209
yield* Effect.sync(emitOpenedProjectsUpdated)
227210
return next
228211
})
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type { Config } from "@/config/config"
2+
import type { Project as ConfigProject } from "@/config/projects"
3+
import { Project } from "@/project/project"
4+
import { ProjectID } from "@/project/schema"
5+
import { ProjectTable } from "@/project/project.sql"
6+
import { Database } from "@/storage/db"
7+
import { Effect } from "effect"
8+
9+
const db = <T>(fn: Parameters<typeof Database.use<T>>[0]) => Effect.sync(() => Database.use(fn))
10+
11+
function openedOnly(projects: ConfigProject[]) {
12+
return projects.map((project) => ({ worktree: project.worktree }))
13+
}
14+
15+
function findProject(projects: Project.Info[], worktree: string) {
16+
return projects.find((project) => project.worktree === worktree || project.sandboxes.includes(worktree))
17+
}
18+
19+
function fromProject(worktree: string, project?: Project.Info): ConfigProject {
20+
return {
21+
worktree,
22+
...(project?.name !== undefined ? { name: project.name } : {}),
23+
...(project?.icon ? { icon: { color: project.icon.color, override: project.icon.override } } : {}),
24+
...(project?.commands ? { commands: project.commands } : {}),
25+
}
26+
}
27+
28+
function projectID(worktree: string) {
29+
return ProjectID.make(`worktree:${worktree}`)
30+
}
31+
32+
const ensureProject = Effect.fn("OpenedProjects.ensureProject")(function* (
33+
project: Project.Interface,
34+
worktree: string,
35+
) {
36+
const existing = findProject(yield* project.list(), worktree)
37+
if (existing) return existing
38+
39+
const now = Date.now()
40+
const result = yield* db((tx) =>
41+
tx
42+
.insert(ProjectTable)
43+
.values({
44+
id: projectID(worktree),
45+
worktree,
46+
vcs: null,
47+
time_created: now,
48+
time_updated: now,
49+
time_initialized: null,
50+
sandboxes: [],
51+
})
52+
.onConflictDoUpdate({
53+
target: ProjectTable.id,
54+
set: {
55+
worktree,
56+
time_updated: now,
57+
},
58+
})
59+
.returning()
60+
.get(),
61+
)
62+
return Project.fromRow(result)
63+
})
64+
65+
const list = Effect.fn("OpenedProjects.list")(function* (config: Config.Interface, project: Project.Interface) {
66+
const cfg = yield* config.getGlobal()
67+
const opened = openedOnly(cfg.projects ?? [])
68+
const dbProjects = yield* project.list()
69+
return opened.map((entry) => fromProject(entry.worktree, findProject(dbProjects, entry.worktree)))
70+
})
71+
72+
const open = Effect.fn("OpenedProjects.open")(function* (
73+
config: Config.Interface,
74+
project: Project.Interface,
75+
worktree: string,
76+
) {
77+
const cfg = yield* config.getGlobal()
78+
const opened = openedOnly(cfg.projects ?? [])
79+
if (opened.some((entry) => entry.worktree === worktree)) return yield* list(config, project)
80+
yield* config.updateGlobal({ ...cfg, projects: [{ worktree }, ...opened] })
81+
return yield* list(config, project)
82+
})
83+
84+
const close = Effect.fn("OpenedProjects.close")(function* (
85+
config: Config.Interface,
86+
project: Project.Interface,
87+
worktree: string,
88+
) {
89+
const cfg = yield* config.getGlobal()
90+
yield* config.updateGlobal({
91+
...cfg,
92+
projects: openedOnly(cfg.projects ?? []).filter((entry) => entry.worktree !== worktree),
93+
})
94+
return yield* list(config, project)
95+
})
96+
97+
const meta = Effect.fn("OpenedProjects.meta")(function* (
98+
config: Config.Interface,
99+
project: Project.Interface,
100+
input: {
101+
worktree: string
102+
name?: string
103+
icon?: { color?: string; override?: string }
104+
commands?: { start?: string }
105+
},
106+
) {
107+
const cfg = yield* config.getGlobal()
108+
if (!(cfg.projects ?? []).some((entry) => entry.worktree === input.worktree)) return yield* list(config, project)
109+
110+
const existing = yield* ensureProject(project, input.worktree)
111+
112+
yield* project.update({
113+
projectID: existing.id,
114+
name: input.name ?? existing.name,
115+
icon: input.icon ? { ...existing.icon, ...input.icon } : existing.icon,
116+
commands: input.commands ? { ...existing.commands, ...input.commands } : existing.commands,
117+
})
118+
return yield* list(config, project)
119+
})
120+
121+
const reorder = Effect.fn("OpenedProjects.reorder")(function* (
122+
config: Config.Interface,
123+
project: Project.Interface,
124+
projects: ConfigProject[],
125+
) {
126+
const cfg = yield* config.getGlobal()
127+
yield* config.updateGlobal({ ...cfg, projects: openedOnly(projects) })
128+
return yield* list(config, project)
129+
})
130+
131+
export const OpenedProjects = {
132+
list,
133+
open,
134+
close,
135+
meta,
136+
reorder,
137+
}

0 commit comments

Comments
 (0)