mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-17 04:04:27 +00:00
Compare commits
4 Commits
refactor/e
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e14e874e51 | ||
|
|
544315dff7 | ||
|
|
f13da808ff | ||
|
|
e416e59ea6 |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -21,3 +21,4 @@ r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-OpenCode2026
|
||||
|
||||
@@ -174,6 +174,8 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
||||
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
||||
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
||||
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
|
||||
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
|
||||
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
||||
|
||||
### Wait on state
|
||||
@@ -182,6 +184,9 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
- Avoid race-prone flows that assume work is finished after an action
|
||||
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
|
||||
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
|
||||
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
|
||||
- Do not treat a visible element as proof that the app will route the next action to it
|
||||
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
|
||||
|
||||
### Add hooks
|
||||
|
||||
@@ -189,11 +194,16 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
|
||||
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
|
||||
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
|
||||
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
|
||||
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
|
||||
|
||||
### Prefer helpers
|
||||
|
||||
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
|
||||
- Use direct locators when the interaction is simple and a helper would not add clarity
|
||||
- Prefer helpers that both perform an action and verify the app consumed it
|
||||
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
|
||||
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
listItemSelector,
|
||||
listItemKeySelector,
|
||||
listItemKeyStartsWithSelector,
|
||||
promptSelector,
|
||||
terminalSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceMenuTriggerSelector,
|
||||
@@ -61,6 +62,15 @@ async function terminalReady(page: Page, term?: Locator) {
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function terminalFocusIdle(page: Page, term?: Locator) {
|
||||
const next = term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
return page.evaluate((id) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||
return (state?.focusing ?? 0) === 0
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
||||
const next = input.term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
@@ -73,6 +83,29 @@ async function terminalHas(page: Page, input: { term?: Locator; token: string })
|
||||
)
|
||||
}
|
||||
|
||||
async function promptSlashActive(page: Page, id: string) {
|
||||
return page.evaluate((id) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||
if (state?.popover !== "slash") return false
|
||||
if (!state.slash.ids.includes(id)) return false
|
||||
return state.slash.active === id
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function promptSlashSelects(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
|
||||
return page.evaluate((input) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||
if (!state) return false
|
||||
return state.selected === input.id && state.selects >= input.count
|
||||
}, input)
|
||||
}
|
||||
|
||||
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input?.timeout ?? 10_000
|
||||
@@ -81,6 +114,43 @@ export async function waitTerminalReady(page: Page, input?: { term?: Locator; ti
|
||||
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input?.timeout ?? 10_000
|
||||
await waitTerminalReady(page, { term, timeout })
|
||||
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function showPromptSlash(
|
||||
page: Page,
|
||||
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||
) {
|
||||
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||
const timeout = input.timeout ?? 10_000
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await prompt.click().catch(() => false)
|
||||
await prompt.fill(input.text).catch(() => false)
|
||||
return promptSlashActive(page, input.id).catch(() => false)
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
export async function runPromptSlash(
|
||||
page: Page,
|
||||
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||
) {
|
||||
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||
const timeout = input.timeout ?? 10_000
|
||||
const count = await promptSlashSelects(page)
|
||||
await showPromptSlash(page, input)
|
||||
await prompt.press("Enter")
|
||||
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
||||
const term = input.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input.timeout ?? 10_000
|
||||
|
||||
@@ -98,6 +98,9 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
|
||||
model: {
|
||||
enabled: true,
|
||||
},
|
||||
prompt: {
|
||||
enabled: true,
|
||||
},
|
||||
terminal: {
|
||||
enabled: true,
|
||||
terminals: {},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
|
||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
@@ -7,29 +7,12 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
|
||||
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await prompt.fill("/terminal")
|
||||
await expect(slash).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||
await waitTerminalFocusIdle(page, { term: terminal })
|
||||
|
||||
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
|
||||
// which can steal focus from the prompt and prevent fill() from triggering
|
||||
// the slash popover. Re-attempt click+fill until all retries are exhausted
|
||||
// and the popover appears.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await prompt.click().catch(() => false)
|
||||
await prompt.fill("/terminal").catch(() => false)
|
||||
return slash.isVisible().catch(() => false)
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
await page.keyboard.press("Enter")
|
||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||
await expect(terminal).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
|
||||
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
|
||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
await waitTerminalFocusIdle(page, { term: terminal })
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
@@ -14,7 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
}
|
||||
|
||||
await waitTerminalReady(page, { term: terminals.first() })
|
||||
await waitTerminalFocusIdle(page, { term: terminals.first() })
|
||||
await expect(terminals).toHaveCount(1)
|
||||
|
||||
// Ghostty captures a lot of keybinds when focused; move focus back
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { promptEnabled, promptProbe } from "@/testing/prompt"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||
@@ -604,6 +605,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||
if (!cmd) return
|
||||
promptProbe.select(cmd.id)
|
||||
closePopover()
|
||||
|
||||
if (cmd.type === "custom") {
|
||||
@@ -692,6 +694,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
})
|
||||
|
||||
if (promptEnabled()) {
|
||||
createEffect(() => {
|
||||
promptProbe.set({
|
||||
popover: store.popover,
|
||||
slash: {
|
||||
active: slashActive() ?? null,
|
||||
ids: slashFlat().map((cmd) => cmd.id),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => promptProbe.clear())
|
||||
}
|
||||
|
||||
const selectPopoverActive = () => {
|
||||
if (store.popover === "at") {
|
||||
const items = atFlat()
|
||||
|
||||
@@ -18,8 +18,10 @@ import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { terminalProbe } from "@/testing/terminal"
|
||||
|
||||
export function TerminalPanel() {
|
||||
const delays = [120, 240]
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const language = useLanguage()
|
||||
@@ -79,16 +81,20 @@ export function TerminalPanel() {
|
||||
)
|
||||
|
||||
const focus = (id: string) => {
|
||||
const probe = terminalProbe(id)
|
||||
probe.focus(delays.length + 1)
|
||||
focusTerminalById(id)
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
probe.step()
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
})
|
||||
|
||||
const timers = [120, 240].map((ms) =>
|
||||
const timers = delays.map((ms) =>
|
||||
window.setTimeout(() => {
|
||||
probe.step()
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
@@ -96,6 +102,7 @@ export function TerminalPanel() {
|
||||
)
|
||||
|
||||
return () => {
|
||||
probe.focus(0)
|
||||
cancelAnimationFrame(frame)
|
||||
for (const timer of timers) clearTimeout(timer)
|
||||
}
|
||||
|
||||
56
packages/app/src/testing/prompt.ts
Normal file
56
packages/app/src/testing/prompt.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { E2EWindow } from "./terminal"
|
||||
|
||||
export type PromptProbeState = {
|
||||
popover: "at" | "slash" | null
|
||||
slash: {
|
||||
active: string | null
|
||||
ids: string[]
|
||||
}
|
||||
selected: string | null
|
||||
selects: number
|
||||
}
|
||||
|
||||
export const promptEnabled = () => {
|
||||
if (typeof window === "undefined") return false
|
||||
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
|
||||
}
|
||||
|
||||
const root = () => {
|
||||
if (!promptEnabled()) return
|
||||
return (window as E2EWindow).__opencode_e2e?.prompt
|
||||
}
|
||||
|
||||
export const promptProbe = {
|
||||
set(input: Omit<PromptProbeState, "selected" | "selects">) {
|
||||
const state = root()
|
||||
if (!state) return
|
||||
state.current = {
|
||||
popover: input.popover,
|
||||
slash: {
|
||||
active: input.slash.active,
|
||||
ids: [...input.slash.ids],
|
||||
},
|
||||
selected: state.current?.selected ?? null,
|
||||
selects: state.current?.selects ?? 0,
|
||||
}
|
||||
},
|
||||
select(id: string) {
|
||||
const state = root()
|
||||
if (!state) return
|
||||
const prev = state.current
|
||||
state.current = {
|
||||
popover: prev?.popover ?? null,
|
||||
slash: {
|
||||
active: prev?.slash.active ?? null,
|
||||
ids: [...(prev?.slash.ids ?? [])],
|
||||
},
|
||||
selected: id,
|
||||
selects: (prev?.selects ?? 0) + 1,
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
const state = root()
|
||||
if (!state) return
|
||||
state.current = undefined
|
||||
},
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type TerminalProbeState = {
|
||||
connects: number
|
||||
rendered: string
|
||||
settled: number
|
||||
focusing: number
|
||||
}
|
||||
|
||||
type TerminalProbeControl = {
|
||||
@@ -19,6 +20,10 @@ export type E2EWindow = Window & {
|
||||
enabled?: boolean
|
||||
current?: ModelProbeState
|
||||
}
|
||||
prompt?: {
|
||||
enabled?: boolean
|
||||
current?: import("./prompt").PromptProbeState
|
||||
}
|
||||
terminal?: {
|
||||
enabled?: boolean
|
||||
terminals?: Record<string, TerminalProbeState>
|
||||
@@ -32,6 +37,7 @@ const seed = (): TerminalProbeState => ({
|
||||
connects: 0,
|
||||
rendered: "",
|
||||
settled: 0,
|
||||
focusing: 0,
|
||||
})
|
||||
|
||||
const root = () => {
|
||||
@@ -88,6 +94,15 @@ export const terminalProbe = (id: string) => {
|
||||
const prev = state[id] ?? seed()
|
||||
state[id] = { ...prev, settled: prev.settled + 1 }
|
||||
},
|
||||
focus(count: number) {
|
||||
set({ focusing: Math.max(0, count) })
|
||||
},
|
||||
step() {
|
||||
const state = terms()
|
||||
if (!state) return
|
||||
const prev = state[id] ?? seed()
|
||||
state[id] = { ...prev, focusing: Math.max(0, prev.focusing - 1) }
|
||||
},
|
||||
control(next: Partial<TerminalProbeControl>) {
|
||||
const state = controls()
|
||||
if (!state) return
|
||||
|
||||
@@ -1052,7 +1052,12 @@ export namespace Config {
|
||||
})
|
||||
.optional(),
|
||||
plugin: z.string().array().optional(),
|
||||
snapshot: z.boolean().optional(),
|
||||
snapshot: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
||||
),
|
||||
share: z
|
||||
.enum(["manual", "auto", "disabled"])
|
||||
.optional()
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { ServiceMap } from "effect";
|
||||
import type { Project } from "@/project/project";
|
||||
import { ServiceMap } from "effect"
|
||||
import type { Project } from "@/project/project"
|
||||
|
||||
export declare namespace InstanceContext {
|
||||
export interface Shape {
|
||||
readonly directory: string;
|
||||
readonly worktree: string;
|
||||
readonly project: Project.Info;
|
||||
}
|
||||
export interface Shape {
|
||||
readonly directory: string
|
||||
readonly project: Project.Info
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceContext extends ServiceMap.Service<
|
||||
InstanceContext,
|
||||
InstanceContext.Shape
|
||||
>()("opencode/InstanceContext") {}
|
||||
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
|
||||
"opencode/InstanceContext",
|
||||
) {}
|
||||
|
||||
@@ -1,83 +1,64 @@
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect";
|
||||
import { FileService } from "@/file";
|
||||
import { FileTimeService } from "@/file/time";
|
||||
import { FileWatcherService } from "@/file/watcher";
|
||||
import { FormatService } from "@/format";
|
||||
import { PermissionService } from "@/permission/service";
|
||||
import { Instance } from "@/project/instance";
|
||||
import { VcsService } from "@/project/vcs";
|
||||
import { ProviderAuthService } from "@/provider/auth-service";
|
||||
import { QuestionService } from "@/question/service";
|
||||
import { SkillService } from "@/skill/skill";
|
||||
import { SnapshotService } from "@/snapshot";
|
||||
import { InstanceContext } from "./instance-context";
|
||||
import { registerDisposer } from "./instance-registry";
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
import { InstanceContext } from "./instance-context"
|
||||
import { ProviderAuthService } from "@/provider/auth-service"
|
||||
import { QuestionService } from "@/question/service"
|
||||
import { PermissionService } from "@/permission/service"
|
||||
import { FileWatcherService } from "@/file/watcher"
|
||||
import { VcsService } from "@/project/vcs"
|
||||
import { FileTimeService } from "@/file/time"
|
||||
import { FormatService } from "@/format"
|
||||
import { FileService } from "@/file"
|
||||
import { SkillService } from "@/skill/skill"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
export { InstanceContext } from "./instance-context";
|
||||
export { InstanceContext } from "./instance-context"
|
||||
|
||||
export type InstanceServices =
|
||||
| QuestionService
|
||||
| PermissionService
|
||||
| ProviderAuthService
|
||||
| FileWatcherService
|
||||
| VcsService
|
||||
| FileTimeService
|
||||
| FormatService
|
||||
| FileService
|
||||
| SkillService
|
||||
| SnapshotService;
|
||||
| QuestionService
|
||||
| PermissionService
|
||||
| ProviderAuthService
|
||||
| FileWatcherService
|
||||
| VcsService
|
||||
| FileTimeService
|
||||
| FormatService
|
||||
| FileService
|
||||
| SkillService
|
||||
|
||||
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
||||
// the full instance context (directory, worktree, project). We read from the
|
||||
// legacy Instance ALS here, which is safe because lookup is only triggered via
|
||||
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
|
||||
// This should go away once the old Instance type is removed and lookup can load
|
||||
// the full context directly.
|
||||
function lookup(_key: string) {
|
||||
const ctx = Layer.sync(InstanceContext, () =>
|
||||
InstanceContext.of(Instance.current),
|
||||
);
|
||||
return Layer.mergeAll(
|
||||
Layer.fresh(QuestionService.layer),
|
||||
Layer.fresh(PermissionService.layer),
|
||||
Layer.fresh(ProviderAuthService.layer),
|
||||
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(VcsService.layer),
|
||||
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(FormatService.layer),
|
||||
Layer.fresh(FileService.layer),
|
||||
Layer.fresh(SkillService.layer),
|
||||
Layer.fresh(SnapshotService.layer),
|
||||
).pipe(Layer.provide(ctx));
|
||||
function lookup(directory: string) {
|
||||
const project = Instance.project
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project }))
|
||||
return Layer.mergeAll(
|
||||
Layer.fresh(QuestionService.layer),
|
||||
Layer.fresh(PermissionService.layer),
|
||||
Layer.fresh(ProviderAuthService.layer),
|
||||
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(VcsService.layer),
|
||||
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(FormatService.layer),
|
||||
Layer.fresh(FileService.layer),
|
||||
Layer.fresh(SkillService.layer),
|
||||
).pipe(Layer.provide(ctx))
|
||||
}
|
||||
|
||||
export class Instances extends ServiceMap.Service<
|
||||
Instances,
|
||||
LayerMap.LayerMap<string, InstanceServices>
|
||||
>()("opencode/Instances") {
|
||||
static readonly layer = Layer.effect(
|
||||
Instances,
|
||||
Effect.gen(function* () {
|
||||
const layerMap = yield* LayerMap.make(lookup, {
|
||||
idleTimeToLive: Infinity,
|
||||
});
|
||||
const unregister = registerDisposer((directory) =>
|
||||
Effect.runPromise(layerMap.invalidate(directory)),
|
||||
);
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unregister));
|
||||
return Instances.of(layerMap);
|
||||
}),
|
||||
);
|
||||
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
|
||||
"opencode/Instances",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
Instances,
|
||||
Effect.gen(function* () {
|
||||
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||
return Instances.of(layerMap)
|
||||
}),
|
||||
)
|
||||
|
||||
static get(
|
||||
directory: string,
|
||||
): Layer.Layer<InstanceServices, never, Instances> {
|
||||
return Layer.unwrap(
|
||||
Instances.use((map) => Effect.succeed(map.get(directory))),
|
||||
);
|
||||
}
|
||||
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
||||
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
||||
}
|
||||
|
||||
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
|
||||
return Instances.use((map) => map.invalidate(directory));
|
||||
}
|
||||
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
|
||||
return Instances.use((map) => map.invalidate(directory))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +1,163 @@
|
||||
import { GlobalBus } from "@/bus/global";
|
||||
import { disposeInstance } from "@/effect/instance-registry";
|
||||
import { Filesystem } from "@/util/filesystem";
|
||||
import { iife } from "@/util/iife";
|
||||
import { Log } from "@/util/log";
|
||||
import { Context } from "../util/context";
|
||||
import { Project } from "./project";
|
||||
import { State } from "./state";
|
||||
import { Log } from "@/util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
import { State } from "./state"
|
||||
import { iife } from "@/util/iife"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { disposeInstance } from "@/effect/instance-registry"
|
||||
|
||||
interface Context {
|
||||
directory: string;
|
||||
worktree: string;
|
||||
project: Project.Info;
|
||||
directory: string
|
||||
worktree: string
|
||||
project: Project.Info
|
||||
}
|
||||
const context = Context.create<Context>("instance");
|
||||
const cache = new Map<string, Promise<Context>>();
|
||||
const context = Context.create<Context>("instance")
|
||||
const cache = new Map<string, Promise<Context>>()
|
||||
|
||||
const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
};
|
||||
|
||||
function emit(directory: string) {
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
});
|
||||
all: undefined as Promise<void> | undefined,
|
||||
}
|
||||
|
||||
function boot(input: {
|
||||
directory: string;
|
||||
init?: () => Promise<any>;
|
||||
project?: Project.Info;
|
||||
worktree?: string;
|
||||
}) {
|
||||
return iife(async () => {
|
||||
const ctx =
|
||||
input.project && input.worktree
|
||||
? {
|
||||
directory: input.directory,
|
||||
worktree: input.worktree,
|
||||
project: input.project,
|
||||
}
|
||||
: await Project.fromDirectory(input.directory).then(
|
||||
({ project, sandbox }) => ({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}),
|
||||
);
|
||||
await context.provide(ctx, async () => {
|
||||
await input.init?.();
|
||||
});
|
||||
return ctx;
|
||||
});
|
||||
function emit(directory: string) {
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
return iife(async () => {
|
||||
const ctx =
|
||||
input.project && input.worktree
|
||||
? {
|
||||
directory: input.directory,
|
||||
worktree: input.worktree,
|
||||
project: input.project,
|
||||
}
|
||||
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}))
|
||||
await context.provide(ctx, async () => {
|
||||
await input.init?.()
|
||||
})
|
||||
return ctx
|
||||
})
|
||||
}
|
||||
|
||||
function track(directory: string, next: Promise<Context>) {
|
||||
const task = next.catch((error) => {
|
||||
if (cache.get(directory) === task) cache.delete(directory);
|
||||
throw error;
|
||||
});
|
||||
cache.set(directory, task);
|
||||
return task;
|
||||
const task = next.catch((error) => {
|
||||
if (cache.get(directory) === task) cache.delete(directory)
|
||||
throw error
|
||||
})
|
||||
cache.set(directory, task)
|
||||
return task
|
||||
}
|
||||
|
||||
export const Instance = {
|
||||
async provide<R>(input: {
|
||||
directory: string;
|
||||
init?: () => Promise<any>;
|
||||
fn: () => R;
|
||||
}): Promise<R> {
|
||||
const directory = Filesystem.resolve(input.directory);
|
||||
let existing = cache.get(directory);
|
||||
if (!existing) {
|
||||
Log.Default.info("creating instance", { directory });
|
||||
existing = track(
|
||||
directory,
|
||||
boot({
|
||||
directory,
|
||||
init: input.init,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const ctx = await existing;
|
||||
return context.provide(ctx, async () => {
|
||||
return input.fn();
|
||||
});
|
||||
},
|
||||
get current() {
|
||||
return context.use();
|
||||
},
|
||||
get directory() {
|
||||
return context.use().directory;
|
||||
},
|
||||
get worktree() {
|
||||
return context.use().worktree;
|
||||
},
|
||||
get project() {
|
||||
return context.use().project;
|
||||
},
|
||||
/**
|
||||
* Check if a path is within the project boundary.
|
||||
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
||||
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
|
||||
*/
|
||||
containsPath(filepath: string) {
|
||||
if (Filesystem.contains(Instance.directory, filepath)) return true;
|
||||
// Non-git projects set worktree to "/" which would match ANY absolute path.
|
||||
// Skip worktree check in this case to preserve external_directory permissions.
|
||||
if (Instance.worktree === "/") return false;
|
||||
return Filesystem.contains(Instance.worktree, filepath);
|
||||
},
|
||||
/**
|
||||
* Captures the current instance ALS context and returns a wrapper that
|
||||
* restores it when called. Use this for callbacks that fire outside the
|
||||
* instance async context (native addons, event emitters, timers, etc.).
|
||||
*/
|
||||
bind<F extends (...args: any[]) => any>(fn: F): F {
|
||||
const ctx = context.use();
|
||||
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F;
|
||||
},
|
||||
state<S>(
|
||||
init: () => S,
|
||||
dispose?: (state: Awaited<S>) => Promise<void>,
|
||||
): () => S {
|
||||
return State.create(() => Instance.directory, init, dispose);
|
||||
},
|
||||
async reload(input: {
|
||||
directory: string;
|
||||
init?: () => Promise<any>;
|
||||
project?: Project.Info;
|
||||
worktree?: string;
|
||||
}) {
|
||||
const directory = Filesystem.resolve(input.directory);
|
||||
Log.Default.info("reloading instance", { directory });
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)]);
|
||||
cache.delete(directory);
|
||||
const next = track(directory, boot({ ...input, directory }));
|
||||
emit(directory);
|
||||
return await next;
|
||||
},
|
||||
async dispose() {
|
||||
const directory = Instance.directory;
|
||||
Log.Default.info("disposing instance", { directory });
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)]);
|
||||
cache.delete(directory);
|
||||
emit(directory);
|
||||
},
|
||||
async disposeAll() {
|
||||
if (disposal.all) return disposal.all;
|
||||
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
||||
const directory = Filesystem.resolve(input.directory)
|
||||
let existing = cache.get(directory)
|
||||
if (!existing) {
|
||||
Log.Default.info("creating instance", { directory })
|
||||
existing = track(
|
||||
directory,
|
||||
boot({
|
||||
directory,
|
||||
init: input.init,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const ctx = await existing
|
||||
return context.provide(ctx, async () => {
|
||||
return input.fn()
|
||||
})
|
||||
},
|
||||
get directory() {
|
||||
return context.use().directory
|
||||
},
|
||||
get worktree() {
|
||||
return context.use().worktree
|
||||
},
|
||||
get project() {
|
||||
return context.use().project
|
||||
},
|
||||
/**
|
||||
* Check if a path is within the project boundary.
|
||||
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
||||
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
|
||||
*/
|
||||
containsPath(filepath: string) {
|
||||
if (Filesystem.contains(Instance.directory, filepath)) return true
|
||||
// Non-git projects set worktree to "/" which would match ANY absolute path.
|
||||
// Skip worktree check in this case to preserve external_directory permissions.
|
||||
if (Instance.worktree === "/") return false
|
||||
return Filesystem.contains(Instance.worktree, filepath)
|
||||
},
|
||||
/**
|
||||
* Captures the current instance ALS context and returns a wrapper that
|
||||
* restores it when called. Use this for callbacks that fire outside the
|
||||
* instance async context (native addons, event emitters, timers, etc.).
|
||||
*/
|
||||
bind<F extends (...args: any[]) => any>(fn: F): F {
|
||||
const ctx = context.use()
|
||||
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
|
||||
},
|
||||
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
||||
return State.create(() => Instance.directory, init, dispose)
|
||||
},
|
||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
const directory = Filesystem.resolve(input.directory)
|
||||
Log.Default.info("reloading instance", { directory })
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
const next = track(directory, boot({ ...input, directory }))
|
||||
emit(directory)
|
||||
return await next
|
||||
},
|
||||
async dispose() {
|
||||
const directory = Instance.directory
|
||||
Log.Default.info("disposing instance", { directory })
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
emit(directory)
|
||||
},
|
||||
async disposeAll() {
|
||||
if (disposal.all) return disposal.all
|
||||
|
||||
disposal.all = iife(async () => {
|
||||
Log.Default.info("disposing all instances");
|
||||
const entries = [...cache.entries()];
|
||||
for (const [key, value] of entries) {
|
||||
if (cache.get(key) !== value) continue;
|
||||
disposal.all = iife(async () => {
|
||||
Log.Default.info("disposing all instances")
|
||||
const entries = [...cache.entries()]
|
||||
for (const [key, value] of entries) {
|
||||
if (cache.get(key) !== value) continue
|
||||
|
||||
const ctx = await value.catch((error) => {
|
||||
Log.Default.warn("instance dispose failed", { key, error });
|
||||
return undefined;
|
||||
});
|
||||
const ctx = await value.catch((error) => {
|
||||
Log.Default.warn("instance dispose failed", { key, error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!ctx) {
|
||||
if (cache.get(key) === value) cache.delete(key);
|
||||
continue;
|
||||
}
|
||||
if (!ctx) {
|
||||
if (cache.get(key) === value) cache.delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cache.get(key) !== value) continue;
|
||||
if (cache.get(key) !== value) continue
|
||||
|
||||
await context.provide(ctx, async () => {
|
||||
await Instance.dispose();
|
||||
});
|
||||
}
|
||||
}).finally(() => {
|
||||
disposal.all = undefined;
|
||||
});
|
||||
await context.provide(ctx, async () => {
|
||||
await Instance.dispose()
|
||||
})
|
||||
}
|
||||
}).finally(() => {
|
||||
disposal.all = undefined
|
||||
})
|
||||
|
||||
return disposal.all;
|
||||
},
|
||||
};
|
||||
return disposal.all
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,516 +1,416 @@
|
||||
import {
|
||||
NodeChildProcessSpawner,
|
||||
NodeFileSystem,
|
||||
NodePath,
|
||||
} from "@effect/platform-node";
|
||||
import {
|
||||
Cause,
|
||||
Duration,
|
||||
Effect,
|
||||
FileSystem,
|
||||
Layer,
|
||||
Schedule,
|
||||
ServiceMap,
|
||||
Stream,
|
||||
} from "effect";
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { InstanceContext } from "@/effect/instance-context";
|
||||
import { runPromiseInstance } from "@/effect/runtime";
|
||||
import { Config } from "../config/config";
|
||||
import { Global } from "../global";
|
||||
import { Log } from "../util/log";
|
||||
|
||||
const log = Log.create({ service: "snapshot" });
|
||||
const PRUNE = "7.days";
|
||||
|
||||
// Common git config flags shared across snapshot operations
|
||||
const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"];
|
||||
const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE];
|
||||
const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"];
|
||||
|
||||
interface GitResult {
|
||||
readonly code: ChildProcessSpawner.ExitCode;
|
||||
readonly text: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Global } from "../global"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Scheduler } from "../scheduler"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
export namespace Snapshot {
|
||||
export const Patch = z.object({
|
||||
hash: z.string(),
|
||||
files: z.string().array(),
|
||||
});
|
||||
export type Patch = z.infer<typeof Patch>;
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const hour = 60 * 60 * 1000
|
||||
const prune = "7.days"
|
||||
|
||||
export const FileDiff = z
|
||||
.object({
|
||||
file: z.string(),
|
||||
before: z.string(),
|
||||
after: z.string(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FileDiff",
|
||||
});
|
||||
export type FileDiff = z.infer<typeof FileDiff>;
|
||||
function args(git: string, cmd: string[]) {
|
||||
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
|
||||
}
|
||||
|
||||
// Promise facade — existing callers use these
|
||||
export function init() {
|
||||
void runPromiseInstance(SnapshotService.use((s) => s.init()));
|
||||
}
|
||||
export function init() {
|
||||
Scheduler.register({
|
||||
id: "snapshot.cleanup",
|
||||
interval: hour,
|
||||
run: cleanup,
|
||||
scope: "instance",
|
||||
})
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.cleanup()));
|
||||
}
|
||||
export async function cleanup() {
|
||||
if (Instance.project.vcs !== "git") return
|
||||
const cfg = await Config.get()
|
||||
if (cfg.snapshot === false) return
|
||||
const git = gitdir()
|
||||
const exists = await fs
|
||||
.stat(git)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) return
|
||||
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
}
|
||||
|
||||
export async function track() {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.track()));
|
||||
}
|
||||
export async function track() {
|
||||
if (Instance.project.vcs !== "git") return
|
||||
const cfg = await Config.get()
|
||||
if (cfg.snapshot === false) return
|
||||
const git = gitdir()
|
||||
if (await fs.mkdir(git, { recursive: true })) {
|
||||
await Process.run(["git", "init"], {
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_DIR: git,
|
||||
GIT_WORK_TREE: Instance.worktree,
|
||||
},
|
||||
nothrow: true,
|
||||
})
|
||||
|
||||
export async function patch(hash: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)));
|
||||
}
|
||||
// Configure git to not convert line endings on Windows
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
|
||||
log.info("initialized")
|
||||
}
|
||||
await add(git)
|
||||
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
}).then((x) => x.text)
|
||||
log.info("tracking", { hash, cwd: Instance.directory, git })
|
||||
return hash.trim()
|
||||
}
|
||||
|
||||
export async function restore(snapshot: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)));
|
||||
}
|
||||
export const Patch = z.object({
|
||||
hash: z.string(),
|
||||
files: z.string().array(),
|
||||
})
|
||||
export type Patch = z.infer<typeof Patch>
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)));
|
||||
}
|
||||
export async function patch(hash: string): Promise<Patch> {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
const result = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
export async function diff(hash: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)));
|
||||
}
|
||||
// If git diff fails, return empty patch
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
|
||||
export async function diffFull(from: string, to: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)));
|
||||
}
|
||||
}
|
||||
|
||||
export namespace SnapshotService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>;
|
||||
readonly cleanup: () => Effect.Effect<void>;
|
||||
readonly track: () => Effect.Effect<string | undefined>;
|
||||
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>;
|
||||
readonly restore: (snapshot: string) => Effect.Effect<void>;
|
||||
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>;
|
||||
readonly diff: (hash: string) => Effect.Effect<string>;
|
||||
readonly diffFull: (
|
||||
from: string,
|
||||
to: string,
|
||||
) => Effect.Effect<Snapshot.FileDiff[]>;
|
||||
}
|
||||
}
|
||||
|
||||
export class SnapshotService extends ServiceMap.Service<
|
||||
SnapshotService,
|
||||
SnapshotService.Service
|
||||
>()("@opencode/Snapshot") {
|
||||
static readonly layer = Layer.effect(
|
||||
SnapshotService,
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceContext;
|
||||
const fileSystem = yield* FileSystem.FileSystem;
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
||||
const { directory, worktree, project } = ctx;
|
||||
const isGit = project.vcs === "git";
|
||||
const snapshotGit = path.join(Global.Path.data, "snapshot", project.id);
|
||||
|
||||
const gitArgs = (cmd: string[]) => [
|
||||
"--git-dir",
|
||||
snapshotGit,
|
||||
"--work-tree",
|
||||
worktree,
|
||||
...cmd,
|
||||
];
|
||||
|
||||
// Run git with nothrow semantics — always returns a result, never fails
|
||||
const git = (
|
||||
args: string[],
|
||||
opts?: { cwd?: string; env?: Record<string, string> },
|
||||
): Effect.Effect<GitResult> =>
|
||||
Effect.gen(function* () {
|
||||
const command = ChildProcess.make("git", args, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
});
|
||||
const handle = yield* spawner.spawn(command);
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[
|
||||
Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
);
|
||||
const code = yield* handle.exitCode;
|
||||
return { code, text, stderr };
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// FileSystem helpers — orDie converts PlatformError to defects
|
||||
const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie);
|
||||
const mkdir = (p: string) =>
|
||||
fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie);
|
||||
const writeFile = (p: string, content: string) =>
|
||||
fileSystem.writeFileString(p, content).pipe(Effect.orDie);
|
||||
const readFile = (p: string) =>
|
||||
fileSystem
|
||||
.readFileString(p)
|
||||
.pipe(Effect.catch(() => Effect.succeed("")));
|
||||
const removeFile = (p: string) =>
|
||||
fileSystem.remove(p).pipe(Effect.catch(() => Effect.void));
|
||||
|
||||
// --- internal Effect helpers ---
|
||||
|
||||
const isEnabled = Effect.gen(function* () {
|
||||
if (!isGit) return false;
|
||||
const cfg = yield* Effect.promise(() => Config.get());
|
||||
return cfg.snapshot !== false;
|
||||
});
|
||||
|
||||
const excludesPath = Effect.gen(function* () {
|
||||
const result = yield* git(
|
||||
["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"],
|
||||
{
|
||||
cwd: worktree,
|
||||
},
|
||||
);
|
||||
const file = result.text.trim();
|
||||
if (!file) return undefined;
|
||||
if (!(yield* exists(file))) return undefined;
|
||||
return file;
|
||||
});
|
||||
|
||||
const syncExclude = Effect.gen(function* () {
|
||||
const file = yield* excludesPath;
|
||||
const target = path.join(snapshotGit, "info", "exclude");
|
||||
yield* mkdir(path.join(snapshotGit, "info"));
|
||||
if (!file) {
|
||||
yield* writeFile(target, "");
|
||||
return;
|
||||
}
|
||||
const text = yield* readFile(file);
|
||||
yield* writeFile(target, text);
|
||||
});
|
||||
|
||||
const add = Effect.gen(function* () {
|
||||
yield* syncExclude;
|
||||
yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory });
|
||||
});
|
||||
|
||||
// --- service methods ---
|
||||
|
||||
const cleanup = Effect.fn("SnapshotService.cleanup")(function* () {
|
||||
if (!(yield* isEnabled)) return;
|
||||
if (!(yield* exists(snapshotGit))) return;
|
||||
const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
|
||||
cwd: directory,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
return;
|
||||
}
|
||||
log.info("cleanup", { prune: PRUNE });
|
||||
});
|
||||
|
||||
const track = Effect.fn("SnapshotService.track")(function* () {
|
||||
if (!(yield* isEnabled)) return undefined;
|
||||
const existed = yield* exists(snapshotGit);
|
||||
yield* mkdir(snapshotGit);
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree },
|
||||
});
|
||||
yield* git([
|
||||
"--git-dir",
|
||||
snapshotGit,
|
||||
"config",
|
||||
"core.autocrlf",
|
||||
"false",
|
||||
]);
|
||||
yield* git([
|
||||
"--git-dir",
|
||||
snapshotGit,
|
||||
"config",
|
||||
"core.longpaths",
|
||||
"true",
|
||||
]);
|
||||
yield* git([
|
||||
"--git-dir",
|
||||
snapshotGit,
|
||||
"config",
|
||||
"core.symlinks",
|
||||
"true",
|
||||
]);
|
||||
yield* git([
|
||||
"--git-dir",
|
||||
snapshotGit,
|
||||
"config",
|
||||
"core.fsmonitor",
|
||||
"false",
|
||||
]);
|
||||
log.info("initialized");
|
||||
}
|
||||
yield* add;
|
||||
const result = yield* git(gitArgs(["write-tree"]), { cwd: directory });
|
||||
const hash = result.text.trim();
|
||||
log.info("tracking", { hash, cwd: directory, git: snapshotGit });
|
||||
return hash;
|
||||
});
|
||||
|
||||
const patch = Effect.fn("SnapshotService.patch")(function* (
|
||||
hash: string,
|
||||
) {
|
||||
yield* add;
|
||||
const result = yield* git(
|
||||
[
|
||||
...GIT_CFG_QUOTE,
|
||||
...gitArgs([
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--name-only",
|
||||
hash,
|
||||
"--",
|
||||
".",
|
||||
]),
|
||||
],
|
||||
{ cwd: directory },
|
||||
);
|
||||
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code });
|
||||
return { hash, files: [] } as Snapshot.Patch;
|
||||
}
|
||||
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x: string) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x: string) => path.join(worktree, x).replaceAll("\\", "/")),
|
||||
} as Snapshot.Patch;
|
||||
});
|
||||
|
||||
const restore = Effect.fn("SnapshotService.restore")(function* (
|
||||
snapshot: string,
|
||||
) {
|
||||
log.info("restore", { commit: snapshot });
|
||||
const result = yield* git(
|
||||
[...GIT_CORE, ...gitArgs(["read-tree", snapshot])],
|
||||
{ cwd: worktree },
|
||||
);
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git(
|
||||
[...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])],
|
||||
{ cwd: worktree },
|
||||
);
|
||||
if (checkout.code === 0) return;
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
});
|
||||
return;
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
});
|
||||
|
||||
const revert = Effect.fn("SnapshotService.revert")(function* (
|
||||
patches: Snapshot.Patch[],
|
||||
) {
|
||||
const seen = new Set<string>();
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue;
|
||||
log.info("reverting", { file, hash: item.hash });
|
||||
const result = yield* git(
|
||||
[...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])],
|
||||
{
|
||||
cwd: worktree,
|
||||
},
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
const relativePath = path.relative(worktree, file);
|
||||
const checkTree = yield* git(
|
||||
[
|
||||
...GIT_CORE,
|
||||
...gitArgs(["ls-tree", item.hash, "--", relativePath]),
|
||||
],
|
||||
{
|
||||
cwd: worktree,
|
||||
},
|
||||
);
|
||||
if (checkTree.code === 0 && checkTree.text.trim()) {
|
||||
log.info(
|
||||
"file existed in snapshot but checkout failed, keeping",
|
||||
{ file },
|
||||
);
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file });
|
||||
yield* removeFile(file);
|
||||
}
|
||||
}
|
||||
seen.add(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) {
|
||||
yield* add;
|
||||
const result = yield* git(
|
||||
[
|
||||
...GIT_CFG_QUOTE,
|
||||
...gitArgs(["diff", "--no-ext-diff", hash, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: worktree,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
return result.text.trim();
|
||||
});
|
||||
|
||||
const diffFull = Effect.fn("SnapshotService.diffFull")(function* (
|
||||
from: string,
|
||||
to: string,
|
||||
) {
|
||||
const result: Snapshot.FileDiff[] = [];
|
||||
const status = new Map<string, "added" | "deleted" | "modified">();
|
||||
|
||||
const statuses = yield* git(
|
||||
[
|
||||
...GIT_CFG_QUOTE,
|
||||
...gitArgs([
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--name-status",
|
||||
"--no-renames",
|
||||
from,
|
||||
to,
|
||||
"--",
|
||||
".",
|
||||
]),
|
||||
],
|
||||
{ cwd: directory },
|
||||
);
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const [code, file] = line.split("\t");
|
||||
if (!code || !file) continue;
|
||||
const kind = code.startsWith("A")
|
||||
? "added"
|
||||
: code.startsWith("D")
|
||||
? "deleted"
|
||||
: "modified";
|
||||
status.set(file, kind);
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[
|
||||
...GIT_CFG_QUOTE,
|
||||
...gitArgs([
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-renames",
|
||||
"--numstat",
|
||||
from,
|
||||
to,
|
||||
"--",
|
||||
".",
|
||||
]),
|
||||
],
|
||||
{ cwd: directory },
|
||||
);
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const [additions, deletions, file] = line.split("\t");
|
||||
const isBinaryFile = additions === "-" && deletions === "-";
|
||||
const [before, after] = isBinaryFile
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([
|
||||
...GIT_CFG,
|
||||
...gitArgs(["show", `${from}:${file}`]),
|
||||
]).pipe(Effect.map((r) => r.text)),
|
||||
git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(
|
||||
Effect.map((r) => r.text),
|
||||
),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
);
|
||||
const added = isBinaryFile ? 0 : parseInt(additions!);
|
||||
const deleted = isBinaryFile ? 0 : parseInt(deletions!);
|
||||
result.push({
|
||||
file: file!,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(added) ? added : 0,
|
||||
deletions: Number.isFinite(deleted) ? deleted : 0,
|
||||
status: status.get(file!) ?? "modified",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Start hourly cleanup fiber — scoped to instance lifetime
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) });
|
||||
return Effect.void;
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.forkScoped,
|
||||
);
|
||||
|
||||
return SnapshotService.of({
|
||||
init: Effect.fn("SnapshotService.init")(function* () {}),
|
||||
cleanup,
|
||||
track,
|
||||
patch,
|
||||
restore,
|
||||
revert,
|
||||
diff,
|
||||
diffFull,
|
||||
});
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
);
|
||||
const files = result.text
|
||||
return {
|
||||
hash,
|
||||
files: files
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(Instance.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}
|
||||
|
||||
export async function restore(snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const git = gitdir()
|
||||
const result = await Process.run(
|
||||
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (result.code === 0) {
|
||||
const checkout = await Process.run(
|
||||
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr.toString(),
|
||||
stdout: checkout.stdout.toString(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
const files = new Set<string>()
|
||||
const git = gitdir()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (files.has(file)) continue
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = await Process.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["checkout", item.hash, "--", file]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
const relativePath = path.relative(Instance.worktree, file)
|
||||
const checkTree = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["ls-tree", item.hash, "--", relativePath]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (checkTree.code === 0 && checkTree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", {
|
||||
file,
|
||||
})
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
await fs.unlink(file).catch(() => {})
|
||||
}
|
||||
}
|
||||
files.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function diff(hash: string) {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
const result = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
return result.text.trim()
|
||||
}
|
||||
|
||||
export const FileDiff = z
|
||||
.object({
|
||||
file: z.string(),
|
||||
before: z.string(),
|
||||
after: z.string(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FileDiff",
|
||||
})
|
||||
export type FileDiff = z.infer<typeof FileDiff>
|
||||
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
|
||||
const git = gitdir()
|
||||
const result: FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
).then((x) => x.text)
|
||||
|
||||
for (const line of statuses.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
|
||||
status.set(file, kind)
|
||||
}
|
||||
|
||||
for (const line of await Process.lines(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)) {
|
||||
if (!line) continue
|
||||
const [additions, deletions, file] = line.split("\t")
|
||||
const isBinaryFile = additions === "-" && deletions === "-"
|
||||
const before = isBinaryFile
|
||||
? ""
|
||||
: await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["show", `${from}:${file}`]),
|
||||
],
|
||||
{ nothrow: true },
|
||||
).then((x) => x.text)
|
||||
const after = isBinaryFile
|
||||
? ""
|
||||
: await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["show", `${to}:${file}`]),
|
||||
],
|
||||
{ nothrow: true },
|
||||
).then((x) => x.text)
|
||||
const added = isBinaryFile ? 0 : parseInt(additions)
|
||||
const deleted = isBinaryFile ? 0 : parseInt(deletions)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(added) ? added : 0,
|
||||
deletions: Number.isFinite(deleted) ? deleted : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function gitdir() {
|
||||
const project = Instance.project
|
||||
return path.join(Global.Path.data, "snapshot", project.id)
|
||||
}
|
||||
|
||||
async function add(git: string) {
|
||||
await syncExclude(git)
|
||||
await Process.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["add", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function syncExclude(git: string) {
|
||||
const file = await excludes()
|
||||
const target = path.join(git, "info", "exclude")
|
||||
await fs.mkdir(path.join(git, "info"), { recursive: true })
|
||||
if (!file) {
|
||||
await Filesystem.write(target, "")
|
||||
return
|
||||
}
|
||||
const text = await Filesystem.readText(file).catch(() => "")
|
||||
|
||||
await Filesystem.write(target, text)
|
||||
}
|
||||
|
||||
async function excludes() {
|
||||
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
}).then((x) => x.text)
|
||||
if (!file.trim()) return
|
||||
const exists = await fs
|
||||
.stat(file.trim())
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) return
|
||||
return file.trim()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ConfigProvider, Layer, ManagedRuntime } from "effect";
|
||||
import { InstanceContext } from "../../src/effect/instance-context";
|
||||
import { Instance } from "../../src/project/instance";
|
||||
import { ConfigProvider, Layer, ManagedRuntime } from "effect"
|
||||
import { InstanceContext } from "../../src/effect/instance-context"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
/** ConfigProvider that enables the experimental file watcher. */
|
||||
export const watcherConfigLayer = ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
|
||||
}),
|
||||
);
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Boot an Instance with the given service layers and run `body` with
|
||||
@@ -19,35 +19,29 @@ export const watcherConfigLayer = ConfigProvider.layer(
|
||||
* Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
|
||||
*/
|
||||
export function withServices<S>(
|
||||
directory: string,
|
||||
layer: Layer.Layer<S, any, InstanceContext>,
|
||||
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
|
||||
options?: { provide?: Layer.Layer<never>[] },
|
||||
directory: string,
|
||||
layer: Layer.Layer<S, any, InstanceContext>,
|
||||
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
|
||||
options?: { provide?: Layer.Layer<never>[] },
|
||||
) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
const ctx = Layer.sync(InstanceContext, () =>
|
||||
InstanceContext.of({
|
||||
directory: Instance.directory,
|
||||
worktree: Instance.worktree,
|
||||
project: Instance.project,
|
||||
}),
|
||||
);
|
||||
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(
|
||||
Layer.provide(ctx),
|
||||
) as any;
|
||||
if (options?.provide) {
|
||||
for (const l of options.provide) {
|
||||
resolved = resolved.pipe(Layer.provide(l)) as any;
|
||||
}
|
||||
}
|
||||
const rt = ManagedRuntime.make(resolved);
|
||||
try {
|
||||
await body(rt);
|
||||
} finally {
|
||||
await rt.dispose();
|
||||
}
|
||||
},
|
||||
});
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
const ctx = Layer.sync(InstanceContext, () =>
|
||||
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
|
||||
)
|
||||
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
|
||||
if (options?.provide) {
|
||||
for (const l of options.provide) {
|
||||
resolved = resolved.pipe(Layer.provide(l)) as any
|
||||
}
|
||||
}
|
||||
const rt = ManagedRuntime.make(resolved)
|
||||
try {
|
||||
await body(rt)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1343,6 +1343,9 @@ export type Config = {
|
||||
ignore?: Array<string>
|
||||
}
|
||||
plugin?: Array<string>
|
||||
/**
|
||||
* Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.
|
||||
*/
|
||||
snapshot?: boolean
|
||||
/**
|
||||
* Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing
|
||||
|
||||
@@ -10405,6 +10405,7 @@
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"description": "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"share": {
|
||||
|
||||
@@ -424,6 +424,23 @@ Customize keybinds in `tui.json`.
|
||||
|
||||
---
|
||||
|
||||
### Snapshot
|
||||
|
||||
OpenCode uses snapshots to track file changes during agent operations, enabling you to undo and revert changes within a session. Snapshots are enabled by default.
|
||||
|
||||
For large repositories or projects with many submodules, the snapshot system can cause slow indexing and significant disk usage as it tracks all changes using an internal git repository. You can disable snapshots using the `snapshot` option.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"snapshot": false
|
||||
}
|
||||
```
|
||||
|
||||
Note that disabling snapshots means changes made by the agent cannot be rolled back through the UI.
|
||||
|
||||
---
|
||||
|
||||
### Autoupdate
|
||||
|
||||
OpenCode will automatically download any new updates when it starts up. You can disable this with the `autoupdate` option.
|
||||
|
||||
Reference in New Issue
Block a user