Compare commits

...
4 Commits
15 changed files with 120 additions and 85 deletions
+1 -2
View File
@@ -417,7 +417,6 @@ jobs:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name == 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
@@ -480,7 +479,7 @@ jobs:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
- name: Build
run: bun run build
+5
View File
@@ -87,6 +87,11 @@ stdenv.mkDerivation (finalAttrs: {
cd packages/desktop
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode-ai/", ""))')
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode2"
bun run build
npx electron-builder --dir \
--config electron-builder.config.ts \
@@ -1,10 +1,5 @@
import { describe, expect, test } from "bun:test"
import {
normalizeNewSessionWorktree,
resolveNewSessionBranch,
resolveNewSessionGit,
resolveNewSessionWorktree,
} from "./controller"
import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller"
describe("new session workspace selection", () => {
test("uses main when the workspace bar is unavailable", () => {
@@ -12,8 +7,6 @@ describe("new session workspace selection", () => {
resolveNewSessionWorktree({
enabled: false,
selected: "/project/feature",
directory: "/project/feature",
projectWorktree: "/project",
}),
).toBe("main")
})
@@ -22,31 +15,21 @@ describe("new session workspace selection", () => {
expect(
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "create",
}),
).toBe("create")
expect(
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "main",
}),
).toBe("/project")
})
test("normalizes main to the project root outside the main worktree", () => {
expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project")
expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main")
})
test("treats equivalent Windows roots as the main worktree", () => {
expect(
resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" }),
).toBe("main")
expect(normalizeNewSessionWorktree("main", "C:\\Repo\\", "c:/repo")).toBe("main")
})
test("keeps local selection when the cached project path is stale", () => {
const input = { enabled: true, directory: "C:/Projects/repo", projectWorktree: "D:/Projects/repo" }
expect(resolveNewSessionWorktree(input)).toBe("main")
expect(resolveNewSessionWorktree({ ...input, selected: "/worktree" })).toBe("/worktree")
})
test("resolves the branch from the active location", () => {
@@ -11,27 +11,15 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
workspaceSelectionDestination,
} from "@/workspaces/paths"
export function resolveNewSessionWorktree(input: {
enabled: boolean
selected?: string
directory: string
projectWorktree?: string
fallback?: string
}) {
export function resolveNewSessionWorktree(input: { enabled: boolean; selected?: string; fallback?: string }) {
if (!input.enabled) return "main"
if (input.selected) return input.selected
return normalizeNewSessionWorktree(input.fallback ?? "main", input.directory, input.projectWorktree)
}
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
if (value === "main" && projectWorktree && !sameDirectory(directory, projectWorktree)) return projectWorktree
return value
return input.fallback ?? "main"
}
export function resolveNewSessionBranch(input: {
@@ -92,8 +80,6 @@ export function createNewSessionWorkspaceController(input: {
resolveNewSessionWorktree({
enabled: visible(),
selected: selected(),
directory: sdk().directory,
projectWorktree: currentProject()?.worktree,
fallback: fallback(),
}),
)
@@ -145,7 +131,7 @@ export function createNewSessionWorkspaceController(input: {
remember,
set: (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
input.setSelectedWorktree(worktree)
remember(worktree)
},
create: (branch: string) => {
+30 -15
View File
@@ -1,25 +1,40 @@
import { createMemo, type Accessor } from "solid-js"
import { createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(
value: Accessor<T | undefined>,
element: Accessor<HTMLElement | null>,
identity?: Accessor<unknown>,
minimumDuration = 0,
) {
const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
(previous) => {
const currentIdentity = identity?.()
const current = value()
const show = current !== undefined
const same = !identity || previous?.identity === currentIdentity
return {
identity: currentIdentity,
show,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
}
},
)
const [tick, setTick] = createSignal(0)
const animation = createMemo<{
identity?: unknown
show: boolean
animate: boolean
value: T | undefined
started: number
}>((previous) => {
tick()
const currentIdentity = identity?.()
const current = value()
const same = !identity || previous?.identity === currentIdentity
const started = same && previous?.show ? previous.started : performance.now()
const remaining =
current === undefined && same && previous?.show ? minimumDuration - (performance.now() - started) : 0
const show = current !== undefined || remaining > 0
if (remaining > 0) {
const timer = setTimeout(() => setTick((value) => value + 1), remaining)
onCleanup(() => clearTimeout(timer))
}
return {
identity: currentIdentity,
show,
started,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
}
})
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
@@ -539,7 +539,12 @@ function MessageTimelineView(
.findLast((ref) => blocking.has(ref.partID))?.partID
})
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
const backgroundHintPresence = createAnimatedPresence(
backgroundHintPartID,
() => backgroundHintRef() ?? null,
sessionID,
1000,
)
const showWorking = createMemo(() => {
const id = sessionID()
if (!id || sessionStatus().type !== "busy") return false
+2
View File
@@ -44,6 +44,8 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
pattern: Schema.String,
path: Schema.optionalKey(RelativePath),
include: Schema.optionalKey(Schema.String),
literal: Schema.optionalKey(Schema.Boolean),
caseSensitive: Schema.optionalKey(Schema.Boolean),
limit: Schema.optionalKey(PositiveInt),
}) {}
+4
View File
@@ -74,6 +74,8 @@ export interface GrepInput {
readonly pattern: string
readonly file?: string
readonly include?: string
readonly literal?: boolean
readonly caseSensitive?: boolean
readonly limit: number
readonly signal?: AbortSignal
}
@@ -220,6 +222,8 @@ const layer = Layer.effect(
"--json",
"--hidden",
"--no-messages",
...(input.literal ? ["--fixed-strings"] : []),
...(input.caseSensitive === false ? ["--ignore-case"] : []),
...(input.include ? [`--glob=${input.include}`] : []),
"--glob=!**/.git/**",
"--",
+12 -2
View File
@@ -18,7 +18,7 @@ export const Input = Schema.Struct({
pattern: FileSystem.GrepInput.fields.pattern
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" }))
.annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)",
description: "Regular expression or literal text to match in file contents.",
}),
path: Schema.optionalKey(RelativePath).annotate({
description: "File or directory to search. Defaults to the current working directory.",
@@ -26,6 +26,12 @@ export const Input = Schema.Struct({
include: FileSystem.GrepInput.fields.include.annotate({
description: 'Glob pattern to filter files (for example, "*.js" or "*.{ts,tsx}")',
}),
literal: FileSystem.GrepInput.fields.literal.annotate({
description: "Treat `pattern` as exact text instead of a regular expression (default: false).",
}),
caseSensitive: FileSystem.GrepInput.fields.caseSensitive.annotate({
description: "Use case-sensitive matching (default: true).",
}),
limit: FileSystem.GrepInput.fields.limit.annotate({
description: `Maximum number of matching lines to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
}),
@@ -70,7 +76,7 @@ export const Plugin = {
name,
options: { codemode: false },
description:
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
"Search file contents using ripgrep's regular expression syntax or literal text matching. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
input: Input,
output: Output,
execute: (input, context) =>
@@ -92,6 +98,8 @@ export const Plugin = {
root: ".",
path: input.path,
include: input.include,
literal: input.literal,
caseSensitive: input.caseSensitive,
limit: input.limit,
},
sessionID: context.sessionID,
@@ -112,6 +120,8 @@ export const Plugin = {
pattern: input.pattern,
file: type === "file" ? path.basename(root) : undefined,
include: input.include,
literal: input.literal,
caseSensitive: input.caseSensitive,
limit: limit + 1,
})
.pipe(
+22
View File
@@ -17,3 +17,25 @@ bundle the assets as an application. The resulting app will be in `dist/`.
```bash
bun run build && bun run package
```
Production builds require a prebuilt V2 CLI distribution. The release workflow supplies the artifact from the same run:
```bash
OPENCODE_CHANNEL=prod OPENCODE_CLI_DIST=/absolute/path/to/packages/cli/dist bun run build
OPENCODE_CHANNEL=prod bun run package
```
Set `OPENCODE_CLI_TARGET` when packaging for a different architecture. The CLI is placed outside `app.asar` in the
application's resources directory, and packaging fails if it is missing.
CLI preparation uses these channel rules:
| Channel | Without `OPENCODE_CLI_DIST` | With `OPENCODE_CLI_DIST` |
| -------------------------------------- | ------------------------------ | --------------------------------------------- |
| `dev`, `local`, unset, or unrecognized | Download the dev CLI | Download the dev CLI; ignore the distribution |
| `beta` | Download the beta CLI | Copy the supplied CLI; fail if it is missing |
| `prod`, `latest` | Fail before changing resources | Copy the supplied CLI; fail if it is missing |
`bun dev` is separate from packaging: it uses local renderer/server mode, the dev app identity, and the CLI source by
default. `bun dev --download-server <version>` instead downloads that CLI version for local development. Neither path
requires `OPENCODE_CLI_DIST` or runs the production prebuild.
@@ -190,19 +190,8 @@ for (const channel of ["dev", "beta"] as const) {
{
from: "resources/",
to: "",
filter: ["opencode-cli*"],
filter: ["opencode-cli", "opencode-cli.exe"],
},
])
})
}
test("does not bundle the CLI in prod builds", async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = "prod"
const module = await import("./electron-builder.config.ts?no-cli-resource=prod")
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.extraResources).toEqual([])
})
+17 -10
View File
@@ -1,4 +1,5 @@
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
@@ -45,6 +46,7 @@ export function macSignOptions(options: CustomMacSignOptions): CustomMacSignOpti
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (raw === "latest") return "prod"
return "dev"
})()
@@ -84,16 +86,21 @@ const getBase = (appId: string): Configuration => ({
"!**/node_modules/js-yaml/dist/{js-yaml.js,js-yaml.min.js,*.map}",
"!**/node_modules/js-yaml/bin{,/**/*}",
],
extraResources:
channel !== "prod"
? [
{
from: "resources/",
to: "",
filter: ["opencode-cli*"],
},
]
: [],
extraResources: [
{
from: "resources/",
to: "",
filter: ["opencode-cli", "opencode-cli.exe"],
},
],
afterPack: async (context) => {
const cli = path.join(
context.packager.getResourcesDir(context.appOutDir),
context.electronPlatformName === "win32" ? "opencode-cli.exe" : "opencode-cli",
)
const file = await stat(cli)
if (!file.isFile() || file.size === 0) throw new Error(`Bundled CLI must be a non-empty file: ${cli}`)
},
mac: {
category: "public.app-category.developer-tools",
icon: `resources/icons/icon.icns`,
+2 -1
View File
@@ -34,7 +34,8 @@ export default defineConfig(({ command }) => ({
dedupe: ["effect"],
},
define: {
"import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel),
// Local renderer/server mode still uses the dev application identity and updater policy.
"import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel === "local" ? "dev" : channel),
},
build: {
minify: command === "build",
+7 -1
View File
@@ -4,9 +4,15 @@ import { $ } from "bun"
import { copyBuiltCliToResources, downloadCliToResources, resolveChannel } from "./utils"
const channel = resolveChannel()
if (channel === "prod" && !Bun.env.OPENCODE_CLI_DIST) {
throw new Error("OPENCODE_CLI_DIST is required for production desktop builds")
}
await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}`
if (channel === "dev") await downloadCliToResources()
if (channel === "beta" && Bun.env.OPENCODE_CLI_DIST) await copyBuiltCliToResources(Bun.env.OPENCODE_CLI_DIST)
if ((channel === "beta" || channel === "prod") && Bun.env.OPENCODE_CLI_DIST) {
await copyBuiltCliToResources(Bun.env.OPENCODE_CLI_DIST)
}
if (channel === "beta" && !Bun.env.OPENCODE_CLI_DIST) await downloadCliToResources("beta")
+1
View File
@@ -10,6 +10,7 @@ export type Channel = "dev" | "beta" | "prod"
export function resolveChannel(): Channel {
const raw = Bun.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (raw === "latest") return "prod"
return "dev"
}