diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2dabfb2d6fba..5d78f7e6ffaf 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -36,6 +36,7 @@ import { Snapshot } from "./snapshot" import { SessionRevert } from "./session/revert" import { Revert } from "@opencode-ai/schema/revert" import { FSUtil } from "./fs-util" +import { SessionDirectory } from "./session/directory" import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" export const RevertState = Revert.State @@ -271,7 +272,6 @@ const layer = Layer.effect( const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder const sortColumn = SessionTable.time_created const conditions: SQL[] = [] - if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) @@ -288,6 +288,7 @@ const layer = Layer.effect( )!, ) } + if ("directory" in input) conditions.push(yield* SessionDirectory.filter(db, input.directory, conditions)) const query = db .select() .from(SessionTable) diff --git a/packages/core/src/session/directory.ts b/packages/core/src/session/directory.ts new file mode 100644 index 000000000000..1d7d308bc7f7 --- /dev/null +++ b/packages/core/src/session/directory.ts @@ -0,0 +1,43 @@ +export * as SessionDirectory from "./directory" + +import { and, eq, inArray, type SQL } from "drizzle-orm" +import { Effect } from "effect" +import { Database } from "../database/database" +import { FSUtil } from "../fs-util" +import { SessionTable } from "./sql" + +const sameSpelling = (a: string, b: string) => a.toUpperCase() === b.toUpperCase() + +const resolveSafe = (directory: string) => + Effect.try({ + try: () => FSUtil.resolve(directory), + catch: () => undefined, + }).pipe(Effect.orElseSucceed(() => undefined)) + +export const filter = Effect.fn("SessionDirectory.filter")(function* ( + db: Database.Interface["db"], + directory: string, + base: SQL[], +) { + const stored = yield* db + .selectDistinct({ directory: SessionTable.directory }) + .from(SessionTable) + .where(base.length > 0 ? and(...base) : undefined) + .all() + .pipe(Effect.orDie) + const aliases = stored + .map((row) => row.directory) + .filter((candidate) => candidate !== directory && sameSpelling(candidate, directory)) + if (aliases.length === 0) return eq(SessionTable.directory, directory) + // Filesystem identity decides: case variants are the same directory on + // case-insensitive volumes and distinct directories elsewhere. Missing + // directories keep exact-spelling history. + const canonical = yield* resolveSafe(directory) + if (canonical === undefined) return eq(SessionTable.directory, directory) + const matched: string[] = [] + for (const candidate of aliases) { + const resolved = yield* resolveSafe(candidate) + if (resolved === canonical) matched.push(candidate) + } + return inArray(SessionTable.directory, [directory, ...matched]) +}) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a2a91cd47b5e..8c2888a7042d 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -33,6 +33,7 @@ import type { InstanceContext } from "../project/instance-context" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" import { ProjectV2 } from "@opencode-ai/core/project" +import { SessionDirectory } from "@opencode-ai/core/session/directory" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionID, MessageID, PartID } from "./schema" @@ -554,12 +555,12 @@ const layer: Layer.Layer< const listGlobal = Effect.fn("Session.listGlobal")(function* (input?: GlobalListInput) { const conditions: SQL[] = [] - if (input?.directory) conditions.push(eq(SessionTable.directory, input.directory)) if (input?.roots) conditions.push(isNull(SessionTable.parent_id)) if (input?.start) conditions.push(gte(SessionTable.time_updated, input.start)) if (input?.cursor) conditions.push(lt(SessionTable.time_updated, input.cursor)) if (input?.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) if (!input?.archived) conditions.push(isNull(SessionTable.time_archived)) + if (input?.directory) conditions.push(yield* SessionDirectory.filter(db, input.directory, conditions)) const query = conditions.length > 0 @@ -959,52 +960,51 @@ function listByProject( experimentalWorkspaces: boolean }, ) { - const conditions = [eq(SessionTable.project_id, input.projectID)] + return Effect.gen(function* () { + const conditions = [eq(SessionTable.project_id, input.projectID)] - if (input.workspaceID) { - conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) - } - if (input.path !== undefined) { - if (input.path) { - const conds = [ - eq(SessionTable.path, input.path), - like(SessionTable.path, sql.param(`${input.path}/%`, SessionTable.path)), - ] - - conditions.push( - input.directory - ? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)! - : or(...conds)!, - ) + if (input.workspaceID) { + conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) } - } else if (input.scope !== "project") { - if (input.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + if (input.roots) { + conditions.push(isNull(SessionTable.parent_id)) + } + if (input.start) { + conditions.push(gte(SessionTable.time_updated, input.start)) + } + if (input.search) { + conditions.push(like(SessionTable.title, `%${input.search}%`)) + } + const directory = input.directory ? yield* SessionDirectory.filter(db, input.directory, conditions) : undefined + if (input.path !== undefined) { + if (input.path) { + const conds = [ + eq(SessionTable.path, input.path), + like(SessionTable.path, sql.param(`${input.path}/%`, SessionTable.path)), + ] + + conditions.push( + directory ? or(...conds, and(isNull(SessionTable.path), directory)!)! : or(...conds)!, + ) + } + } else if (input.scope !== "project") { + if (directory) { + conditions.push(directory) + } } - } - if (input.roots) { - conditions.push(isNull(SessionTable.parent_id)) - } - if (input.start) { - conditions.push(gte(SessionTable.time_updated, input.start)) - } - if (input.search) { - conditions.push(like(SessionTable.title, `%${input.search}%`)) - } - const limit = input.limit ?? 100 - - return db - .select() - .from(SessionTable) - .where(and(...conditions)) - .orderBy(desc(SessionTable.time_updated)) - .limit(limit) - .all() - .pipe( - Effect.orDie, - Effect.map((rows) => rows.map(fromRow)), - ) + const limit = input.limit ?? 100 + + const rows = yield* db + .select() + .from(SessionTable) + .where(and(...conditions)) + .orderBy(desc(SessionTable.time_updated)) + .limit(limit) + .all() + .pipe(Effect.orDie) + return rows.map(fromRow) + }) } export const node = LayerNode.make({ diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 354a578b23c9..e5a9db26d17f 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -6,7 +6,7 @@ import { Database } from "@opencode-ai/core/database/database" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Session as SessionNs } from "@/session/session" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" -import { mkdir } from "fs/promises" +import { mkdir, readdir } from "fs/promises" import path from "path" import { SessionTable } from "@opencode-ai/core/session/sql" import { eq } from "drizzle-orm" @@ -139,6 +139,50 @@ describe("session.list", () => { { git: true }, ) + it.instance( + "matches historical directory spellings when the filesystem confirms identity", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const base = path.join(test.directory, "case") + const upper = path.join(base, "Checkout") + const lower = path.join(base, "checkout") + yield* Effect.promise(() => mkdir(upper, { recursive: true })) + yield* Effect.promise(() => mkdir(lower, { recursive: true }).catch(() => undefined)) + // One entry on case-insensitive volumes, two where case matters. + const same = (yield* Effect.promise(() => readdir(base))).length === 1 + + const first = yield* withSession({ title: "case-first" }).pipe(provideInstance(upper)) + const second = yield* withSession({ title: "case-second" }).pipe(provideInstance(upper)) + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ directory: lower }) + .where(eq(SessionTable.id, second.id)) + .run() + .pipe(Effect.orDie) + + const upperIDs = (yield* SessionNs.Service.use((session) => session.list({ directory: upper }))).map( + (session) => session.id, + ) + const lowerIDs = (yield* SessionNs.Service.use((session) => session.list({ directory: lower }))).map( + (session) => session.id, + ) + if (same) { + expect(upperIDs).toContain(first.id) + expect(upperIDs).toContain(second.id) + expect(lowerIDs).toContain(first.id) + expect(lowerIDs).toContain(second.id) + return + } + expect(upperIDs).toContain(first.id) + expect(upperIDs).not.toContain(second.id) + expect(lowerIDs).not.toContain(first.id) + expect(lowerIDs).toContain(second.id) + }), + { git: true }, + ) + it.instance( "filters by path and ignores directory when path is provided", () =>