Compare commits

...

29 Commits

Author SHA1 Message Date
Dax Raad dfadb61a54 fix(core): connect custom providers 2026-08-05 22:41:29 +00:00
Kit Langton 285444aab4 refactor(web): remove superseded ornate logos (#40750) 2026-08-05 22:35:41 +00:00
Kit Langton 56c33e84a3 fix(tui): keep model search order stable (#40753) 2026-08-05 18:19:26 -04:00
Kit Langton 73581b3c3b refactor(web): remove unused icons (#40744) 2026-08-05 21:51:54 +00:00
Kit Langton 6e4d01f846 refactor(session-ui): remove unused panel title (#40741) 2026-08-05 21:50:46 +00:00
Kit Langton 8646587c95 refactor(web): remove unused share anchor (#40742) 2026-08-05 17:46:40 -04:00
Kit Langton fb47f06228 refactor(desktop): remove superseded local sidecar (#40743) 2026-08-05 17:46:19 -04:00
Kit Langton 3acaa5a359 refactor(core): remove unused shell posix metadata (#40734) 2026-08-05 21:31:35 +00:00
Kit Langton 0726a25142 refactor(console): remove abandoned desktop promo (#40737) 2026-08-05 21:30:53 +00:00
Kit Langton 0df96ddeb0 refactor(codemode): remove unused spread helper (#40729) 2026-08-05 21:26:33 +00:00
Kit Langton 887673310b refactor(core): remove unused session exports (#40730) 2026-08-05 21:26:16 +00:00
Kit Langton 693a1bff81 refactor(core): remove unused copy strategy registry (#40735) 2026-08-05 21:25:18 +00:00
Kit Langton b40ed3aa85 refactor(console): remove unused mail helpers (#40740) 2026-08-05 21:24:21 +00:00
Kit Langton 732bb9c3cb refactor(core): remove discarded MCP connection details (#40726) 2026-08-05 17:20:13 -04:00
Kit Langton 2fd1660b4d refactor(core): remove test-only ignore helper (#40733) 2026-08-05 21:15:22 +00:00
Kit Langton 681526d348 refactor(core): remove duplicate directory query (#40731) 2026-08-05 21:10:01 +00:00
Kit Langton e78869c849 refactor(core): remove unused agent default method (#40725) 2026-08-05 20:46:36 +00:00
Kit Langton 934935963d refactor(core): remove unused filesystem search methods (#40703) 2026-08-05 19:42:30 +00:00
Kit Langton f3912a2a8a refactor(app): remove unused UI helpers (#40686) 2026-08-05 15:15:15 -04:00
Kit Langton 45d58717a4 fix(core): bound protected home searches (#40518) 2026-08-05 15:15:02 -04:00
Kit Langton 74e3155ef0 refactor(session-ui): remove unused exported components (#40685) 2026-08-05 15:13:39 -04:00
Kit Langton 0af6c82563 refactor(core): remove orphaned message projector paths (#40705) 2026-08-05 15:13:15 -04:00
Kit Langton 686127f809 refactor(core): remove unreachable repository validation (#40700) 2026-08-05 14:30:29 -04:00
Kit Langton 5256655c4d refactor(core): remove obsolete project commit bridge (#40701) 2026-08-05 18:16:55 +00:00
Kit Langton d10b652637 refactor(core): remove unused formatter methods (#40684) 2026-08-05 13:45:23 -04:00
Kit Langton b03ca0d4e2 refactor(core): remove unused bus sequences (#40681) 2026-08-05 13:41:38 -04:00
Kit Langton 25aaea3d31 refactor(core): remove unused shell tree killer (#40680) 2026-08-05 13:38:59 -04:00
Kit Langton cae7a139bc refactor(server): remove obsolete auth header helpers (#40682) 2026-08-05 13:37:29 -04:00
Kit Langton 5ea62ab05f refactor(core): remove orphaned git change operations (#40683) 2026-08-05 13:32:03 -04:00
63 changed files with 267 additions and 5865 deletions
-5
View File
@@ -248,11 +248,6 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
return IS_MAC ? parts.join("") : parts.join("+")
}
// KeybindV2 takes an array instead of a string
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
return formatKeybindParts(config, t)
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
-7
View File
@@ -286,13 +286,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
children: tree.children,
expand: tree.expandDir,
collapse: tree.collapseDir,
toggle(input: string) {
if (tree.dirState(input)?.expanded) {
tree.collapseDir(input)
return
}
tree.expandDir(input)
},
},
get,
load,
@@ -153,18 +153,6 @@ export function normalizeProviderList(
}
}
export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project
return {
...project,
icon: {
...project.icon,
url: undefined,
override: undefined,
},
}
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return {
...project,
-30
View File
@@ -753,9 +753,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
mobileSidebar: {
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
show() {
setStore("mobileSidebar", "opened", true)
},
hide() {
setStore("mobileSidebar", "opened", false)
},
@@ -961,33 +958,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (current.reviewOpen.includes(path)) return
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
},
closePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current) return
const index = current.indexOf(path)
if (index === -1) return
setStore(
"sessionView",
session,
"reviewOpen",
produce((draft) => {
if (!draft) return
draft.splice(index, 1)
}),
)
},
togglePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current || !current.includes(path)) {
this.openPath(path)
return
}
this.closePath(path)
},
},
}
},
@@ -22,8 +22,6 @@ type TabsInput = {
fileBrowser?: Accessor<boolean>
}
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
return input.opened && input.visible
}
@@ -58,13 +58,3 @@ export const setMethods = new Set([
"isSupersetOf",
"isDisjointFrom",
])
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
if (Array.isArray(value)) return value
if (typeof value === "string") return Array.from(value)
if (value instanceof CodeModeMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
if (value instanceof CodeModeSet) return Array.from(value.set.values())
if (value instanceof CodeModeURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
return undefined
}
import { CodeModeMap, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
@@ -1,103 +0,0 @@
[data-component="desktop-promo"] {
--promo-background: hsl(0, 20%, 99%);
--promo-background-weak: hsl(0, 8%, 97%);
--promo-text: hsl(0, 1%, 39%);
--promo-text-strong: hsl(0, 5%, 12%);
--promo-border: hsla(0, 100%, 3%, 0.12);
position: fixed;
z-index: 20;
right: 1.5rem;
bottom: 1.5rem;
width: min(28rem, calc(100vw - 2rem));
padding: 4px;
overflow: hidden;
color: var(--promo-text);
border: 1px solid var(--promo-border);
border-radius: 8px;
background: var(--promo-background);
box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 15%);
font-family: var(--font-mono);
@media (prefers-color-scheme: dark) {
--promo-background: hsl(0, 9%, 7%);
--promo-background-weak: hsl(0, 6%, 10%);
--promo-text: hsl(0, 4%, 71%);
--promo-text-strong: hsl(0, 15%, 94%);
--promo-border: hsl(0, 4%, 23%);
}
@media (max-width: 40rem) {
right: 1rem;
bottom: 1rem;
}
[data-slot="desktop-promo-link"] {
display: block;
color: var(--promo-text);
text-decoration: none;
}
[data-slot="desktop-promo-link"]:focus-visible {
outline: 2px solid var(--promo-text-strong);
outline-offset: -3px;
}
video {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 4px;
background: var(--promo-background-weak);
}
[data-slot="desktop-promo-copy"] {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 1rem;
font-size: 0.875rem;
line-height: 1.4;
}
[data-slot="desktop-promo-copy"] strong {
color: var(--promo-text-strong);
font-weight: 500;
}
[data-slot="desktop-promo-close"] {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: grid;
width: 2rem;
height: 2rem;
padding: 0;
place-items: center;
cursor: pointer;
color: white;
border: none;
border-radius: 0.25rem;
background: rgb(0 0 0 / 70%);
opacity: 0;
transition:
opacity 150ms ease,
background 150ms ease;
}
&:hover [data-slot="desktop-promo-close"],
[data-slot="desktop-promo-close"]:focus-visible {
opacity: 1;
}
[data-slot="desktop-promo-close"]:hover {
background: rgb(0 0 0 / 90%);
}
@media (hover: none) {
[data-slot="desktop-promo-close"] {
opacity: 1;
}
}
}
@@ -1,60 +0,0 @@
import "./desktop-promo.css"
import { A, useLocation } from "@solidjs/router"
import { createSignal, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import desktopPromoVideo from "~/asset/lander/desktop-tabs-landscape.mp4"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { strip } from "~/lib/language"
const DISMISSED_COOKIE = "desktop_promo_dismissed"
export function DesktopPromo() {
const i18n = useI18n()
const language = useLanguage()
const location = useLocation()
const request = getRequestEvent()?.request
const cookie = request?.headers.get("cookie") ?? (typeof document === "object" ? document.cookie : "")
const [visible, setVisible] = createSignal(
!cookie.split(";").some((value) => value.trim() === `${DISMISSED_COOKIE}=1`),
)
const hostname = request ? new URL(request.url).hostname : typeof window === "object" ? window.location.hostname : ""
const primaryHost =
hostname === "opencode.ai" || hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
return (
<Show
when={
visible() &&
primaryHost &&
strip(location.pathname) !== "/download" &&
!strip(location.pathname).startsWith("/download/")
}
>
<aside data-component="desktop-promo">
<A href={language.route("/download")} data-slot="desktop-promo-link">
<video src={desktopPromoVideo} autoplay playsinline loop muted preload="metadata" aria-hidden="true" />
<span data-slot="desktop-promo-copy">
<strong>{i18n.t("home.promo.title")}</strong>
<span>
{i18n.t("home.promo.body")} {i18n.t("home.promo.cta")}
</span>
</span>
</A>
<button
type="button"
data-slot="desktop-promo-close"
onClick={() => {
document.cookie = `${DISMISSED_COOKIE}=1; Path=/; Max-Age=31536000; SameSite=Lax`
setVisible(false)
}}
>
<span class="sr-only">{i18n.t("home.promo.close")}</span>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 5L15 15M15 5L5 15" stroke="currentColor" stroke-width="1.5" />
</svg>
</button>
</aside>
</Show>
)
}
@@ -19,10 +19,6 @@ export function Span({ children, ...props }: SpanProps) {
return React.createElement("span", props, children)
}
export function Wbr({ children, ...props }: WbrProps) {
return React.createElement("wbr", props, children)
}
export function Fonts({ assetsUrl }: { assetsUrl: string }) {
return (
<>
@@ -59,14 +55,3 @@ export function Fonts({ assetsUrl }: { assetsUrl: string }) {
</>
)
}
export function SplitString({ text, split }: { text: string; split: number }) {
const segments: JSX.Element[] = []
for (let i = 0; i < text.length; i += split) {
segments.push(<>{text.slice(i, i + split)}</>)
if (i + split < text.length) {
segments.push(<Wbr key={`${i}wbr`} />)
}
}
return <>{segments}</>
}
-4
View File
@@ -44,7 +44,6 @@ export type Draft = {
export interface Interface extends State.Transformable<Draft> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
readonly select: (id?: ID | string) => Effect.Effect<Selection>
readonly list: () => Effect.Effect<Info[]>
@@ -110,9 +109,6 @@ const layer = Layer.effect(
get: Effect.fn("Agent.get")(function* (id) {
return state.get().agents.get(id)
}),
default: Effect.fn("Agent.default")(function* () {
return selectedDefault()
}),
resolve: Effect.fn("Agent.resolve")(function* (id) {
if (id !== undefined) return state.get().agents.get(ID.make(id))
return selectedDefault()
+1 -17
View File
@@ -3,7 +3,7 @@ export * as Bus from "./bus"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { and, asc, eq, gt, inArray, lte, sql } from "drizzle-orm"
import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
@@ -134,8 +134,6 @@ export interface Interface {
readonly after?: number
readonly follow?: boolean
}) => Stream.Stream<LogItem>
/** Latest committed seq per aggregate. Aggregates without events are absent. */
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Event.Seq>>
/** @deprecated Use `subscribe()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
@@ -657,19 +655,6 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Event.Seq>> => {
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
return db
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, Array.from(aggregateIDs)))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Event.Seq.make(row.seq)]))),
)
}
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
listeners.push(listener)
@@ -691,7 +676,6 @@ export const layerWith = (options?: LayerOptions) =>
publish,
subscribe,
log,
sequences,
listen,
project,
replay,
+6 -8
View File
@@ -13,17 +13,15 @@ export const Plugin = define({
const loaded = { entries: yield* config.entries() }
yield* ctx.integration.transform((integrations) => {
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
provider.env === undefined ? [] : [id],
),
),
)
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = id
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
if (!integrations.get(integrationID)) {
integrations.method.update({
integrationID,
method: { type: "key", label: "Manually enter API Key" },
})
}
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
+1 -5
View File
@@ -7,7 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search"
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem"
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
export const ReadInput = Schema.Struct({
@@ -53,8 +53,6 @@ export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
@@ -76,8 +74,6 @@ const baseLayer = Layer.effect(
})
return Service.of({
find: search.find,
glob: search.glob,
grep: search.grep,
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
-38
View File
@@ -3,9 +3,6 @@ import {
type DirItem,
type DirSearchResult,
type FileItem,
type GrepCursor,
type GrepMatch,
type GrepResult,
type InitOptions,
type MixedItem,
type MixedSearchResult,
@@ -45,19 +42,6 @@ export interface MixedSearch {
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export type Cursor = GrepCursor | null
export type Hit = GrepMatch
export interface Grep {
items: GrepResult["items"]
totalMatched: number
totalFilesSearched: number
totalFiles: number
filteredFileCount: number
nextCursor: Cursor
regexFallbackError?: string
}
export interface Picker {
destroy(): void
isScanning(): boolean
@@ -71,14 +55,6 @@ export interface Picker {
pageSize?: number
},
): Result<Search>
glob(
pattern: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
@@ -95,18 +71,6 @@ export interface Picker {
pageSize?: number
},
): Result<MixedSearch>
grep(
query: string,
opts?: {
mode?: "plain" | "regex" | "fuzzy"
maxMatchesPerFile?: number
timeBudgetMs?: number
beforeContext?: number
afterContext?: number
cursor?: Cursor
pageSize?: number
},
): Result<Grep>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
@@ -127,10 +91,8 @@ export function create(opts: Init): Result<Picker> {
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
glob: (pattern, next) => pick.glob(pattern, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
grep: (query, next) => pick.grep(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
-38
View File
@@ -2,9 +2,6 @@ import type {
DirItem,
DirSearchResult,
FileItem,
GrepCursor,
GrepMatch,
GrepResult,
InitOptions,
MixedItem,
MixedSearchResult,
@@ -42,19 +39,6 @@ export interface MixedSearch {
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export type Cursor = GrepCursor | null
export type Hit = GrepMatch
export interface Grep {
items: GrepResult["items"]
totalMatched: number
totalFilesSearched: number
totalFiles: number
filteredFileCount: number
nextCursor: Cursor
regexFallbackError?: string
}
export interface Picker {
destroy(): void
isScanning(): boolean
@@ -68,14 +52,6 @@ export interface Picker {
pageSize?: number
},
): Result<Search>
glob(
pattern: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
@@ -92,18 +68,6 @@ export interface Picker {
pageSize?: number
},
): Result<MixedSearch>
grep(
query: string,
opts?: {
mode?: "plain" | "regex" | "fuzzy"
maxMatchesPerFile?: number
timeBudgetMs?: number
beforeContext?: number
afterContext?: number
cursor?: Cursor
pageSize?: number
},
): Result<Grep>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
@@ -125,10 +89,8 @@ export function create(opts: Init): Result<Picker> {
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
glob: (pattern, next) => pick.glob(pattern, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
grep: (query, next) => pick.grep(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
-19
View File
@@ -1,5 +1,3 @@
import { Glob } from "@opencode-ai/util/glob"
const FOLDERS = new Set([
"node_modules",
"bower_components",
@@ -47,21 +45,4 @@ const FILES = [
export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`]
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
for (const pattern of opts?.whitelist || []) {
if (Glob.match(pattern, filepath)) return false
}
const parts = filepath.split(/[/\\]/)
for (const part of parts) {
if (FOLDERS.has(part)) return true
}
for (const pattern of [...FILES, ...(opts?.extra || [])]) {
if (Glob.match(pattern, filepath)) return true
}
return false
}
export * as Ignore from "./ignore"
@@ -3,7 +3,6 @@ export * as LocationWatcher from "./location-watcher"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import os from "os"
import path from "path"
import { Config } from "../config"
import { Bus } from "../bus"
@@ -44,7 +43,7 @@ const layer = Layer.effect(
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = path.resolve(location.directory) === path.resolve(os.homedir())
const home = Protected.isHome(location.directory)
if (!home && location.vcs) {
const updates = yield* watcher.subscribe({
@@ -3,6 +3,10 @@ import path from "path"
const home = os.homedir()
export function isHome(directory: string) {
return path.resolve(directory) === path.resolve(home)
}
const DARWIN_HOME = [
"Music",
"Pictures",
+15 -111
View File
@@ -6,15 +6,13 @@ import { Context, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { Protected } from "./protected"
export interface Interface {
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
readonly glob: (input: FileSystem.GlobInput) => Effect.Effect<readonly FileSystem.Entry[]>
readonly grep: (input: FileSystem.GrepInput) => Effect.Effect<readonly FileSystem.Match[]>
}
export const Options = Schema.Struct({
@@ -27,17 +25,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Fi
export const ripgrepLayer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
const files: string[] = []
const directories = new Set<string>()
const home = Protected.isHome(location.directory)
yield* ripgrep
.find({
cwd: location.directory,
pattern: "*",
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
onEntry: (entry) =>
Effect.sync(() => {
files.push(entry.path)
@@ -47,57 +46,6 @@ export const ripgrepLayer = Layer.effect(
})
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
return Service.of({
glob: (input) =>
Effect.gen(function* () {
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(target).pipe(Effect.orDie)
const cwd = info.type === "File" ? path.dirname(target) : target
return yield* ripgrep
.glob({
cwd,
pattern: input.pattern,
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
})
.pipe(
Effect.map((result) =>
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
Effect.orDie,
)
}),
grep: (input) =>
Effect.gen(function* () {
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(target).pipe(Effect.orDie)
const cwd = info.type === "File" ? path.dirname(target) : target
return yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: info.type === "File" ? path.basename(target) : undefined,
include: input.include,
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
})
.pipe(
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
}),
}),
),
),
Effect.orDie,
)
}),
find: (input) =>
Effect.gen(function* () {
const items =
@@ -139,55 +87,10 @@ export const fffLayer = Layer.effect(
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
return Service.of({
find: () => Effect.succeed([]),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
})
}
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
return Service.of({
glob: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
pageIndex: 0,
pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
})
if (!found.ok) throw found.error
return found.value.items.map((item) =>
FileSystem.Entry.make({
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
type: "file",
}),
)
}),
grep: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.grep(
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
.filter((value) => value !== undefined)
.join(" "),
{ mode: "regex", pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, timeBudgetMs: 1_500 },
)
if (!found.ok) throw found.error
return found.value.items.map((match) => {
const bytes = Buffer.from(match.lineContent)
return FileSystem.Match.make({
entry: FileSystem.Entry.make({
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
type: "file",
}),
line: match.lineNumber,
offset: match.byteOffset,
text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent,
submatches: match.matchRanges.map(([start, end]) => ({
text: bytes.subarray(start, end).toString("utf8"),
start,
end,
})),
})
})
}),
find: (input) =>
Effect.sync(() => {
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
@@ -232,18 +135,19 @@ export const fffLayer = Layer.effect(
}),
)
export const layer = (options?: Options) => Layer.unwrap(
Effect.gen(function* () {
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
return ripgrepLayer
const location = yield* Location.Service
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
return location.vcs ? fffLayer : ripgrepLayer
}),
)
export const layer = (options?: Options) =>
Layer.unwrap(
Effect.gen(function* () {
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
return ripgrepLayer
const location = yield* Location.Service
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
return location.vcs && !Protected.isHome(location.directory) ? fffLayer : ripgrepLayer
}),
)
export function configured(options?: Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [FSUtil.node, Location.node, Ripgrep.node] })
return makeLocationNode({ service: Service, layer: layer(options), deps: [Location.node, Ripgrep.node] })
}
export const node = configured()
+2 -28
View File
@@ -1,6 +1,6 @@
export * as Formatter from "./formatter"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -11,16 +11,7 @@ import { Config } from "./config"
import { Location } from "./location"
import { make, type Info } from "./formatter/builtins"
export const Status = Schema.Struct({
name: Schema.String,
extensions: Schema.Array(Schema.String),
enabled: Schema.Boolean,
}).annotate({ identifier: "FormatterStatus" })
export type Status = typeof Status.Type
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
readonly file: (filepath: string) => Effect.Effect<boolean>
}
@@ -84,23 +75,6 @@ const layer = Layer.effect(
return result
})
const init = Effect.fn("Formatter.init")(function* () {
yield* load
})
const status = Effect.fn("Formatter.status")(function* () {
yield* load
return yield* Effect.forEach(formatters, (formatter) =>
command(formatter).pipe(
Effect.map((enabled) => ({
name: formatter.name,
extensions: [...formatter.extensions],
enabled: enabled !== false,
})),
),
)
})
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
yield* load
const matching = formatters.filter((formatter) =>
@@ -143,7 +117,7 @@ const layer = Layer.effect(
return false
})
return Service.of({ init, status, file })
return Service.of({ file })
}),
)
+1 -152
View File
@@ -1,7 +1,7 @@
export * as Git from "./git"
import path from "path"
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath, RelativePath } from "./schema"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -35,9 +35,6 @@ const snapshotConfig = `[core]
threads = true
`
export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet"))
export type ChangeSet = typeof ChangeSet.Type
export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
export type TreeID = typeof TreeID.Type
@@ -72,13 +69,6 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
cause: Schema.optional(Schema.Defect()),
}) {}
export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
operation: Schema.Literals(["capture", "apply", "reset"]),
directory: AbsolutePath,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export interface Interface {
readonly repo: {
readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
@@ -115,20 +105,6 @@ export interface Interface {
) => Effect.Effect<void, OperationError>
readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
}
readonly change: {
readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect<ChangeSet, PatchError>
readonly apply: (input: {
repository: Repository
path: AbsolutePath
changes: ChangeSet
}) => Effect.Effect<void, PatchError>
readonly discard: (input: {
repository: Repository
path: AbsolutePath
index: "preserve" | "reset"
untracked: "preserve" | "remove"
}) => Effect.Effect<void, PatchError>
}
readonly worktree: {
readonly create: (input: {
repository: Repository
@@ -678,132 +654,6 @@ const layer = Layer.effect(
),
)
const capture = Effect.fn("Git.change.capture")(function* (input: { repository: Repository; path: AbsolutePath }) {
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
const tracked = yield* execute(
input.repository.worktree,
proc,
)(["diff", "--binary", "HEAD", "--", scope]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
),
)
if (tracked.exitCode !== 0) {
return yield* new PatchError({
operation: "capture",
directory: input.path,
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
})
}
const untracked = yield* execute(
input.repository.worktree,
proc,
)(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
),
)
if (untracked.exitCode !== 0) {
return yield* new PatchError({
operation: "capture",
directory: input.path,
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
})
}
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
execute(
input.repository.worktree,
proc,
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
),
Effect.flatMap((result) =>
// git diff --no-index returns 1 when differences were found.
result.exitCode === 0 || result.exitCode === 1
? Effect.succeed(result.text)
: Effect.fail(
new PatchError({
operation: "capture",
directory: input.path,
message:
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
}),
),
),
),
)
return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n"))
})
const apply = Effect.fn("Git.change.apply")(function* (input: {
repository: Repository
path: AbsolutePath
changes: ChangeSet
}) {
const result = yield* proc
.run(
ChildProcess.make("git", ["apply", "-"], {
cwd: input.path,
extendEnv: true,
stdin: Stream.make(new TextEncoder().encode(input.changes)),
}),
)
.pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "apply", directory: input.path, message: cause.message, cause }),
),
)
if (result.exitCode === 0) return
return yield* new PatchError({
operation: "apply",
directory: input.path,
message:
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
})
})
const discard = Effect.fn("Git.change.discard")(function* (input: {
repository: Repository
path: AbsolutePath
index: "preserve" | "reset"
untracked: "preserve" | "remove"
}) {
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
const restore = yield* execute(
input.repository.worktree,
proc,
)(input.index === "reset" ? ["checkout", "HEAD", "--", scope] : ["checkout", "--", scope]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
),
)
if (restore.exitCode !== 0) {
return yield* new PatchError({
operation: "reset",
directory: input.path,
message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes",
})
}
if (input.untracked === "preserve") return
const clean = yield* execute(
input.repository.worktree,
proc,
)(["clean", "-fd", "--", scope]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
),
)
if (clean.exitCode === 0) return
return yield* new PatchError({
operation: "reset",
directory: input.path,
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
})
})
const worktreeRun = Effect.fnUntraced(function* (
operation: "create" | "remove" | "list",
repository: Repository,
@@ -879,7 +729,6 @@ const layer = Layer.effect(
remote: { get: remote },
history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots },
sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset },
change: { capture, apply, discard },
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
index: { refresh, ignored },
tree: {
+6 -19
View File
@@ -12,7 +12,6 @@ import { Credential } from "../credential"
import { Bus } from "../bus"
import { Form } from "../form"
import { Integration } from "../integration"
import { IntegrationConnection } from "../integration/connection"
import { KeyedMutex } from "../effect/keyed-mutex"
import { Location } from "../location"
import { waitForAbort } from "@opencode-ai/util/process"
@@ -31,7 +30,6 @@ export class ServerInfo extends Schema.Class<ServerInfo>("MCP.ServerInfo")({
name: ServerName,
status: Status,
integrationID: Integration.ID.pipe(Schema.optional),
connection: IntegrationConnection.Info.pipe(Schema.optional),
}) {}
export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.ServerInstructions")({
@@ -247,14 +245,6 @@ export const layer = (options?: Options) => Layer.effect(
return { name, entry }
})
const info = (name: ServerName, entry: ServerEntry, connection: IntegrationConnection.Info | undefined) =>
new ServerInfo({
name,
status: entry.status,
integrationID: entry.integrationID,
connection,
})
// Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and
// refreshes stored tokens, persisting refreshes back to the same credential row. The provider never
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
@@ -603,15 +593,12 @@ export const layer = (options?: Options) => Layer.effect(
)
return Service.of({
servers: Effect.fn("MCP.servers")(function* () {
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
return yield* Effect.forEach(entries, ([name, entry]) =>
Effect.gen(function* () {
const connection = entry.integrationID
? yield* integration.connection.active(entry.integrationID)
: undefined
return info(name, entry, connection)
}),
)
return Array.from(runtime)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(
([name, entry]) =>
new ServerInfo({ name, status: entry.status, integrationID: entry.integrationID }),
)
}),
add: Effect.fn("MCP.add")(function* (server, config) {
const name = ServerName.make(server)
+1 -15
View File
@@ -59,16 +59,6 @@ export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
/**
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
*
* This exists while the old opencode project service and this core project
* service work together: core resolves the ID, while the old service still owns
* database migration and persistence. The old service should call this after it
* finishes migrating from `resolve().previous` to `resolve().id`; once project
* persistence moves into core, this separate bridge method can go away.
*/
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@@ -268,11 +258,7 @@ const layer = Layer.effect(
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
})
return Service.of({ list, directories, resolve, commit })
return Service.of({ list, directories, resolve })
}),
)
+12 -32
View File
@@ -70,11 +70,6 @@ export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUn
{ strategy: StrategyID },
) {}
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
"ProjectCopy.DuplicateStrategyError",
{ strategy: StrategyID },
) {}
export type Error =
| SourceDirectoryNotFoundError
| DestinationExistsError
@@ -99,7 +94,6 @@ export interface Strategy {
export { Event }
export interface Interface {
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
@@ -144,29 +138,18 @@ const layer = Layer.effect(
return resolved
})
const registry = new Map<StrategyID, Strategy>()
const register = Effect.fn("ProjectCopy.register")(function* (strategy: Strategy) {
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
registry.set(strategy.id, strategy)
})
// Register default strategies
yield* register(makeGitWorktreeStrategy({ git, canonical })).pipe(Effect.orDie)
const strategies = () => Array.from(registry.values())
const strategy = makeGitWorktreeStrategy({ git, canonical })
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
const sourceDirectory = yield* canonical(input)
if (!(yield* directories.contains({ projectID, directory: sourceDirectory })))
if ((yield* directories.get({ projectID, directory: sourceDirectory })) === undefined)
return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
return sourceDirectory
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
const found = registry.get(id)
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
return found
if (id !== strategy.id) return yield* new StrategyUnavailableError({ strategy: id })
return strategy
})
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
@@ -226,20 +209,18 @@ const layer = Layer.effect(
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
Effect.forEach(strategies(), (strategy) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "copy" ? strategy.id : undefined,
})),
),
strategy.list(sourceDirectory).pipe(
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "copy" ? strategy.id : undefined,
})),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
Effect.map((sets) => new Map(sets.flat().map((item) => [item.directory, item] as const)).values().toArray()),
)
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const result = yield* db
@@ -271,7 +252,6 @@ const layer = Layer.effect(
})
return Service.of({
register,
create,
remove,
refresh,
-21
View File
@@ -41,7 +41,6 @@ export interface Interface {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) => Effect.Effect<Directory | undefined>
readonly contains: (input: { projectID: ProjectSchema.ID; directory: AbsolutePath }) => Effect.Effect<boolean>
readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
}
@@ -99,25 +98,6 @@ const layer = Layer.effect(
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
})
const contains = Effect.fn("ProjectDirectories.contains")(function* (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) {
return (
(yield* db
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
eq(ProjectDirectoryTable.directory, input.directory),
),
)
.get()
.pipe(Effect.orDie)) !== undefined
)
})
const get = Effect.fn("ProjectDirectories.get")(function* (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
@@ -139,7 +119,6 @@ const layer = Layer.effect(
return Service.of({
list,
get,
contains,
create,
remove,
})
-17
View File
@@ -31,14 +31,6 @@ export type EnsureInput = {
readonly branch?: string
}
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
"RepositoryCacheInvalidRepositoryError",
{
repository: Schema.String,
message: Schema.String,
},
) {}
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
"RepositoryCacheInvalidBranchError",
{
@@ -86,7 +78,6 @@ export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationE
) {}
export type Error =
| InvalidRepositoryError
| InvalidBranchError
| CloneFailedError
| FetchFailedError
@@ -103,7 +94,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Re
export function isError(error: unknown): error is Error {
return (
error instanceof InvalidRepositoryError ||
error instanceof InvalidBranchError ||
error instanceof CloneFailedError ||
error instanceof FetchFailedError ||
@@ -114,13 +104,6 @@ export function isError(error: unknown): error is Error {
)
}
export const parseRemote = Effect.fn("RepositoryCache.parseRemote")(function* (repository: string) {
return yield* Effect.try({
try: () => Repository.parseRemote(repository),
catch: (error) => new InvalidRepositoryError({ repository, message: errorMessage(error) }),
})
})
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
return yield* Effect.try({
try: () => Repository.validateBranch(branch),
-10
View File
@@ -44,16 +44,6 @@ export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchErr
message: Schema.String,
}) {}
export type Error = InvalidReferenceError | UnsupportedLocalRepositoryError | InvalidBranchError
export function isError(error: unknown): error is Error {
return (
error instanceof InvalidReferenceError ||
error instanceof UnsupportedLocalRepositoryError ||
error instanceof InvalidBranchError
)
}
export function parse(input: string): Reference | undefined {
const cleaned = normalizeInput(input)
if (!cleaned) return
+2
View File
@@ -52,6 +52,7 @@ export interface FindInput {
readonly cwd: string
readonly pattern: string
readonly limit: number
readonly exclude?: readonly string[]
readonly hidden?: boolean
readonly follow?: boolean
readonly signal?: AbortSignal
@@ -195,6 +196,7 @@ const layer = Layer.effect(
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
...(input.exclude ?? []).map((pattern) => `--glob=!${pattern}`),
"--glob=!**/.git/**",
".",
],
-27
View File
@@ -52,9 +52,6 @@ import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex"
import { fileURLToPath } from "url"
export const RevertState = Session.Revert
export type RevertState = Session.Revert
// get project -> project.locations
//
// get all sessions
@@ -110,13 +107,6 @@ type ForkInput = {
boundary: Session.ForkRequestBoundary
}
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
"Session.OperationUnavailableError",
{
operation: Schema.Literals(["move", "skill", "switchAgent", "compact"]),
},
) {}
export { MessageDecodeError, NotFoundError }
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
@@ -160,23 +150,6 @@ export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<Destin
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
export type Error =
| NotFoundError
| MessageDecodeError
| OperationUnavailableError
| PromptConflictError
| SyntheticConflictError
| AttachmentError
| CompactionConflictError
| BusyError
| SkillNotFoundError
| DestinationNotFoundError
| DestinationNotDirectoryError
| Command.NotFoundError
| Command.EvaluationError
| MessageNotFoundError
| SessionGenerate.Error
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
readonly data: SessionSchema.Info[]
+8 -120
View File
@@ -1,12 +1,8 @@
import { castDraft, produce, type WritableDraft } from "immer"
import { DateTime, Effect } from "effect"
import { DateTime, Effect, Match, pipe } from "effect"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
export type MemoryState = {
messages: SessionMessage.Info[]
}
export interface Adapter {
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
@@ -23,89 +19,7 @@ export interface Adapter {
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
}
export function memory(state: MemoryState): Adapter {
const assistantIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
const shellIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
const compactionIndex = () =>
state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
return {
getModel() {
return Effect.sync(
() =>
state.messages.findLast(
(message): message is SessionMessage.ModelSelected | SessionMessage.Assistant =>
message.type === "model-switched" || message.type === "assistant",
)?.model,
)
},
getCurrentAssistant() {
return Effect.sync(() => {
const index = latestAssistantIndex()
if (index < 0) return
const assistant = state.messages[index]
return assistant?.type === "assistant" && !assistant.time.completed ? assistant : undefined
})
},
getAssistant(messageID) {
return Effect.sync(() => {
const index = assistantIndex(messageID)
if (index < 0) return
const assistant = state.messages[index]
return assistant?.type === "assistant" ? assistant : undefined
})
},
getShell(shellID) {
return Effect.sync(() => {
return state.messages.find((message): message is SessionMessage.Shell => {
return message.type === "shell" && message.shellID === shellID
})
})
},
getCompaction() {
return Effect.sync(() => {
const index = compactionIndex()
const message = state.messages[index]
return message?.type === "compaction" ? message : undefined
})
},
updateAssistant(assistant) {
return Effect.sync(() => {
const index = assistantIndex(assistant.id)
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "assistant") return
state.messages[index] = assistant
})
},
updateShell(shell) {
return Effect.sync(() => {
const index = shellIndex(shell.id)
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "shell") return
state.messages[index] = shell
})
},
updateCompaction(compaction) {
return Effect.sync(() => {
const index = state.messages.findLastIndex((message) => message.id === compaction.id)
if (index >= 0) state.messages[index] = compaction
})
},
appendMessage(message) {
return Effect.sync(() => {
state.messages.push(message)
})
},
}
}
export function update(adapter: Adapter, event: SessionEvent.Event) {
export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
type DraftTool = WritableDraft<SessionMessage.AssistantTool>
type DraftText = WritableDraft<SessionMessage.AssistantText>
@@ -139,9 +53,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"session.usage.updated": () => Effect.void,
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return adapter.appendMessage(
@@ -321,12 +235,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
})
},
"session.text.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft)
if (match) match.text += event.data.delta
})
},
"session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft)
@@ -351,7 +259,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
})
},
"session.tool.input.delta": () => Effect.void,
"session.tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id)
@@ -375,14 +282,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"session.tool.progress": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id)
if (match && match.state.status === "running") {
match.state.metadata = event.data.metadata
}
})
},
// Terminal tool events are self-contained; projection is a direct copy and
// never reaches into ephemeral progress history.
"session.tool.success": (event) => {
@@ -436,12 +335,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
})
},
"session.reasoning.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft)
if (match) match.text += event.data.delta
})
},
"session.reasoning.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft)
@@ -475,12 +368,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
time: { created: event.created },
}),
),
"session.compaction.delta": (event) =>
Effect.gen(function* () {
const current = yield* adapter.getCompaction()
if (current?.status !== "running") return
yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text })
}),
"session.compaction.ended": (event) => {
return Effect.gen(function* () {
const current = yield* adapter.getCompaction()
@@ -526,8 +413,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.revert.staged": () => Effect.void,
"session.revert.cleared": () => Effect.void,
"session.revert.committed": () => Effect.void,
})
})
}),
)
return project(event)
}
export * as SessionMessageUpdater from "./message-updater"
+6 -44
View File
@@ -1,25 +1,22 @@
export * as ShellSelect from "./select"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { setTimeout } from "node:timers/promises"
import { Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { which } from "../util/which"
const SIGKILL_TIMEOUT_MS = 200
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
bash: { login: true, posix: true },
dash: { login: true, posix: true },
const META: Record<string, { deny?: boolean; login?: boolean; ps?: boolean }> = {
bash: { login: true },
dash: { login: true },
fish: { deny: true, login: true },
ksh: { login: true, posix: true },
ksh: { login: true },
nu: { deny: true },
powershell: { ps: true },
pwsh: { ps: true },
sh: { login: true, posix: true },
zsh: { login: true, posix: true },
sh: { login: true },
zsh: { login: true },
}
export type Item = {
@@ -33,37 +30,6 @@ export const Options = Schema.Struct({
})
export type Options = typeof Options.Type
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
if (!pid || opts?.exited?.()) return
if (process.platform === "win32") {
await new Promise<void>((resolve) => {
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
})
killer.once("exit", () => resolve())
killer.once("error", () => resolve())
})
return
}
try {
process.kill(-pid, "SIGTERM")
await setTimeout(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch {
proc.kill("SIGTERM")
await setTimeout(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
}
}
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
@@ -150,10 +116,6 @@ export function login(file: string) {
return meta(file)?.login === true
}
export function posix(file: string) {
return meta(file)?.posix === true
}
export function ps(file: string) {
return meta(file)?.ps === true
}
-20
View File
@@ -1298,24 +1298,4 @@ describe("Bus", () => {
}),
)
it.effect("sequences returns the latest committed seq per aggregate and omits unknown aggregates", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const first = Session.ID.create()
const second = Session.ID.create()
yield* bus.publish(DurableMessage, durableData(first, "zero"))
yield* bus.publish(DurableMessage, durableData(first, "one"))
yield* bus.publish(DurableMessage, durableData(second, "zero"))
const sequences = yield* bus.sequences([first, second, Session.ID.create()])
expect(sequences).toEqual(
new Map([
[first, Event.Seq.make(1)],
[second, Event.Seq.make(0)],
]),
)
expect(yield* bus.sequences([])).toEqual(new Map())
}),
)
})
@@ -49,6 +49,33 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const entries = [
new Config.Document({
type: "document",
info: decode({
providers: {
litellm: {
package: "aisdk:@ai-sdk/openai-compatible",
models: { chat: {} },
},
},
}),
}),
]
yield* addPlugin(entries)
expect(yield* integrations.get(Integration.ID.make("litellm"))).toMatchObject({
id: "litellm",
name: "litellm",
methods: [{ type: "key", label: "Manually enter API Key" }],
})
}),
)
it.effect("defaults custom models to agent capabilities", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -80,7 +80,6 @@ describe("node build", () => {
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
commit: () => Effect.void,
})
}),
)
@@ -3,14 +3,6 @@ import { Ignore } from "@opencode-ai/core/filesystem/ignore"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
test("match nested and non-nested", () => {
expect(Ignore.match("node_modules/index.js")).toBe(true)
expect(Ignore.match("node_modules")).toBe(true)
expect(Ignore.match("node_modules/")).toBe(true)
expect(Ignore.match("node_modules/bar")).toBe(true)
expect(Ignore.match("node_modules/bar/")).toBe(true)
})
test("parcel patterns ignore built-in folders at any depth", async () => {
let ignoreGlobs: string[] = []
const watcher = createWrapper({
+52 -37
View File
@@ -1,44 +1,59 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import { describe, expect, test } from "bun:test"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Protected } from "@opencode-ai/core/filesystem/protected"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Location } from "@opencode-ai/core/location"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { location } from "../fixture/location"
const it = testEffect(LayerNode.compile(Ripgrep.node))
describe("FileSystemSearch", () => {
test("bounds a home scan even when home is detected as a repository", async () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
observed = input
if (input.onEntry)
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }))
return []
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
const withTmp = <A, E, R>(f: (directory: AbsolutePath) => Effect.Effect<A, E, R>) =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(AbsolutePath.make(tmp.path))))
describe("Ripgrep", () => {
it.live("globs files as an array", () =>
withTmp((cwd) =>
await Effect.runPromise(
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
}),
),
)
it.live("greps files with include filtering", () =>
withTmp((cwd) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
expect(result).toHaveLength(1)
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
expect(result[0]?.submatches[0]?.text).toBe("needle")
}),
),
)
const search = yield* FileSystemSearch.Service
yield* Effect.sleep("10 millis")
expect(observed?.limit).toBe(100_000)
expect(observed?.exclude).toEqual([...Protected.names()].map((name) => `${name}/**`))
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
RelativePath.make(`src${path.sep}`),
)
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
})
+34 -57
View File
@@ -56,52 +56,22 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
}
describe("Formatter", () => {
it.live("status() returns empty list when no formatters are configured", () =>
it.live("does not run formatters marked as disabled in config", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
),
)
it.live("status() returns built-in formatters when formatter is true", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
expect(gofmt).toBeDefined()
expect(gofmt?.extensions).toContain(".go")
}),
).pipe(Effect.provide(formatterLayer(directory, true))),
),
)
it.live("status() keeps built-in formatters when config object is provided", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
}),
).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
),
)
it.live("status() excludes formatters marked as disabled in config", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
}),
).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
),
)
it.live("service initializes without error", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
Effect.gen(function* () {
const file = path.join(directory, "test.disabled")
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
}).pipe(
Effect.provide(
formatterLayer(directory, {
disabled: {
disabled: true,
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".disabled"],
},
}),
),
),
),
)
@@ -115,22 +85,29 @@ describe("Formatter", () => {
),
)
it.live("status() initializes formatter state per directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([off, on]) =>
it.live("loads formatter state per directory", () =>
withTemp((off) =>
withTemp((on) =>
Effect.gen(function* () {
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
Effect.provide(formatterLayer(off.path, false)),
const offFile = path.join(off, "test.isolated")
const onFile = path.join(on, "test.isolated")
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
Effect.provide(formatterLayer(off, false)),
)
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
Effect.provide(formatterLayer(on.path, true)),
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
Effect.provide(
formatterLayer(on, {
isolated: {
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".isolated"],
},
}),
),
)
expect(disabled).toEqual([])
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
expect(disabled).toBe(false)
expect(enabled).toBe(true)
}),
(directories) =>
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
),
),
)
-1
View File
@@ -21,7 +21,6 @@ const projectLayer = Layer.succeed(
canonical: AbsolutePath.make("/main/repo"),
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
}),
commit: () => Effect.void,
}),
)
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
+1 -11
View File
@@ -83,20 +83,10 @@ describe("ProjectCopy", () => {
}),
)
it.effect("rejects duplicate strategies and reports unavailable ids", () =>
it.effect("reports unavailable strategy ids", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const strategy: ProjectCopy.Strategy = {
id: ProjectCopy.StrategyID.make("test/duplicate"),
create: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
list: () => Effect.succeed([]),
}
yield* copy.register(strategy)
expect(yield* copy.register(strategy).pipe(Effect.flip)).toBeInstanceOf(ProjectCopy.DuplicateStrategyError)
const unavailable = ProjectCopy.StrategyID.make("acme/missing")
const error = yield* copy
.create({
+1 -4
View File
@@ -101,13 +101,10 @@ describe("RepositoryCache", () => {
),
)
it.live("returns typed validation and clone failures", () =>
it.live("returns typed branch validation and clone failures", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const invalidRepository = yield* Effect.flip(RepositoryCache.parseRemote("not-a-repo"))
expect(invalidRepository).toBeInstanceOf(RepositoryCache.InvalidRepositoryError)
const invalidBranch = yield* Effect.flip(cache.ensure({ reference: fixture.reference, branch: "../unsafe" }))
expect(invalidBranch).toBeInstanceOf(RepositoryCache.InvalidBranchError)
+61
View File
@@ -11,6 +11,44 @@ import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(Ripgrep.node))
describe("Ripgrep", () => {
it.live("globs files as an array", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).glob({ cwd: tmp.path, pattern: "**/*.ts", limit: 10 })
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("greps files with include filtering", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "skip.txt"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).grep({
cwd: tmp.path,
pattern: "needle",
include: "*.ts",
limit: 10,
})
expect(result).toHaveLength(1)
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
expect(result[0]?.submatches[0]?.text).toBe("needle")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("keeps ignored files out of catch-all find results", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -63,6 +101,29 @@ describe("Ripgrep", () => {
),
)
it.live("excludes protected directory trees from catch-all find results", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "Pictures")))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "Pictures", "private.jpg"), "private\n"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "visible.txt"), "visible\n"))
const files = yield* (yield* Ripgrep.Service).find({
cwd: tmp.path,
pattern: "*",
limit: 10,
exclude: ["Pictures/**"],
})
expect(files.map((item) => item.path)).toContain(RelativePath.make("visible.txt"))
expect(files.map((item) => item.path)).not.toContain(RelativePath.make("Pictures/private.jpg"))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("returns a bounded preview for matches on oversized lines", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -36,7 +36,6 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
let requests: LLMRequest[] = []
@@ -34,7 +34,6 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
@@ -56,7 +56,6 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const permission = Layer.succeed(
+1 -4
View File
@@ -23,7 +23,6 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
@@ -41,17 +40,15 @@ describe("Session.log", () => {
it.effect("replays public session events and marks synced at the aggregate watermark", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
const watermark = (yield* bus.sequences([created.id])).get(created.id)
// Session creation commits a non-public durable event, so the marker's
// seq covers more of the aggregate than the public events emitted.
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) })
}),
)
@@ -17,7 +17,6 @@ import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Money } from "@opencode-ai/schema/money"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { fromRow } from "@opencode-ai/core/session/info"
@@ -126,34 +125,6 @@ describe("SessionProjector", () => {
}),
)
it.effect("folds live compaction deltas into running memory state", () =>
Effect.gen(function* () {
const state = {
messages: [
SessionMessage.CompactionRunning.make({
id: SessionMessage.ID.make("msg_compaction"),
type: "compaction",
status: "running",
reason: "manual",
summary: "partial ",
recent: "recent",
time: { created },
}),
],
}
yield* SessionMessageUpdater.update(
SessionMessageUpdater.memory(state),
SessionEvent.Compaction.Delta.make({
id: Event.ID.make("evt_delta"),
type: "session.compaction.delta",
created,
data: { sessionID, text: "summary" },
}),
)
expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" })
}),
)
it.effect("projects staged, cleared, and committed reverts", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
@@ -550,31 +521,6 @@ describe("SessionProjector", () => {
}),
)
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
Effect.gen(function* () {
const stale = SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: build,
model,
content: [],
time: { created },
})
const completed = SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant",
agent: build,
model,
content: [],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
})
expect(
yield* SessionMessageUpdater.memory({ messages: [stale, completed] }).getCurrentAssistant(),
).toBeUndefined()
}),
)
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
@@ -19,7 +19,6 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
-6
View File
@@ -34,12 +34,6 @@ describe("shell", () => {
expect(ShellSelect.login("C:/tools/pwsh.exe")).toBe(false)
})
test("detects posix shells", () => {
expect(ShellSelect.posix("/bin/bash")).toBe(true)
expect(ShellSelect.posix("/bin/fish")).toBe(false)
expect(ShellSelect.posix("C:/tools/pwsh.exe")).toBe(false)
})
test("falls back when configured shell cannot be resolved", async () => {
await withShell(undefined, async () => {
const preferred = ShellSelect.preferred()
-12
View File
@@ -5,15 +5,3 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "virtual:opencode-server" {
export namespace Server {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
}
export namespace Config {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}
-181
View File
@@ -1,32 +1,8 @@
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { app, utilityProcess } from "electron"
import type { Details } from "electron"
import { getLogger } from "./logging"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
export type HealthCheck = { wait: Promise<void> }
type SidecarMessage =
| { type: "ready" }
| { type: "stopped" }
| { type: "error"; error: { message: string; stack?: string } }
export type SidecarListener = { stop: () => Promise<void> }
const SIDECAR_SERVICE_NAME = "opencode server"
const SIDECAR_START_STALL_TIMEOUT = 60_000
const SIDECAR_STOP_TIMEOUT = 6_000
type SpawnLocalServerOptions = {
userDataPath: string
onStdout?: (message: string) => void
onStderr?: (message: string) => void
onExit?: (code: number) => void
}
export function getDefaultServerUrl(): string | null {
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
@@ -54,135 +30,6 @@ export function preferAppEnv(userDataPath: string) {
return shellEnv
}
export async function spawnLocalServer(
hostname: string,
port: number,
password: string,
options: SpawnLocalServerOptions,
) {
const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
const child = utilityProcess.fork(sidecar, [], {
cwd: process.cwd(),
env: createSidecarEnv(),
serviceName: SIDECAR_SERVICE_NAME,
stdio: "pipe",
})
let exited = false
const exit = defer<number>()
const onProcessGone = (_event: unknown, details: Details) => {
if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return
options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`)
}
app.on("child-process-gone", onProcessGone)
child.once("exit", (code) => {
exited = true
app.off("child-process-gone", onProcessGone)
options.onExit?.(code)
exit.resolve(code)
})
child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`))
child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd()))
child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd()))
await new Promise<void>((resolve, reject) => {
let done = false
let timeout: NodeJS.Timeout
const fail = (error: Error) => {
if (done) return
done = true
cleanup()
reject(error)
}
const refreshTimeout = () => {
clearTimeout(timeout)
timeout = setTimeout(() => {
fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`))
}, SIDECAR_START_STALL_TIMEOUT)
}
const onMessage = (message: SidecarMessage) => {
if (message.type === "ready") {
if (done) return
done = true
cleanup()
resolve()
return
}
if (message.type === "error") {
fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
}
}
const onExit = (code: number) => {
fail(new Error(`Sidecar exited before ready with code ${code}`))
}
const cleanup = () => {
clearTimeout(timeout)
child.off("message", onMessage)
child.off("exit", onExit)
}
child.on("message", onMessage)
child.on("exit", onExit)
refreshTimeout()
child.postMessage({
type: "start",
hostname,
port,
password,
userDataPath: options.userDataPath,
})
}).catch((error) => {
if (!exited) child.kill()
throw error
})
const wait = (async () => {
const url = `http://${hostname}:${port}`
let healthy = false
const gone = exit.promise.then((code) => {
if (healthy) return
throw new Error(`Sidecar exited before health check passed with code ${code}`)
})
const ready = async () => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (await checkHealth(url, password)) {
healthy = true
return
}
}
}
await Promise.race([ready(), gone])
})()
let stopping: Promise<void> | undefined
return {
listener: {
stop: () => {
if (stopping) return stopping
if (exited) return Promise.resolve()
child.postMessage({ type: "stop" })
stopping = Promise.race([
exit.promise.then(() => undefined),
delay(SIDECAR_STOP_TIMEOUT).then(() => {
if (!exited) child.kill()
}),
])
return stopping
},
},
health: { wait },
}
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
let healthUrls: URL[]
try {
@@ -209,31 +56,3 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
return false
}
function createSidecarEnv(): Record<string, string> {
const env = Object.fromEntries(
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
)
delete env.DEBUG
if (process.platform === "linux") delete env.LD_PRELOAD
return env
}
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
function serializeError(error: unknown) {
if (error instanceof Error) return { message: error.message, stack: error.stack }
return { message: String(error) }
}
function defer<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
-157
View File
@@ -1,157 +0,0 @@
import * as http from "node:http"
import * as tls from "node:tls"
type NodeHttpWithEnvProxy = typeof http & {
setGlobalProxyFromEnv: () => void
}
type NodeTlsWithSystemCertificates = typeof tls & {
getCACertificates: (type: "default" | "system") => string[]
setDefaultCACertificates: (certificates: string[]) => void
}
type StartCommand = {
type: "start"
hostname: string
port: number
password: string
userDataPath: string
}
type StopCommand = { type: "stop" }
type SidecarCommand = StartCommand | StopCommand
type SidecarMessage =
| { type: "ready" }
| { type: "stopped" }
| { type: "error"; error: { message: string; stack?: string } }
type ParentPort = {
postMessage(message: SidecarMessage): void
on(event: "message", listener: (event: { data: unknown }) => void): void
}
type Listener = {
stop(close?: boolean): void | Promise<void>
}
const parentPort = getParentPort()
let listener: Listener | undefined
parentPort.on("message", (event) => {
const command = parseCommand(event.data)
if (!command) return
if (command.type === "stop") {
void stop()
return
}
void start(command)
})
async function start(command: StartCommand) {
try {
prepareSidecarEnv(command.password, command.userDataPath)
ensureLoopbackNoProxy()
useSystemCertificates()
useEnvProxy()
const { Server } = await import("virtual:opencode-server")
listener = await Server.listen({
port: command.port,
hostname: command.hostname,
username: "opencode",
password: command.password,
cors: ["oc://renderer"],
})
parentPort.postMessage({ type: "ready" })
} catch (error) {
parentPort.postMessage({ type: "error", error: serializeError(error) })
setImmediate(() => process.exit(1))
}
}
async function stop() {
try {
await listener?.stop()
} finally {
listener = undefined
parentPort.postMessage({ type: "stopped" })
setImmediate(() => process.exit(0))
}
}
function prepareSidecarEnv(password: string, userDataPath: string) {
Object.assign(process.env, {
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
})
}
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
const upsert = (key: string) => {
const items = (process.env[key] ?? "")
.split(",")
.map((value: string) => value.trim())
.filter((value: string) => Boolean(value))
for (const host of loopback) {
if (items.some((value: string) => value.toLowerCase() === host)) continue
items.push(host)
}
process.env[key] = items.join(",")
}
upsert("NO_PROXY")
upsert("no_proxy")
}
function useSystemCertificates() {
try {
const nodeTls = tls as NodeTlsWithSystemCertificates
nodeTls.setDefaultCACertificates([
...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]),
])
} catch (error) {
console.warn("failed to load system certificates", error)
}
}
function useEnvProxy() {
try {
;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv()
} catch (error) {
console.warn("failed to load proxy environment", error)
}
}
function parseCommand(value: unknown): SidecarCommand | undefined {
if (!value || typeof value !== "object") return
const command = value as Partial<StartCommand | StopCommand>
if (command.type === "stop") return { type: "stop" }
if (command.type !== "start") return
if (typeof command.hostname !== "string") return
if (typeof command.port !== "number") return
if (typeof command.password !== "string") return
if (typeof command.userDataPath !== "string") return
return {
type: "start",
hostname: command.hostname,
port: command.port,
password: command.password,
userDataPath: command.userDataPath,
}
}
function serializeError(error: unknown) {
if (error instanceof Error) return { message: error.message, stack: error.stack }
return { message: String(error) }
}
function getParentPort() {
const port = process.parentPort as ParentPort | undefined
if (!port) throw new Error("Sidecar parent port unavailable")
return port
}
-17
View File
@@ -2,10 +2,6 @@ export * as ServerAuth from "./auth"
import { Context, Layer, Option, Redacted } from "effect"
export type Credentials = {
password?: string
}
export type DecodedCredentials = {
readonly username: string
readonly password: Redacted.Redacted
@@ -37,16 +33,3 @@ export function authorized(credentials: DecodedCredentials, config: Info) {
Redacted.value(credentials.password) === config.password.value
)
}
export function header(credentials?: Credentials) {
const password = credentials?.password
if (!password) return undefined
return `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
}
export function headers(credentials?: Credentials) {
const authorization = header(credentials)
if (!authorization) return undefined
return { Authorization: authorization }
}
-4
View File
@@ -7,7 +7,3 @@ test("accepts only the fixed opencode username", () => {
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
})
test("encodes the fixed opencode username", () => {
expect(ServerAuth.header({ password: "secret" })).toBe(`Basic ${Buffer.from("opencode:secret").toString("base64")}`)
})
@@ -36,10 +36,6 @@ export const lineCommentStyles = `
border: none;
}
[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
background: var(--syntax-diff-add);
}
[data-component="line-comment"] [data-component="icon"] {
color: var(--white);
}
@@ -9,7 +9,7 @@ import { useI18n } from "@opencode-ai/ui/context/i18n"
installLineCommentStyles()
export type LineCommentVariant = "default" | "editor" | "add"
export type LineCommentVariant = "default" | "editor"
function InlineGlyph(props: { icon: "comment" | "plus" }) {
return (
@@ -156,25 +156,6 @@ export const LineComment = (props: LineCommentProps) => {
)
}
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
label?: string
}
export const LineCommentAdd = (props: LineCommentAddProps) => {
const [split, rest] = splitProps(props, ["label"])
const i18n = useI18n()
return (
<LineCommentAnchor
{...rest}
open={false}
variant="add"
icon="plus"
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
/>
)
}
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
value: string
selection: JSX.Element
@@ -948,10 +948,6 @@ function ExaOutput(props: { output?: string }) {
)
}
export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
export function Message(props: MessageProps) {
return (
<Switch>
@@ -34,10 +34,6 @@ export function SessionFilePanelV2(props: {
)
}
export function SessionFilePanelV2Title(props: ParentProps) {
return <div data-slot="session-review-v2-toolbar-title">{props.children}</div>
}
export function SessionFilePanelV2Empty(props: ParentProps) {
return <div data-slot="session-review-v2-empty">{props.children}</div>
}
+12 -4
View File
@@ -7,12 +7,14 @@ import { DialogVariant } from "./dialog-variant"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const data = useData()
const dialog = useDialog()
const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected()
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
@@ -63,14 +65,14 @@ export function DialogModel(props: { providerID?: string }) {
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => {
const provider = providers().get(model.providerID)
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
const key = modelPreferenceKey({ providerID: model.providerID, modelID: model.id })
const favorite = favorites.some((item) => modelPreferenceKey(item) === key)
return {
value: { providerID: model.providerID, modelID: model.id },
providerID: model.providerID,
providerName: provider?.name ?? model.providerID,
title: model.name,
releaseDate: model.time.released,
favorite,
description: favorite ? "(Favorite)" : undefined,
category: connected() ? (provider?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
@@ -98,6 +100,7 @@ export function DialogModel(props: { providerID?: string }) {
if (needle) {
return prioritizeFavorites(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
favoritePriority,
)
}
@@ -162,8 +165,13 @@ export function DialogModel(props: { providerID?: string }) {
)
}
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
export function prioritizeFavorites<T extends { value: { providerID: string; modelID: string } }>(
options: T[],
favorites: Set<string>,
) {
return options.toSorted(
(a, b) => Number(favorites.has(modelPreferenceKey(b.value))) - Number(favorites.has(modelPreferenceKey(a.value))),
)
}
export function sortModelOptions<
@@ -2,13 +2,16 @@ import { describe, expect, test } from "bun:test"
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
describe("prioritizeFavorites", () => {
test("moves favorites first while preserving fuzzy result order", () => {
const prioritized = prioritizeFavorites([
{ title: "Best match", favorite: false },
{ title: "Favorite match", favorite: true },
{ title: "Second best match", favorite: false },
{ title: "Second favorite match", favorite: true },
])
test("uses the favorite order captured when the dialog opened", () => {
const prioritized = prioritizeFavorites(
[
{ title: "Best match", value: { providerID: "test", modelID: "best" } },
{ title: "Favorite match", value: { providerID: "test", modelID: "favorite" } },
{ title: "Second best match", value: { providerID: "test", modelID: "second-best" } },
{ title: "Second favorite match", value: { providerID: "test", modelID: "second-favorite" } },
],
new Set(["test/favorite", "test/second-favorite"]),
)
expect(prioritized.map((model) => model.title)).toEqual([
"Favorite match",
@@ -1,18 +0,0 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,18 +0,0 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long
+1 -38
View File
@@ -1,7 +1,6 @@
import { createContext, createSignal, splitProps, useContext } from "solid-js"
import { createContext, createSignal, useContext } from "solid-js"
import type { JSX } from "solid-js/jsx-runtime"
import { makeResizeObserver } from "@solid-primitives/resize-observer"
import { IconCheckCircle, IconHashtag } from "../icons"
export type ShareMessages = { locale: string } & Record<string, string>
@@ -41,42 +40,6 @@ export function formatCount(value: number, locale: string, singular: string, plu
return `${formatNumber(value, locale)} ${unit}`
}
interface AnchorProps extends JSX.HTMLAttributes<HTMLDivElement> {
id: string
}
export function AnchorIcon(props: AnchorProps) {
const [local, rest] = splitProps(props, ["id", "children"])
const [copied, setCopied] = createSignal(false)
const messages = useShareMessages()
return (
<div {...rest} data-element-anchor title={messages.link_to_message} data-status={copied() ? "copied" : ""}>
<a
href={`#${local.id}`}
onClick={(e) => {
e.preventDefault()
const anchor = e.currentTarget
const hash = anchor.getAttribute("href") || ""
const { origin, pathname, search } = window.location
navigator.clipboard
.writeText(`${origin}${pathname}${search}${hash}`)
.catch((err) => console.error("Copy failed", err))
setCopied(true)
setTimeout(() => setCopied(false), 3000)
}}
>
{local.children}
<IconHashtag width={18} height={18} />
<IconCheckCircle width={18} height={18} />
</a>
<span data-element-tooltip>{messages.copied}</span>
</div>
)
}
export function createOverflow() {
const [overflow, setOverflow] = createSignal(false)
return {