feat(core): order projects by recent activity (#50790)

This commit is contained in:
Dax
2026-09-23 01:59:32 +00:00
committed by GitHub
parent bab26d63ea
commit 53179daefa
22 changed files with 180 additions and 40 deletions
@@ -93,7 +93,7 @@ describe("normalizeProviderList", () => {
describe("normalizeProjectInfo", () => {
test("keeps the project VCS backend", () => {
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1, active: 1 }, sandboxes: [] }
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
@@ -124,7 +124,7 @@ describe("updateProjectInfo", () => {
canonical: "/repo",
name: "Repo",
icon: { color: "purple" },
time: { created: 1, updated: 2 },
time: { created: 1, updated: 2, active: 2 },
sandboxes: ["/repo-sandbox"],
} satisfies Project
@@ -24,7 +24,7 @@ test("settings project inventory reads metadata without acquiring directory stor
name: `Project ${index}`,
icon: { color: "orange" },
commands: { start: "bun install" },
time: { created: 1, updated: 1 },
time: { created: 1, updated: 1, active: 1 },
sandboxes: [],
worktrees: [],
}))
@@ -107,7 +107,7 @@ describe("withWorktreeInventory", () => {
id: "project",
canonical: "/repo",
name: "Before",
time: { created: 1, updated: 1 },
time: { created: 1, updated: 1, active: 1 },
sandboxes: [],
}
@@ -296,7 +296,7 @@ export type ProjectIcon = { url?: string; override?: string; color?: string }
export type ProjectCommands = { start?: string }
export type ProjectTime = { created: number; updated: number }
export type ProjectTime = { created: number; updated: number; active: number }
export type PermissionSource = { type: "tool"; messageID: string; id: string }
+1 -1
View File
@@ -1788,7 +1788,7 @@ export function createData(config: CreateDataInput) {
},
project: {
list() {
return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated)
return Object.values(store.project.info).toSorted((a, b) => b.time.active - a.time.active)
},
get(projectID: string) {
return store.project.info[projectID]
+12 -2
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"id": "e98619ef-c334-45e3-8c48-9932ed1ef290",
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
"ddl": [
{
"name": "account_state",
@@ -700,6 +700,16 @@
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "time_active",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
+2
View File
@@ -46,6 +46,7 @@ import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
import m47 from "./migration/20260923013825_project_time_active.js"
export const migrations = [
m00,
@@ -95,4 +96,5 @@ export const migrations = [
m44,
m45,
m46,
m47,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260923013825_project_time_active",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`project\` ADD \`time_active\` integer DEFAULT 0 NOT NULL;`)
yield* tx.run(`UPDATE \`project\` SET \`time_active\` = \`time_updated\`;`)
})
},
}
export default migration
+1
View File
@@ -110,6 +110,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_initialized\` integer,
\`time_active\` integer DEFAULT 0 NOT NULL,
\`sandboxes\` text NOT NULL,
\`commands\` text
);
@@ -523,8 +523,8 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, time_active, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
@@ -753,11 +753,11 @@ function importNextDatabase(
yield* tx.run(sql`
INSERT OR IGNORE INTO project (
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
time_created, time_updated, time_initialized, sandboxes, commands
time_created, time_updated, time_initialized, time_active, sandboxes, commands
) VALUES (
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
${project.time_initialized}, ${project.time_updated}, ${project.sandboxes}, ${project.commands}
)
`)
const existing = yield* tx
+18 -2
View File
@@ -1,13 +1,18 @@
export * as LocationLifecycle from "./location-lifecycle.js"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { LocationEvent } from "@opencode/schema/location-event"
import { Bus } from "./bus.js"
import { Form } from "./form.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { Permission } from "./permission.js"
import { Project } from "./project.js"
import { Rpc } from "./rpc.js"
import { SessionEvent } from "./session/event.js"
const isSessionEvent = Schema.is(SessionEvent.Durable)
export class Service extends Context.Service<
Service,
@@ -22,6 +27,17 @@ const layer = Layer.effect(
const permission = yield* Permission.Service
const forms = yield* Form.Service
const rpc = yield* Rpc.Service
const project = yield* Project.Service
const scope = yield* Effect.scope
yield* project.activate(location.project.id)
const unsubscribe = yield* bus.listen((event) => {
if (!isSessionEvent(event) || !event.location) return Effect.void
const ref = LocationServiceMap.canonical(event.location)
if (ref.directory !== location.directory || ref.workspaceID !== location.workspaceID) return Effect.void
// Listeners run inside publish; keep the write and its Project event off that path.
return project.activate(location.project.id).pipe(Effect.forkIn(scope), Effect.asVoid)
})
yield* Effect.addFinalizer(() => unsubscribe)
let closed = false
const shutdown = yield* Effect.cached(
Effect.gen(function* () {
@@ -48,5 +64,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Permission.node, Form.node, Rpc.node],
deps: [Bus.node, Location.node, Permission.node, Project.node, Form.node, Rpc.node],
})
+24 -3
View File
@@ -2,7 +2,7 @@ export * as Project from "./project.js"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { and, asc, desc, eq, gte, isNull, lte } from "drizzle-orm"
import { and, asc, desc, eq, gte, isNull, lte, sql } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { Bus } from "./bus.js"
@@ -57,9 +57,13 @@ export const root = Effect.fn("Project.root")(function* (
)
})
const ACTIVATE_INTERVAL = 60_000
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
/** Records Project activity for recency ordering, at most once per minute per Project. */
readonly activate: (projectID: ID) => Effect.Effect<void>
/** Resolves and persists the owning Project. */
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
}
@@ -85,6 +89,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info {
time: {
created: row.time_created,
updated: row.time_updated,
active: row.time_active,
},
sandboxes: row.sandboxes,
}
@@ -201,7 +206,7 @@ const layer = Layer.effect(
const rows = yield* db
.select()
.from(ProjectTable)
.orderBy(desc(ProjectTable.time_updated), asc(ProjectTable.id))
.orderBy(desc(ProjectTable.time_active), asc(ProjectTable.id))
.all()
.pipe(Effect.orDie)
return rows.map(fromRow)
@@ -233,6 +238,22 @@ const layer = Layer.effect(
return project
})
const activated = new Map<ID, number>()
const activate = Effect.fn("Project.activate")(function* (projectID: ID) {
const now = Date.now()
if (now - (activated.get(projectID) ?? 0) < ACTIVATE_INTERVAL) return
activated.set(projectID, now)
const row = yield* db
.update(ProjectTable)
// Activity is not a metadata edit, so keep time_updated out of the implicit bump.
.set({ time_active: now, time_updated: sql`${ProjectTable.time_updated}` })
.where(eq(ProjectTable.id, projectID))
.returning()
.get()
.pipe(Effect.orDie)
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@@ -361,7 +382,7 @@ const layer = Layer.effect(
})
})
return Service.of({ list, update, resolve })
return Service.of({ list, update, activate, resolve })
}),
)
+4
View File
@@ -19,6 +19,10 @@ export const ProjectTable = sqliteTable("project", {
icon_color: text(),
...Timestamps,
time_initialized: integer(),
time_active: integer()
.notNull()
.default(0)
.$defaultFn(() => Date.now()),
sandboxes: absoluteArrayColumn().notNull(),
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})
@@ -100,6 +100,7 @@ describe("node build", () => {
return Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
}),
+1
View File
@@ -13,6 +13,7 @@ export const globalProjectNode = makeGlobalNode({
return Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
resolve: (directory) => {
const project = { id: Project.ID.global, directory, canonical: directory }
return upsertProject(database.db, project).pipe(Effect.orDie, Effect.as(project))
+1
View File
@@ -14,6 +14,7 @@ const projectLayer = Layer.succeed(
Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
activate: () => Effect.void,
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
+35 -9
View File
@@ -18,7 +18,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node])))
describe("Project.list", () => {
it.effect("returns complete projects ordered by recent update", () =>
it.effect("returns complete projects ordered by recent activity", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const project = yield* Project.Service
@@ -35,6 +35,7 @@ describe("Project.list", () => {
sandboxes: [abs("/older/sandbox")],
time_created: 1,
time_updated: 1,
time_active: 4,
},
{
id: Project.ID.make("newer"),
@@ -43,17 +44,12 @@ describe("Project.list", () => {
time_created: 2,
time_updated: 2,
time_initialized: 3,
time_active: 3,
},
])
.run()
expect(yield* project.list()).toEqual([
{
id: Project.ID.make("newer"),
canonical: abs("/newer"),
time: { created: 2, updated: 2 },
sandboxes: [],
},
{
id: Project.ID.make("older"),
canonical: abs("/older"),
@@ -61,9 +57,15 @@ describe("Project.list", () => {
name: "Older",
icon: { color: "#000000" },
commands: { start: "bun dev" },
time: { created: 1, updated: 1 },
time: { created: 1, updated: 1, active: 4 },
sandboxes: [abs("/older/sandbox")],
},
{
id: Project.ID.make("newer"),
canonical: abs("/newer"),
time: { created: 2, updated: 2, active: 3 },
sandboxes: [],
},
])
}),
)
@@ -83,6 +85,7 @@ describe("Project.update", () => {
sandboxes: [],
time_created: 1,
time_updated: 1,
time_active: 1,
})
.run()
@@ -111,13 +114,36 @@ describe("Project.update", () => {
expect((yield* project.list())[0]).toEqual({
id,
canonical: abs("/update"),
time: { created: 1, updated: expect.any(Number) },
time: { created: 1, updated: expect.any(Number), active: 1 },
sandboxes: [],
})
}),
)
})
describe("Project.activate", () => {
it.effect("records activity without editing metadata time and throttles repeats", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const project = yield* Project.Service
const id = Project.ID.make("activate")
yield* db
.insert(ProjectTable)
.values({ id, worktree: abs("/activate"), sandboxes: [], time_created: 1, time_updated: 1, time_active: 1 })
.run()
yield* project.activate(id)
const first = (yield* project.list())[0]
expect(first?.time.updated).toBe(1)
expect(first?.time.active).toBeGreaterThan(1)
yield* db.update(ProjectTable).set({ time_active: 2, time_updated: 1 }).run()
yield* project.activate(id)
expect((yield* project.list())[0]?.time).toEqual({ created: 1, updated: 1, active: 2 })
}),
)
})
function remoteID(remote: string) {
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
}
+15 -1
View File
@@ -1556,6 +1556,16 @@
}
]
},
"metadata": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Metadata"
},
{
"type": "null"
}
]
},
"permissions": {
"anyOf": [
{
@@ -16308,9 +16318,13 @@
"updated": {
"type": "integer",
"minimum": 0
},
"active": {
"type": "integer",
"minimum": 0
}
},
"required": ["created", "updated"],
"required": ["created", "updated", "active"],
"additionalProperties": false
},
"Project.Vcs": {
+1
View File
@@ -32,6 +32,7 @@ export interface Commands extends Schema.Schema.Type<typeof Commands> {}
export const Time = Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
active: NonNegativeInt,
}).annotate({ identifier: "Project.Time" })
export interface Time extends Schema.Schema.Type<typeof Time> {}
+11 -11
View File
@@ -153,7 +153,7 @@ test("syncs VCS info and applies branch updates", async () => {
}
})
test("proactively syncs project metadata newest first", async () => {
test("proactively syncs project metadata most recently active first", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== "/api/project") return
@@ -162,14 +162,14 @@ test("proactively syncs project metadata newest first", async () => {
id: "proj_old",
canonical: "/old/project",
name: "Old project",
time: { created: 1, updated: 1 },
time: { created: 1, updated: 1, active: 3 },
sandboxes: [],
},
{
id: "proj_test",
canonical: worktree,
name: "OpenCode",
time: { created: 1, updated: 2 },
time: { created: 1, updated: 2, active: 2 },
sandboxes: [],
},
])
@@ -196,18 +196,18 @@ test("proactively syncs project metadata newest first", async () => {
try {
await wait(() => data.project.get("proj_test") !== undefined)
expect(data.project.list()).toEqual([
{
id: "proj_test",
canonical: worktree,
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_old",
canonical: "/old/project",
name: "Old project",
time: { created: 1, updated: 1 },
time: { created: 1, updated: 1, active: 3 },
sandboxes: [],
},
{
id: "proj_test",
canonical: worktree,
name: "OpenCode",
time: { created: 1, updated: 2, active: 2 },
sandboxes: [],
},
])
+15 -1
View File
@@ -1556,6 +1556,16 @@
}
]
},
"metadata": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Metadata"
},
{
"type": "null"
}
]
},
"permissions": {
"anyOf": [
{
@@ -16308,9 +16318,13 @@
"updated": {
"type": "integer",
"minimum": 0
},
"active": {
"type": "integer",
"minimum": 0
}
},
"required": ["created", "updated"],
"required": ["created", "updated", "active"],
"additionalProperties": false
},
"Project.Vcs": {
+15 -1
View File
@@ -1556,6 +1556,16 @@
}
]
},
"metadata": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Metadata"
},
{
"type": "null"
}
]
},
"permissions": {
"anyOf": [
{
@@ -16308,9 +16318,13 @@
"updated": {
"type": "integer",
"minimum": 0
},
"active": {
"type": "integer",
"minimum": 0
}
},
"required": ["created", "updated"],
"required": ["created", "updated", "active"],
"additionalProperties": false
},
"Project.Vcs": {