Quellcode durchsuchen

feat(core): filter sessions by path and add setting to disable (#24849)

James Long vor 3 Monaten
Ursprung
Commit
9209c04370

+ 12 - 0
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -736,6 +736,18 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
         dialog.clear()
       },
     },
+    {
+      title: kv.get("session_directory_filter_enabled", true)
+        ? "Disable session directory filtering"
+        : "Enable session directory filtering",
+      value: "app.toggle.session_directory_filter",
+      category: "System",
+      onSelect: async (dialog) => {
+        kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
+        await sync.session.refresh()
+        dialog.clear()
+      },
+    },
     {
       title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
       value: "app.toggle.diffwrap",

+ 8 - 5
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -32,11 +32,14 @@ export function DialogSessionList() {
   const [toDelete, setToDelete] = createSignal<string>()
   const [search, setSearch] = createDebouncedSignal("", 150)
 
-  const [searchResults, { refetch }] = createResource(search, async (query) => {
-    if (!query) return undefined
-    const result = await sdk.client.session.list({ search: query, limit: 30 })
-    return result.data ?? []
-  })
+  const [searchResults, { refetch }] = createResource(
+    () => ({ query: search(), filter: sync.session.query() }),
+    async (input) => {
+      if (!input.query) return undefined
+      const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
+      return result.data ?? []
+    },
+  )
 
   const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
   const sessions = createMemo(() => searchResults() ?? sync.data.session)

+ 25 - 9
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -30,6 +30,8 @@ import { useArgs } from "./args"
 import { batch, onMount } from "solid-js"
 import * as Log from "@opencode-ai/core/util/log"
 import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
+import path from "path"
+import { useKV } from "./kv"
 
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
@@ -107,10 +109,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
     const event = useEvent()
     const project = useProject()
     const sdk = useSDK()
+    const kv = useKV()
 
     const fullSyncedSessions = new Set<string>()
     let syncedWorkspace = project.workspace.current()
 
+    function sessionListQuery(): { scope?: "project"; path?: string } {
+      if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
+      if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
+      return {
+        path: path
+          .relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
+          .replaceAll("\\", "/"),
+      }
+    }
+
+    function listSessions() {
+      return sdk.client.session
+        .list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
+        .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
+    }
+
     event.subscribe((event) => {
       switch (event.type) {
         case "server.instance.disposed":
@@ -360,10 +379,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         fullSyncedSessions.clear()
         syncedWorkspace = workspace
       }
-      const start = Date.now() - 30 * 24 * 60 * 60 * 1000
-      const sessionListPromise = sdk.client.session
-        .list({ start: start })
-        .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
+      const projectPromise = project.sync()
+      const sessionListPromise = projectPromise.then(() => listSessions())
 
       // blocking - include session.list when continuing a session
       const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
@@ -374,7 +391,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         .catch(() => emptyConsoleState)
       const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
       const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
-      const projectPromise = project.sync()
       const blockingRequests: Promise<unknown>[] = [
         providersPromise,
         providerListPromise,
@@ -479,11 +495,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           if (match.found) return store.session[match.index]
           return undefined
         },
+        query() {
+          return sessionListQuery()
+        },
         async refresh() {
-          const start = Date.now() - 30 * 24 * 60 * 60 * 1000
-          const list = await sdk.client.session
-            .list({ start })
-            .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
+          const list = await listSessions()
           setStore("session", reconcile(list))
         },
         status(sessionID: string) {

+ 4 - 0
packages/opencode/src/server/routes/instance/httpapi/session.ts

@@ -45,6 +45,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
 )
 const ListQuery = Schema.Struct({
   directory: Schema.optional(Schema.String),
+  scope: Schema.optional(Schema.Literals(["project"])),
+  path: Schema.optional(Schema.String),
   roots: Schema.optional(QueryBoolean),
   start: Schema.optional(Schema.NumberFromString),
   search: Schema.optional(Schema.String),
@@ -444,6 +446,8 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand
         Array.from(
           Session.list({
             directory: ctx.query.directory,
+            scope: ctx.query.scope,
+            path: ctx.query.path,
             roots: ctx.query.roots,
             start: ctx.query.start,
             search: ctx.query.search,

+ 7 - 2
packages/opencode/src/server/routes/instance/session.ts

@@ -62,7 +62,11 @@ export const SessionRoutes = lazy(() =>
       validator(
         "query",
         z.object({
-          directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
+          directory: z.string().optional().meta({ description: "Filter sessions by directory" }),
+          // TODO: in 2.0 remove `scope` and `directory` and default
+          // to list all sessions for a project
+          scope: z.enum(["project"]).optional().meta({ description: "List all sessions for the current project" }),
+          path: z.string().optional().meta({ description: "Filter sessions by project-relative path" }),
           roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }),
           start: z.coerce
             .number()
@@ -76,7 +80,8 @@ export const SessionRoutes = lazy(() =>
         const query = c.req.valid("query")
         const sessions: Session.Info[] = []
         for await (const session of Session.list({
-          directory: query.directory,
+          directory: query.scope === "project" ? undefined : query.directory,
+          path: query.path,
           roots: queryBoolean(query.roots),
           start: query.start,
           search: query.search,

+ 14 - 1
packages/opencode/src/session/session.ts

@@ -18,6 +18,7 @@ import { desc } from "drizzle-orm"
 import { like } from "drizzle-orm"
 import { inArray } from "drizzle-orm"
 import { lt } from "drizzle-orm"
+import { or } from "drizzle-orm"
 import { SyncEvent } from "../sync"
 import type { SQL } from "drizzle-orm"
 import { PartTable, SessionTable } from "./session.sql"
@@ -759,6 +760,8 @@ export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(S
 
 export function* list(input?: {
   directory?: string
+  scope?: "project"
+  path?: string
   workspaceID?: WorkspaceID
   roots?: boolean
   start?: number
@@ -771,7 +774,17 @@ export function* list(input?: {
   if (input?.workspaceID) {
     conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
   }
-  if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
+  if (input?.path !== undefined) {
+    if (input.path) {
+      const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)]
+
+      conditions.push(
+        input.directory
+          ? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)!
+          : or(...conds)!,
+      )
+    }
+  } else if (input?.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
     if (input?.directory) {
       conditions.push(eq(SessionTable.directory, input.directory))
     }

+ 149 - 0
packages/opencode/test/cli/cmd/tui/sync.test.tsx

@@ -0,0 +1,149 @@
+/** @jsxImportSource @opentui/solid */
+import { describe, expect, test } from "bun:test"
+import { testRender } from "@opentui/solid"
+import { onMount } from "solid-js"
+import { Global } from "@opencode-ai/core/global"
+import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args"
+import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
+import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv"
+import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project"
+import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk"
+import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
+import { tmpdir } from "../../../fixture/fixture"
+
+const worktree = "/tmp/opencode"
+const directory = `${worktree}/packages/opencode`
+
+async function wait(fn: () => boolean, timeout = 2000) {
+  const start = Date.now()
+  while (!fn()) {
+    if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
+    await Bun.sleep(10)
+  }
+}
+
+function json(data: unknown) {
+  return new Response(JSON.stringify(data), {
+    headers: { "content-type": "application/json" },
+  })
+}
+
+function eventSource(): EventSource {
+  return {
+    subscribe: async () => () => {},
+  }
+}
+
+function createFetch() {
+  const session = [] as URL[]
+  const fetch = (async (input: RequestInfo | URL) => {
+    const url = new URL(input instanceof Request ? input.url : String(input))
+    if (url.pathname === "/session") session.push(url)
+
+    switch (url.pathname) {
+      case "/agent":
+      case "/command":
+      case "/experimental/workspace":
+      case "/experimental/workspace/status":
+      case "/formatter":
+      case "/lsp":
+        return json([])
+      case "/config":
+      case "/experimental/resource":
+      case "/mcp":
+      case "/provider/auth":
+      case "/session/status":
+        return json({})
+      case "/config/providers":
+        return json({ providers: {}, default: {} })
+      case "/experimental/console":
+        return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
+      case "/path":
+        return json({ home: "", state: "", config: "", worktree, directory })
+      case "/project/current":
+        return json({ id: "proj_test" })
+      case "/provider":
+        return json({ all: [], default: {}, connected: [] })
+      case "/session":
+        return json([])
+      case "/vcs":
+        return json({ branch: "main" })
+    }
+
+    throw new Error(`unexpected request: ${url.pathname}`)
+  }) as typeof globalThis.fetch
+
+  return { fetch, session }
+}
+
+async function mount() {
+  const calls = createFetch()
+  let sync!: ReturnType<typeof useSync>
+  let kv!: ReturnType<typeof useKV>
+  let done!: () => void
+  const ready = new Promise<void>((resolve) => {
+    done = resolve
+  })
+
+  const app = await testRender(() => (
+    <ArgsProvider>
+      <ExitProvider>
+        <KVProvider>
+          <SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={eventSource()}>
+            <ProjectProvider>
+              <SyncProvider>
+                <Probe
+                  onReady={(ctx) => {
+                    sync = ctx.sync
+                    kv = ctx.kv
+                    done()
+                  }}
+                />
+              </SyncProvider>
+            </ProjectProvider>
+          </SDKProvider>
+        </KVProvider>
+      </ExitProvider>
+    </ArgsProvider>
+  ))
+
+  await ready
+  await wait(() => sync.status === "complete")
+  return { app, kv, sync, session: calls.session }
+}
+
+function Probe(props: { onReady: (ctx: { kv: ReturnType<typeof useKV>; sync: ReturnType<typeof useSync> }) => void }) {
+  const kv = useKV()
+  const sync = useSync()
+
+  onMount(() => {
+    props.onReady({ kv, sync })
+  })
+
+  return <box />
+}
+
+describe("tui sync", () => {
+  test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
+    const previous = Global.Path.state
+    await using tmp = await tmpdir()
+    Global.Path.state = tmp.path
+    await Bun.write(`${tmp.path}/kv.json`, "{}")
+    const { app, kv, sync, session } = await mount()
+
+    try {
+      expect(kv.get("session_directory_filter_enabled", true)).toBe(true)
+      expect(session.at(-1)?.searchParams.get("scope")).toBeNull()
+      expect(session.at(-1)?.searchParams.get("path")).toBe("packages/opencode")
+
+      kv.set("session_directory_filter_enabled", false)
+      await sync.session.refresh()
+
+      expect(session.at(-1)?.searchParams.get("scope")).toBe("project")
+      expect(session.at(-1)?.searchParams.get("path")).toBeNull()
+    } finally {
+      app.renderer.destroy()
+      Global.Path.state = previous
+    }
+  })
+})

+ 129 - 10
packages/opencode/test/server/session-list.test.ts

@@ -4,8 +4,15 @@ import { Instance } from "../../src/project/instance"
 import { Session as SessionNs } from "@/session/session"
 import * as Log from "@opencode-ai/core/util/log"
 import { tmpdir } from "../fixture/fixture"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { mkdir } from "fs/promises"
+import path from "path"
+import { Database } from "@/storage/db"
+import { SessionTable } from "@/session/session.sql"
+import { eq } from "drizzle-orm"
 
 void Log.init({ print: false })
+const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
 
 function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
   return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
@@ -19,28 +26,140 @@ const svc = {
 }
 
 afterEach(async () => {
+  Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
   await Instance.disposeAll()
 })
 
 describe("session.list", () => {
-  test("filters by directory", async () => {
+  test("does not filter by directory when directory is omitted", async () => {
+    Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
     await using tmp = await tmpdir({ git: true })
+    await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true })
+    await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
+
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const first = await svc.create({})
+        const root = await svc.create({ title: "root" })
 
-        await using other = await tmpdir({ git: true })
-        const second = await Instance.provide({
-          directory: other.path,
-          fn: async () => svc.create({}),
+        const parent = await Instance.provide({
+          directory: path.join(tmp.path, "packages"),
+          fn: async () => svc.create({ title: "parent" }),
+        })
+        const current = await Instance.provide({
+          directory: path.join(tmp.path, "packages", "opencode"),
+          fn: async () => svc.create({ title: "current" }),
+        })
+        const sibling = await Instance.provide({
+          directory: path.join(tmp.path, "packages", "app"),
+          fn: async () => svc.create({ title: "sibling" }),
         })
 
-        const sessions = [...svc.list({ directory: tmp.path })]
-        const ids = sessions.map((s) => s.id)
+        const ids = [...svc.list()].map((s) => s.id)
+        expect(ids).toContain(root.id)
+        expect(ids).toContain(parent.id)
+        expect(ids).toContain(current.id)
+        expect(ids).toContain(sibling.id)
+      },
+    })
+  })
+
+  test("filters by directory when directory is provided", async () => {
+    Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
+    await using tmp = await tmpdir({ git: true })
+    await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true })
+    await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const root = await svc.create({ title: "root" })
+
+        const parent = await Instance.provide({
+          directory: path.join(tmp.path, "packages"),
+          fn: async () => svc.create({ title: "parent" }),
+        })
+        const current = await Instance.provide({
+          directory: path.join(tmp.path, "packages", "opencode"),
+          fn: async () => svc.create({ title: "current" }),
+        })
+        const sibling = await Instance.provide({
+          directory: path.join(tmp.path, "packages", "app"),
+          fn: async () => svc.create({ title: "sibling" }),
+        })
+
+        const ids = [...svc.list({ directory: path.join(tmp.path, "packages", "opencode") })].map((s) => s.id)
+        expect(ids).not.toContain(root.id)
+        expect(ids).not.toContain(parent.id)
+        expect(ids).toContain(current.id)
+        expect(ids).not.toContain(sibling.id)
+      },
+    })
+  })
+
+  test("filters by path and ignores directory when path is provided", async () => {
+    Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
+    await using tmp = await tmpdir({ git: true })
+    await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true })
+    await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const parent = await Instance.provide({
+          directory: path.join(tmp.path, "packages", "opencode"),
+          fn: async () => svc.create({ title: "parent" }),
+        })
+        const current = await Instance.provide({
+          directory: path.join(tmp.path, "packages", "opencode", "src"),
+          fn: async () => svc.create({ title: "current" }),
+        })
+        const deeper = await Instance.provide({
+          directory: path.join(tmp.path, "packages", "opencode", "src", "deep"),
+          fn: async () => svc.create({ title: "deeper" }),
+        })
+        const sibling = await Instance.provide({
+          directory: path.join(tmp.path, "packages", "app"),
+          fn: async () => svc.create({ title: "sibling" }),
+        })
+
+        const pathIDs = [
+          ...svc.list({ directory: path.join(tmp.path, "packages", "app"), path: "packages/opencode/src" }),
+        ].map((s) => s.id)
+        expect(pathIDs).not.toContain(parent.id)
+        expect(pathIDs).toContain(current.id)
+        expect(pathIDs).toContain(deeper.id)
+        expect(pathIDs).not.toContain(sibling.id)
+      },
+    })
+  })
+
+  test("falls back to directory when filtering legacy sessions without path", async () => {
+    Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
+    await using tmp = await tmpdir({ git: true })
+    await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true })
+    await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const current = await Instance.provide({
+          directory: path.join(tmp.path, "packages", "opencode", "src"),
+          fn: async () => svc.create({ title: "legacy-current" }),
+        })
+        const sibling = await Instance.provide({
+          directory: path.join(tmp.path, "packages", "app"),
+          fn: async () => svc.create({ title: "legacy-sibling" }),
+        })
+
+        Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run())
+        Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run())
 
-        expect(ids).toContain(first.id)
-        expect(ids).not.toContain(second.id)
+        const pathIDs = [
+          ...svc.list({ directory: path.join(tmp.path, "packages", "opencode", "src"), path: "packages/opencode/src" }),
+        ].map((s) => s.id)
+        expect(pathIDs).toContain(current.id)
+        expect(pathIDs).not.toContain(sibling.id)
       },
     })
   })

+ 4 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -1647,6 +1647,8 @@ export class Session2 extends HeyApiClient {
     parameters?: {
       directory?: string
       workspace?: string
+      scope?: "project"
+      path?: string
       roots?: boolean | "true" | "false"
       start?: number
       search?: string
@@ -1661,6 +1663,8 @@ export class Session2 extends HeyApiClient {
           args: [
             { in: "query", key: "directory" },
             { in: "query", key: "workspace" },
+            { in: "query", key: "scope" },
+            { in: "query", key: "path" },
             { in: "query", key: "roots" },
             { in: "query", key: "start" },
             { in: "query", key: "search" },

+ 8 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -3289,6 +3289,14 @@ export type SessionListData = {
      */
     directory?: string
     workspace?: string
+    /**
+     * List all sessions for the current project
+     */
+    scope?: "project"
+    /**
+     * Filter sessions by project-relative path
+     */
+    path?: string
     /**
      * Only return root sessions (no parentID)
      */