Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}%`))
Expand All @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/session/directory.ts
Original file line number Diff line number Diff line change
@@ -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])
})
86 changes: 43 additions & 43 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
46 changes: 45 additions & 1 deletion packages/opencode/test/server/session-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
() =>
Expand Down
Loading