Merge remote-tracking branch 'origin/dev' into httpapi-codegen
This commit is contained in:
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Discord Community
|
||||
url: https://discord.gg/opencode
|
||||
about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question.
|
||||
about: For support, troubleshooting, how-to questions, and real-time discussion.
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
name: Question
|
||||
description: Ask a question
|
||||
body:
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
description: What's your question?
|
||||
validations:
|
||||
required: true
|
||||
@@ -27,6 +27,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
writableTmpDirAsHomeHook
|
||||
];
|
||||
|
||||
postPatch = ''
|
||||
# NOTE: Relax Bun version check to be a warning instead of an error
|
||||
substituteInPlace packages/script/src/index.ts \
|
||||
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
|
||||
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
|
||||
'';
|
||||
|
||||
configurePhase = ''
|
||||
runHook preConfigure
|
||||
|
||||
|
||||
@@ -185,7 +185,9 @@ function TargetDirectoryLayout(props: ParentProps) {
|
||||
if (!search.draftId) return undefined
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
|
||||
})
|
||||
const directory = createMemo<string | undefined>((prev) => prev ?? resolvedDirectory())
|
||||
const directory = createMemo<string | undefined>((prev) =>
|
||||
search.draftId ? resolvedDirectory() : (prev ?? resolvedDirectory()),
|
||||
)
|
||||
const home = () => !params.serverKey && !search.draftId
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
|
||||
@@ -1379,7 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||
|
||||
const [promptReady] = createResource(
|
||||
() => prompt.ready().promise,
|
||||
() => prompt.ready.promise,
|
||||
(p) => p,
|
||||
)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ let variant: string | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const prompt = {
|
||||
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
dirty: () => true,
|
||||
|
||||
@@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { WindowsAppMenu } from "./windows-app-menu"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -264,15 +263,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const serverSdk = useServerSDK()
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
|
||||
const newSessionHref = () => {
|
||||
if (params.dir) return `/${params.dir}/session`
|
||||
|
||||
const project = layout.projects.list()[0]
|
||||
if (!project) return "/"
|
||||
|
||||
return `/${base64Encode(project.worktree)}/session`
|
||||
}
|
||||
const global = useGlobal()
|
||||
|
||||
const tabs = useTabs()
|
||||
const tabsStore = tabs.store
|
||||
@@ -337,7 +328,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
tabsStoreActions.removeSessions(detail)
|
||||
})
|
||||
|
||||
const openNewTab = () => navigate(newSessionHref())
|
||||
const openNewTab = () => {
|
||||
const route = layout.route()
|
||||
const activeSession = session()
|
||||
if (route.type === "session" && activeSession) {
|
||||
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
|
||||
return
|
||||
}
|
||||
|
||||
const current = layout.projects.list()[0]
|
||||
if (current) {
|
||||
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
|
||||
return
|
||||
}
|
||||
|
||||
const fallback = global.servers.list().flatMap((conn) => {
|
||||
const project = global.createServerCtx(conn).projects.list()[0]
|
||||
return project ? [{ server: ServerConnection.key(conn), project }] : []
|
||||
})[0]
|
||||
if (!fallback) return
|
||||
|
||||
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
|
||||
}
|
||||
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
|
||||
|
||||
command.register("titlebar-home", () => [
|
||||
@@ -512,10 +524,16 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}
|
||||
|
||||
const [session] = createResource(
|
||||
() => tab.sessionId,
|
||||
(sessionID) =>
|
||||
serverSdk()
|
||||
.client.session.get({ sessionID })
|
||||
() => {
|
||||
const id = tab.sessionId
|
||||
const conn = server.list.find((s) => ServerConnection.key(s) === tab.server)
|
||||
if (!conn) return null
|
||||
const { sdk } = global.createServerCtx(conn)
|
||||
return { id, sdk }
|
||||
},
|
||||
({ id, sdk }) =>
|
||||
sdk.client.session
|
||||
.get({ sessionID: id })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined),
|
||||
)
|
||||
@@ -592,8 +610,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
as="a"
|
||||
href={newSessionHref()}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -181,7 +181,7 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
|
||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||
}
|
||||
|
||||
function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
promptTarget(serverScope, scope),
|
||||
createStore<PromptStore>(promptStore()),
|
||||
@@ -190,6 +190,12 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
return { ready, ...createPromptStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
export function createPromptReady(session: Accessor<PromptSession>) {
|
||||
return Object.defineProperty(() => session().ready(), "promise", {
|
||||
get: () => session().ready.promise,
|
||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||
}
|
||||
|
||||
function promptStore(): PromptStore {
|
||||
return {
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
@@ -247,7 +253,7 @@ export function createPromptState() {
|
||||
const [store, setStore] = createStore<PromptStore>(promptStore())
|
||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||
return {
|
||||
ready: () => ready,
|
||||
ready,
|
||||
...createPromptStateValue(store, setStore),
|
||||
}
|
||||
}
|
||||
@@ -308,9 +314,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
|
||||
)
|
||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
return {
|
||||
ready: () => session().ready,
|
||||
ready,
|
||||
current: () => session().current(),
|
||||
cursor: () => session().cursor(),
|
||||
dirty: () => session().dirty(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, type ParentProps } from "solid-js"
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton } from "@/components/help-button"
|
||||
@@ -28,7 +28,7 @@ export default function NewLayout(props: ParentProps) {
|
||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<Titlebar update={update} />
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
{props.children}
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<HelpButton />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
@@ -25,11 +25,12 @@ import { pathKey } from "@/utils/path-key"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { type DraftTab, useTabs } from "@/context/tabs"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useGlobal } from "@/context/global"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
@@ -73,26 +74,52 @@ export function SessionComposerRegion(props: {
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const view = layout.view(route.sessionKey)
|
||||
|
||||
const draft = createMemo(() => {
|
||||
if (!search.draftId) return
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
})
|
||||
const projectServer = createMemo(() => {
|
||||
if (!search.draftId) return server.current
|
||||
const target = draft()?.server
|
||||
if (!target) return
|
||||
return server.list.find((conn) => ServerConnection.key(conn) === target)
|
||||
})
|
||||
const projectServerCtx = createMemo(() => {
|
||||
const conn = projectServer()
|
||||
if (conn) return global.createServerCtx(conn)
|
||||
})
|
||||
const projects = createMemo(() =>
|
||||
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
|
||||
)
|
||||
|
||||
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
|
||||
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
|
||||
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
|
||||
const selectProject = (worktree: string) => {
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
const conn = projectServer()
|
||||
const target = projectServerCtx()
|
||||
if (search.draftId) {
|
||||
tabs.updateDraft(search.draftId, { server: server.key, directory: worktree })
|
||||
if (!conn || !target) return
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||
return
|
||||
}
|
||||
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
const addProject = (title: string) => {
|
||||
if (!server.current) return
|
||||
const conn = projectServer()
|
||||
if (!conn) return
|
||||
pickDirectory({
|
||||
server: server.current,
|
||||
server: conn,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
@@ -115,7 +142,7 @@ export function SessionComposerRegion(props: {
|
||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||
},
|
||||
projects: {
|
||||
available: layout.projects.list(),
|
||||
available: projects(),
|
||||
directory: sdk().directory,
|
||||
select: selectProject,
|
||||
add: addProject,
|
||||
@@ -216,6 +243,12 @@ export function SessionComposerRegion(props: {
|
||||
update()
|
||||
})
|
||||
|
||||
const ready = Promise.resolve()
|
||||
const [promptReadyResource] = createResource(
|
||||
() => prompt.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.setPromptDockRef}
|
||||
@@ -258,7 +291,7 @@ export function SessionComposerRegion(props: {
|
||||
|
||||
<Show when={showComposer()}>
|
||||
<Show
|
||||
when={prompt.ready()}
|
||||
when={promptReadyResource()}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={rolled()} keyed>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { createEffect, createRoot } from "solid-js"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
let Prompt: typeof import("@/context/prompt")
|
||||
let read: ((value: string | null) => void) | undefined
|
||||
|
||||
const storage: AsyncStorage = {
|
||||
getItem: () => new Promise((resolve) => (read = resolve)),
|
||||
setItem: async () => undefined,
|
||||
removeItem: async () => undefined,
|
||||
clear: async () => undefined,
|
||||
key: async () => null,
|
||||
getLength: async () => 0,
|
||||
length: Promise.resolve(0),
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useParams: () => ({}),
|
||||
useSearchParams: () => [{}],
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
use: () => undefined,
|
||||
provider: () => undefined,
|
||||
}),
|
||||
}))
|
||||
mock.module("@/context/platform", () => ({
|
||||
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
|
||||
}))
|
||||
|
||||
Prompt = await import("@/context/prompt")
|
||||
})
|
||||
|
||||
describe("prompt persistence", () => {
|
||||
test("waits for an async draft to hydrate before reporting ready", async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
createRoot((dispose) => {
|
||||
const session = Prompt.createPromptSession(ServerScope.local, { draftID: "draft-async" })
|
||||
const ready = Prompt.createPromptReady(() => session)
|
||||
|
||||
expect(ready()).toBe(false)
|
||||
expect(session.current()[0]).toMatchObject({ type: "text", content: "" })
|
||||
|
||||
read?.(
|
||||
JSON.stringify({
|
||||
prompt: [{ type: "text", content: "persisted draft", start: 0, end: 15 }],
|
||||
cursor: 15,
|
||||
context: { items: [] },
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
try {
|
||||
expect(session.current()[0]).toMatchObject({ type: "text", content: "persisted draft" })
|
||||
dispose()
|
||||
resolve()
|
||||
} catch (error) {
|
||||
dispose()
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -157,15 +157,12 @@ describe("ModelsDev Service", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* writeCacheText("{")
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const context = yield* Layer.build(buildLayer(state))
|
||||
const result = yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
|
||||
}),
|
||||
() =>
|
||||
provided(
|
||||
state,
|
||||
ModelsDev.Service.use((s) => s.get()),
|
||||
),
|
||||
() => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
|
||||
export type PromptPart = SessionV1.TextPartInput | SessionV1.FilePartInput
|
||||
@@ -76,7 +76,26 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
|
||||
case "resource":
|
||||
if ("text" in block.resource) {
|
||||
return [{ type: "text", text: block.resource.text }]
|
||||
try {
|
||||
const parsed = new URL(block.resource.uri)
|
||||
if (parsed.protocol === "file:") {
|
||||
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
|
||||
let filepath: string
|
||||
try {
|
||||
filepath = fileURLToPath(parsed)
|
||||
} catch {
|
||||
filepath = decodeURIComponent(parsed.pathname)
|
||||
}
|
||||
if (path.sep === "\\") filepath = filepath.replace(/\\/g, "/")
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
} catch {}
|
||||
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
|
||||
}
|
||||
if (block.resource.mimeType) {
|
||||
return [
|
||||
|
||||
@@ -129,6 +129,14 @@ export function resources(client: Client, timeout?: number) {
|
||||
)
|
||||
}
|
||||
|
||||
export function resourceTemplates(client: Client, timeout?: number) {
|
||||
if (!client.getServerCapabilities()?.resources) return Promise.resolve([])
|
||||
return paginate(
|
||||
(cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }),
|
||||
(result) => result.resourceTemplates,
|
||||
)
|
||||
}
|
||||
|
||||
function listTools(client: Client, timeout: number) {
|
||||
return Effect.tryPromise({
|
||||
try: () =>
|
||||
|
||||
@@ -125,6 +125,7 @@ const pendingOAuthTransports = new Map<string, TransportWithAuth>()
|
||||
// Prompt cache types
|
||||
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
|
||||
type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
|
||||
type ResourceTemplateInfo = Awaited<ReturnType<MCPClient["listResourceTemplates"]>>["resourceTemplates"][number]
|
||||
type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
|
||||
|
||||
function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info {
|
||||
@@ -162,6 +163,9 @@ export interface Interface {
|
||||
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
||||
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
||||
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||
readonly resourceTemplates: (
|
||||
clientName?: string,
|
||||
) => Effect.Effect<Record<string, ResourceTemplateInfo & { client: string }>>
|
||||
readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
|
||||
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
|
||||
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
|
||||
@@ -643,7 +647,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
|
||||
for (const mcpTool of listed) {
|
||||
const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name)
|
||||
const key = "mcp__" + McpCatalog.sanitize(clientName) + "__" + McpCatalog.sanitize(mcpTool.name)
|
||||
result[key] = McpCatalog.convertTool(mcpTool, client, timeout)
|
||||
}
|
||||
}
|
||||
@@ -690,6 +694,16 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const resourceTemplates = Effect.fn("MCP.resourceTemplates")(function* (clientName?: string) {
|
||||
return yield* collectFromConnected(
|
||||
yield* InstanceState.get(state),
|
||||
McpCatalog.resourceTemplates,
|
||||
"resource templates",
|
||||
(template) => template.uriTemplate,
|
||||
clientName,
|
||||
)
|
||||
})
|
||||
|
||||
const withClient = Effect.fnUntraced(function* <A>(
|
||||
clientName: string,
|
||||
fn: (client: MCPClient, timeout?: number) => Promise<A>,
|
||||
@@ -931,6 +945,7 @@ export const layer = Layer.effect(
|
||||
tools,
|
||||
prompts,
|
||||
resources,
|
||||
resourceTemplates,
|
||||
add,
|
||||
connect,
|
||||
disconnect,
|
||||
|
||||
@@ -22,8 +22,11 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
const LIST_MCP_RESOURCES_TOOL = "list_mcp_resources"
|
||||
const READ_MCP_RESOURCE_TOOL = "read_mcp_resource"
|
||||
const MCP_RESOURCE_TOOLS = {
|
||||
list: "list_mcp_resources",
|
||||
listTemplates: "list_mcp_resource_templates",
|
||||
read: "read_mcp_resource",
|
||||
} as const
|
||||
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
|
||||
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
|
||||
"application/pdf",
|
||||
@@ -130,7 +133,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
(client) => !!client.getServerCapabilities()?.resources,
|
||||
)
|
||||
if (hasMcpResourceServer) {
|
||||
tools[LIST_MCP_RESOURCES_TOOL] = tool({
|
||||
tools[MCP_RESOURCE_TOOLS.list] = tool({
|
||||
description:
|
||||
"Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
|
||||
inputSchema: jsonSchema(
|
||||
@@ -167,7 +170,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
: resourceServers.map((server) => `mcp:${server}:*`)
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
yield* ctx.ask({
|
||||
@@ -200,7 +203,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
}
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||
output,
|
||||
)
|
||||
if (opts.abortSignal?.aborted) {
|
||||
@@ -212,7 +215,90 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
},
|
||||
})
|
||||
|
||||
tools[READ_MCP_RESOURCE_TOOL] = tool({
|
||||
tools[MCP_RESOURCE_TOOLS.listTemplates] = tool({
|
||||
description:
|
||||
"Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.",
|
||||
inputSchema: jsonSchema(
|
||||
ProviderTransform.schema(input.model, {
|
||||
type: "object",
|
||||
properties: {
|
||||
server: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional MCP server name. When omitted, lists resource templates from every connected server.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}),
|
||||
),
|
||||
execute(args, opts) {
|
||||
return run.promise(
|
||||
Effect.gen(function* () {
|
||||
const parsed = parseListMcpResourcesArgs(args)
|
||||
const ctx = context(toRecord(args), opts)
|
||||
const clients = yield* mcp.clients()
|
||||
const resourceServers = Object.entries(clients)
|
||||
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
|
||||
.map((entry) => entry[0])
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
if (parsed.server && !resourceServers.includes(parsed.server)) {
|
||||
throw new Error(
|
||||
resourceServers.length === 0
|
||||
? `MCP server "${parsed.server}" does not support resources`
|
||||
: `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
|
||||
)
|
||||
}
|
||||
const permissionPatterns = parsed.server
|
||||
? [`mcp:${parsed.server}:*`]
|
||||
: resourceServers.map((server) => `mcp:${server}:*`)
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "read",
|
||||
metadata: parsed.server ? { server: parsed.server } : {},
|
||||
patterns: permissionPatterns,
|
||||
always: permissionPatterns,
|
||||
})
|
||||
|
||||
const templates = Object.values(yield* mcp.resourceTemplates(parsed.server))
|
||||
const filtered = templates
|
||||
.filter((template) => !parsed.server || template.client === parsed.server)
|
||||
.toSorted((a, b) =>
|
||||
(a.client + "\u0000" + a.name + "\u0000" + a.uriTemplate).localeCompare(
|
||||
b.client + "\u0000" + b.name + "\u0000" + b.uriTemplate,
|
||||
),
|
||||
)
|
||||
const content = JSON.stringify({ resourceTemplates: filtered.map(formatMcpResourceTemplate) }, null, 2)
|
||||
const truncated = yield* truncate.output(content, {}, input.agent)
|
||||
const output = {
|
||||
title: parsed.server ? `MCP resource templates: ${parsed.server}` : "MCP resource templates",
|
||||
metadata: {
|
||||
count: filtered.length,
|
||||
servers: resourceServers,
|
||||
...(parsed.server ? { server: parsed.server } : {}),
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
},
|
||||
output: truncated.content,
|
||||
}
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||
output,
|
||||
)
|
||||
if (opts.abortSignal?.aborted) {
|
||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||
}
|
||||
return output
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
tools[MCP_RESOURCE_TOOLS.read] = tool({
|
||||
description:
|
||||
"Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.",
|
||||
inputSchema: jsonSchema(
|
||||
@@ -247,7 +333,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
}
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
yield* ctx.ask({
|
||||
@@ -282,7 +368,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
}
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||
output,
|
||||
)
|
||||
if (opts.abortSignal?.aborted) {
|
||||
@@ -432,6 +518,11 @@ function formatMcpResource(resource: MCP.Resource) {
|
||||
return { ...result, server: resource.client }
|
||||
}
|
||||
|
||||
function formatMcpResourceTemplate(template: Record<string, unknown> & { client: string }) {
|
||||
const result = Object.fromEntries(Object.entries(template).filter((entry) => entry[0] !== "client"))
|
||||
return { ...result, server: template.client }
|
||||
}
|
||||
|
||||
function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) {
|
||||
const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
|
||||
const text: string[] = []
|
||||
|
||||
@@ -82,7 +82,9 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
|
||||
|
||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||
|
||||
const feed = (list: string[]) => list.join("\0") + "\0"
|
||||
const encodeNulTerminatedPaths = (files: string[]) => files.join("\0") + "\0"
|
||||
const encodeTopLevelLiteralPathspecs = (files: string[]) =>
|
||||
encodeNulTerminatedPaths(files.map((file) => `:(top,literal)${file}`))
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string>; stdin?: string }) {
|
||||
@@ -107,6 +109,8 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
|
||||
|
||||
const ignore = Effect.fnUntraced(function* (files: string[]) {
|
||||
if (!files.length) return new Set<string>()
|
||||
// check-ignore treats a leading colon as pathspec magic but accepts and echoes a protective ./ prefix.
|
||||
const checkIgnorePaths = files.map((item) => (item.startsWith(":") ? `./${item}` : item))
|
||||
const check = yield* git(
|
||||
[
|
||||
...quote,
|
||||
@@ -120,12 +124,17 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
|
||||
"-z",
|
||||
],
|
||||
{
|
||||
cwd: state.directory,
|
||||
stdin: feed(files),
|
||||
cwd: state.worktree,
|
||||
stdin: encodeNulTerminatedPaths(checkIgnorePaths),
|
||||
},
|
||||
)
|
||||
if (check.code !== 0 && check.code !== 1) return new Set<string>()
|
||||
return new Set(check.text.split("\0").filter(Boolean))
|
||||
return new Set(
|
||||
check.text
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((item) => (item.startsWith("./:") ? item.slice(2) : item)),
|
||||
)
|
||||
})
|
||||
|
||||
const drop = Effect.fnUntraced(function* (files: string[]) {
|
||||
@@ -136,8 +145,8 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
|
||||
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
|
||||
],
|
||||
{
|
||||
cwd: state.directory,
|
||||
stdin: feed(files),
|
||||
cwd: state.worktree,
|
||||
stdin: encodeTopLevelLiteralPathspecs(files),
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -147,8 +156,8 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
|
||||
const result = yield* git(
|
||||
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
stdin: feed(files),
|
||||
cwd: state.worktree,
|
||||
stdin: encodeTopLevelLiteralPathspecs(files),
|
||||
},
|
||||
)
|
||||
if (result.code === 0) return
|
||||
@@ -238,7 +247,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
|
||||
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
||||
git([...quote, ...args(["ls-files", "--full-name", "--others", "--exclude-standard", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
],
|
||||
@@ -277,7 +286,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
|
||||
(yield* Effect.all(
|
||||
allow.map((item) =>
|
||||
fs
|
||||
.stat(path.join(state.directory, item))
|
||||
.stat(path.join(state.worktree, item))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(
|
||||
Effect.map((stat) => {
|
||||
|
||||
@@ -99,17 +99,51 @@ describe("acp content conversion", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("resource with text becomes a text part", () => {
|
||||
test("resource with text becomes a sourced text part", () => {
|
||||
const result = contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/context.txt#L12-L14",
|
||||
mimeType: "text/plain",
|
||||
text: "context",
|
||||
},
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.type).toBe("text")
|
||||
if (result[0]?.type === "text") {
|
||||
expect(result[0].text.endsWith("\ncontext")).toBe(true)
|
||||
expect(result[0].text.includes("context.txt")).toBe(true)
|
||||
expect(result[0].text.includes("12")).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("resource with text uses URI fallback for non-file resources", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/context.txt",
|
||||
mimeType: "text/plain",
|
||||
uri: "mcp://server/context",
|
||||
text: "context",
|
||||
},
|
||||
}),
|
||||
).toEqual([{ type: "text", text: "context" }])
|
||||
).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }])
|
||||
})
|
||||
|
||||
test("resource with text includes file path", () => {
|
||||
const result = contentBlockToParts({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/context.txt",
|
||||
mimeType: "text/plain",
|
||||
text: "context",
|
||||
},
|
||||
})
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.type).toBe("text")
|
||||
if (result[0]?.type === "text") {
|
||||
expect(result[0].text.endsWith("\ncontext")).toBe(true)
|
||||
expect(result[0].text.includes("context.txt")).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("resource with blob and mimeType becomes a data URL file part", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ interface MockClientState {
|
||||
listToolsCalls: number
|
||||
listPromptsCalls: number
|
||||
listResourcesCalls: number
|
||||
listResourceTemplatesCalls: number
|
||||
getPromptTimeout?: number
|
||||
readResourceTimeout?: number
|
||||
requestCalls: number
|
||||
@@ -26,6 +27,7 @@ interface MockClientState {
|
||||
listResourcesShouldFail: boolean
|
||||
prompts: Array<{ name: string; description?: string }>
|
||||
resources: Array<{ name: string; uri: string; description?: string }>
|
||||
resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>
|
||||
toolPages: Record<
|
||||
string,
|
||||
{
|
||||
@@ -38,6 +40,10 @@ interface MockClientState {
|
||||
string,
|
||||
{ resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
|
||||
>
|
||||
resourceTemplatePages: Record<
|
||||
string,
|
||||
{ resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>; nextCursor?: string }
|
||||
>
|
||||
closed: boolean
|
||||
clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
|
||||
requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
|
||||
@@ -67,6 +73,7 @@ function getOrCreateClientState(name?: string): MockClientState {
|
||||
listToolsCalls: 0,
|
||||
listPromptsCalls: 0,
|
||||
listResourcesCalls: 0,
|
||||
listResourceTemplatesCalls: 0,
|
||||
requestCalls: 0,
|
||||
listToolsShouldFail: false,
|
||||
listToolsError: "listTools failed",
|
||||
@@ -74,9 +81,11 @@ function getOrCreateClientState(name?: string): MockClientState {
|
||||
listResourcesShouldFail: false,
|
||||
prompts: [],
|
||||
resources: [],
|
||||
resourceTemplates: [],
|
||||
toolPages: {},
|
||||
promptPages: {},
|
||||
resourcePages: {},
|
||||
resourceTemplatePages: {},
|
||||
closed: false,
|
||||
requestHandlers: new Map(),
|
||||
notificationHandlers: new Map(),
|
||||
@@ -224,6 +233,13 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
return { resources: this._state?.resources ?? [] }
|
||||
}
|
||||
|
||||
async listResourceTemplates(params?: { cursor?: string }) {
|
||||
if (this._state) this._state.listResourceTemplatesCalls++
|
||||
const page = this._state?.resourceTemplatePages[params === undefined ? "initial" : (params.cursor ?? "")]
|
||||
if (page) return page
|
||||
return { resourceTemplates: this._state?.resourceTemplates ?? [] }
|
||||
}
|
||||
|
||||
async getPrompt(_params: unknown, options?: { timeout?: number }) {
|
||||
if (this._state) this._state.getPromptTimeout = options?.timeout
|
||||
return { messages: [] }
|
||||
@@ -353,18 +369,30 @@ it.instance(
|
||||
initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" },
|
||||
"resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] },
|
||||
}
|
||||
serverState.resourceTemplatePages = {
|
||||
initial: {
|
||||
resourceTemplates: [{ name: "template-one", uriTemplate: "test://one/{id}" }],
|
||||
nextCursor: "resource-templates-2",
|
||||
},
|
||||
"resource-templates-2": { resourceTemplates: [{ name: "template-two", uriTemplate: "test://two/{id}" }] },
|
||||
}
|
||||
|
||||
yield* mcp.add("paged-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
|
||||
expect(Object.keys(yield* mcp.tools())).toEqual(["mcp__paged-server__tool-one", "mcp__paged-server__tool-two"])
|
||||
expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
|
||||
expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
|
||||
expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([
|
||||
"paged-server:test://one/{id}",
|
||||
"paged-server:test://two/{id}",
|
||||
])
|
||||
expect(serverState.listToolsCalls).toBe(2)
|
||||
expect(serverState.listPromptsCalls).toBe(2)
|
||||
expect(serverState.listResourcesCalls).toBe(2)
|
||||
expect(serverState.listResourceTemplatesCalls).toBe(2)
|
||||
}),
|
||||
),
|
||||
{ config: { mcp: {} } },
|
||||
@@ -916,7 +944,7 @@ it.instance(
|
||||
|
||||
expect(statusName(result.status, "tools-only-server")).toBe("connected")
|
||||
expect(serverState.listToolsCalls).toBe(1)
|
||||
expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only-server_test_tool"])
|
||||
expect(Object.keys(yield* mcp.tools())).toEqual(["mcp__tools-only-server__test_tool"])
|
||||
expect(yield* mcp.prompts()).toEqual({})
|
||||
expect(yield* mcp.resources()).toEqual({})
|
||||
expect(serverState.listPromptsCalls).toBe(0)
|
||||
@@ -1109,7 +1137,7 @@ it.instance(
|
||||
const keys = Object.keys(tools)
|
||||
|
||||
// Server name dots should be replaced with underscores
|
||||
expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
|
||||
expect(keys.some((k) => k.startsWith("mcp__my_special-server__"))).toBe(true)
|
||||
// Tool name dots should be replaced with underscores
|
||||
expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
|
||||
expect(keys.length).toBe(2)
|
||||
|
||||
@@ -116,6 +116,7 @@ const mcp = Layer.succeed(
|
||||
tools: () => Effect.succeed({}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => Effect.succeed({}),
|
||||
resourceTemplates: () => Effect.succeed({}),
|
||||
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
|
||||
connect: () => Effect.void,
|
||||
disconnect: () => Effect.void,
|
||||
|
||||
@@ -40,6 +40,7 @@ const mcp = Layer.succeed(
|
||||
tools: () => Effect.succeed({}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => Effect.succeed({}),
|
||||
resourceTemplates: () => Effect.succeed({}),
|
||||
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
|
||||
connect: () => Effect.void,
|
||||
disconnect: () => Effect.void,
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer))
|
||||
// Windows forbids both * and : in directory names.
|
||||
const nonWindowsIt = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
|
||||
// with path.join (which produces \ on Windows) then normalizes back to /.
|
||||
@@ -448,6 +450,97 @@ it.live(
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"subdirectory snapshots include scoped changes only",
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* scopedGitTmpdir()
|
||||
const frontend = path.join(dir, "frontend")
|
||||
yield* write(`${frontend}/tracked.txt`, "initial")
|
||||
yield* write(`${frontend}/deleted.txt`, "initial")
|
||||
yield* write(`${dir}/backend/tracked.txt`, "initial")
|
||||
yield* write(`${dir}/backend/deleted.txt`, "initial")
|
||||
yield* exec(dir, ["git", "add", "."])
|
||||
yield* exec(dir, ["git", "commit", "-m", "init"])
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
yield* write(`${frontend}/tracked.txt`, "changed")
|
||||
yield* write(`${frontend}/untracked.txt`, "new")
|
||||
yield* rm(`${frontend}/deleted.txt`)
|
||||
yield* write(`${dir}/backend/tracked.txt`, "changed")
|
||||
yield* rm(`${dir}/backend/deleted.txt`)
|
||||
const patch = yield* snapshot.patch(before!)
|
||||
const diff = yield* snapshot.diff(before!)
|
||||
expect(patch.files).toContain(fwd(frontend, "tracked.txt"))
|
||||
expect(patch.files).toContain(fwd(frontend, "untracked.txt"))
|
||||
expect(patch.files).toContain(fwd(frontend, "deleted.txt"))
|
||||
expect(patch.files).not.toContain(fwd(dir, "backend", "tracked.txt"))
|
||||
expect(patch.files).not.toContain(fwd(dir, "backend", "deleted.txt"))
|
||||
expect(diff).not.toContain("backend/tracked.txt")
|
||||
expect(diff).not.toContain("backend/deleted.txt")
|
||||
}).pipe(provideInstance(frontend))
|
||||
}),
|
||||
)
|
||||
|
||||
nonWindowsIt(
|
||||
"subdirectory snapshots treat wildcard characters literally",
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* scopedGitTmpdir()
|
||||
const subdir = path.join(dir, "src*")
|
||||
yield* write(`${subdir}/file.txt`, "initial")
|
||||
yield* write(`${subdir}/later-ignored.txt`, "initial")
|
||||
yield* write(`${dir}/srca/file.txt`, "initial")
|
||||
yield* exec(dir, ["git", "add", "."])
|
||||
yield* exec(dir, ["git", "commit", "-m", "init"])
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
yield* write(`${subdir}/file.txt`, "changed")
|
||||
yield* write(`${subdir}/later-ignored.txt`, "changed")
|
||||
yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n")
|
||||
yield* write(`${dir}/srca/file.txt`, "changed")
|
||||
const patch = yield* snapshot.patch(before!)
|
||||
const diff = yield* snapshot.diff(before!)
|
||||
expect(patch.files).toContain(fwd(subdir, "file.txt"))
|
||||
expect(patch.files).toContain(fwd(subdir, ".gitignore"))
|
||||
expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt"))
|
||||
expect(patch.files).not.toContain(fwd(dir, "srca", "file.txt"))
|
||||
expect(diff).toContain("src*/later-ignored.txt")
|
||||
expect(diff).toContain("deleted file mode")
|
||||
expect(diff).not.toContain("srca/file.txt")
|
||||
}).pipe(provideInstance(subdir))
|
||||
}),
|
||||
)
|
||||
|
||||
nonWindowsIt(
|
||||
"subdirectory snapshots treat leading colons literally",
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* scopedGitTmpdir()
|
||||
const subdir = path.join(dir, ":src")
|
||||
yield* write(`${subdir}/kept.txt`, "initial")
|
||||
yield* write(`${subdir}/later-ignored.txt`, "initial")
|
||||
yield* exec(dir, ["git", "add", "."])
|
||||
yield* exec(dir, ["git", "commit", "-m", "init"])
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
yield* write(`${subdir}/kept.txt`, "changed")
|
||||
yield* write(`${subdir}/later-ignored.txt`, "changed")
|
||||
yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n")
|
||||
const patch = yield* snapshot.patch(before!)
|
||||
const diff = yield* snapshot.diff(before!)
|
||||
expect(patch.files).toContain(fwd(subdir, "kept.txt"))
|
||||
expect(patch.files).toContain(fwd(subdir, ".gitignore"))
|
||||
expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt"))
|
||||
expect(diff).toContain(":src/later-ignored.txt")
|
||||
expect(diff).toContain("deleted file mode")
|
||||
}).pipe(provideInstance(subdir))
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"gitignore changes",
|
||||
withTrackedSnapshot(({ tmp, snapshot, before }) =>
|
||||
|
||||
Reference in New Issue
Block a user