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
7 changes: 2 additions & 5 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -806,13 +806,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
{
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()
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand All @@ -297,7 +309,7 @@ export function DialogSessionList() {
},
},
]}
footerHints={quickSwitchFooterHints()}
footerHints={footerHints()}
/>
)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/tui/config/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
70 changes: 57 additions & 13 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,64 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({

const fullSyncedSessions = new Set<string>()

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() })
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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))
Expand Down
36 changes: 28 additions & 8 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/cli/cmd/tui/sync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions packages/opencode/test/server/session-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
() =>
Expand Down
Loading