mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-09 18:34:21 +00:00
Compare commits
38 Commits
release-no
...
release-hi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c380d389aa | ||
|
|
ec2ab639bb | ||
|
|
d05ed5ca83 | ||
|
|
37f1a1a4ef | ||
|
|
b8e8d82323 | ||
|
|
801eb5d2cb | ||
|
|
ebeed03115 | ||
|
|
d9eed4c6ca | ||
|
|
7e34d27b77 | ||
|
|
783121c06e | ||
|
|
984518b1c0 | ||
|
|
7fcdbd155b | ||
|
|
5856ea4e75 | ||
|
|
32a0dcedcb | ||
|
|
f48784d152 | ||
|
|
3dce6a6608 | ||
|
|
39a73d4894 | ||
|
|
b1fbfa7e94 | ||
|
|
805ae19c9a | ||
|
|
fcea7e18a5 | ||
|
|
7c34319b19 | ||
|
|
cd4676171b | ||
|
|
7016be0739 | ||
|
|
ff35db0360 | ||
|
|
af3d8c383e | ||
|
|
7f75f71f6b | ||
|
|
84b12a8fb7 | ||
|
|
1934ee13d8 | ||
|
|
6c1e18f111 | ||
|
|
3296b90372 | ||
|
|
0d651eab3b | ||
|
|
0edd304f42 | ||
|
|
6b83b172ae | ||
|
|
444934a4c1 | ||
|
|
c4f1087e58 | ||
|
|
c87232d5df | ||
|
|
d03c5f6b3f | ||
|
|
9a33b1ec88 |
1
STATS.md
1
STATS.md
@@ -211,3 +211,4 @@
|
||||
| 2026-01-23 | 6,096,236 (+329,896) | 2,096,235 (+66,748) | 8,192,471 (+396,644) |
|
||||
| 2026-01-24 | 6,371,019 (+274,783) | 2,156,870 (+60,635) | 8,527,889 (+335,418) |
|
||||
| 2026-01-25 | 6,639,082 (+268,063) | 2,187,853 (+30,983) | 8,826,935 (+299,046) |
|
||||
| 2026-01-26 | 6,941,620 (+302,538) | 2,232,115 (+44,262) | 9,173,735 (+346,800) |
|
||||
|
||||
20
bun.lock
20
bun.lock
@@ -296,8 +296,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.74",
|
||||
"@opentui/solid": "0.1.74",
|
||||
"@opentui/core": "0.1.75",
|
||||
"@opentui/solid": "0.1.75",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -1227,21 +1227,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.74", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.74", "@opentui/core-darwin-x64": "0.1.74", "@opentui/core-linux-arm64": "0.1.74", "@opentui/core-linux-x64": "0.1.74", "@opentui/core-win32-arm64": "0.1.74", "@opentui/core-win32-x64": "0.1.74", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-g4W16ymv12JdgZ+9B4t7mpIICvzWy2+eHERfmDf80ALduOQCUedKQdULcBFhVCYUXIkDRtIy6CID5thMAah3FA=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.75", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.74", "", { "os": "darwin", "cpu": "x64" }, "sha512-WAD8orsDV0ZdW/5GwjOOB4FY96772xbkz+rcV7WRzEFUVaqoBaC04IuqYzS9d5s+cjkbT5Cpj47hrVYkkVQKng=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.74", "", { "os": "linux", "cpu": "arm64" }, "sha512-lgmHzrzLy4e+rgBS+lhtsMLLgIMLbtLNMm6EzVPyYVDlLDGjM7+ulXMem7AtpaRrWrUUl4REiG9BoQUsCFDwYA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.74", "", { "os": "linux", "cpu": "x64" }, "sha512-8Mn2WbdBQ29xCThuPZezjDhd1N3+fXwKkGvCBOdTI0le6h2A/vCNbfUVjwfr/EGZSRXxCG+Yapol34BAULGpOA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.74", "", { "os": "win32", "cpu": "arm64" }, "sha512-dvYUXz03avnI6ZluyLp00HPmR0UT/IE/6QS97XBsgJlUTtpnbKkBtB5jD1NHwWkElaRj1Qv2QP36ngFoJqbl9g=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.74", "", { "os": "win32", "cpu": "x64" }, "sha512-3wfWXaAKOIlDQz6ZZIESf2M+YGZ7uFHijjTEM8w/STRlLw8Y6+QyGYi1myHSM4d6RSO+/s2EMDxvjDf899W9vQ=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.74", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.74", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-Vz82cI8T9YeJjGsVg4ULp6ral4N+xyt1j9A6Tbu3aaQgEKiB74LW03EXREehfjPr1irOFxtKfWPbx5NKH0Upag=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-olTZ+tKugAY3LxizsJMlbK3TW78HZUoM03PigvQLP4A=",
|
||||
"aarch64-linux": "sha256-xdKDeqMEnYM2+vGySfb8pbcYyo/xMmgxG/ZhPCKaZEg=",
|
||||
"aarch64-darwin": "sha256-fihCTrHIiUG+py4vuqdr+YshqSKm2/B5onY50b97sPM=",
|
||||
"x86_64-darwin": "sha256-inlQQPNAOdkmKK6HQAMI2bG/ZFlfwmUQu9a6vm6Q0jQ="
|
||||
"x86_64-linux": "sha256-AkI3guNjnE+bLZQVfzm0z14UENOECv2QBqMo5Lzkvt8=",
|
||||
"aarch64-linux": "sha256-dBfdyVTqW+fBZKCxC9Ld+1m3cP+nIbS6UDo0tUfPOSk=",
|
||||
"aarch64-darwin": "sha256-tOw31AMnHkW2cEDi+iqT3P93lU3SiMve26TEIqPz97k=",
|
||||
"x86_64-darwin": "sha256-wL/DmdZmxCmh+r4dsS1XGXuj8VPwR4pUqy5VIA76jl0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,14 @@ import { GlobalSyncProvider } from "@/context/global-sync"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||
import { ServerProvider, useServer } from "@/context/server"
|
||||
import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
||||
@@ -85,8 +86,19 @@ function ServerKey(props: ParentProps) {
|
||||
}
|
||||
|
||||
export function AppInterface(props: { defaultUrl?: string }) {
|
||||
const platform = usePlatform()
|
||||
|
||||
const stored = (() => {
|
||||
if (platform.platform !== "web") return
|
||||
const result = platform.getDefaultServerUrl?.()
|
||||
if (result instanceof Promise) return
|
||||
if (!result) return
|
||||
return normalizeServerUrl(result)
|
||||
})()
|
||||
|
||||
const defaultServerUrl = () => {
|
||||
if (props.defaultUrl) return props.defaultUrl
|
||||
if (stored) return stored
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
@@ -105,9 +117,11 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
<ModelsProvider>
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
@@ -29,35 +29,34 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
iconUrl: props.project.icon?.override || "",
|
||||
startup: props.project.commands?.start ?? "",
|
||||
saving: false,
|
||||
dragOver: false,
|
||||
iconHover: false,
|
||||
})
|
||||
|
||||
const [dragOver, setDragOver] = createSignal(false)
|
||||
const [iconHover, setIconHover] = createSignal(false)
|
||||
|
||||
function handleFileSelect(file: File) {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
setStore("iconUrl", e.target?.result as string)
|
||||
setIconHover(false)
|
||||
setStore("iconHover", false)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
setStore("dragOver", false)
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file) handleFileSelect(file)
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
setStore("dragOver", true)
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
setDragOver(false)
|
||||
setStore("dragOver", false)
|
||||
}
|
||||
|
||||
function handleInputChange(e: Event) {
|
||||
@@ -116,19 +115,23 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
|
||||
<div class="flex gap-3 items-start">
|
||||
<div class="relative" onMouseEnter={() => setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
|
||||
<div
|
||||
class="relative"
|
||||
onMouseEnter={() => setStore("iconHover", true)}
|
||||
onMouseLeave={() => setStore("iconHover", false)}
|
||||
>
|
||||
<div
|
||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
||||
classList={{
|
||||
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
|
||||
"border-border-base hover:border-border-strong": !dragOver(),
|
||||
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
|
||||
"border-border-base hover:border-border-strong": !store.dragOver,
|
||||
"overflow-hidden": !!store.iconUrl,
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => {
|
||||
if (store.iconUrl && iconHover()) {
|
||||
if (store.iconUrl && store.iconHover) {
|
||||
clearIcon()
|
||||
} else {
|
||||
document.getElementById("icon-upload")?.click()
|
||||
@@ -166,7 +169,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
"border-radius": "6px",
|
||||
"z-index": 10,
|
||||
"pointer-events": "none",
|
||||
opacity: iconHover() && !store.iconUrl ? 1 : 0,
|
||||
opacity: store.iconHover && !store.iconUrl ? 1 : 0,
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
@@ -185,7 +188,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
"border-radius": "6px",
|
||||
"z-index": 10,
|
||||
"pointer-events": "none",
|
||||
opacity: iconHover() && store.iconUrl ? 1 : 0,
|
||||
opacity: store.iconHover && store.iconUrl ? 1 : 0,
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
|
||||
@@ -155,7 +155,7 @@ export function DialogSelectServer() {
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
const isDesktop = platform.platform === "desktop"
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
@@ -505,7 +505,7 @@ export function DialogSelectServer() {
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={isDesktop && defaultUrl() !== i}>
|
||||
<Show when={canDefault() && defaultUrl() !== i}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={async () => {
|
||||
await platform.setDefaultServerUrl?.(i)
|
||||
@@ -517,7 +517,7 @@ export function DialogSelectServer() {
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={isDesktop && defaultUrl() === i}>
|
||||
<Show when={canDefault() && defaultUrl() === i}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={async () => {
|
||||
await platform.setDefaultServerUrl?.(null)
|
||||
|
||||
@@ -6,12 +6,8 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SettingsGeneral } from "./settings-general"
|
||||
import { SettingsKeybinds } from "./settings-keybinds"
|
||||
import { SettingsPermissions } from "./settings-permissions"
|
||||
import { SettingsProviders } from "./settings-providers"
|
||||
import { SettingsModels } from "./settings-models"
|
||||
import { SettingsAgents } from "./settings-agents"
|
||||
import { SettingsCommands } from "./settings-commands"
|
||||
import { SettingsMcp } from "./settings-mcp"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
const language = useLanguage()
|
||||
@@ -45,6 +41,10 @@ export const DialogSettings: Component = () => {
|
||||
<Icon name="server" />
|
||||
{language.t("settings.providers.title")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="models">
|
||||
<Icon name="server" />
|
||||
{language.t("settings.models.title")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,9 +64,9 @@ export const DialogSettings: Component = () => {
|
||||
<Tabs.Content value="providers" class="no-scrollbar">
|
||||
<SettingsProviders />
|
||||
</Tabs.Content>
|
||||
{/* <Tabs.Content value="models" class="no-scrollbar"> */}
|
||||
{/* <SettingsModels /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
<Tabs.Content value="models" class="no-scrollbar">
|
||||
<SettingsModels />
|
||||
</Tabs.Content>
|
||||
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
|
||||
{/* <SettingsAgents /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
|
||||
@@ -1,111 +1,207 @@
|
||||
import { useLocal, type LocalFile } from "@/context/local"
|
||||
import { useFile } from "@/context/file"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
Match,
|
||||
splitProps,
|
||||
Switch,
|
||||
untrack,
|
||||
type ComponentProps,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export default function FileTree(props: {
|
||||
path: string
|
||||
class?: string
|
||||
nodeClass?: string
|
||||
level?: number
|
||||
onFileClick?: (file: LocalFile) => void
|
||||
allowed?: readonly string[]
|
||||
modified?: readonly string[]
|
||||
draggable?: boolean
|
||||
tooltip?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
}) {
|
||||
const local = useLocal()
|
||||
const file = useFile()
|
||||
const level = props.level ?? 0
|
||||
const draggable = () => props.draggable ?? true
|
||||
const tooltip = () => props.tooltip ?? true
|
||||
|
||||
const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => (
|
||||
<Dynamic
|
||||
component={p.as ?? "div"}
|
||||
classList={{
|
||||
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
|
||||
// "bg-background-element": local.file.active()?.path === p.node.path,
|
||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||
}}
|
||||
style={`padding-left: ${level * 10}px`}
|
||||
draggable={true}
|
||||
onDragStart={(e: any) => {
|
||||
const evt = e as globalThis.DragEvent
|
||||
evt.dataTransfer!.effectAllowed = "copy"
|
||||
evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`)
|
||||
const filter = createMemo(() => {
|
||||
const allowed = props.allowed
|
||||
if (!allowed) return
|
||||
|
||||
// Create custom drag image without margins
|
||||
const dragImage = document.createElement("div")
|
||||
dragImage.className =
|
||||
"flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1"
|
||||
dragImage.style.position = "absolute"
|
||||
dragImage.style.top = "-1000px"
|
||||
const files = new Set(allowed)
|
||||
const dirs = new Set<string>()
|
||||
|
||||
// Copy only the icon and text content without padding
|
||||
const icon = e.currentTarget.querySelector("svg")
|
||||
const text = e.currentTarget.querySelector("span")
|
||||
if (icon && text) {
|
||||
dragImage.innerHTML = icon.outerHTML + text.outerHTML
|
||||
}
|
||||
for (const item of allowed) {
|
||||
const parts = item.split("/")
|
||||
const parents = parts.slice(0, -1)
|
||||
for (const [idx] of parents.entries()) {
|
||||
const dir = parents.slice(0, idx + 1).join("/")
|
||||
if (dir) dirs.add(dir)
|
||||
}
|
||||
}
|
||||
|
||||
document.body.appendChild(dragImage)
|
||||
evt.dataTransfer!.setDragImage(dragImage, 0, 12)
|
||||
setTimeout(() => document.body.removeChild(dragImage), 0)
|
||||
}}
|
||||
{...p}
|
||||
>
|
||||
{p.children}
|
||||
<span
|
||||
return { files, dirs }
|
||||
})
|
||||
|
||||
const marks = createMemo(() => {
|
||||
const modified = props.modified
|
||||
if (!modified || modified.length === 0) return
|
||||
return new Set(modified)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = filter()
|
||||
if (!current) return
|
||||
if (level !== 0) return
|
||||
|
||||
for (const dir of current.dirs) {
|
||||
const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false
|
||||
if (expanded) continue
|
||||
file.tree.expand(dir)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
void file.tree.list(props.path)
|
||||
})
|
||||
|
||||
const nodes = createMemo(() => {
|
||||
const nodes = file.tree.children(props.path)
|
||||
const current = filter()
|
||||
if (!current) return nodes
|
||||
return nodes.filter((node) => {
|
||||
if (node.type === "file") return current.files.has(node.path)
|
||||
return current.dirs.has(node.path)
|
||||
})
|
||||
})
|
||||
|
||||
const Node = (
|
||||
p: ParentProps &
|
||||
ComponentProps<"div"> &
|
||||
ComponentProps<"button"> & {
|
||||
node: FileNode
|
||||
as?: "div" | "button"
|
||||
},
|
||||
) => {
|
||||
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
|
||||
return (
|
||||
<Dynamic
|
||||
component={local.as ?? "div"}
|
||||
classList={{
|
||||
"text-xs whitespace-nowrap truncate": true,
|
||||
"text-text-muted/40": p.node.ignored,
|
||||
"text-text-muted/80": !p.node.ignored,
|
||||
// "!text-text": local.file.active()?.path === p.node.path,
|
||||
// "!text-primary": local.file.changed(p.node.path),
|
||||
"w-full min-w-0 flex items-center justify-start gap-x-2 rounded-md px-2 py-1 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||
}}
|
||||
style={`padding-left: ${8 + level * 12}px`}
|
||||
draggable={draggable()}
|
||||
onDragStart={(e: DragEvent) => {
|
||||
if (!draggable()) return
|
||||
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||
e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
|
||||
|
||||
const dragImage = document.createElement("div")
|
||||
dragImage.className =
|
||||
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
||||
dragImage.style.position = "absolute"
|
||||
dragImage.style.top = "-1000px"
|
||||
|
||||
const icon =
|
||||
(e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
|
||||
(e.currentTarget as HTMLElement).querySelector("svg")
|
||||
const text = (e.currentTarget as HTMLElement).querySelector("span")
|
||||
if (icon && text) {
|
||||
dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
||||
}
|
||||
|
||||
document.body.appendChild(dragImage)
|
||||
e.dataTransfer?.setDragImage(dragImage, 0, 12)
|
||||
setTimeout(() => document.body.removeChild(dragImage), 0)
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{p.node.name}
|
||||
</span>
|
||||
{/* <Show when={local.file.changed(p.node.path)}> */}
|
||||
{/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
|
||||
{/* </Show> */}
|
||||
</Dynamic>
|
||||
)
|
||||
{local.children}
|
||||
<span
|
||||
classList={{
|
||||
"flex-1 min-w-0 text-12-regular whitespace-nowrap truncate": true,
|
||||
"text-text-weaker": local.node.ignored,
|
||||
"text-text-weak": !local.node.ignored,
|
||||
}}
|
||||
>
|
||||
{local.node.name}
|
||||
</span>
|
||||
{local.node.type === "file" && marks()?.has(local.node.path) ? (
|
||||
<div class="shrink-0 size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
) : null}
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`flex flex-col ${props.class}`}>
|
||||
<For each={local.file.children(props.path)}>
|
||||
{(node) => (
|
||||
<Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right">
|
||||
<div class={`flex flex-col ${props.class ?? ""}`}>
|
||||
<For each={nodes()}>
|
||||
{(node) => {
|
||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||
const Wrapper = (p: ParentProps) => {
|
||||
if (!tooltip()) return p.children
|
||||
return (
|
||||
<Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right" class="w-full">
|
||||
{p.children}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={node.type === "directory"}>
|
||||
<Collapsible
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
forceMount={false}
|
||||
// open={local.file.node(node.path)?.expanded}
|
||||
onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
|
||||
open={expanded()}
|
||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<Node node={node}>
|
||||
<Collapsible.Arrow class="text-text-muted/60 ml-1" />
|
||||
<FileIcon
|
||||
node={node}
|
||||
// expanded={local.file.node(node.path).expanded}
|
||||
class="text-text-muted/60 -ml-1"
|
||||
/>
|
||||
</Node>
|
||||
<Wrapper>
|
||||
<Node node={node}>
|
||||
<Collapsible.Arrow class="text-icon-weak ml-1" />
|
||||
<FileIcon node={node} expanded={expanded()} class="text-icon-weak -ml-1 size-4" />
|
||||
</Node>
|
||||
</Wrapper>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} />
|
||||
<FileTree
|
||||
path={node.path}
|
||||
level={level + 1}
|
||||
allowed={props.allowed}
|
||||
modified={props.modified}
|
||||
draggable={props.draggable}
|
||||
tooltip={props.tooltip}
|
||||
onFileClick={props.onFileClick}
|
||||
/>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={node.type === "file"}>
|
||||
<Node node={node} as="button" onClick={() => props.onFileClick?.(node)}>
|
||||
<div class="w-4 shrink-0" />
|
||||
<FileIcon node={node} class="text-primary" />
|
||||
</Node>
|
||||
<Wrapper>
|
||||
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
|
||||
<div class="w-4 shrink-0" />
|
||||
<FileIcon node={node} class="text-icon-weak size-4" />
|
||||
</Node>
|
||||
</Wrapper>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tooltip>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -131,7 +131,7 @@ export function SessionHeader() {
|
||||
<Portal mount={mount()}>
|
||||
<button
|
||||
type="button"
|
||||
class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
||||
class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
||||
onClick={() => command.trigger("file.open")}
|
||||
aria-label={language.t("session.header.searchFiles")}
|
||||
>
|
||||
@@ -280,6 +280,32 @@ export function SessionHeader() {
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="hidden md:block shrink-0">
|
||||
<Tooltip value="Toggle file tree" placement="bottom">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/file-tree-toggle size-5 p-0"
|
||||
onClick={() => {
|
||||
const opening = !layout.fileTree.opened()
|
||||
if (opening && !view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.toggle()
|
||||
}}
|
||||
aria-label="Toggle file tree"
|
||||
aria-expanded={layout.fileTree.opened()}
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4">
|
||||
<Icon
|
||||
size="small"
|
||||
name="bullet-list"
|
||||
classList={{
|
||||
"text-icon-strong": layout.fileTree.opened(),
|
||||
"text-icon-weak": !layout.fileTree.opened(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="hidden md:block shrink-0">
|
||||
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
@@ -12,11 +13,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
const terminal = useTerminal()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.terminal.id)
|
||||
const [editing, setEditing] = createSignal(false)
|
||||
const [title, setTitle] = createSignal(props.terminal.title)
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
const [menuPosition, setMenuPosition] = createSignal({ x: 0, y: 0 })
|
||||
const [blurEnabled, setBlurEnabled] = createSignal(false)
|
||||
const [store, setStore] = createStore({
|
||||
editing: false,
|
||||
title: props.terminal.title,
|
||||
menuOpen: false,
|
||||
menuPosition: { x: 0, y: 0 },
|
||||
blurEnabled: false,
|
||||
})
|
||||
|
||||
const isDefaultTitle = () => {
|
||||
const number = props.terminal.titleNumber
|
||||
@@ -47,7 +50,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
if (editing()) return
|
||||
if (store.editing) return
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
@@ -71,26 +74,26 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
setBlurEnabled(false)
|
||||
setTitle(props.terminal.title)
|
||||
setEditing(true)
|
||||
setStore("blurEnabled", false)
|
||||
setStore("title", props.terminal.title)
|
||||
setStore("editing", true)
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
|
||||
if (!input) return
|
||||
input.focus()
|
||||
input.select()
|
||||
setTimeout(() => setBlurEnabled(true), 100)
|
||||
setTimeout(() => setStore("blurEnabled", true), 100)
|
||||
}, 10)
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
if (!blurEnabled()) return
|
||||
if (!store.blurEnabled) return
|
||||
|
||||
const value = title().trim()
|
||||
const value = store.title.trim()
|
||||
if (value && value !== props.terminal.title) {
|
||||
terminal.update({ id: props.terminal.id, title: value })
|
||||
}
|
||||
setEditing(false)
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
@@ -101,14 +104,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setEditing(false)
|
||||
setStore("editing", false)
|
||||
}
|
||||
}
|
||||
|
||||
const menu = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setMenuOpen(true)
|
||||
setStore("menuPosition", { x: e.clientX, y: e.clientY })
|
||||
setStore("menuOpen", true)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -143,17 +146,17 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span onDblClick={edit} style={{ visibility: editing() ? "hidden" : "visible" }}>
|
||||
<span onDblClick={edit} style={{ visibility: store.editing ? "hidden" : "visible" }}>
|
||||
{label()}
|
||||
</span>
|
||||
</Tabs.Trigger>
|
||||
<Show when={editing()}>
|
||||
<Show when={store.editing}>
|
||||
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
|
||||
<input
|
||||
id={`terminal-title-input-${props.terminal.id}`}
|
||||
type="text"
|
||||
value={title()}
|
||||
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||
value={store.title}
|
||||
onInput={(e) => setStore("title", e.currentTarget.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={keydown}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
@@ -161,13 +164,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${menuPosition().x}px`,
|
||||
top: `${menuPosition().y}px`,
|
||||
left: `${store.menuPosition.x}px`,
|
||||
top: `${store.menuPosition.y}px`,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={edit}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -111,24 +112,26 @@ export const SettingsKeybinds: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
|
||||
const [active, setActive] = createSignal<string | null>(null)
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [store, setStore] = createStore({
|
||||
active: null as string | null,
|
||||
filter: "",
|
||||
})
|
||||
|
||||
const stop = () => {
|
||||
if (!active()) return
|
||||
setActive(null)
|
||||
if (!store.active) return
|
||||
setStore("active", null)
|
||||
command.keybinds(true)
|
||||
}
|
||||
|
||||
const start = (id: string) => {
|
||||
if (active() === id) {
|
||||
if (store.active === id) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
if (active()) stop()
|
||||
if (store.active) stop()
|
||||
|
||||
setActive(id)
|
||||
setStore("active", id)
|
||||
command.keybinds(false)
|
||||
}
|
||||
|
||||
@@ -203,7 +206,7 @@ export const SettingsKeybinds: Component = () => {
|
||||
})
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const query = filter().toLowerCase().trim()
|
||||
const query = store.filter.toLowerCase().trim()
|
||||
if (!query) return grouped()
|
||||
|
||||
const map = list()
|
||||
@@ -285,7 +288,7 @@ export const SettingsKeybinds: Component = () => {
|
||||
|
||||
onMount(() => {
|
||||
const handle = (event: KeyboardEvent) => {
|
||||
const id = active()
|
||||
const id = store.active
|
||||
if (!id) return
|
||||
|
||||
event.preventDefault()
|
||||
@@ -345,7 +348,7 @@ export const SettingsKeybinds: Component = () => {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (active()) command.keybinds(true)
|
||||
if (store.active) command.keybinds(true)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -370,8 +373,8 @@ export const SettingsKeybinds: Component = () => {
|
||||
<TextField
|
||||
variant="ghost"
|
||||
type="text"
|
||||
value={filter()}
|
||||
onChange={setFilter}
|
||||
value={store.filter}
|
||||
onChange={(v) => setStore("filter", v)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
@@ -379,8 +382,8 @@ export const SettingsKeybinds: Component = () => {
|
||||
autocapitalize="off"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Show when={filter()}>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} />
|
||||
<Show when={store.filter}>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -402,13 +405,13 @@ export const SettingsKeybinds: Component = () => {
|
||||
classList={{
|
||||
"h-8 px-3 rounded-md text-12-regular": true,
|
||||
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
||||
active() !== id,
|
||||
"border border-border-weak-base bg-surface-inset-base text-text-weak": active() === id,
|
||||
store.active !== id,
|
||||
"border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id,
|
||||
}}
|
||||
onClick={() => start(id)}
|
||||
>
|
||||
<Show
|
||||
when={active() === id}
|
||||
when={store.active === id}
|
||||
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||
>
|
||||
{language.t("settings.shortcuts.pressKeys")}
|
||||
@@ -423,11 +426,11 @@ export const SettingsKeybinds: Component = () => {
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={filter() && !hasResults()}>
|
||||
<Show when={store.filter && !hasResults()}>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span>
|
||||
<Show when={filter()}>
|
||||
<span class="text-14-regular text-text-strong mt-1">"{filter()}"</span>
|
||||
<Show when={store.filter}>
|
||||
<span class="text-14-regular text-text-strong mt-1">"{store.filter}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -1,14 +1,135 @@
|
||||
import { Component } from "solid-js"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
|
||||
export const SettingsModels: Component = () => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
|
||||
const list = useFilteredList<ModelItem>({
|
||||
items: (_filter) => models.list(),
|
||||
key: (x) => `${x.provider.id}:${x.id}`,
|
||||
filterKeys: ["provider.name", "name", "id"],
|
||||
sortBy: (a, b) => a.name.localeCompare(b.name),
|
||||
groupBy: (x) => x.provider.id,
|
||||
sortGroupsBy: (a, b) => {
|
||||
const aIndex = popularProviders.indexOf(a.category)
|
||||
const bIndex = popularProviders.indexOf(b.category)
|
||||
const aPopular = aIndex >= 0
|
||||
const bPopular = bIndex >= 0
|
||||
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
if (aPopular && bPopular) return aIndex - bIndex
|
||||
|
||||
const aName = a.items[0].provider.name
|
||||
const bName = b.items[0].provider.name
|
||||
return aName.localeCompare(bName)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.models.description")}</p>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
|
||||
<div
|
||||
class="sticky top-0 z-10"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||
<TextField
|
||||
variant="ghost"
|
||||
type="text"
|
||||
value={list.filter()}
|
||||
onChange={list.onInput}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Show when={list.filter()}>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={list.clear} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||
<Show
|
||||
when={!list.grouped.loading}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<span class="text-14-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={list.flat().length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<span class="text-14-regular text-text-weak">{language.t("dialog.model.empty")}</span>
|
||||
<Show when={list.filter()}>
|
||||
<span class="text-14-regular text-text-strong mt-1">"{list.filter()}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<ProviderIcon id={group.category as IconName} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
||||
</div>
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const key = { providerID: item.provider.id, modelID: item.id }
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="min-w-0">
|
||||
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<Switch
|
||||
checked={models.visible(key)}
|
||||
onChange={(checked) => {
|
||||
models.setVisibility(key, checked)
|
||||
}}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -39,9 +39,10 @@ export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<string, ServerStatus | undefined>,
|
||||
loading: null as string | null,
|
||||
defaultServerUrl: undefined as string | undefined,
|
||||
})
|
||||
|
||||
const servers = createMemo(() => {
|
||||
@@ -97,8 +98,8 @@ export function StatusPopover() {
|
||||
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
|
||||
|
||||
const toggleMcp = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
if (store.loading) return
|
||||
setStore("loading", name)
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
@@ -107,7 +108,7 @@ export function StatusPopover() {
|
||||
}
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
setLoading(null)
|
||||
setStore("loading", null)
|
||||
}
|
||||
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
@@ -123,15 +124,21 @@ export function StatusPopover() {
|
||||
|
||||
const serverCount = createMemo(() => sortedServers().length)
|
||||
|
||||
const [defaultServerUrl, setDefaultServerUrl] = createSignal<string | undefined>()
|
||||
|
||||
createEffect(() => {
|
||||
const refreshDefaultServerUrl = () => {
|
||||
const result = platform.getDefaultServerUrl?.()
|
||||
if (result instanceof Promise) {
|
||||
result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined))
|
||||
if (!result) {
|
||||
setStore("defaultServerUrl", undefined)
|
||||
return
|
||||
}
|
||||
if (result) setDefaultServerUrl(normalizeServerUrl(result))
|
||||
if (result instanceof Promise) {
|
||||
result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
|
||||
return
|
||||
}
|
||||
setStore("defaultServerUrl", normalizeServerUrl(result))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
refreshDefaultServerUrl()
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -212,7 +219,7 @@ export function StatusPopover() {
|
||||
<For each={sortedServers()}>
|
||||
{(url) => {
|
||||
const isActive = () => url === server.url
|
||||
const isDefault = () => url === defaultServerUrl()
|
||||
const isDefault = () => url === store.defaultServerUrl
|
||||
const status = () => store.status[url]
|
||||
const isBlocked = () => status()?.healthy === false
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
@@ -294,7 +301,7 @@ export function StatusPopover() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</Button>
|
||||
@@ -321,7 +328,7 @@ export function StatusPopover() {
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => toggleMcp(item.name)}
|
||||
disabled={loading() === item.name}
|
||||
disabled={store.loading === item.name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
@@ -337,7 +344,7 @@ export function StatusPopover() {
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={loading() === item.name}
|
||||
disabled={store.loading === item.name}
|
||||
onChange={() => toggleMcp(item.name)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -24,6 +24,15 @@ function normalizeKey(key: string) {
|
||||
return key.toLowerCase()
|
||||
}
|
||||
|
||||
function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
|
||||
const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
|
||||
return `${key}:${mask}`
|
||||
}
|
||||
|
||||
function signatureFromEvent(event: KeyboardEvent) {
|
||||
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
|
||||
}
|
||||
|
||||
export type KeybindConfig = string
|
||||
|
||||
export interface Keybind {
|
||||
@@ -156,8 +165,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const [store, setStore] = createStore({
|
||||
registrations: [] as Accessor<CommandOption[]>[],
|
||||
suspendCount: 0,
|
||||
})
|
||||
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
@@ -175,7 +186,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const seen = new Set<string>()
|
||||
const all: CommandOption[] = []
|
||||
|
||||
for (const reg of registrations()) {
|
||||
for (const reg of store.registrations) {
|
||||
for (const opt of reg()) {
|
||||
if (seen.has(opt.id)) continue
|
||||
seen.add(opt.id)
|
||||
@@ -221,7 +232,31 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
]
|
||||
})
|
||||
|
||||
const suspended = () => suspendCount() > 0
|
||||
const suspended = () => store.suspendCount > 0
|
||||
|
||||
const palette = createMemo(() => {
|
||||
const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
|
||||
const keybinds = parseKeybind(config)
|
||||
return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)))
|
||||
})
|
||||
|
||||
const keymap = createMemo(() => {
|
||||
const map = new Map<string, CommandOption>()
|
||||
for (const option of options()) {
|
||||
if (option.id.startsWith(SUGGESTED_PREFIX)) continue
|
||||
if (option.disabled) continue
|
||||
if (!option.keybind) continue
|
||||
|
||||
const keybinds = parseKeybind(option.keybind)
|
||||
for (const kb of keybinds) {
|
||||
if (!kb.key) continue
|
||||
const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
|
||||
if (map.has(sig)) continue
|
||||
map.set(sig, option)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const run = (id: string, source?: "palette" | "keybind" | "slash") => {
|
||||
for (const option of options()) {
|
||||
@@ -239,24 +274,18 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (suspended() || dialog.active) return
|
||||
|
||||
const paletteKeybinds = parseKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
|
||||
if (matchKeybind(paletteKeybinds, event)) {
|
||||
const sig = signatureFromEvent(event)
|
||||
|
||||
if (palette().has(sig)) {
|
||||
event.preventDefault()
|
||||
showPalette()
|
||||
return
|
||||
}
|
||||
|
||||
for (const option of options()) {
|
||||
if (option.disabled) continue
|
||||
if (!option.keybind) continue
|
||||
|
||||
const keybinds = parseKeybind(option.keybind)
|
||||
if (matchKeybind(keybinds, event)) {
|
||||
event.preventDefault()
|
||||
option.onSelect?.("keybind")
|
||||
return
|
||||
}
|
||||
}
|
||||
const option = keymap().get(sig)
|
||||
if (!option) return
|
||||
event.preventDefault()
|
||||
option.onSelect?.("keybind")
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -270,9 +299,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
return {
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
setRegistrations((arr) => [results, ...arr])
|
||||
setStore("registrations", (arr) => [results, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||
setStore("registrations", (arr) => arr.filter((x) => x !== results))
|
||||
})
|
||||
},
|
||||
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
||||
@@ -294,7 +323,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
},
|
||||
show: showPalette,
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||
setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
|
||||
},
|
||||
suspended,
|
||||
get catalog() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js"
|
||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams } from "@solidjs/router"
|
||||
@@ -37,8 +37,16 @@ function createCommentSession(dir: string, id: string | undefined) {
|
||||
}),
|
||||
)
|
||||
|
||||
const [focus, setFocus] = createSignal<CommentFocus | null>(null)
|
||||
const [active, setActive] = createSignal<CommentFocus | null>(null)
|
||||
const [state, setState] = createStore({
|
||||
focus: null as CommentFocus | null,
|
||||
active: null as CommentFocus | null,
|
||||
})
|
||||
|
||||
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
||||
setState("focus", value)
|
||||
|
||||
const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
||||
setState("active", value)
|
||||
|
||||
const list = (file: string) => store.comments[file] ?? []
|
||||
|
||||
@@ -74,10 +82,10 @@ function createCommentSession(dir: string, id: string | undefined) {
|
||||
all,
|
||||
add,
|
||||
remove,
|
||||
focus: createMemo(() => focus()),
|
||||
focus: createMemo(() => state.focus),
|
||||
setFocus,
|
||||
clearFocus: () => setFocus(null),
|
||||
active: createMemo(() => active()),
|
||||
active: createMemo(() => state.active),
|
||||
setActive,
|
||||
clearActive: () => setActive(null),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
@@ -39,6 +39,14 @@ export type FileState = {
|
||||
content?: FileContent
|
||||
}
|
||||
|
||||
type DirectoryState = {
|
||||
expanded: boolean
|
||||
loaded?: boolean
|
||||
loading?: boolean
|
||||
error?: string
|
||||
children?: string[]
|
||||
}
|
||||
|
||||
function stripFileProtocol(input: string) {
|
||||
if (!input.startsWith("file://")) return input
|
||||
return input.slice("file://".length)
|
||||
@@ -57,6 +65,62 @@ function stripQueryAndHash(input: string) {
|
||||
return input
|
||||
}
|
||||
|
||||
function unquoteGitPath(input: string) {
|
||||
if (!input.startsWith('"')) return input
|
||||
if (!input.endsWith('"')) return input
|
||||
const body = input.slice(1, -1)
|
||||
const bytes: number[] = []
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const char = body[i]!
|
||||
if (char !== "\\") {
|
||||
bytes.push(char.charCodeAt(0))
|
||||
continue
|
||||
}
|
||||
|
||||
const next = body[i + 1]
|
||||
if (!next) {
|
||||
bytes.push("\\".charCodeAt(0))
|
||||
continue
|
||||
}
|
||||
|
||||
if (next >= "0" && next <= "7") {
|
||||
const chunk = body.slice(i + 1, i + 4)
|
||||
const match = chunk.match(/^[0-7]{1,3}/)
|
||||
if (!match) {
|
||||
bytes.push(next.charCodeAt(0))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
bytes.push(parseInt(match[0], 8))
|
||||
i += match[0].length
|
||||
continue
|
||||
}
|
||||
|
||||
const escaped =
|
||||
next === "n"
|
||||
? "\n"
|
||||
: next === "r"
|
||||
? "\r"
|
||||
: next === "t"
|
||||
? "\t"
|
||||
: next === "b"
|
||||
? "\b"
|
||||
: next === "f"
|
||||
? "\f"
|
||||
: next === "v"
|
||||
? "\v"
|
||||
: next === "\\" || next === '"'
|
||||
? next
|
||||
: undefined
|
||||
|
||||
bytes.push((escaped ?? next).charCodeAt(0))
|
||||
i++
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(new Uint8Array(bytes))
|
||||
}
|
||||
|
||||
export function selectionFromLines(range: SelectedLineRange): FileSelection {
|
||||
const startLine = Math.min(range.start, range.end)
|
||||
const endLine = Math.max(range.start, range.end)
|
||||
@@ -197,7 +261,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
const root = directory()
|
||||
const prefix = root.endsWith("/") ? root : root + "/"
|
||||
|
||||
let path = stripQueryAndHash(stripFileProtocol(input))
|
||||
let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
|
||||
|
||||
if (path.startsWith(prefix)) {
|
||||
path = path.slice(prefix.length)
|
||||
@@ -229,6 +293,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const treeInflight = new Map<string, Promise<void>>()
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
file: Record<string, FileState>
|
||||
@@ -236,10 +301,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
file: {},
|
||||
})
|
||||
|
||||
const [tree, setTree] = createStore<{
|
||||
node: Record<string, FileNode>
|
||||
dir: Record<string, DirectoryState>
|
||||
}>({
|
||||
node: {},
|
||||
dir: { "": { expanded: true } },
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
scope()
|
||||
inflight.clear()
|
||||
treeInflight.clear()
|
||||
setStore("file", {})
|
||||
setTree("node", {})
|
||||
setTree("dir", { "": { expanded: true } })
|
||||
})
|
||||
|
||||
const viewCache = new Map<string, ViewCacheEntry>()
|
||||
@@ -351,14 +427,156 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
return promise
|
||||
}
|
||||
|
||||
function normalizeDir(input: string) {
|
||||
return normalize(input).replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function ensureDir(path: string) {
|
||||
if (tree.dir[path]) return
|
||||
setTree("dir", path, { expanded: false })
|
||||
}
|
||||
|
||||
function listDir(input: string, options?: { force?: boolean }) {
|
||||
const dir = normalizeDir(input)
|
||||
ensureDir(dir)
|
||||
|
||||
const current = tree.dir[dir]
|
||||
if (!options?.force && current?.loaded) return Promise.resolve()
|
||||
|
||||
const pending = treeInflight.get(dir)
|
||||
if (pending) return pending
|
||||
|
||||
setTree(
|
||||
"dir",
|
||||
dir,
|
||||
produce((draft) => {
|
||||
draft.loading = true
|
||||
draft.error = undefined
|
||||
}),
|
||||
)
|
||||
|
||||
const directory = scope()
|
||||
|
||||
const promise = sdk.client.file
|
||||
.list({ path: dir })
|
||||
.then((x) => {
|
||||
if (scope() !== directory) return
|
||||
const nodes = x.data ?? []
|
||||
const prevChildren = tree.dir[dir]?.children ?? []
|
||||
const nextChildren = nodes.map((node) => node.path)
|
||||
const nextSet = new Set(nextChildren)
|
||||
|
||||
setTree(
|
||||
"node",
|
||||
produce((draft) => {
|
||||
const removedDirs: string[] = []
|
||||
|
||||
for (const child of prevChildren) {
|
||||
if (nextSet.has(child)) continue
|
||||
const existing = draft[child]
|
||||
if (existing?.type === "directory") removedDirs.push(child)
|
||||
delete draft[child]
|
||||
}
|
||||
|
||||
if (removedDirs.length > 0) {
|
||||
const keys = Object.keys(draft)
|
||||
for (const key of keys) {
|
||||
for (const removed of removedDirs) {
|
||||
if (!key.startsWith(removed + "/")) continue
|
||||
delete draft[key]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
draft[node.path] = node
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
setTree(
|
||||
"dir",
|
||||
dir,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.loading = false
|
||||
draft.children = nextChildren
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (scope() !== directory) return
|
||||
setTree(
|
||||
"dir",
|
||||
dir,
|
||||
produce((draft) => {
|
||||
draft.loading = false
|
||||
draft.error = e.message
|
||||
}),
|
||||
)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Failed to list files",
|
||||
description: e.message,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
treeInflight.delete(dir)
|
||||
})
|
||||
|
||||
treeInflight.set(dir, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
function expandDir(input: string) {
|
||||
const dir = normalizeDir(input)
|
||||
ensureDir(dir)
|
||||
setTree("dir", dir, "expanded", true)
|
||||
void listDir(dir)
|
||||
}
|
||||
|
||||
function collapseDir(input: string) {
|
||||
const dir = normalizeDir(input)
|
||||
ensureDir(dir)
|
||||
setTree("dir", dir, "expanded", false)
|
||||
}
|
||||
|
||||
function dirState(input: string) {
|
||||
const dir = normalizeDir(input)
|
||||
return tree.dir[dir]
|
||||
}
|
||||
|
||||
function children(input: string) {
|
||||
const dir = normalizeDir(input)
|
||||
const ids = tree.dir[dir]?.children
|
||||
if (!ids) return []
|
||||
const out: FileNode[] = []
|
||||
for (const id of ids) {
|
||||
const node = tree.node[id]
|
||||
if (node) out.push(node)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const stop = sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "file.watcher.updated") return
|
||||
const path = normalize(event.properties.file)
|
||||
if (!path) return
|
||||
if (path.startsWith(".git/")) return
|
||||
if (!store.file[path]) return
|
||||
load(path, { force: true })
|
||||
|
||||
if (store.file[path]) {
|
||||
load(path, { force: true })
|
||||
}
|
||||
|
||||
const kind = event.properties.event
|
||||
if (kind !== "add" && kind !== "unlink") return
|
||||
|
||||
const parent = path.split("/").slice(0, -1).join("/")
|
||||
if (!tree.dir[parent]?.loaded) return
|
||||
|
||||
listDir(parent, { force: true })
|
||||
})
|
||||
|
||||
const get = (input: string) => store.file[normalize(input)]
|
||||
@@ -392,6 +610,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
normalize,
|
||||
tab,
|
||||
pathFromTab,
|
||||
tree: {
|
||||
list: listDir,
|
||||
refresh: (input: string) => listDir(input, { force: true }),
|
||||
state: dirState,
|
||||
children,
|
||||
expand: expandDir,
|
||||
collapse: collapseDir,
|
||||
toggle(input: string) {
|
||||
if (dirState(input)?.expanded) {
|
||||
collapseDir(input)
|
||||
return
|
||||
}
|
||||
expandDir(input)
|
||||
},
|
||||
},
|
||||
get,
|
||||
load,
|
||||
scrollTop,
|
||||
|
||||
@@ -82,6 +82,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
diffStyle: "split" as ReviewDiffStyle,
|
||||
panelOpened: true,
|
||||
},
|
||||
fileTree: {
|
||||
opened: false,
|
||||
width: 260,
|
||||
},
|
||||
session: {
|
||||
width: 600,
|
||||
},
|
||||
@@ -218,7 +222,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = globalSync.child(project.worktree)
|
||||
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
|
||||
const projectID = childStore.project
|
||||
const metadata = projectID
|
||||
? globalSync.data.project.find((x) => x.id === projectID)
|
||||
@@ -449,6 +453,38 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setStore("review", "diffStyle", diffStyle)
|
||||
},
|
||||
},
|
||||
fileTree: {
|
||||
opened: createMemo(() => store.fileTree?.opened ?? false),
|
||||
width: createMemo(() => store.fileTree?.width ?? 260),
|
||||
open() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: 260 })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", true)
|
||||
},
|
||||
close() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: false, width: 260 })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", false)
|
||||
},
|
||||
toggle() {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width: 260 })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "opened", (x) => !x)
|
||||
},
|
||||
resize(width: number) {
|
||||
if (!store.fileTree) {
|
||||
setStore("fileTree", { opened: true, width })
|
||||
return
|
||||
}
|
||||
setStore("fileTree", "width", width)
|
||||
},
|
||||
},
|
||||
session: {
|
||||
width: createMemo(() => store.session?.width ?? 600),
|
||||
resize(width: number) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { DateTime } from "luxon"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useModels } from "@/context/models"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -112,18 +110,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})()
|
||||
|
||||
const model = (() => {
|
||||
const [store, setStore, _, modelReady] = persisted(
|
||||
Persist.global("model", ["model.v1"]),
|
||||
createStore<{
|
||||
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
|
||||
recent: ModelKey[]
|
||||
variant?: Record<string, string | undefined>
|
||||
}>({
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
const models = useModels()
|
||||
|
||||
const [ephemeral, setEphemeral] = createStore<{
|
||||
model: Record<string, ModelKey | undefined>
|
||||
@@ -131,57 +118,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
model: {},
|
||||
})
|
||||
|
||||
const available = createMemo(() =>
|
||||
providers.connected().flatMap((p) =>
|
||||
Object.values(p.models).map((m) => ({
|
||||
...m,
|
||||
provider: p,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
const latest = createMemo(() =>
|
||||
pipe(
|
||||
available(),
|
||||
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
|
||||
groupBy((x) => x.provider.id),
|
||||
mapValues((models) =>
|
||||
pipe(
|
||||
models,
|
||||
groupBy((x) => x.family),
|
||||
values(),
|
||||
(groups) =>
|
||||
groups.flatMap((g) => {
|
||||
const first = firstBy(g, [(x) => x.release_date, "desc"])
|
||||
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
|
||||
}),
|
||||
),
|
||||
),
|
||||
values(),
|
||||
flat(),
|
||||
),
|
||||
)
|
||||
|
||||
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
|
||||
|
||||
const userVisibilityMap = createMemo(() => {
|
||||
const map = new Map<string, "show" | "hide">()
|
||||
for (const item of store.user) {
|
||||
map.set(`${item.providerID}:${item.modelID}`, item.visibility)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const list = createMemo(() =>
|
||||
available().map((m) => ({
|
||||
...m,
|
||||
name: m.name.replace("(latest)", "").trim(),
|
||||
latest: m.name.includes("(latest)"),
|
||||
})),
|
||||
)
|
||||
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const fallbackModel = createMemo<ModelKey | undefined>(() => {
|
||||
if (sync.data.config.model) {
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
@@ -193,7 +129,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of store.recent) {
|
||||
for (const item of models.recent.list()) {
|
||||
if (isModelValid(item)) {
|
||||
return item
|
||||
}
|
||||
@@ -225,10 +161,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
fallbackModel,
|
||||
)
|
||||
if (!key) return undefined
|
||||
return find(key)
|
||||
return models.find(key)
|
||||
})
|
||||
|
||||
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
||||
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
|
||||
|
||||
const cycle = (direction: 1 | -1) => {
|
||||
const recentList = recent()
|
||||
@@ -253,54 +189,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
}
|
||||
|
||||
function updateVisibility(model: ModelKey, visibility: "show" | "hide") {
|
||||
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
||||
if (index >= 0) {
|
||||
setStore("user", index, { visibility })
|
||||
} else {
|
||||
setStore("user", store.user.length, { ...model, visibility })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: modelReady,
|
||||
ready: models.ready,
|
||||
current,
|
||||
recent,
|
||||
list,
|
||||
list: models.list,
|
||||
cycle,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
const currentAgent = agent.current()
|
||||
const next = model ?? fallbackModel()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
||||
if (model) updateVisibility(model, "show")
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
setStore("recent", uniq)
|
||||
}
|
||||
if (model) models.setVisibility(model, true)
|
||||
if (options?.recent && model) models.recent.push(model)
|
||||
})
|
||||
},
|
||||
visible(model: ModelKey) {
|
||||
const key = `${model.providerID}:${model.modelID}`
|
||||
const visibility = userVisibilityMap().get(key)
|
||||
if (visibility === "hide") return false
|
||||
if (visibility === "show") return true
|
||||
if (latestSet().has(key)) return true
|
||||
// For models without valid release_date (e.g. custom models), show by default
|
||||
const m = find(model)
|
||||
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
|
||||
return false
|
||||
return models.visible(model)
|
||||
},
|
||||
setVisibility(model: ModelKey, visible: boolean) {
|
||||
updateVisibility(model, visible ? "show" : "hide")
|
||||
models.setVisibility(model, visible)
|
||||
},
|
||||
variant: {
|
||||
current() {
|
||||
const m = current()
|
||||
if (!m) return undefined
|
||||
const key = `${m.provider.id}/${m.id}`
|
||||
return store.variant?.[key]
|
||||
return models.variant.get({ providerID: m.provider.id, modelID: m.id })
|
||||
},
|
||||
list() {
|
||||
const m = current()
|
||||
@@ -311,12 +225,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
set(value: string | undefined) {
|
||||
const m = current()
|
||||
if (!m) return
|
||||
const key = `${m.provider.id}/${m.id}`
|
||||
if (!store.variant) {
|
||||
setStore("variant", { [key]: value })
|
||||
} else {
|
||||
setStore("variant", key, value)
|
||||
}
|
||||
models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
|
||||
140
packages/app/src/context/models.tsx
Normal file
140
packages/app/src/context/models.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
type Visibility = "show" | "hide"
|
||||
type User = ModelKey & { visibility: Visibility; favorite?: boolean }
|
||||
type Store = {
|
||||
user: User[]
|
||||
recent: ModelKey[]
|
||||
variant?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
||||
name: "Models",
|
||||
init: () => {
|
||||
const providers = useProviders()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model", ["model.v1"]),
|
||||
createStore<Store>({
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
|
||||
const available = createMemo(() =>
|
||||
providers.connected().flatMap((p) =>
|
||||
Object.values(p.models).map((m) => ({
|
||||
...m,
|
||||
provider: p,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
const latest = createMemo(() =>
|
||||
pipe(
|
||||
available(),
|
||||
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
|
||||
groupBy((x) => x.provider.id),
|
||||
mapValues((models) =>
|
||||
pipe(
|
||||
models,
|
||||
groupBy((x) => x.family),
|
||||
values(),
|
||||
(groups) =>
|
||||
groups.flatMap((g) => {
|
||||
const first = firstBy(g, [(x) => x.release_date, "desc"])
|
||||
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
|
||||
}),
|
||||
),
|
||||
),
|
||||
values(),
|
||||
flat(),
|
||||
),
|
||||
)
|
||||
|
||||
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
|
||||
|
||||
const visibility = createMemo(() => {
|
||||
const map = new Map<string, Visibility>()
|
||||
for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
|
||||
return map
|
||||
})
|
||||
|
||||
const list = createMemo(() =>
|
||||
available().map((m) => ({
|
||||
...m,
|
||||
name: m.name.replace("(latest)", "").trim(),
|
||||
latest: m.name.includes("(latest)"),
|
||||
})),
|
||||
)
|
||||
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
|
||||
|
||||
function update(model: ModelKey, state: Visibility) {
|
||||
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
||||
if (index >= 0) {
|
||||
setStore("user", index, { visibility: state })
|
||||
return
|
||||
}
|
||||
setStore("user", store.user.length, { ...model, visibility: state })
|
||||
}
|
||||
|
||||
const visible = (model: ModelKey) => {
|
||||
const key = `${model.providerID}:${model.modelID}`
|
||||
const state = visibility().get(key)
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (latestSet().has(key)) return true
|
||||
const m = find(model)
|
||||
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const setVisibility = (model: ModelKey, state: boolean) => {
|
||||
update(model, state ? "show" : "hide")
|
||||
}
|
||||
|
||||
const push = (model: ModelKey) => {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
setStore("recent", uniq)
|
||||
}
|
||||
|
||||
const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
|
||||
const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
|
||||
|
||||
const setVariant = (model: ModelKey, value: string | undefined) => {
|
||||
const key = variantKey(model)
|
||||
if (!store.variant) {
|
||||
setStore("variant", { [key]: value })
|
||||
return
|
||||
}
|
||||
setStore("variant", key, value)
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
list,
|
||||
find,
|
||||
visible,
|
||||
setVisibility,
|
||||
recent: {
|
||||
list: createMemo(() => store.recent),
|
||||
push,
|
||||
},
|
||||
variant: {
|
||||
get: getVariant,
|
||||
set: setVariant,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
@@ -52,6 +52,15 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
|
||||
const empty: Notification[] = []
|
||||
|
||||
const currentDirectory = createMemo(() => {
|
||||
if (!params.dir) return
|
||||
return base64Decode(params.dir)
|
||||
})
|
||||
|
||||
const currentSession = createMemo(() => params.id)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("notification", ["notification.v1"]),
|
||||
createStore({
|
||||
@@ -72,13 +81,59 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
setStore("list", (list) => pruneNotifications([...list, notification]))
|
||||
}
|
||||
|
||||
const index = createMemo(() => {
|
||||
const sessionAll = new Map<string, Notification[]>()
|
||||
const sessionUnseen = new Map<string, Notification[]>()
|
||||
const projectAll = new Map<string, Notification[]>()
|
||||
const projectUnseen = new Map<string, Notification[]>()
|
||||
|
||||
for (const notification of store.list) {
|
||||
const session = notification.session
|
||||
if (session) {
|
||||
const list = sessionAll.get(session)
|
||||
if (list) list.push(notification)
|
||||
else sessionAll.set(session, [notification])
|
||||
if (!notification.viewed) {
|
||||
const unseen = sessionUnseen.get(session)
|
||||
if (unseen) unseen.push(notification)
|
||||
else sessionUnseen.set(session, [notification])
|
||||
}
|
||||
}
|
||||
|
||||
const directory = notification.directory
|
||||
if (directory) {
|
||||
const list = projectAll.get(directory)
|
||||
if (list) list.push(notification)
|
||||
else projectAll.set(directory, [notification])
|
||||
if (!notification.viewed) {
|
||||
const unseen = projectUnseen.get(directory)
|
||||
if (unseen) unseen.push(notification)
|
||||
else projectUnseen.set(directory, [notification])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session: {
|
||||
all: sessionAll,
|
||||
unseen: sessionUnseen,
|
||||
},
|
||||
project: {
|
||||
all: projectAll,
|
||||
unseen: projectUnseen,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
const activeDirectory = params.dir ? base64Decode(params.dir) : undefined
|
||||
const activeSession = params.id
|
||||
const viewed = (sessionID?: string) => {
|
||||
const activeDirectory = currentDirectory()
|
||||
const activeSession = currentSession()
|
||||
if (!activeDirectory) return false
|
||||
if (!activeSession) return false
|
||||
if (!sessionID) return false
|
||||
@@ -88,7 +143,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
switch (event.type) {
|
||||
case "session.idle": {
|
||||
const sessionID = event.properties.sessionID
|
||||
const [syncStore] = globalSync.child(directory)
|
||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
const session = match.found ? syncStore.session[match.index] : undefined
|
||||
if (session?.parentID) break
|
||||
@@ -115,7 +170,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
}
|
||||
case "session.error": {
|
||||
const sessionID = event.properties.sessionID
|
||||
const [syncStore] = globalSync.child(directory)
|
||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
||||
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
|
||||
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
|
||||
if (session?.parentID) break
|
||||
@@ -148,10 +203,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
ready,
|
||||
session: {
|
||||
all(session: string) {
|
||||
return store.list.filter((n) => n.session === session)
|
||||
return index().session.all.get(session) ?? empty
|
||||
},
|
||||
unseen(session: string) {
|
||||
return store.list.filter((n) => n.session === session && !n.viewed)
|
||||
return index().session.unseen.get(session) ?? empty
|
||||
},
|
||||
markViewed(session: string) {
|
||||
setStore("list", (n) => n.session === session, "viewed", true)
|
||||
@@ -159,10 +214,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
},
|
||||
project: {
|
||||
all(directory: string) {
|
||||
return store.list.filter((n) => n.directory === directory)
|
||||
return index().project.all.get(directory) ?? empty
|
||||
},
|
||||
unseen(directory: string) {
|
||||
return store.list.filter((n) => n.directory === directory && !n.viewed)
|
||||
return index().project.unseen.get(directory) ?? empty
|
||||
},
|
||||
markViewed(directory: string) {
|
||||
setStore("list", (n) => n.directory === directory, "viewed", true)
|
||||
|
||||
@@ -41,11 +41,11 @@ export type Platform = {
|
||||
/** Fetch override */
|
||||
fetch?: typeof fetch
|
||||
|
||||
/** Get the configured default server URL (desktop only) */
|
||||
getDefaultServerUrl?(): Promise<string | null>
|
||||
/** Get the configured default server URL (platform-specific) */
|
||||
getDefaultServerUrl?(): Promise<string | null> | string | null
|
||||
|
||||
/** Set the default server URL to use on app startup (desktop only) */
|
||||
setDefaultServerUrl?(url: string | null): Promise<void>
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
||||
|
||||
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
||||
parseMarkdown?(markdown: string): Promise<string>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -40,12 +40,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
}),
|
||||
)
|
||||
|
||||
const [active, setActiveRaw] = createSignal("")
|
||||
const [state, setState] = createStore({
|
||||
active: "",
|
||||
healthy: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
const healthy = () => state.healthy
|
||||
|
||||
function setActive(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
setActiveRaw(url)
|
||||
setState("active", url)
|
||||
}
|
||||
|
||||
function add(input: string) {
|
||||
@@ -54,7 +59,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
|
||||
const fallback = normalizeServerUrl(props.defaultUrl)
|
||||
if (fallback && url === fallback) {
|
||||
setActiveRaw(url)
|
||||
setState("active", url)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +67,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
if (!store.list.includes(url)) {
|
||||
setStore("list", store.list.length, url)
|
||||
}
|
||||
setActiveRaw(url)
|
||||
setState("active", url)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,25 +76,23 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
if (!url) return
|
||||
|
||||
const list = store.list.filter((x) => x !== url)
|
||||
const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active()
|
||||
const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
|
||||
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setActiveRaw(next)
|
||||
setState("active", next)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (active()) return
|
||||
if (state.active) return
|
||||
const url = normalizeServerUrl(props.defaultUrl)
|
||||
if (!url) return
|
||||
setActiveRaw(url)
|
||||
setState("active", url)
|
||||
})
|
||||
|
||||
const isReady = createMemo(() => ready() && !!active())
|
||||
|
||||
const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
|
||||
const isReady = createMemo(() => ready() && !!state.active)
|
||||
|
||||
const check = (url: string) => {
|
||||
const sdk = createOpencodeClient({
|
||||
@@ -104,10 +107,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const url = active()
|
||||
const url = state.active
|
||||
if (!url) return
|
||||
|
||||
setHealthy(undefined)
|
||||
setState("healthy", undefined)
|
||||
|
||||
let alive = true
|
||||
let busy = false
|
||||
@@ -118,7 +121,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
void check(url)
|
||||
.then((next) => {
|
||||
if (!alive) return
|
||||
setHealthy(next)
|
||||
setState("healthy", next)
|
||||
})
|
||||
.finally(() => {
|
||||
busy = false
|
||||
@@ -134,7 +137,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
})
|
||||
})
|
||||
|
||||
const origin = createMemo(() => projectsKey(active()))
|
||||
const origin = createMemo(() => projectsKey(state.active))
|
||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||
const isLocal = createMemo(() => origin() === "local")
|
||||
|
||||
@@ -143,10 +146,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
healthy,
|
||||
isLocal,
|
||||
get url() {
|
||||
return active()
|
||||
return state.active
|
||||
},
|
||||
get name() {
|
||||
return serverDisplayName(active())
|
||||
return serverDisplayName(state.active)
|
||||
},
|
||||
get list() {
|
||||
return store.list
|
||||
|
||||
@@ -6,6 +6,8 @@ import { dict as en } from "@/i18n/en"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import pkg from "../package.json"
|
||||
|
||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
const locale = (() => {
|
||||
@@ -62,6 +64,26 @@ const platform: Platform = {
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
getDefaultServerUrl: () => {
|
||||
if (typeof localStorage === "undefined") return null
|
||||
try {
|
||||
return localStorage.getItem(DEFAULT_SERVER_URL_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
setDefaultServerUrl: (url) => {
|
||||
if (typeof localStorage === "undefined") return
|
||||
try {
|
||||
if (url) {
|
||||
localStorage.setItem(DEFAULT_SERVER_URL_KEY, url)
|
||||
return
|
||||
}
|
||||
localStorage.removeItem(DEFAULT_SERVER_URL_KEY)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
|
||||
@@ -23,6 +23,11 @@ export default function Home() {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
const recent = createMemo(() => {
|
||||
return sync.data.project
|
||||
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
function openProject(directory: string) {
|
||||
layout.projects.open(directory)
|
||||
@@ -84,11 +89,7 @@ export default function Home() {
|
||||
</Button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<For
|
||||
each={sync.data.project
|
||||
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice(0, 5)}
|
||||
>
|
||||
<For each={recent()}>
|
||||
{(project) => (
|
||||
<Button
|
||||
size="large"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
// Minimal types to avoid relying on non-standard DOM typings
|
||||
type RecognitionResult = {
|
||||
@@ -59,9 +60,15 @@ export function createSpeechRecognition(opts?: {
|
||||
typeof window !== "undefined" &&
|
||||
Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
|
||||
|
||||
const [isRecording, setIsRecording] = createSignal(false)
|
||||
const [committed, setCommitted] = createSignal("")
|
||||
const [interim, setInterim] = createSignal("")
|
||||
const [store, setStore] = createStore({
|
||||
isRecording: false,
|
||||
committed: "",
|
||||
interim: "",
|
||||
})
|
||||
|
||||
const isRecording = () => store.isRecording
|
||||
const committed = () => store.committed
|
||||
const interim = () => store.interim
|
||||
|
||||
let recognition: Recognition | undefined
|
||||
let shouldContinue = false
|
||||
@@ -82,7 +89,7 @@ export function createSpeechRecognition(opts?: {
|
||||
const nextCommitted = appendSegment(committedText, segment)
|
||||
if (nextCommitted === committedText) return
|
||||
committedText = nextCommitted
|
||||
setCommitted(committedText)
|
||||
setStore("committed", committedText)
|
||||
if (opts?.onFinal) opts.onFinal(segment.trim())
|
||||
}
|
||||
|
||||
@@ -98,7 +105,7 @@ export function createSpeechRecognition(opts?: {
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
setStore("interim", "")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
}
|
||||
|
||||
@@ -107,7 +114,7 @@ export function createSpeechRecognition(opts?: {
|
||||
pendingHypothesis = hypothesis
|
||||
lastInterimSuffix = suffix
|
||||
shrinkCandidate = undefined
|
||||
setInterim(suffix)
|
||||
setStore("interim", suffix)
|
||||
if (opts?.onInterim) {
|
||||
opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
|
||||
}
|
||||
@@ -122,7 +129,7 @@ export function createSpeechRecognition(opts?: {
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
setStore("interim", "")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
}, COMMIT_DELAY)
|
||||
}
|
||||
@@ -162,7 +169,7 @@ export function createSpeechRecognition(opts?: {
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
setStore("interim", "")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
return
|
||||
}
|
||||
@@ -211,7 +218,7 @@ export function createSpeechRecognition(opts?: {
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
if (e.error === "no-speech" && shouldContinue) {
|
||||
setInterim("")
|
||||
setStore("interim", "")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
setTimeout(() => {
|
||||
try {
|
||||
@@ -221,7 +228,7 @@ export function createSpeechRecognition(opts?: {
|
||||
return
|
||||
}
|
||||
shouldContinue = false
|
||||
setIsRecording(false)
|
||||
setStore("isRecording", false)
|
||||
}
|
||||
|
||||
recognition.onstart = () => {
|
||||
@@ -230,16 +237,16 @@ export function createSpeechRecognition(opts?: {
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
setStore("interim", "")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
setIsRecording(true)
|
||||
setStore("isRecording", true)
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setIsRecording(false)
|
||||
setStore("isRecording", false)
|
||||
if (shouldContinue) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
@@ -258,7 +265,7 @@ export function createSpeechRecognition(opts?: {
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
setStore("interim", "")
|
||||
try {
|
||||
recognition.start()
|
||||
} catch {}
|
||||
@@ -271,7 +278,7 @@ export function createSpeechRecognition(opts?: {
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
setStore("interim", "")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
try {
|
||||
recognition.stop()
|
||||
@@ -284,7 +291,7 @@ export function createSpeechRecognition(opts?: {
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
setStore("interim", "")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
try {
|
||||
recognition?.stop()
|
||||
|
||||
@@ -20,6 +20,9 @@ type HighlightGroup = {
|
||||
items: HighlightItem[]
|
||||
}
|
||||
|
||||
const ok = "public, max-age=1, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400"
|
||||
const error = "public, max-age=1, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400"
|
||||
|
||||
function parseHighlights(body: string): HighlightGroup[] {
|
||||
const groups = new Map<string, HighlightItem[]>()
|
||||
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
|
||||
@@ -90,25 +93,48 @@ export async function GET() {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "OpenCode-Console",
|
||||
},
|
||||
})
|
||||
cf: {
|
||||
// best-effort edge caching (ignored outside Cloudflare)
|
||||
cacheTtl: 60 * 5,
|
||||
cacheEverything: true,
|
||||
},
|
||||
} as any).catch(() => undefined)
|
||||
|
||||
if (!response.ok) {
|
||||
return { releases: [] }
|
||||
}
|
||||
const fail = () =>
|
||||
new Response(JSON.stringify({ releases: [] }), {
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": error,
|
||||
},
|
||||
})
|
||||
|
||||
const releases = (await response.json()) as Release[]
|
||||
if (!response?.ok) return fail()
|
||||
|
||||
return {
|
||||
releases: releases.map((release) => {
|
||||
const parsed = parseMarkdown(release.body || "")
|
||||
return {
|
||||
tag: release.tag_name,
|
||||
name: release.name,
|
||||
date: release.published_at,
|
||||
url: release.html_url,
|
||||
highlights: parsed.highlights,
|
||||
sections: parsed.sections,
|
||||
}
|
||||
const data = await response.json().catch(() => undefined)
|
||||
if (!Array.isArray(data)) return fail()
|
||||
|
||||
const releases = data as Release[]
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
releases: releases.map((release) => {
|
||||
const parsed = parseMarkdown(release.body || "")
|
||||
return {
|
||||
tag: release.tag_name,
|
||||
name: release.name,
|
||||
date: release.published_at,
|
||||
url: release.html_url,
|
||||
highlights: parsed.highlights,
|
||||
sections: parsed.sections,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
}
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": ok,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -371,7 +371,7 @@
|
||||
top: 80px;
|
||||
align-self: start;
|
||||
background: var(--color-background);
|
||||
padding: 8px 0;
|
||||
padding: 44px 0 8px;
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
position: static;
|
||||
|
||||
@@ -1,44 +1,12 @@
|
||||
import "./index.css"
|
||||
import { Title, Meta, Link } from "@solidjs/meta"
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { createAsync } from "@solidjs/router"
|
||||
import { Header } from "~/component/header"
|
||||
import { Footer } from "~/component/footer"
|
||||
import { Legal } from "~/component/legal"
|
||||
import { config } from "~/config"
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
|
||||
type Release = {
|
||||
tag_name: string
|
||||
name: string
|
||||
body: string
|
||||
published_at: string
|
||||
html_url: string
|
||||
}
|
||||
|
||||
const getReleases = query(async () => {
|
||||
"use server"
|
||||
const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "OpenCode-Console",
|
||||
},
|
||||
cf: {
|
||||
cacheTtl: 60 * 5,
|
||||
cacheEverything: true,
|
||||
},
|
||||
} as any)
|
||||
if (!response.ok) return []
|
||||
return response.json() as Promise<Release[]>
|
||||
}, "releases.get")
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
|
||||
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
|
||||
|
||||
@@ -54,68 +22,33 @@ type HighlightGroup = {
|
||||
items: HighlightItem[]
|
||||
}
|
||||
|
||||
function parseHighlights(body: string): HighlightGroup[] {
|
||||
const groups = new Map<string, HighlightItem[]>()
|
||||
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(body)) !== null) {
|
||||
const source = match[1]
|
||||
const content = match[2]
|
||||
|
||||
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
|
||||
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
|
||||
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="[^"]*"\s+src="([^"]+)"/)
|
||||
const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
|
||||
|
||||
let media: HighlightMedia | undefined
|
||||
if (videoMatch) {
|
||||
media = { type: "video", src: videoMatch[1] }
|
||||
} else if (imgMatch) {
|
||||
media = { type: "image", src: imgMatch[3], width: imgMatch[1], height: imgMatch[2] }
|
||||
}
|
||||
|
||||
if (titleMatch && media) {
|
||||
const item: HighlightItem = {
|
||||
title: titleMatch[1],
|
||||
description: pMatch?.[2] || "",
|
||||
shortDescription: pMatch?.[1],
|
||||
media,
|
||||
}
|
||||
|
||||
if (!groups.has(source)) {
|
||||
groups.set(source, [])
|
||||
}
|
||||
groups.get(source)!.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([source, items]) => ({ source, items }))
|
||||
type ChangelogRelease = {
|
||||
tag: string
|
||||
name: string
|
||||
date: string
|
||||
url: string
|
||||
highlights: HighlightGroup[]
|
||||
sections: { title: string; items: string[] }[]
|
||||
}
|
||||
|
||||
function parseMarkdown(body: string) {
|
||||
const lines = body.split("\n")
|
||||
const sections: { title: string; items: string[] }[] = []
|
||||
let current: { title: string; items: string[] } | null = null
|
||||
let skip = false
|
||||
async function getReleases() {
|
||||
const event = getRequestEvent()
|
||||
const url = event ? new URL("/changelog.json", event.request.url).toString() : "/changelog.json"
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ")) {
|
||||
if (current) sections.push(current)
|
||||
const title = line.slice(3).trim()
|
||||
current = { title, items: [] }
|
||||
skip = false
|
||||
} else if (line.startsWith("**Thank you")) {
|
||||
skip = true
|
||||
} else if (line.startsWith("- ") && !skip) {
|
||||
current?.items.push(line.slice(2).trim())
|
||||
}
|
||||
}
|
||||
if (current) sections.push(current)
|
||||
const response = await fetch(url).catch(() => undefined)
|
||||
if (!response?.ok) return []
|
||||
|
||||
const highlights = parseHighlights(body)
|
||||
const json = await response.json().catch(() => undefined)
|
||||
return Array.isArray(json?.releases) ? (json.releases as ChangelogRelease[]) : []
|
||||
}
|
||||
|
||||
return { sections, highlights }
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function ReleaseItem(props: { item: string }) {
|
||||
@@ -217,28 +150,27 @@ export default function Changelog() {
|
||||
<section data-component="releases">
|
||||
<For each={releases()}>
|
||||
{(release) => {
|
||||
const parsed = () => parseMarkdown(release.body || "")
|
||||
return (
|
||||
<article data-component="release">
|
||||
<header>
|
||||
<div data-slot="version">
|
||||
<a href={release.html_url} target="_blank" rel="noopener noreferrer">
|
||||
{release.tag_name}
|
||||
<a href={release.url} target="_blank" rel="noopener noreferrer">
|
||||
{release.tag}
|
||||
</a>
|
||||
</div>
|
||||
<time dateTime={release.published_at}>{formatDate(release.published_at)}</time>
|
||||
<time dateTime={release.date}>{formatDate(release.date)}</time>
|
||||
</header>
|
||||
<div data-slot="content">
|
||||
<Show when={parsed().highlights.length > 0}>
|
||||
<Show when={release.highlights.length > 0}>
|
||||
<div data-component="highlights">
|
||||
<For each={parsed().highlights}>{(group) => <HighlightSection group={group} />}</For>
|
||||
<For each={release.highlights}>{(group) => <HighlightSection group={group} />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={parsed().highlights.length > 0 && parsed().sections.length > 0}>
|
||||
<CollapsibleSections sections={parsed().sections} />
|
||||
<Show when={release.highlights.length > 0 && release.sections.length > 0}>
|
||||
<CollapsibleSections sections={release.sections} />
|
||||
</Show>
|
||||
<Show when={parsed().highlights.length === 0}>
|
||||
<For each={parsed().sections}>
|
||||
<Show when={release.highlights.length === 0}>
|
||||
<For each={release.sections}>
|
||||
{(section) => (
|
||||
<div data-component="section">
|
||||
<h3>{section.title}</h3>
|
||||
@@ -255,9 +187,9 @@ export default function Changelog() {
|
||||
}}
|
||||
</For>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<Legal />
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="flex flex-col h-dvh"></div>
|
||||
<div id="root" class="flex flex-col h-dvh p-px"></div>
|
||||
<div data-tauri-decorum-tb class="w-0 h-0 hidden" />
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
|
||||
@@ -328,18 +328,23 @@ render(() => {
|
||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||
const platform = createPlatform(() => serverPassword())
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||
if (link?.href) {
|
||||
e.preventDefault()
|
||||
platform.openLink(link.href)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("click", handleClick)
|
||||
// Handle external links - open in system browser instead of webview
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const link = target.closest("a") as HTMLAnchorElement | null
|
||||
|
||||
if (link?.href && !link.href.startsWith("javascript:") && !link.href.startsWith("#")) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.stopImmediatePropagation()
|
||||
void shellOpen(link.href).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClick, true)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("click", handleClick)
|
||||
document.removeEventListener("click", handleClick, true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { createStore } from "solid-js/store"
|
||||
import z from "zod"
|
||||
import NotFound from "../[...404]"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
||||
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
|
||||
import { clientOnly } from "@solidjs/start"
|
||||
@@ -362,6 +363,15 @@ export default function () {
|
||||
{title()}
|
||||
</div>
|
||||
<div class="flex items-start justify-start h-full min-h-0">
|
||||
<Show when={messages().length > 1}>
|
||||
<MessageNav
|
||||
class="sticky top-0 shrink-0 py-2 pl-4"
|
||||
messages={messages()}
|
||||
current={activeMessage()}
|
||||
size="compact"
|
||||
onMessageSelect={setActiveMessage}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
messageID={store.messageId ?? firstUserMessage()!.id!}
|
||||
|
||||
@@ -82,8 +82,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.74",
|
||||
"@opentui/solid": "0.1.74",
|
||||
"@opentui/core": "0.1.75",
|
||||
"@opentui/solid": "0.1.75",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -73,6 +73,7 @@ export namespace Agent {
|
||||
const result: Record<string, Info> = {
|
||||
build: {
|
||||
name: "build",
|
||||
description: "The default agent. Executes tools based on configured permissions.",
|
||||
options: {},
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
@@ -87,6 +88,7 @@ export namespace Agent {
|
||||
},
|
||||
plan: {
|
||||
name: "plan",
|
||||
description: "Plan mode. Disallows all edit tools.",
|
||||
options: {},
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
|
||||
@@ -153,6 +153,7 @@ async function createToolContext(agent: Agent.Info) {
|
||||
callID: Identifier.ascending("part"),
|
||||
agent: agent.name,
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
||||
for (const pattern of req.patterns) {
|
||||
|
||||
@@ -570,6 +570,16 @@ function App() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
value: "app.toggle.diffwrap",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
const current = kv.get("diff_wrap_mode", "word")
|
||||
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -145,7 +145,7 @@ export function Session() {
|
||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
||||
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
|
||||
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
||||
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
||||
|
||||
const wide = createMemo(() => dimensions().width > 120)
|
||||
@@ -503,7 +503,7 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle code concealment",
|
||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
keybind: "messages_toggle_conceal" as any,
|
||||
category: "Session",
|
||||
@@ -538,18 +538,6 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle diff wrapping",
|
||||
value: "session.toggle.diffwrap",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "diffwrap",
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
setDiffWrapMode((prev) => (prev === "word" ? "none" : "word"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: showDetails() ? "Hide tool details" : "Show tool details",
|
||||
value: "session.toggle.actions",
|
||||
@@ -1705,10 +1693,29 @@ function Glob(props: ToolProps<typeof GlobTool>) {
|
||||
}
|
||||
|
||||
function Read(props: ToolProps<typeof ReadTool>) {
|
||||
const { theme } = useTheme()
|
||||
const loaded = createMemo(() => {
|
||||
if (props.part.state.status !== "completed") return []
|
||||
if (props.part.state.time.compacted) return []
|
||||
const value = props.metadata.loaded
|
||||
if (!value || !Array.isArray(value)) return []
|
||||
return value.filter((p): p is string => typeof p === "string")
|
||||
})
|
||||
return (
|
||||
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
|
||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||
</InlineTool>
|
||||
<>
|
||||
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
|
||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||
</InlineTool>
|
||||
<For each={loaded()}>
|
||||
{(filepath) => (
|
||||
<box paddingLeft={3}>
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
↳ Loaded {normalizePath(filepath)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,11 @@ export namespace File {
|
||||
const project = Instance.project
|
||||
if (project.vcs !== "git") return []
|
||||
|
||||
const diffOutput = await $`git diff --numstat HEAD`.cwd(Instance.directory).quiet().nothrow().text()
|
||||
const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
|
||||
const changedFiles: Info[] = []
|
||||
|
||||
@@ -223,7 +227,7 @@ export namespace File {
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput = await $`git ls-files --others --exclude-standard`
|
||||
const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
@@ -248,7 +252,7 @@ export namespace File {
|
||||
}
|
||||
|
||||
// Get deleted files
|
||||
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
|
||||
const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
|
||||
164
packages/opencode/src/session/instruction.ts
Normal file
164
packages/opencode/src/session/instruction.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "../util/log"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
|
||||
const log = Log.create({ service: "instruction" })
|
||||
|
||||
const FILES = [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
|
||||
function globalFiles() {
|
||||
const files = [path.join(Global.Path.config, "AGENTS.md")]
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
||||
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
}
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function resolveRelative(instruction: string): Promise<string[]> {
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
|
||||
}
|
||||
if (!Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.warn(
|
||||
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
|
||||
)
|
||||
return []
|
||||
}
|
||||
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
|
||||
}
|
||||
|
||||
export namespace InstructionPrompt {
|
||||
export async function systemPaths() {
|
||||
const config = await Config.get()
|
||||
const paths = new Set<string>()
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of FILES) {
|
||||
const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((p) => paths.add(path.resolve(p)))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of globalFiles()) {
|
||||
if (await Bun.file(file).exists()) {
|
||||
paths.add(path.resolve(file))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (config.instructions) {
|
||||
for (let instruction of config.instructions) {
|
||||
if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
|
||||
if (instruction.startsWith("~/")) {
|
||||
instruction = path.join(os.homedir(), instruction.slice(2))
|
||||
}
|
||||
const matches = path.isAbsolute(instruction)
|
||||
? await Array.fromAsync(
|
||||
new Bun.Glob(path.basename(instruction)).scan({
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
}),
|
||||
).catch(() => [])
|
||||
: await resolveRelative(instruction)
|
||||
matches.forEach((p) => paths.add(path.resolve(p)))
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
export async function system() {
|
||||
const config = await Config.get()
|
||||
const paths = await systemPaths()
|
||||
|
||||
const files = Array.from(paths).map(async (p) => {
|
||||
const content = await Bun.file(p)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
return content ? "Instructions from: " + p + "\n" + content : ""
|
||||
})
|
||||
|
||||
const urls: string[] = []
|
||||
if (config.instructions) {
|
||||
for (const instruction of config.instructions) {
|
||||
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
|
||||
urls.push(instruction)
|
||||
}
|
||||
}
|
||||
}
|
||||
const fetches = urls.map((url) =>
|
||||
fetch(url, { signal: AbortSignal.timeout(5000) })
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.catch(() => "")
|
||||
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
|
||||
)
|
||||
|
||||
return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean))
|
||||
}
|
||||
|
||||
export function loaded(messages: MessageV2.WithParts[]) {
|
||||
const paths = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
|
||||
if (part.state.time.compacted) continue
|
||||
const loaded = part.state.metadata?.loaded
|
||||
if (!loaded || !Array.isArray(loaded)) continue
|
||||
for (const p of loaded) {
|
||||
if (typeof p === "string") paths.add(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
export async function find(dir: string) {
|
||||
for (const file of FILES) {
|
||||
const filepath = path.resolve(path.join(dir, file))
|
||||
if (await Bun.file(filepath).exists()) return filepath
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolve(messages: MessageV2.WithParts[], filepath: string) {
|
||||
const system = await systemPaths()
|
||||
const already = loaded(messages)
|
||||
const results: { filepath: string; content: string }[] = []
|
||||
|
||||
let current = path.dirname(path.resolve(filepath))
|
||||
const root = path.resolve(Instance.directory)
|
||||
|
||||
while (current.startsWith(root)) {
|
||||
const found = await find(current)
|
||||
if (found && !system.has(found) && !already.has(found)) {
|
||||
const content = await Bun.file(found)
|
||||
.text()
|
||||
.catch(() => undefined)
|
||||
if (content) {
|
||||
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
|
||||
}
|
||||
}
|
||||
if (current === root) break
|
||||
current = path.dirname(current)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -631,7 +631,7 @@ export namespace MessageV2 {
|
||||
sessionID: Identifier.schema("session"),
|
||||
messageID: Identifier.schema("message"),
|
||||
}),
|
||||
async (input) => {
|
||||
async (input): Promise<WithParts> => {
|
||||
return {
|
||||
info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
|
||||
parts: await parts(input.messageID),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Instance } from "../project/instance"
|
||||
import { Bus } from "../bus"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { InstructionPrompt } from "./instruction"
|
||||
import { Plugin } from "../plugin"
|
||||
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||
@@ -386,6 +387,7 @@ export namespace SessionPrompt {
|
||||
abort,
|
||||
callID: part.callID,
|
||||
extra: { bypassAgentCheck: true },
|
||||
messages: msgs,
|
||||
async metadata(input) {
|
||||
await Session.updatePart({
|
||||
...part,
|
||||
@@ -561,6 +563,7 @@ export namespace SessionPrompt {
|
||||
tools: lastUser.tools,
|
||||
processor,
|
||||
bypassAgentCheck,
|
||||
messages: msgs,
|
||||
})
|
||||
|
||||
if (step === 1) {
|
||||
@@ -598,7 +601,7 @@ export namespace SessionPrompt {
|
||||
agent,
|
||||
abort,
|
||||
sessionID,
|
||||
system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
|
||||
system: [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())],
|
||||
messages: [
|
||||
...MessageV2.toModelMessages(sessionMessages, model),
|
||||
...(isLastStep
|
||||
@@ -650,6 +653,7 @@ export namespace SessionPrompt {
|
||||
tools?: Record<string, boolean>
|
||||
processor: SessionProcessor.Info
|
||||
bypassAgentCheck: boolean
|
||||
messages: MessageV2.WithParts[]
|
||||
}) {
|
||||
using _ = log.time("resolveTools")
|
||||
const tools: Record<string, AITool> = {}
|
||||
@@ -661,6 +665,7 @@ export namespace SessionPrompt {
|
||||
callID: options.toolCallId,
|
||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
|
||||
agent: input.agent.name,
|
||||
messages: input.messages,
|
||||
metadata: async (val: { title?: string; metadata?: any }) => {
|
||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||
if (match && match.state.status === "running") {
|
||||
@@ -1008,6 +1013,7 @@ export namespace SessionPrompt {
|
||||
agent: input.agent!,
|
||||
messageID: info.id,
|
||||
extra: { bypassCwdCheck: true, model },
|
||||
messages: [],
|
||||
metadata: async () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
@@ -1069,6 +1075,7 @@ export namespace SessionPrompt {
|
||||
agent: input.agent!,
|
||||
messageID: info.id,
|
||||
extra: { bypassCwdCheck: true },
|
||||
messages: [],
|
||||
metadata: async () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
@@ -1349,7 +1356,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
const session = await Session.get(input.sessionID)
|
||||
if (session.revert) {
|
||||
SessionRevert.cleanup(session)
|
||||
await SessionRevert.cleanup(session)
|
||||
}
|
||||
const agent = await Agent.get(input.agent)
|
||||
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
|
||||
|
||||
@@ -20,6 +20,62 @@ import { Agent } from "@/agent/agent"
|
||||
export namespace SessionSummary {
|
||||
const log = Log.create({ service: "session.summary" })
|
||||
|
||||
function unquoteGitPath(input: string) {
|
||||
if (!input.startsWith('"')) return input
|
||||
if (!input.endsWith('"')) return input
|
||||
const body = input.slice(1, -1)
|
||||
const bytes: number[] = []
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const char = body[i]!
|
||||
if (char !== "\\") {
|
||||
bytes.push(char.charCodeAt(0))
|
||||
continue
|
||||
}
|
||||
|
||||
const next = body[i + 1]
|
||||
if (!next) {
|
||||
bytes.push("\\".charCodeAt(0))
|
||||
continue
|
||||
}
|
||||
|
||||
if (next >= "0" && next <= "7") {
|
||||
const chunk = body.slice(i + 1, i + 4)
|
||||
const match = chunk.match(/^[0-7]{1,3}/)
|
||||
if (!match) {
|
||||
bytes.push(next.charCodeAt(0))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
bytes.push(parseInt(match[0], 8))
|
||||
i += match[0].length
|
||||
continue
|
||||
}
|
||||
|
||||
const escaped =
|
||||
next === "n"
|
||||
? "\n"
|
||||
: next === "r"
|
||||
? "\r"
|
||||
: next === "t"
|
||||
? "\t"
|
||||
: next === "b"
|
||||
? "\b"
|
||||
: next === "f"
|
||||
? "\f"
|
||||
: next === "v"
|
||||
? "\v"
|
||||
: next === "\\" || next === '"'
|
||||
? next
|
||||
: undefined
|
||||
|
||||
bytes.push((escaped ?? next).charCodeAt(0))
|
||||
i++
|
||||
}
|
||||
|
||||
return Buffer.from(bytes).toString()
|
||||
}
|
||||
|
||||
export const summarize = fn(
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
@@ -116,7 +172,18 @@ export namespace SessionSummary {
|
||||
messageID: Identifier.schema("message").optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
return Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
|
||||
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
|
||||
const next = diffs.map((item) => {
|
||||
const file = unquoteGitPath(item.file)
|
||||
if (file === item.file) return item
|
||||
return {
|
||||
...item,
|
||||
file,
|
||||
}
|
||||
})
|
||||
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
|
||||
if (changed) Storage.write(["session_diff", input.sessionID], next).catch(() => {})
|
||||
return next
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,37 +1,14 @@
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
import { Instance } from "../project/instance"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
|
||||
import PROMPT_BEAST from "./prompt/beast.txt"
|
||||
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
|
||||
import PROMPT_CODEX from "./prompt/codex_header.txt"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
const log = Log.create({ service: "system-prompt" })
|
||||
|
||||
async function resolveRelativeInstruction(instruction: string): Promise<string[]> {
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
|
||||
}
|
||||
if (!Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.warn(
|
||||
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
|
||||
)
|
||||
return []
|
||||
}
|
||||
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
|
||||
}
|
||||
|
||||
export namespace SystemPrompt {
|
||||
export function instructions() {
|
||||
@@ -72,81 +49,4 @@ export namespace SystemPrompt {
|
||||
].join("\n"),
|
||||
]
|
||||
}
|
||||
|
||||
const LOCAL_RULE_FILES = [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
const GLOBAL_RULE_FILES = [path.join(Global.Path.config, "AGENTS.md")]
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
||||
GLOBAL_RULE_FILES.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
|
||||
}
|
||||
|
||||
export async function custom() {
|
||||
const config = await Config.get()
|
||||
const paths = new Set<string>()
|
||||
|
||||
// Only scan local rule files when project discovery is enabled
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const localRuleFile of LOCAL_RULE_FILES) {
|
||||
const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree)
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((path) => paths.add(path))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const globalRuleFile of GLOBAL_RULE_FILES) {
|
||||
if (await Bun.file(globalRuleFile).exists()) {
|
||||
paths.add(globalRuleFile)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const urls: string[] = []
|
||||
if (config.instructions) {
|
||||
for (let instruction of config.instructions) {
|
||||
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
|
||||
urls.push(instruction)
|
||||
continue
|
||||
}
|
||||
if (instruction.startsWith("~/")) {
|
||||
instruction = path.join(os.homedir(), instruction.slice(2))
|
||||
}
|
||||
let matches: string[] = []
|
||||
if (path.isAbsolute(instruction)) {
|
||||
matches = await Array.fromAsync(
|
||||
new Bun.Glob(path.basename(instruction)).scan({
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
}),
|
||||
).catch(() => [])
|
||||
} else {
|
||||
matches = await resolveRelativeInstruction(instruction)
|
||||
}
|
||||
matches.forEach((path) => paths.add(path))
|
||||
}
|
||||
}
|
||||
|
||||
const foundFiles = Array.from(paths).map((p) =>
|
||||
Bun.file(p)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
.then((x) => "Instructions from: " + p + "\n" + x),
|
||||
)
|
||||
const foundUrls = urls.map((url) =>
|
||||
fetch(url, { signal: AbortSignal.timeout(5000) })
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.catch(() => "")
|
||||
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
|
||||
)
|
||||
return Promise.all([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ export namespace Snapshot {
|
||||
const git = gitdir()
|
||||
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
|
||||
const result =
|
||||
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
|
||||
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
@@ -196,7 +196,7 @@ export namespace Snapshot {
|
||||
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
|
||||
const git = gitdir()
|
||||
const result: FileDiff[] = []
|
||||
for await (const line of $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
|
||||
for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
|
||||
@@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Identifier } from "../id/id"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { InstructionPrompt } from "../session/instruction"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -59,6 +60,8 @@ export const ReadTool = Tool.define("read", {
|
||||
throw new Error(`File not found: ${filepath}`)
|
||||
}
|
||||
|
||||
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath)
|
||||
|
||||
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
||||
const isImage =
|
||||
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
|
||||
@@ -72,6 +75,7 @@ export const ReadTool = Tool.define("read", {
|
||||
metadata: {
|
||||
preview: msg,
|
||||
truncated: false,
|
||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
@@ -133,12 +137,17 @@ export const ReadTool = Tool.define("read", {
|
||||
LSP.touchFile(filepath, false)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
if (instructions.length > 0) {
|
||||
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
output,
|
||||
metadata: {
|
||||
preview,
|
||||
truncated,
|
||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ export namespace Tool {
|
||||
abort: AbortSignal
|
||||
callID?: string
|
||||
extra?: { [key: string]: any }
|
||||
messages: MessageV2.WithParts[]
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||
}
|
||||
|
||||
46
packages/opencode/test/session/instruction.test.ts
Normal file
46
packages/opencode/test/session/instruction.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { InstructionPrompt } from "../../src/session/instruction"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("InstructionPrompt.resolve", () => {
|
||||
test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions")
|
||||
await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const system = await InstructionPrompt.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"))
|
||||
expect(results).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const system = await InstructionPrompt.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
|
||||
|
||||
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "subdir", "nested", "file.ts"))
|
||||
expect(results.length).toBe(1)
|
||||
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ const baseCtx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const ctx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const ctx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const ctx = {
|
||||
callID: "test-call",
|
||||
agent: "test-agent",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const ctx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
@@ -330,3 +331,26 @@ root_type Monster;`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.read loaded instructions", () => {
|
||||
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "test.txt"), "test content")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx)
|
||||
expect(result.output).toContain("test content")
|
||||
expect(result.output).toContain("system-reminder")
|
||||
expect(result.output).toContain("Test Instructions")
|
||||
expect(result.metadata.loaded).toBeDefined()
|
||||
expect(result.metadata.loaded).toContain(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
&:focus:not(:disabled) {
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
|
||||
@@ -128,20 +128,56 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
const notifyRendered = () => {
|
||||
if (!local.onRendered) return
|
||||
const lineCount = () => {
|
||||
const text = local.file.contents
|
||||
const total = text.split("\n").length - (text.endsWith("\n") ? 1 : 0)
|
||||
return Math.max(1, total)
|
||||
}
|
||||
|
||||
const applySelection = (range: SelectedLineRange | null) => {
|
||||
const root = getRoot()
|
||||
if (!root) return false
|
||||
|
||||
const lines = lineCount()
|
||||
if (root.querySelectorAll("[data-line]").length < lines) return false
|
||||
|
||||
if (!range) {
|
||||
file().setSelectedLines(null)
|
||||
return true
|
||||
}
|
||||
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
|
||||
if (start < 1 || end > lines) {
|
||||
file().setSelectedLines(null)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
|
||||
file().setSelectedLines(null)
|
||||
return true
|
||||
}
|
||||
|
||||
const normalized = (() => {
|
||||
if (range.endSide != null) return { start: range.start, end: range.end }
|
||||
if (range.side !== "deletions") return range
|
||||
if (root.querySelector("[data-deletions]") != null) return range
|
||||
return { start: range.start, end: range.end }
|
||||
})()
|
||||
|
||||
file().setSelectedLines(normalized)
|
||||
return true
|
||||
}
|
||||
|
||||
const notifyRendered = () => {
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
renderToken++
|
||||
|
||||
const token = renderToken
|
||||
|
||||
const lines = (() => {
|
||||
const text = local.file.contents
|
||||
const total = text.split("\n").length - (text.endsWith("\n") ? 1 : 0)
|
||||
return Math.max(1, total)
|
||||
})()
|
||||
const lines = lineCount()
|
||||
|
||||
const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
|
||||
|
||||
@@ -152,6 +188,7 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
observer = undefined
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== renderToken) return
|
||||
applySelection(lastSelection)
|
||||
local.onRendered?.()
|
||||
})
|
||||
}
|
||||
@@ -241,7 +278,7 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
|
||||
const setSelectedLines = (range: SelectedLineRange | null) => {
|
||||
lastSelection = range
|
||||
file().setSelectedLines(range)
|
||||
applySelection(range)
|
||||
}
|
||||
|
||||
const scheduleSelectionUpdate = () => {
|
||||
|
||||
@@ -38,6 +38,77 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
fileDiffRef.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
const lineIndex = (split: boolean, element: HTMLElement) => {
|
||||
const raw = element.dataset.lineIndex
|
||||
if (!raw) return
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => parseInt(value, 10))
|
||||
.filter((value) => !Number.isNaN(value))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(split, node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
|
||||
}
|
||||
}
|
||||
|
||||
const fixSelection = (range: SelectedLineRange | null) => {
|
||||
if (!range) return range
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const diffs = root.querySelector("[data-diffs]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.type === "split"
|
||||
|
||||
const start = rowIndex(root, split, range.start, range.side)
|
||||
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
|
||||
if (start === undefined || end === undefined) {
|
||||
if (root.querySelector("[data-line], [data-alt-line]") == null) return
|
||||
return null
|
||||
}
|
||||
if (start <= end) return range
|
||||
|
||||
const side = range.endSide ?? range.side
|
||||
const swapped: SelectedLineRange = {
|
||||
start: range.end,
|
||||
end: range.start,
|
||||
}
|
||||
if (side) swapped.side = side
|
||||
if (range.endSide && range.side) swapped.endSide = range.side
|
||||
|
||||
return swapped
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
|
||||
const fixed = fixSelection(range)
|
||||
if (fixed === undefined) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => setSelectedLines(range, attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
diff.setSelectedLines(fixed)
|
||||
}
|
||||
|
||||
const findSide = (element: HTMLElement): "additions" | "deletions" => {
|
||||
const line = element.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
@@ -159,14 +230,14 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
fileDiffInstance.setSelectedLines(local.selectedLines ?? null)
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
|
||||
createEffect(() => {
|
||||
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
fileDiffInstance?.setSelectedLines(local.selectedLines ?? null)
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -115,9 +115,64 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
host.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
const notifyRendered = () => {
|
||||
if (!local.onRendered) return
|
||||
const lineIndex = (split: boolean, element: HTMLElement) => {
|
||||
const raw = element.dataset.lineIndex
|
||||
if (!raw) return
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => parseInt(value, 10))
|
||||
.filter((value) => !Number.isNaN(value))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: SelectionSide | undefined) => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(split, node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
|
||||
}
|
||||
}
|
||||
|
||||
const fixSelection = (range: SelectedLineRange | null) => {
|
||||
if (!range) return range
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const diffs = root.querySelector("[data-diffs]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.type === "split"
|
||||
|
||||
const start = rowIndex(root, split, range.start, range.side)
|
||||
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
if (start === undefined || end === undefined) {
|
||||
if (root.querySelector("[data-line], [data-alt-line]") == null) return
|
||||
return null
|
||||
}
|
||||
if (start <= end) return range
|
||||
|
||||
const side = range.endSide ?? range.side
|
||||
const swapped: SelectedLineRange = {
|
||||
start: range.end,
|
||||
end: range.start,
|
||||
}
|
||||
|
||||
if (side) swapped.side = side
|
||||
if (range.endSide && range.side) swapped.endSide = range.side
|
||||
|
||||
return swapped
|
||||
}
|
||||
|
||||
const notifyRendered = () => {
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
renderToken++
|
||||
@@ -134,6 +189,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
observer = undefined
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== renderToken) return
|
||||
setSelectedLines(lastSelection)
|
||||
local.onRendered?.()
|
||||
})
|
||||
}
|
||||
@@ -173,7 +229,8 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
const root = getRoot()
|
||||
if (typeof MutationObserver === "undefined") {
|
||||
if (!root || !isReady(root)) return
|
||||
local.onRendered()
|
||||
setSelectedLines(lastSelection)
|
||||
local.onRendered?.()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -214,41 +271,14 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
)
|
||||
if (code.length === 0) return
|
||||
|
||||
const lineIndex = (element: HTMLElement) => {
|
||||
const raw = element.dataset.lineIndex
|
||||
if (!raw) return
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => parseInt(value, 10))
|
||||
.filter((value) => !Number.isNaN(value))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
const rowIndex = (line: number, side: SelectionSide | undefined) => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
|
||||
}
|
||||
}
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = rowIndex(range.start, range.side)
|
||||
const start = rowIndex(root, split, range.start, range.side)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return rowIndex(range.end, range.endSide ?? range.side)
|
||||
return rowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
@@ -258,7 +288,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
for (const block of code) {
|
||||
for (const element of Array.from(block.children)) {
|
||||
if (!(element instanceof HTMLElement)) continue
|
||||
const idx = lineIndex(element)
|
||||
const idx = lineIndex(split, element)
|
||||
if (idx === undefined) continue
|
||||
if (idx > last) break
|
||||
if (idx < first) continue
|
||||
@@ -275,8 +305,15 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
const setSelectedLines = (range: SelectedLineRange | null) => {
|
||||
const active = current()
|
||||
if (!active) return
|
||||
lastSelection = range
|
||||
active.setSelectedLines(range)
|
||||
|
||||
const fixed = fixSelection(range)
|
||||
if (fixed === undefined) {
|
||||
lastSelection = range
|
||||
return
|
||||
}
|
||||
|
||||
lastSelection = fixed
|
||||
active.setSelectedLines(fixed)
|
||||
}
|
||||
|
||||
const updateSelection = () => {
|
||||
|
||||
@@ -3,19 +3,20 @@ import { ComponentProps, JSXElement, ParentProps, splitProps } from "solid-js"
|
||||
|
||||
export interface HoverCardProps extends ParentProps, Omit<ComponentProps<typeof Kobalte>, "children"> {
|
||||
trigger: JSXElement
|
||||
mount?: HTMLElement
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
export function HoverCard(props: HoverCardProps) {
|
||||
const [local, rest] = splitProps(props, ["trigger", "class", "classList", "children"])
|
||||
const [local, rest] = splitProps(props, ["trigger", "mount", "class", "classList", "children"])
|
||||
|
||||
return (
|
||||
<Kobalte gutter={4} {...rest}>
|
||||
<Kobalte.Trigger as="div" data-slot="hover-card-trigger">
|
||||
{local.trigger}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Portal mount={local.mount}>
|
||||
<Kobalte.Content
|
||||
data-component="hover-card-content"
|
||||
classList={{
|
||||
|
||||
@@ -90,8 +90,8 @@
|
||||
/* color: var(--icon-hover); */
|
||||
/* } */
|
||||
}
|
||||
&:focus:not(:disabled) {
|
||||
background-color: var(--surface-focus);
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-active);
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus:not(:disabled),
|
||||
&:focus-visible:not(:disabled),
|
||||
&:active:not(:disabled) {
|
||||
background-color: transparent;
|
||||
opacity: 0.7;
|
||||
@@ -91,7 +91,7 @@
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus:not(:disabled),
|
||||
&:focus-visible:not(:disabled),
|
||||
&:active:not(:disabled) {
|
||||
background-color: transparent;
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
transform: translateX(50%);
|
||||
cursor: col-resize;
|
||||
|
||||
&[data-edge="start"] {
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 3px;
|
||||
inset-block: 0;
|
||||
@@ -36,6 +42,12 @@
|
||||
transform: translateY(-50%);
|
||||
cursor: row-resize;
|
||||
|
||||
&[data-edge="end"] {
|
||||
inset-block-start: auto;
|
||||
inset-block-end: 0;
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
height: 3px;
|
||||
inset-inline: 0;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { splitProps, type JSX } from "solid-js"
|
||||
|
||||
export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "onResize"> {
|
||||
direction: "horizontal" | "vertical"
|
||||
edge?: "start" | "end"
|
||||
size: number
|
||||
min: number
|
||||
max: number
|
||||
@@ -13,6 +14,7 @@ export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElemen
|
||||
export function ResizeHandle(props: ResizeHandleProps) {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"direction",
|
||||
"edge",
|
||||
"size",
|
||||
"min",
|
||||
"max",
|
||||
@@ -25,6 +27,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end")
|
||||
const start = local.direction === "horizontal" ? e.clientX : e.clientY
|
||||
const startSize = local.size
|
||||
let current = startSize
|
||||
@@ -34,7 +37,14 @@ export function ResizeHandle(props: ResizeHandleProps) {
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY
|
||||
const delta = local.direction === "vertical" ? start - pos : pos - start
|
||||
const delta =
|
||||
local.direction === "vertical"
|
||||
? edge === "end"
|
||||
? pos - start
|
||||
: start - pos
|
||||
: edge === "start"
|
||||
? start - pos
|
||||
: pos - start
|
||||
current = startSize + delta
|
||||
const clamped = Math.min(local.max, Math.max(local.min, current))
|
||||
local.onResize(clamped)
|
||||
@@ -61,6 +71,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
|
||||
{...rest}
|
||||
data-component="resize-handle"
|
||||
data-direction={local.direction}
|
||||
data-edge={local.edge ?? (local.direction === "vertical" ? "start" : "end")}
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:not([data-expanded]):focus {
|
||||
&:not([data-expanded]):focus-visible {
|
||||
&[data-variant="secondary"] {
|
||||
background-color: var(--button-secondary-base);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
||||
@@ -118,6 +119,12 @@ function dataUrlFromValue(value: unknown): string | undefined {
|
||||
return `data:${mime};base64,${content}`
|
||||
}
|
||||
|
||||
function diffId(file: string): string | undefined {
|
||||
const sum = checksum(file)
|
||||
if (!sum) return
|
||||
return `session-review-diff-${sum}`
|
||||
}
|
||||
|
||||
type SessionReviewSelection = {
|
||||
file: string
|
||||
range: SelectedLineRange
|
||||
@@ -489,7 +496,12 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
|
||||
<Accordion.Item
|
||||
value={diff.file}
|
||||
id={diffId(diff.file)}
|
||||
data-file={diff.file}
|
||||
data-slot="session-review-accordion-item"
|
||||
>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-review-trigger-content">
|
||||
|
||||
@@ -212,6 +212,58 @@
|
||||
/* } */
|
||||
}
|
||||
|
||||
&[data-variant="pill"][data-orientation="horizontal"] {
|
||||
background-color: transparent;
|
||||
|
||||
[data-slot="tabs-list"] {
|
||||
height: auto;
|
||||
padding: 6px 0;
|
||||
gap: 4px;
|
||||
background-color: var(--background-base);
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="tabs-trigger-wrapper"] {
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: transparent;
|
||||
gap: 0;
|
||||
|
||||
/* text-13-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
[data-slot="tabs-trigger"] {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
&:has([data-selected]) {
|
||||
background-color: var(--surface-raised-base-active);
|
||||
color: var(--text-strong);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-orientation="vertical"] {
|
||||
flex-direction: row;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Show, splitProps, type JSX } from "solid-js"
|
||||
import type { ComponentProps, ParentProps, Component } from "solid-js"
|
||||
|
||||
export interface TabsProps extends ComponentProps<typeof Kobalte> {
|
||||
variant?: "normal" | "alt" | "settings"
|
||||
variant?: "normal" | "alt" | "pill" | "settings"
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
export interface TabsListProps extends ComponentProps<typeof Kobalte.List> {}
|
||||
|
||||
@@ -33,18 +33,18 @@ export const auraTheme = auraThemeJson as DesktopTheme
|
||||
|
||||
export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
|
||||
"oc-1": oc1Theme,
|
||||
tokyonight: tokyonightTheme,
|
||||
dracula: draculaTheme,
|
||||
monokai: monokaiTheme,
|
||||
solarized: solarizedTheme,
|
||||
nord: nordTheme,
|
||||
catppuccin: catppuccinTheme,
|
||||
aura: auraTheme,
|
||||
ayu: ayuTheme,
|
||||
carbonfox: carbonfoxTheme,
|
||||
catppuccin: catppuccinTheme,
|
||||
dracula: draculaTheme,
|
||||
gruvbox: gruvboxTheme,
|
||||
monokai: monokaiTheme,
|
||||
nightowl: nightowlTheme,
|
||||
nord: nordTheme,
|
||||
onedarkpro: oneDarkProTheme,
|
||||
shadesofpurple: shadesOfPurpleTheme,
|
||||
nightowl: nightowlTheme,
|
||||
solarized: solarizedTheme,
|
||||
tokyonight: tokyonightTheme,
|
||||
vesper: vesperTheme,
|
||||
carbonfox: carbonfoxTheme,
|
||||
gruvbox: gruvboxTheme,
|
||||
aura: auraTheme,
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 # Disable only .claude/skills
|
||||
|
||||
When opencode starts, it looks for rule files in this order:
|
||||
|
||||
1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`, or `CONTEXT.md`)
|
||||
1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`)
|
||||
2. **Global file** at `~/.config/opencode/AGENTS.md`
|
||||
3. **Claude Code file** at `~/.claude/CLAUDE.md` (unless disabled)
|
||||
|
||||
|
||||
@@ -4,6 +4,35 @@ import { $ } from "bun"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { buildNotes, getLatestRelease } from "./changelog"
|
||||
|
||||
const highlightsTemplate = `## Highlights
|
||||
|
||||
<!--
|
||||
Add highlights before publishing. Delete this section if no highlights.
|
||||
|
||||
- For multiple highlights, use multiple <highlight> tags
|
||||
- Highlights with the same source attribute get grouped together
|
||||
-->
|
||||
|
||||
<!--
|
||||
<highlight source="SourceName (TUI/Desktop/Web/Core)">
|
||||
<h2>Feature title goes here</h2>
|
||||
<p short="Short description used for Desktop Recap">
|
||||
Full description of the feature or change
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/uuid-for-video (you will want to drag & drop the video or picture)
|
||||
|
||||
<img
|
||||
width="1912"
|
||||
height="1164"
|
||||
alt="image"
|
||||
src="https://github.com/user-attachments/assets/uuid-for-image"
|
||||
/>
|
||||
</highlight>
|
||||
-->
|
||||
|
||||
`
|
||||
|
||||
let notes: string[] = []
|
||||
|
||||
console.log("=== publishing ===\n")
|
||||
@@ -11,6 +40,7 @@ console.log("=== publishing ===\n")
|
||||
if (!Script.preview) {
|
||||
const previous = await getLatestRelease()
|
||||
notes = await buildNotes(previous, "HEAD")
|
||||
notes.unshift(highlightsTemplate)
|
||||
}
|
||||
|
||||
const pkgjsons = await Array.fromAsync(
|
||||
|
||||
Reference in New Issue
Block a user