mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-30 16:03:34 +00:00
fix(tui): correct project-aware session lists
This commit is contained in:
parent
97786afdd8
commit
488445a679
38 changed files with 506 additions and 220 deletions
|
|
@ -129,8 +129,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
|||
api.list().then((projects) => {
|
||||
return projects
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.map(normalizeProjectInfo)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ export function sanitizeProject(project: Project) {
|
|||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||
return {
|
||||
...project,
|
||||
worktree: "canonical" in project ? project.canonical : project.worktree,
|
||||
vcs: project.vcs === "git" ? "git" : undefined,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
|||
const located = <T>(data: T, value?: { directory?: string }) => ({
|
||||
location: {
|
||||
directory: directory(value) ?? "",
|
||||
project: { id: "", directory: directory(value) ?? "" },
|
||||
project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" },
|
||||
},
|
||||
data,
|
||||
})
|
||||
|
|
@ -298,12 +298,19 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
|||
project: {
|
||||
...input.current.project,
|
||||
async list() {
|
||||
return ((await legacy().project.list()).data ?? []) as Project[]
|
||||
return ((await legacy().project.list()).data ?? []).map((project) => ({
|
||||
...project,
|
||||
canonical: project.worktree,
|
||||
}))
|
||||
},
|
||||
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
|
||||
const result = await legacy(value?.location).project.current()
|
||||
if (!result.data) throw new Error("Project not found")
|
||||
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
|
||||
return {
|
||||
id: result.data.id,
|
||||
directory: result.data.worktree,
|
||||
canonical: result.data.worktree,
|
||||
} satisfies ProjectCurrent
|
||||
},
|
||||
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
||||
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } fro
|
|||
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
|
||||
|
||||
function location(directory: string, workspaceID?: string): LocationGetOutput {
|
||||
return { directory, workspaceID, project: { id: "project", directory } }
|
||||
return { directory, workspaceID, project: { id: "project", directory, canonical: directory } }
|
||||
}
|
||||
|
||||
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ export type ProjectCommands = { start?: string }
|
|||
|
||||
export type ProjectTime = { created: number; updated: number; initialized?: number }
|
||||
|
||||
export type ProjectCurrent = { id: string; directory: string }
|
||||
export type ProjectCurrent = { id: string; directory: string; canonical: string }
|
||||
|
||||
export type ProjectDirectory = { directory: string; strategy?: string }
|
||||
|
||||
|
|
@ -1430,7 +1430,7 @@ export type McpResourceCatalog = { resources: Array<McpResource>; templates: Arr
|
|||
|
||||
export type Project = {
|
||||
id: string
|
||||
worktree: string
|
||||
canonical: string
|
||||
vcs?: ProjectVcs
|
||||
name?: string
|
||||
icon?: ProjectIcon
|
||||
|
|
@ -2515,7 +2515,11 @@ export type LocationGetInput = {
|
|||
}["location"]
|
||||
}
|
||||
|
||||
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
export type LocationGetOutput = {
|
||||
directory: string
|
||||
workspaceID?: string
|
||||
project: { id: string; directory: string; canonical: string }
|
||||
}
|
||||
|
||||
export type AgentListInput = {
|
||||
readonly location?: {
|
||||
|
|
@ -2524,7 +2528,7 @@ export type AgentListInput = {
|
|||
}
|
||||
|
||||
export type AgentListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<AgentInfo>
|
||||
}
|
||||
|
||||
|
|
@ -2536,7 +2540,7 @@ export type AgentGetInput = {
|
|||
}
|
||||
|
||||
export type AgentGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: AgentInfo
|
||||
}
|
||||
|
||||
|
|
@ -2547,7 +2551,7 @@ export type PluginListInput = {
|
|||
}
|
||||
|
||||
export type PluginListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
|
|
@ -3231,7 +3235,7 @@ export type ModelListInput = {
|
|||
}
|
||||
|
||||
export type ModelListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<ModelInfo>
|
||||
}
|
||||
|
||||
|
|
@ -3242,7 +3246,7 @@ export type ModelDefaultInput = {
|
|||
}
|
||||
|
||||
export type ModelDefaultOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: ModelInfo | null
|
||||
}
|
||||
|
||||
|
|
@ -3269,7 +3273,7 @@ export type ProviderListInput = {
|
|||
}
|
||||
|
||||
export type ProviderListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<ProviderInfo>
|
||||
}
|
||||
|
||||
|
|
@ -3281,7 +3285,7 @@ export type ProviderGetInput = {
|
|||
}
|
||||
|
||||
export type ProviderGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: ProviderInfo
|
||||
}
|
||||
|
||||
|
|
@ -3292,7 +3296,7 @@ export type IntegrationListInput = {
|
|||
}
|
||||
|
||||
export type IntegrationListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<IntegrationInfo>
|
||||
}
|
||||
|
||||
|
|
@ -3304,7 +3308,7 @@ export type IntegrationGetInput = {
|
|||
}
|
||||
|
||||
export type IntegrationGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: IntegrationInfo | null
|
||||
}
|
||||
|
||||
|
|
@ -3351,7 +3355,7 @@ export type IntegrationOauthConnectInput = {
|
|||
}
|
||||
|
||||
export type IntegrationOauthConnectOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: {
|
||||
attemptID: string
|
||||
url: string
|
||||
|
|
@ -3370,7 +3374,7 @@ export type IntegrationOauthStatusInput = {
|
|||
}
|
||||
|
||||
export type IntegrationOauthStatusOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: IntegrationAttemptStatus
|
||||
}
|
||||
|
||||
|
|
@ -3405,7 +3409,7 @@ export type IntegrationCommandConnectInput = {
|
|||
}
|
||||
|
||||
export type IntegrationCommandConnectOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: IntegrationCommandAttempt
|
||||
}
|
||||
|
||||
|
|
@ -3418,7 +3422,7 @@ export type IntegrationCommandStatusInput = {
|
|||
}
|
||||
|
||||
export type IntegrationCommandStatusOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: IntegrationCommandAttemptStatus
|
||||
}
|
||||
|
||||
|
|
@ -3439,7 +3443,7 @@ export type McpListInput = {
|
|||
}
|
||||
|
||||
export type McpListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<McpServer>
|
||||
}
|
||||
|
||||
|
|
@ -3528,7 +3532,7 @@ export type McpResourceCatalogInput = {
|
|||
}
|
||||
|
||||
export type McpResourceCatalogOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: McpResourceCatalog
|
||||
}
|
||||
|
||||
|
|
@ -3577,7 +3581,7 @@ export type FormRequestListInput = {
|
|||
}
|
||||
|
||||
export type FormRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<FormInfo>
|
||||
}
|
||||
|
||||
|
|
@ -4433,7 +4437,7 @@ export type PermissionRequestListInput = {
|
|||
}
|
||||
|
||||
export type PermissionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PermissionRequest>
|
||||
}
|
||||
|
||||
|
|
@ -4555,7 +4559,7 @@ export type FileListInput = {
|
|||
}
|
||||
|
||||
export type FileListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<FileSystemEntry>
|
||||
}
|
||||
|
||||
|
|
@ -4587,7 +4591,7 @@ export type FileFindInput = {
|
|||
}
|
||||
|
||||
export type FileFindOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<FileSystemEntry>
|
||||
}
|
||||
|
||||
|
|
@ -4598,7 +4602,7 @@ export type CommandListInput = {
|
|||
}
|
||||
|
||||
export type CommandListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<CommandInfo>
|
||||
}
|
||||
|
||||
|
|
@ -4609,7 +4613,7 @@ export type SkillListInput = {
|
|||
}
|
||||
|
||||
export type SkillListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<SkillInfo>
|
||||
}
|
||||
|
||||
|
|
@ -4622,7 +4626,7 @@ export type PtyListInput = {
|
|||
}
|
||||
|
||||
export type PtyListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<Pty>
|
||||
}
|
||||
|
||||
|
|
@ -4668,7 +4672,7 @@ export type PtyCreateInput = {
|
|||
}
|
||||
|
||||
export type PtyCreateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Pty
|
||||
}
|
||||
|
||||
|
|
@ -4680,7 +4684,7 @@ export type PtyGetInput = {
|
|||
}
|
||||
|
||||
export type PtyGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Pty
|
||||
}
|
||||
|
||||
|
|
@ -4697,7 +4701,7 @@ export type PtyUpdateInput = {
|
|||
}
|
||||
|
||||
export type PtyUpdateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Pty
|
||||
}
|
||||
|
||||
|
|
@ -4717,7 +4721,7 @@ export type ShellListInput = {
|
|||
}
|
||||
|
||||
export type ShellListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<ShellInfo1>
|
||||
}
|
||||
|
||||
|
|
@ -4752,7 +4756,7 @@ export type ShellCreateInput = {
|
|||
}
|
||||
|
||||
export type ShellCreateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: ShellInfo1
|
||||
}
|
||||
|
||||
|
|
@ -4764,7 +4768,7 @@ export type ShellGetInput = {
|
|||
}
|
||||
|
||||
export type ShellGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: ShellInfo1
|
||||
}
|
||||
|
||||
|
|
@ -4777,7 +4781,7 @@ export type ShellTimeoutInput = {
|
|||
}
|
||||
|
||||
export type ShellTimeoutOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: ShellInfo1
|
||||
}
|
||||
|
||||
|
|
@ -4801,7 +4805,7 @@ export type ShellOutputInput = {
|
|||
}
|
||||
|
||||
export type ShellOutputOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: { output: string; cursor: number; size: number; truncated: boolean }
|
||||
}
|
||||
|
||||
|
|
@ -4821,7 +4825,7 @@ export type QuestionRequestListInput = {
|
|||
}
|
||||
|
||||
export type QuestionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<QuestionRequest>
|
||||
}
|
||||
|
||||
|
|
@ -4851,7 +4855,7 @@ export type ReferenceListInput = {
|
|||
}
|
||||
|
||||
export type ReferenceListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
|
||||
|
|
@ -4894,7 +4898,7 @@ export type VcsStatusInput = {
|
|||
}
|
||||
|
||||
export type VcsStatusOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<VcsFileStatus>
|
||||
}
|
||||
|
||||
|
|
@ -4917,7 +4921,7 @@ export type VcsDiffInput = {
|
|||
}
|
||||
|
||||
export type VcsDiffOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<FileDiffInfo>
|
||||
}
|
||||
|
||||
|
|
@ -4938,7 +4942,7 @@ export type WebsearchProvidersInput = {
|
|||
}
|
||||
|
||||
export type WebsearchProvidersOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<WebSearchProvider>
|
||||
}
|
||||
|
||||
|
|
@ -4951,6 +4955,6 @@ export type WebsearchQueryInput = {
|
|||
}
|
||||
|
||||
export type WebsearchQueryOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: { providerID: string; results: Array<WebSearchResult> }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ const layer = (ref: Ref) =>
|
|||
return Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: resolved.id, directory: resolved.directory },
|
||||
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
|
||||
vcs: resolved.vcs,
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as Project from "./project"
|
|||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { asc, desc } from "drizzle-orm"
|
||||
import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { Database } from "./database/database"
|
||||
|
|
@ -40,6 +40,7 @@ export interface Resolved {
|
|||
readonly previous?: ID
|
||||
readonly id: ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly canonical: AbsolutePath
|
||||
readonly vcs?: Vcs
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +84,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info {
|
|||
: undefined
|
||||
return {
|
||||
id: row.id,
|
||||
worktree: row.worktree,
|
||||
canonical: row.worktree,
|
||||
vcs: row.vcs ?? undefined,
|
||||
name: row.name ?? undefined,
|
||||
icon,
|
||||
|
|
@ -106,6 +107,40 @@ const layer = Layer.effect(
|
|||
const db = (yield* Database.Service).db
|
||||
const projectDirectories = yield* ProjectDirectories.Service
|
||||
|
||||
const persist = Effect.fnUntraced(function* (project: Resolved) {
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = project.vcs?.type
|
||||
yield* tx
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
if (!project.vcs) return
|
||||
yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
|
||||
if (project.directory === project.canonical) return
|
||||
yield* projectDirectories.create(
|
||||
{
|
||||
projectID: project.id,
|
||||
directory: project.directory,
|
||||
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
return project
|
||||
})
|
||||
|
||||
const list = Effect.fn("Project.list")(function* () {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
|
|
@ -211,17 +246,25 @@ const layer = Layer.effect(
|
|||
if (repo) {
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
return {
|
||||
const canonical = yield* git.worktree
|
||||
.list(repo)
|
||||
.pipe(
|
||||
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
|
||||
Effect.catch(() => Effect.succeed(repo.worktree)),
|
||||
)
|
||||
return yield* persist({
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
directory: repo.worktree,
|
||||
canonical,
|
||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const hg = yield* hgDiscover(input)
|
||||
if (hg) return hg
|
||||
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
||||
if (hg) return yield* persist({ ...hg, canonical: hg.directory })
|
||||
const directory = AbsolutePath.make(path.parse(input).root)
|
||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||
})
|
||||
|
||||
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export * from "./session/schema"
|
|||
|
||||
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project"
|
||||
import { Workspace } from "./workspace"
|
||||
import { Model } from "./model"
|
||||
|
|
@ -325,6 +325,22 @@ const layer = Layer.effect(
|
|||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const persistProject = (project: Project.Resolved) => {
|
||||
const vcs = project.vcs?.type
|
||||
return db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
|
|
@ -347,12 +363,7 @@ const layer = Layer.effect(
|
|||
if (location === undefined)
|
||||
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
||||
const project = yield* projects.resolve(location.directory)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* persistProject(project)
|
||||
const now = Date.now()
|
||||
const info = SessionV1.SessionInfo.make({
|
||||
id: sessionID,
|
||||
|
|
@ -451,6 +462,7 @@ const layer = Layer.effect(
|
|||
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 ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.parentID !== undefined)
|
||||
conditions.push(
|
||||
|
|
@ -732,12 +744,7 @@ const layer = Layer.effect(
|
|||
)
|
||||
return
|
||||
const project = yield* projects.resolve(directory)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* persistProject(project)
|
||||
if ((yield* execution.active).has(input.sessionID)) {
|
||||
yield* execution.interrupt(input.sessionID)
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ describe("node build", () => {
|
|||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: service.directory },
|
||||
project: { id: Project.ID.global, directory: service.directory, canonical: service.directory },
|
||||
}),
|
||||
),
|
||||
{ idleTimeToLive: "1 minute" },
|
||||
|
|
@ -79,7 +79,7 @@ describe("node build", () => {
|
|||
return Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
commit: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { Effect, Layer } from "effect"
|
|||
import { tmpdir } from "./tmpdir"
|
||||
|
||||
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
|
||||
const directory = input.projectDirectory ?? ref.directory
|
||||
return {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
|
||||
project: { id: Project.ID.global, directory, canonical: directory },
|
||||
vcs: input.vcs,
|
||||
} satisfies Location.Interface
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const projectLayer = Layer.succeed(
|
|||
Effect.succeed({
|
||||
id: Project.ID.make("project"),
|
||||
directory: AbsolutePath.make("/repo"),
|
||||
canonical: AbsolutePath.make("/main/repo"),
|
||||
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
|
||||
}),
|
||||
commit: () => Effect.void,
|
||||
|
|
@ -34,6 +35,7 @@ describe("Location", () => {
|
|||
expect(location.workspaceID).toBe(workspaceID)
|
||||
expect(location.project.id).toBe(Project.ID.make("project"))
|
||||
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
|
||||
expect(location.project.canonical).toBe(AbsolutePath.make("/main/repo"))
|
||||
expect(location.vcs).toEqual({
|
||||
type: "git",
|
||||
store: AbsolutePath.make("/repo/.git"),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
|
|
@ -88,11 +87,6 @@ describe("MoveSession", () => {
|
|||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
|
|
@ -144,11 +138,6 @@ describe("MoveSession", () => {
|
|||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move_nested")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
|
|
@ -204,11 +193,6 @@ describe("MoveSession", () => {
|
|||
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
|
||||
const sessionID = Session.ID.make("ses_move_project")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
|
|
@ -268,11 +252,6 @@ describe("MoveSession", () => {
|
|||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move_nested_checkout")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
|
|
|
|||
|
|
@ -121,7 +121,11 @@ export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] {
|
|||
? Effect.succeed({
|
||||
location: new Location.Info({
|
||||
directory: AbsolutePath.make("/"),
|
||||
project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") },
|
||||
project: {
|
||||
id: Project.ID.make("test"),
|
||||
directory: AbsolutePath.make("/"),
|
||||
canonical: AbsolutePath.make("/"),
|
||||
},
|
||||
}),
|
||||
data: agentInfo(value),
|
||||
})
|
||||
|
|
@ -163,7 +167,11 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog
|
|||
Effect.map((data) => ({
|
||||
location: new Location.Info({
|
||||
directory: AbsolutePath.make("/"),
|
||||
project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") },
|
||||
project: {
|
||||
id: Project.ID.make("test"),
|
||||
directory: AbsolutePath.make("/"),
|
||||
canonical: AbsolutePath.make("/"),
|
||||
},
|
||||
}),
|
||||
data: data.map(modelInfo),
|
||||
})),
|
||||
|
|
@ -357,7 +365,11 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
|
|||
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
|
||||
const location = Location.Info.make({
|
||||
directory: AbsolutePath.make("/tmp/websearch-test"),
|
||||
project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") },
|
||||
project: {
|
||||
id: Project.ID.make("websearch-test"),
|
||||
directory: AbsolutePath.make("/tmp/websearch-test"),
|
||||
canonical: AbsolutePath.make("/tmp/websearch-test"),
|
||||
},
|
||||
})
|
||||
return {
|
||||
providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))),
|
||||
|
|
|
|||
|
|
@ -47,13 +47,13 @@ describe("Project.list", () => {
|
|||
expect(yield* project.list()).toEqual([
|
||||
{
|
||||
id: Project.ID.make("newer"),
|
||||
worktree: abs("/newer"),
|
||||
canonical: abs("/newer"),
|
||||
time: { created: 2, updated: 2, initialized: 3 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: Project.ID.make("older"),
|
||||
worktree: abs("/older"),
|
||||
canonical: abs("/older"),
|
||||
vcs: "git",
|
||||
name: "Older",
|
||||
icon: { color: "#000000" },
|
||||
|
|
@ -105,6 +105,7 @@ describe("Project.resolve", () => {
|
|||
|
||||
expect(result.id).toBe(Project.ID.make("global"))
|
||||
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
|
||||
expect(result.canonical).toBe(result.directory)
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs).toBeUndefined()
|
||||
}),
|
||||
|
|
@ -123,6 +124,7 @@ describe("Project.resolve", () => {
|
|||
|
||||
expect(result.id).toBe(Project.ID.make("global"))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.canonical).toBe(result.directory)
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
|
|
@ -327,13 +329,46 @@ describe("Project.resolve", () => {
|
|||
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
|
||||
yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
|
||||
const project = yield* Project.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const id = remoteID("github.com/owner/repo")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id,
|
||||
worktree: abs("/stale-worktree"),
|
||||
vcs: "hg",
|
||||
name: "Preserved name",
|
||||
icon_color: "#123456",
|
||||
commands: { start: "bun dev" },
|
||||
sandboxes: [abs("/preserved-sandbox")],
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
time_initialized: 2,
|
||||
})
|
||||
.run()
|
||||
|
||||
const result = yield* project.resolve(abs(worktree))
|
||||
|
||||
expect(result.directory).toBe(yield* real(worktree))
|
||||
expect(result.canonical).toBe(yield* real(tmp.path))
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(result.id).toBe(id)
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
expect((yield* project.list()).find((item) => item.id === id)).toMatchObject({
|
||||
canonical: yield* real(tmp.path),
|
||||
vcs: "git",
|
||||
name: "Preserved name",
|
||||
icon: { color: "#123456" },
|
||||
commands: { start: "bun dev" },
|
||||
sandboxes: [abs("/preserved-sandbox")],
|
||||
time: { created: 1, initialized: 2 },
|
||||
})
|
||||
expect(
|
||||
(yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
).toEqual([
|
||||
{ directory: yield* real(tmp.path) },
|
||||
{ directory: yield* real(worktree), strategy: "git_worktree" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const projects = Layer.succeed(
|
|||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { Model } from "@opencode-ai/core/model"
|
|||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
|
|
@ -32,7 +32,7 @@ const projects = Layer.succeed(
|
|||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
|
|
@ -184,6 +184,26 @@ describe("Session.create", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("filters project sessions by subpath", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const { db } = yield* Database.Service
|
||||
const root = yield* session.create({ location, title: "root" })
|
||||
const nested = yield* session.create({ location, title: "nested" })
|
||||
|
||||
yield* db.update(SessionTable).set({ path: "packages/tui" }).where(eq(SessionTable.id, nested.id)).run()
|
||||
|
||||
const page = yield* session.list({
|
||||
project: Project.ID.global,
|
||||
subpath: RelativePath.make("packages/tui"),
|
||||
parentID: null,
|
||||
})
|
||||
|
||||
expect(page.data.map((item) => item.id)).toEqual([nested.id])
|
||||
expect(page.data.map((item) => item.id)).not.toContain(root.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forks a session by replaying a durable fork event into copied projected rows", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { Location } from "@opencode-ai/core/location"
|
|||
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
|
||||
import { ID } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
|
@ -191,11 +190,6 @@ const setup = Effect.gen(function* () {
|
|||
agent.mode = "primary"
|
||||
}),
|
||||
)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ const projects = Layer.succeed(
|
|||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const projects = Layer.succeed(
|
|||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const projects = Layer.succeed(
|
|||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect"
|
|||
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const projects = Layer.mock(Project.Service, {
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
})
|
||||
const skills = Layer.mock(Skill.Service, {
|
||||
list: () =>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { testEffect } from "./lib/effect"
|
|||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const awaited: Session.ID[] = []
|
||||
const projects = Layer.mock(Project.Service, {
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
})
|
||||
const execution = Layer.mock(SessionExecution.Service, {
|
||||
awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
OpenCodeEvent,
|
||||
PermissionSavedInfo,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderInfo,
|
||||
ReferenceInfo,
|
||||
SessionInfo,
|
||||
|
|
@ -77,6 +78,10 @@ export interface Data {
|
|||
}
|
||||
}
|
||||
readonly project: {
|
||||
list(): Project[]
|
||||
get(projectID: string): Project | undefined
|
||||
sync(): Promise<void>
|
||||
invalidate(): void
|
||||
readonly permission: {
|
||||
list(projectID: string): PermissionSavedInfo[] | undefined
|
||||
sync(projectID: string): Promise<void>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export class Info extends Schema.Class<Info>("Location.Info")({
|
|||
project: Schema.Struct({
|
||||
id: ProjectID,
|
||||
directory: AbsolutePath,
|
||||
canonical: AbsolutePath,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export const Vcs = Schema.Literals(["git", "hg"]).annotate({ identifier: "Projec
|
|||
export const Current = Schema.Struct({
|
||||
id: ID,
|
||||
directory: AbsolutePath,
|
||||
canonical: AbsolutePath,
|
||||
}).annotate({ identifier: "Project.Current" })
|
||||
export interface Current extends Schema.Schema.Type<typeof Current> {}
|
||||
export const Directory = Schema.Struct({
|
||||
|
|
@ -46,7 +47,7 @@ export interface Time extends Schema.Schema.Type<typeof Time> {}
|
|||
|
||||
export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
worktree: Schema.String,
|
||||
canonical: AbsolutePath,
|
||||
vcs: optional(Vcs),
|
||||
name: optional(Schema.String),
|
||||
icon: optional(Icon),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl
|
|||
.handle("project.list", () => Project.Service.use((project) => project.list()))
|
||||
.handle("project.current", () =>
|
||||
Location.Service.use((location) =>
|
||||
Effect.succeed({ id: location.project.id, directory: location.project.directory }),
|
||||
Effect.succeed({
|
||||
id: location.project.id,
|
||||
directory: location.project.directory,
|
||||
canonical: location.project.canonical,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handle("project.directories", (ctx) =>
|
||||
|
|
|
|||
|
|
@ -102,12 +102,12 @@ export function DialogIntegration(
|
|||
title="Connect a service"
|
||||
options={options()}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No integrations available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No integrations found</text>
|
||||
</box>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||
options={options()}
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load project directories
|
||||
</text>
|
||||
|
|
@ -336,17 +336,17 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
|
||||
</box>
|
||||
) : directories.loading || loadedProject.loading ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>Loading project directories…</text>
|
||||
</box>
|
||||
) : (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No project directories available</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No project directories found</text>
|
||||
</box>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||
import path from "path"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
|
|
@ -32,52 +33,69 @@ export function DialogSessionList() {
|
|||
const shortcuts = Keymap.useShortcuts()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [allProjects, setAllProjects] = createSignal(false)
|
||||
|
||||
const [searchResults] = createResource(search, async (query) => {
|
||||
if (!query) return
|
||||
try {
|
||||
if (!data.location.info()) await data.location.sync()
|
||||
const current = data.location.info()
|
||||
if (!current) throw new Error("Location unavailable")
|
||||
const response = await client.api.session.list({
|
||||
project: current.project.id,
|
||||
search: query,
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
parentID: null,
|
||||
})
|
||||
return { query, sessions: response.data, error: undefined }
|
||||
} catch (error) {
|
||||
// A transient transport failure must degrade search, not crash the TUI
|
||||
// through the root ErrorBoundary when the errored resource is read.
|
||||
return { query, sessions: [] as SessionInfo[], error }
|
||||
}
|
||||
})
|
||||
const [searchResults, { mutate: setSearchResults }] = createResource(
|
||||
() => ({ query: search().trim(), allProjects: allProjects() }),
|
||||
async ({ query, allProjects }) => {
|
||||
try {
|
||||
if (!data.location.info()) await data.location.sync()
|
||||
const current = data.location.info()
|
||||
if (!current) throw new Error("Location unavailable")
|
||||
const response = await client.api.session.list({
|
||||
...(allProjects
|
||||
? {}
|
||||
: {
|
||||
project: current.project.id,
|
||||
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
|
||||
}),
|
||||
...(query ? { search: query } : {}),
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
parentID: null,
|
||||
})
|
||||
return { query, allProjects, sessions: response.data, error: undefined }
|
||||
} catch (error) {
|
||||
// A transient transport failure must degrade search, not crash the TUI
|
||||
// through the root ErrorBoundary when the errored resource is read.
|
||||
return { query, allProjects, sessions: [] as SessionInfo[], error }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const localSessions = createMemo(() => {
|
||||
const query = filter().trim().toLowerCase()
|
||||
const sessions = data.session.list()
|
||||
const current = data.location.info()
|
||||
const sessions = data.session
|
||||
.list()
|
||||
.filter(
|
||||
(session) =>
|
||||
allProjects() ||
|
||||
(session.projectID === current?.project.id && session.location.directory === current.directory),
|
||||
)
|
||||
if (!query) return sessions
|
||||
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
|
||||
})
|
||||
const sessions = createMemo(() => {
|
||||
const query = filter()
|
||||
const query = filter().trim()
|
||||
const local = localSessions()
|
||||
if (!query) return local
|
||||
if (query !== search() || searchResults.loading) return local
|
||||
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
|
||||
const result = searchResults()
|
||||
if (result?.query !== query || result.error) return local
|
||||
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
|
||||
return result.sessions
|
||||
})
|
||||
const searchState = createMemo(() => {
|
||||
const query = filter()
|
||||
if (!query) return { message: "No sessions available", error: false }
|
||||
if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false }
|
||||
const query = filter().trim()
|
||||
if (query !== search().trim() || searchResults.loading)
|
||||
return { message: query ? "Searching sessions…" : "Loading sessions…", error: false }
|
||||
const result = searchResults()
|
||||
if (result?.query === query && result.error)
|
||||
return { message: "Could not search sessions. Change the search to try again.", error: true }
|
||||
return { message: "No sessions found", error: false }
|
||||
return {
|
||||
message: query ? "Could not search sessions. Change the search to try again." : "Could not load sessions.",
|
||||
error: true,
|
||||
}
|
||||
return { message: query ? "No sessions found" : "No sessions available", error: false }
|
||||
})
|
||||
|
||||
const quickSwitchHint = createMemo(() => {
|
||||
|
|
@ -91,6 +109,13 @@ export function DialogSessionList() {
|
|||
const hint = quickSwitchHint()
|
||||
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
||||
})
|
||||
const currentProjectName = createMemo(() => {
|
||||
const current = data.location.info()
|
||||
if (!current) return ""
|
||||
const project = data.project.get(current.project.id)
|
||||
if (!project) return ""
|
||||
return project.name || path.basename(project.canonical)
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
|
|
@ -105,8 +130,12 @@ export function DialogSessionList() {
|
|||
|
||||
const option = (session: SessionInfo, category: string) => {
|
||||
const directory = session.location.directory
|
||||
const footer =
|
||||
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
|
||||
const project = data.project.get(session.projectID)
|
||||
const footer = allProjects()
|
||||
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
|
||||
: directory !== data.location.info()?.project.directory
|
||||
? Locale.truncate(path.basename(directory), 20)
|
||||
: ""
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
|
|
@ -139,6 +168,16 @@ export function DialogSessionList() {
|
|||
return (
|
||||
<DialogSelect
|
||||
title="Sessions"
|
||||
titleView={
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Sessions
|
||||
</text>
|
||||
<Show when={!allProjects() && currentProjectName()}>
|
||||
<text fg={theme.text.subdued}> for {currentProjectName()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
options={options()}
|
||||
skipFilter={true}
|
||||
current={currentSessionID()}
|
||||
|
|
@ -146,13 +185,25 @@ export function DialogSessionList() {
|
|||
setFilter(query)
|
||||
setSearch(query)
|
||||
}}
|
||||
bindings={[
|
||||
{
|
||||
bind: "ctrl+a",
|
||||
title: allProjects() ? "Show current directory sessions" : "Show all project sessions",
|
||||
group: "Dialog",
|
||||
run: () => {
|
||||
setAllProjects((value) => !value)
|
||||
},
|
||||
},
|
||||
]}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={theme.text.subdued}>No sessions available</text>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
|
||||
{searchState().message}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
|
||||
{searchState().message}
|
||||
</text>
|
||||
|
|
@ -178,24 +229,34 @@ export function DialogSessionList() {
|
|||
setToDelete(option.value)
|
||||
return
|
||||
}
|
||||
void client.api.session.remove({ sessionID: option.value }).catch((error) => {
|
||||
setToDelete(undefined)
|
||||
toast.show({
|
||||
message: `Failed to delete session: ${errorMessage(error)}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
void client.api.session
|
||||
.remove({ sessionID: option.value })
|
||||
.then(() => {
|
||||
setSearchResults((result) =>
|
||||
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
setToDelete(undefined)
|
||||
toast.show({
|
||||
message: `Failed to delete session: ${errorMessage(error)}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "session.rename",
|
||||
title: "rename",
|
||||
onTrigger: (option: { value: string }) =>
|
||||
DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
|
||||
onTrigger: (option: { value: string; title: string }) =>
|
||||
DialogSessionRename.show(dialog, option.value, option.title),
|
||||
},
|
||||
]}
|
||||
footerHints={quickSwitchFooterHints()}
|
||||
footerHints={[
|
||||
...quickSwitchFooterHints(),
|
||||
{ title: allProjects() ? "current directory" : "all projects", label: "ctrl+a", side: "right" },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,13 +62,13 @@ export function DialogSkill(props: DialogSkillProps) {
|
|||
emptyView={
|
||||
<Switch
|
||||
fallback={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No skills available</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Match when={showError()}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load skills
|
||||
</text>
|
||||
|
|
@ -77,14 +77,14 @@ export function DialogSkill(props: DialogSkillProps) {
|
|||
</box>
|
||||
</Match>
|
||||
<Match when={skills.loading}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>Loading skills…</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No skills found</text>
|
||||
</box>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
ModelInfo,
|
||||
PermissionSavedInfo,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderInfo,
|
||||
ReferenceInfo,
|
||||
SessionMessageInfo,
|
||||
|
|
@ -80,6 +81,7 @@ type Store = {
|
|||
form: Record<string, FormWithLocation[]>
|
||||
}
|
||||
project: {
|
||||
info: Record<string, Project>
|
||||
permission: Record<string, PermissionSavedInfo[]>
|
||||
}
|
||||
location: Record<string, LocationData>
|
||||
|
|
@ -139,6 +141,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
form: {},
|
||||
},
|
||||
project: {
|
||||
info: {},
|
||||
permission: {},
|
||||
},
|
||||
location: {},
|
||||
|
|
@ -954,10 +957,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
},
|
||||
},
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session:${sessionID}`, async () => {
|
||||
setStore("session", "info", sessionID, await client.api.session.get({ sessionID }))
|
||||
registerSession(sessionID)
|
||||
sync(sessionID: string, options?: { children?: boolean }) {
|
||||
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
|
||||
const [info, children] = await Promise.all([
|
||||
client.api.session.get({ sessionID }),
|
||||
options?.children
|
||||
? client.api.session.list({ parentID: sessionID, order: "desc" }).then((response) => response.data)
|
||||
: [],
|
||||
])
|
||||
const sessions = [info, ...children]
|
||||
setStore(
|
||||
"session",
|
||||
"info",
|
||||
produce((draft) => {
|
||||
for (const session of sessions) draft[session.id] = session
|
||||
}),
|
||||
)
|
||||
for (const session of sessions) {
|
||||
sync.complete(`session:${session.id}`)
|
||||
registerSession(session.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
|
|
@ -1037,6 +1056,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
},
|
||||
},
|
||||
project: {
|
||||
list() {
|
||||
return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
},
|
||||
get(projectID: string) {
|
||||
return store.project.info[projectID]
|
||||
},
|
||||
sync() {
|
||||
return sync.run("project", async () => {
|
||||
const projects = await client.api.project.list()
|
||||
setStore("project", "info", reconcile(Object.fromEntries(projects.map((project) => [project.id, project]))))
|
||||
})
|
||||
},
|
||||
invalidate() {
|
||||
sync.invalidate("project")
|
||||
},
|
||||
permission: {
|
||||
list(projectID: string) {
|
||||
return store.project.permission[projectID]
|
||||
|
|
@ -1318,27 +1352,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
.then((location) => {
|
||||
const key = locationKey(location)
|
||||
setStore("location", key, { ...store.location[key], info: location })
|
||||
return client.api.session.list({
|
||||
project: location.project.id,
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
parentID: null,
|
||||
})
|
||||
})
|
||||
.then((response) => {
|
||||
setStore(
|
||||
"session",
|
||||
"info",
|
||||
produce((draft) => {
|
||||
for (const session of response.data) draft[session.id] = session
|
||||
}),
|
||||
)
|
||||
for (const session of response.data) {
|
||||
sync.complete(`session:${session.id}`)
|
||||
registerSession(session.id)
|
||||
}
|
||||
})
|
||||
.catch((error) => console.error("Failed to preload sessions", error))
|
||||
.catch((error) => console.error("Failed to preload location", error))
|
||||
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
|
||||
return
|
||||
}
|
||||
handleEvent(details)
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ export function Session() {
|
|||
const sessionID = route.sessionID
|
||||
void (async () => {
|
||||
await Promise.all([
|
||||
data.session.sync(sessionID),
|
||||
data.session.sync(sessionID, { children: true }),
|
||||
data.session.permission.sync(sessionID),
|
||||
data.session.form.sync(sessionID),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -615,14 +615,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
when={props.renderFilter !== false && store.filter.length > 0}
|
||||
fallback={
|
||||
props.emptyView ?? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No items available</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
>
|
||||
{props.noMatchView ?? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No results found</text>
|
||||
</box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { ClientProvider, useClient } from "../../../src/context/client"
|
|||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
|
|
@ -71,11 +71,13 @@ function durable(sessionID: string, seq = 0, version = 1) {
|
|||
return { aggregateID: sessionID, seq, version }
|
||||
}
|
||||
|
||||
test("preloads root sessions before applying the session limit", async () => {
|
||||
test("does not preload session summaries into the data context", async () => {
|
||||
const events = createEventStream()
|
||||
let request: URL | undefined
|
||||
let location = false
|
||||
let sessions = false
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") request = url
|
||||
if (url.pathname === "/api/location") location = true
|
||||
if (url.pathname === "/api/session") sessions = true
|
||||
return undefined
|
||||
}, events)
|
||||
|
||||
|
|
@ -92,10 +94,58 @@ test("preloads root sessions before applying the session limit", async () => {
|
|||
))
|
||||
|
||||
try {
|
||||
await wait(() => request !== undefined)
|
||||
expect(request?.searchParams.get("project")).toBe("proj_test")
|
||||
expect(request?.searchParams.get("limit")).toBe("50")
|
||||
expect(request?.searchParams.get("parentID")).toBe("null")
|
||||
await wait(() => location)
|
||||
await Bun.sleep(20)
|
||||
expect(sessions).toBe(false)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("proactively syncs project metadata", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/project") return
|
||||
return json([
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: worktree,
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => data.project.get("proj_test") !== undefined)
|
||||
expect(data.project.list()).toEqual([
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: worktree,
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
|
@ -2619,8 +2669,18 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) {
|
|||
// the family-index tests below.
|
||||
async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") {
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
return json({
|
||||
data: Object.entries(parents)
|
||||
.filter(([, parent]) => parent === parentID)
|
||||
.map(([id, parent]) => sessionInfo(id, parent, costs[id])),
|
||||
cursor: {},
|
||||
})
|
||||
}
|
||||
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
|
||||
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
|
||||
if (match && match[1] !== "active")
|
||||
return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
|
||||
})
|
||||
let data!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
|
|
@ -2647,6 +2707,20 @@ async function mountData(parents: Record<string, string>, costs: Record<string,
|
|||
return { data, app }
|
||||
}
|
||||
|
||||
test("syncs direct child session info with a navigated root", async () => {
|
||||
const { data, app } = await mountData({ child: "root", sibling: "root", grandchild: "child" })
|
||||
try {
|
||||
await data.session.sync("root", { children: true })
|
||||
expect(data.session.get("root")?.id).toBe("root")
|
||||
expect(data.session.get("child")?.parentID).toBe("root")
|
||||
expect(data.session.get("sibling")?.parentID).toBe("root")
|
||||
expect(data.session.get("grandchild")).toBeUndefined()
|
||||
expect(data.session.family("root")).toEqual(["root", "child", "sibling"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("groups an orphan child under its missing parent until the root arrives", async () => {
|
||||
const { data, app } = await mountData({ child: "root" })
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -185,6 +185,19 @@ test("dialog actions run without options while row actions still require a selec
|
|||
}
|
||||
})
|
||||
|
||||
test("renders one gap before an empty state", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const app = await renderSelect(tmp.path, [], () => {}, () => {})
|
||||
|
||||
try {
|
||||
await app.waitForFrame((frame) => frame.includes("No items available"))
|
||||
const lines = app.captureCharFrame().split("\n").map((line) => line.trim())
|
||||
expect(lines.indexOf("No items available") - lines.indexOf("Search")).toBe(2)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("footer actions run when filtering leaves no selected row", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let global = 0
|
||||
|
|
|
|||
|
|
@ -93,24 +93,26 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: true })
|
||||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
|
||||
if (url.pathname === "/api/fs/list")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
|
||||
if (url.pathname === "/api/project") return json([])
|
||||
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
|
||||
if (url.pathname === "/api/shell")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/mcp")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree } },
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
data: { resources: [], templates: [] },
|
||||
})
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json({ data: {} })
|
||||
if (url.pathname === "/api/permission/request")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/form/request")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
|
||||
|
|
@ -120,13 +122,13 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||
)
|
||||
)
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree } },
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
data: [],
|
||||
})
|
||||
if (url.pathname === "/api/reference")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
|
||||
if (url.pathname === "/api/websearch/provider") {
|
||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
|
||||
}
|
||||
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
|
||||
if (url.pathname === "/session") return json([])
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ describe("run interactive runtime", () => {
|
|||
directory: "/tmp",
|
||||
target: async () => ({
|
||||
sessionID: "ses_root",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "build",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
|
|
@ -130,7 +130,7 @@ describe("run interactive runtime", () => {
|
|||
await refreshCatalog?.()
|
||||
expect(defaultModel).toHaveBeenCalledTimes(1)
|
||||
selected.resolve({
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
data: model,
|
||||
})
|
||||
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
|
||||
|
|
@ -165,7 +165,7 @@ describe("run interactive runtime", () => {
|
|||
directory: "/tmp",
|
||||
target: async () => ({
|
||||
sessionID: "ses_root",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "model" },
|
||||
variant: undefined,
|
||||
|
|
@ -261,7 +261,7 @@ describe("run interactive runtime", () => {
|
|||
return {
|
||||
sessionID: "ses-deferred",
|
||||
sessionTitle: "Deferred",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "build",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: undefined,
|
||||
|
|
@ -349,7 +349,7 @@ describe("run interactive runtime", () => {
|
|||
target: async () => ({
|
||||
sessionID: "ses-resume",
|
||||
sessionTitle: "Resume",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "review",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "high",
|
||||
|
|
@ -432,7 +432,7 @@ describe("run interactive runtime", () => {
|
|||
target: async () => ({
|
||||
sessionID: "ses-resume-abort",
|
||||
sessionTitle: "Cached title",
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
agent: "build",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
|
|
@ -490,7 +490,7 @@ describe("run interactive runtime", () => {
|
|||
location: {
|
||||
directory: "/session",
|
||||
workspaceID: "work-1",
|
||||
project: { id: "pro-1", directory: "/session" },
|
||||
project: { id: "pro-1", directory: "/session", canonical: "/session" },
|
||||
},
|
||||
data: [{ path: "src/index.ts", type: "file" }],
|
||||
} as never)
|
||||
|
|
@ -507,7 +507,7 @@ describe("run interactive runtime", () => {
|
|||
location: {
|
||||
directory: "/session",
|
||||
workspaceID: "work-1",
|
||||
project: { id: "location-project", directory: "/session" },
|
||||
project: { id: "location-project", directory: "/session", canonical: "/session" },
|
||||
},
|
||||
agent: "review",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
|
|
|
|||
|
|
@ -167,7 +167,11 @@ function sdk(input: {
|
|||
location: {
|
||||
directory: input.globalLocation?.directory ?? "/tmp",
|
||||
workspaceID: input.globalLocation?.workspaceID,
|
||||
project: { id: "proj_1", directory: input.globalLocation?.directory ?? "/tmp" },
|
||||
project: {
|
||||
id: "proj_1",
|
||||
directory: input.globalLocation?.directory ?? "/tmp",
|
||||
canonical: input.globalLocation?.directory ?? "/tmp",
|
||||
},
|
||||
},
|
||||
data: input.globals ?? [],
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue