diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 530ba3ff239a..d76aec6b11b9 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -806,13 +806,10 @@ function App(props: { onSnapshot?: () => Promise }) { }, { name: "app.toggle.session_directory_filter", - title: kv.get("session_directory_filter_enabled", true) - ? "Disable session directory filtering" - : "Enable session directory filtering", + title: sync.session.filterEnabled() ? "Disable session directory filtering" : "Enable session directory filtering", category: "System", run: async () => { - kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true)) - await sync.session.refresh() + await sync.session.setFilterEnabled(!sync.session.filterEnabled()) dialog.clear() }, }, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 17653af6b9a9..808533ce40ac 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -142,6 +142,10 @@ export function DialogSessionList() { const hint = quickSwitchHint() return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : [] }) + const footerHints = createMemo(() => [ + { title: sync.session.filterEnabled() ? "filtered current worktree" : "showing all sessions", label: "" }, + ...quickSwitchFooterHints(), + ]) const options = createMemo(() => { const today = new Date().toDateString() @@ -289,6 +293,14 @@ export function DialogSessionList() { setToDelete(option.value) }, }, + { + command: "app.toggle.session_directory_filter", + title: sync.session.filterEnabled() ? "show all" : "filter current", + onTrigger: async () => { + await sync.session.setFilterEnabled(!sync.session.filterEnabled()) + if (search()) await refetch() + }, + }, { command: "session.rename", title: "rename", @@ -297,7 +309,7 @@ export function DialogSessionList() { }, }, ]} - footerHints={quickSwitchFooterHints()} + footerHints={footerHints()} /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index c03123aed1c0..f1505c063d0f 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -55,7 +55,7 @@ export const Definitions = { app_toggle_file_context: keybind("none", "Toggle file context"), app_toggle_diffwrap: keybind("none", "Toggle diff wrapping"), app_toggle_paste_summary: keybind("none", "Toggle paste summary"), - app_toggle_session_directory_filter: keybind("none", "Toggle session directory filtering"), + app_toggle_session_directory_filter: keybind("ctrl+o", "Toggle session directory filtering"), command_list: keybind("ctrl+p", "List available commands"), help_show: keybind("none", "Open help dialog"), docs_open: keybind("none", "Open documentation"), diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 9f8a384f777f..46504df9ede0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -114,16 +114,64 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const fullSyncedSessions = new Set() - function sessionListQuery(): { scope?: "project"; path?: string } { - if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" } + function filterEnabled() { + return kv.get("session_directory_filter_enabled", true) + } + + function sessionListQuery(): { scope?: "project"; path?: string; directory?: string } { + if (!filterEnabled()) return { scope: "project" } if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" } return { + directory: project.data.instance.path.directory, path: path .relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory) .replaceAll("\\", "/"), } } + function sessionRootFromQuery(query: { path?: string; directory?: string }) { + if (!query.directory) return undefined + if (!query.path) return path.resolve(query.directory) + return path.resolve(query.directory, ...query.path.split("/").map(() => "..")) + } + + function includesSession(session: Session) { + const query = sessionListQuery() + if (query.scope === "project") return true + const root = sessionRootFromQuery(query) + if (!root) return true + const relative = path.relative(root, session.directory) + if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) return false + if (!query.path) return true + if (session.path) return session.path === query.path || session.path.startsWith(`${query.path}/`) + return session.directory === query.directory + } + + function upsertSession(session: Session) { + const result = Binary.search(store.session, session.id, (s) => s.id) + if (!includesSession(session)) { + if (result.found) { + setStore( + "session", + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + return + } + if (result.found) { + setStore("session", result.index, reconcile(session)) + return + } + setStore( + "session", + produce((draft) => { + draft.splice(result.index, 0, session) + }), + ) + } + function listSessions() { return sdk.client.session .list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() }) @@ -230,18 +278,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } break } + case "session.created": case "session.updated": { - const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) - if (result.found) { - setStore("session", result.index, reconcile(event.properties.info)) - break - } - setStore( - "session", - produce((draft) => { - draft.splice(result.index, 0, event.properties.info) - }), - ) + upsertSession(event.properties.info) break } @@ -504,6 +543,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ query() { return sessionListQuery() }, + filterEnabled, + async setFilterEnabled(enabled: boolean) { + kv.set("session_directory_filter_enabled", enabled) + await result.session.refresh() + }, async refresh() { const list = await listSessions() setStore("session", reconcile(list)) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 0131d443893d..a28a1a84a416 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -158,6 +158,16 @@ function sessionPath(worktree: string, cwd: string) { return path.relative(path.resolve(worktree), cwd).replaceAll("\\", "/") } +function sessionRootFromPath(directory: string, relativePath: string) { + if (!relativePath) return path.resolve(directory) + return path.resolve(directory, ...relativePath.split("/").map(() => "..")) +} + +function directoryPrefix(directory: string) { + const resolved = path.resolve(directory) + return resolved.endsWith(path.sep) ? `${resolved}%` : `${resolved}${path.sep}%` +} + const Summary = Schema.Struct({ additions: Schema.Finite, deletions: Schema.Finite, @@ -900,14 +910,24 @@ function* listByProject( 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, `${input.path}/%`)] - - conditions.push( - input.directory - ? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)! - : or(...conds)!, - ) + if (input.directory) { + const root = sessionRootFromPath(input.directory, input.path) + const directoryConds = [eq(SessionTable.directory, root), like(SessionTable.directory, directoryPrefix(root))] + if (!input.path) { + conditions.push(or(...directoryConds)!) + } else { + conditions.push( + or( + and( + or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!, + or(...directoryConds)!, + )!, + and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!, + )!, + ) + } + } else if (input.path) { + conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!) } } else if (input.scope !== "project" && !input.experimentalWorkspaces) { if (input.directory) { diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index 714c39a781be..43bcaa44cc67 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -29,6 +29,7 @@ describe("tui sync", () => { 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("directory")).toBe("/tmp/opencode/packages/opencode") expect(session.at(-1)?.searchParams.get("path")).toBe("packages/opencode") kv.set("session_directory_filter_enabled", false) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 467ab7c9a5b4..25ea0220d09d 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -171,6 +171,61 @@ describe("session.list", () => { { git: true }, ) + it.instance( + "filters path scoped sessions to the current worktree directory", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const mainWorktree = path.join(test.directory, "main", "packages", "opencode") + const workspaceWorktree = path.join(test.directory, "workspace", "packages", "opencode") + yield* Effect.promise(() => mkdir(path.join(mainWorktree, "src", "deep"), { recursive: true })) + yield* Effect.promise(() => mkdir(path.join(workspaceWorktree, "src", "deep"), { recursive: true })) + + const main = yield* withSession({ title: "main" }).pipe(provideInstance(path.join(mainWorktree, "src"))) + const mainDeep = yield* withSession({ title: "main-deep" }).pipe( + provideInstance(path.join(mainWorktree, "src", "deep")), + ) + const workspace = yield* withSession({ title: "workspace" }).pipe( + provideInstance(path.join(workspaceWorktree, "src")), + ) + const workspaceDeep = yield* withSession({ title: "workspace-deep" }).pipe( + provideInstance(path.join(workspaceWorktree, "src", "deep")), + ) + + yield* Effect.forEach( + [main, workspace].map((session) => ({ session, sessionPath: "packages/opencode/src" })), + (input) => + Effect.sync(() => + Database.use((db) => + db.update(SessionTable).set({ path: input.sessionPath }).where(eq(SessionTable.id, input.session.id)).run(), + ), + ), + ) + yield* Effect.forEach( + [mainDeep, workspaceDeep].map((session) => ({ session, sessionPath: "packages/opencode/src/deep" })), + (input) => + Effect.sync(() => + Database.use((db) => + db.update(SessionTable).set({ path: input.sessionPath }).where(eq(SessionTable.id, input.session.id)).run(), + ), + ), + ) + + const ids = (yield* SessionNs.Service.use((session) => + session.list({ + directory: path.join(workspaceWorktree, "src"), + path: "packages/opencode/src", + }), + )).map((session) => session.id) + + expect(ids).not.toContain(main.id) + expect(ids).not.toContain(mainDeep.id) + expect(ids).toContain(workspace.id) + expect(ids).toContain(workspaceDeep.id) + }), + { git: true }, + ) + it.instance( "filters root sessions", () =>