Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98ed41332c | ||
|
|
0f26e19d38 | ||
|
|
6c6e81884f |
@@ -211,4 +211,3 @@
|
|||||||
| 2026-01-23 | 6,096,236 (+329,896) | 2,096,235 (+66,748) | 8,192,471 (+396,644) |
|
| 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-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-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) |
|
|
||||||
|
|||||||
@@ -296,8 +296,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||||
"@opentui/core": "0.1.75",
|
"@opentui/core": "0.1.74",
|
||||||
"@opentui/solid": "0.1.75",
|
"@opentui/solid": "0.1.74",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -1227,21 +1227,21 @@
|
|||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
"@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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.74", "", { "os": "darwin", "cpu": "x64" }, "sha512-WAD8orsDV0ZdW/5GwjOOB4FY96772xbkz+rcV7WRzEFUVaqoBaC04IuqYzS9d5s+cjkbT5Cpj47hrVYkkVQKng=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.74", "", { "os": "linux", "cpu": "arm64" }, "sha512-lgmHzrzLy4e+rgBS+lhtsMLLgIMLbtLNMm6EzVPyYVDlLDGjM7+ulXMem7AtpaRrWrUUl4REiG9BoQUsCFDwYA=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.74", "", { "os": "linux", "cpu": "x64" }, "sha512-8Mn2WbdBQ29xCThuPZezjDhd1N3+fXwKkGvCBOdTI0le6h2A/vCNbfUVjwfr/EGZSRXxCG+Yapol34BAULGpOA=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.74", "", { "os": "win32", "cpu": "arm64" }, "sha512-dvYUXz03avnI6ZluyLp00HPmR0UT/IE/6QS97XBsgJlUTtpnbKkBtB5jD1NHwWkElaRj1Qv2QP36ngFoJqbl9g=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.74", "", { "os": "win32", "cpu": "x64" }, "sha512-3wfWXaAKOIlDQz6ZZIESf2M+YGZ7uFHijjTEM8w/STRlLw8Y6+QyGYi1myHSM4d6RSO+/s2EMDxvjDf899W9vQ=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-AkI3guNjnE+bLZQVfzm0z14UENOECv2QBqMo5Lzkvt8=",
|
"x86_64-linux": "sha256-olTZ+tKugAY3LxizsJMlbK3TW78HZUoM03PigvQLP4A=",
|
||||||
"aarch64-linux": "sha256-dBfdyVTqW+fBZKCxC9Ld+1m3cP+nIbS6UDo0tUfPOSk=",
|
"aarch64-linux": "sha256-xdKDeqMEnYM2+vGySfb8pbcYyo/xMmgxG/ZhPCKaZEg=",
|
||||||
"aarch64-darwin": "sha256-tOw31AMnHkW2cEDi+iqT3P93lU3SiMve26TEIqPz97k=",
|
"aarch64-darwin": "sha256-fihCTrHIiUG+py4vuqdr+YshqSKm2/B5onY50b97sPM=",
|
||||||
"x86_64-darwin": "sha256-wL/DmdZmxCmh+r4dsS1XGXuj8VPwR4pUqy5VIA76jl0="
|
"x86_64-darwin": "sha256-inlQQPNAOdkmKK6HQAMI2bG/ZFlfwmUQu9a6vm6Q0jQ="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -14,14 +14,13 @@ import { GlobalSyncProvider } from "@/context/global-sync"
|
|||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||||
import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server"
|
import { ServerProvider, useServer } from "@/context/server"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TerminalProvider } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider } from "@/context/notification"
|
||||||
import { ModelsProvider } from "@/context/models"
|
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
import { LanguageProvider, useLanguage } from "@/context/language"
|
||||||
@@ -86,19 +85,8 @@ function ServerKey(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppInterface(props: { defaultUrl?: string }) {
|
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 = () => {
|
const defaultServerUrl = () => {
|
||||||
if (props.defaultUrl) return props.defaultUrl
|
if (props.defaultUrl) return props.defaultUrl
|
||||||
if (stored) return stored
|
|
||||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
if (import.meta.env.DEV)
|
if (import.meta.env.DEV)
|
||||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||||
@@ -117,11 +105,9 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
|||||||
<PermissionProvider>
|
<PermissionProvider>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<ModelsProvider>
|
<CommandProvider>
|
||||||
<CommandProvider>
|
<Layout>{props.children}</Layout>
|
||||||
<Layout>{props.children}</Layout>
|
</CommandProvider>
|
||||||
</CommandProvider>
|
|
||||||
</ModelsProvider>
|
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
@@ -29,34 +29,35 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
iconUrl: props.project.icon?.override || "",
|
iconUrl: props.project.icon?.override || "",
|
||||||
startup: props.project.commands?.start ?? "",
|
startup: props.project.commands?.start ?? "",
|
||||||
saving: false,
|
saving: false,
|
||||||
dragOver: false,
|
|
||||||
iconHover: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [dragOver, setDragOver] = createSignal(false)
|
||||||
|
const [iconHover, setIconHover] = createSignal(false)
|
||||||
|
|
||||||
function handleFileSelect(file: File) {
|
function handleFileSelect(file: File) {
|
||||||
if (!file.type.startsWith("image/")) return
|
if (!file.type.startsWith("image/")) return
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
setStore("iconUrl", e.target?.result as string)
|
setStore("iconUrl", e.target?.result as string)
|
||||||
setStore("iconHover", false)
|
setIconHover(false)
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
function handleDrop(e: DragEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStore("dragOver", false)
|
setDragOver(false)
|
||||||
const file = e.dataTransfer?.files[0]
|
const file = e.dataTransfer?.files[0]
|
||||||
if (file) handleFileSelect(file)
|
if (file) handleFileSelect(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragOver(e: DragEvent) {
|
function handleDragOver(e: DragEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStore("dragOver", true)
|
setDragOver(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragLeave() {
|
function handleDragLeave() {
|
||||||
setStore("dragOver", false)
|
setDragOver(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInputChange(e: Event) {
|
function handleInputChange(e: Event) {
|
||||||
@@ -115,23 +116,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
|
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
|
||||||
<div class="flex gap-3 items-start">
|
<div class="flex gap-3 items-start">
|
||||||
<div
|
<div class="relative" onMouseEnter={() => setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
|
||||||
class="relative"
|
|
||||||
onMouseEnter={() => setStore("iconHover", true)}
|
|
||||||
onMouseLeave={() => setStore("iconHover", false)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
||||||
classList={{
|
classList={{
|
||||||
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
|
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
|
||||||
"border-border-base hover:border-border-strong": !store.dragOver,
|
"border-border-base hover:border-border-strong": !dragOver(),
|
||||||
"overflow-hidden": !!store.iconUrl,
|
"overflow-hidden": !!store.iconUrl,
|
||||||
}}
|
}}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (store.iconUrl && store.iconHover) {
|
if (store.iconUrl && iconHover()) {
|
||||||
clearIcon()
|
clearIcon()
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("icon-upload")?.click()
|
document.getElementById("icon-upload")?.click()
|
||||||
@@ -169,7 +166,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
"border-radius": "6px",
|
"border-radius": "6px",
|
||||||
"z-index": 10,
|
"z-index": 10,
|
||||||
"pointer-events": "none",
|
"pointer-events": "none",
|
||||||
opacity: store.iconHover && !store.iconUrl ? 1 : 0,
|
opacity: iconHover() && !store.iconUrl ? 1 : 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
"align-items": "center",
|
"align-items": "center",
|
||||||
"justify-content": "center",
|
"justify-content": "center",
|
||||||
@@ -188,7 +185,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
"border-radius": "6px",
|
"border-radius": "6px",
|
||||||
"z-index": 10,
|
"z-index": 10,
|
||||||
"pointer-events": "none",
|
"pointer-events": "none",
|
||||||
opacity: store.iconHover && store.iconUrl ? 1 : 0,
|
opacity: iconHover() && store.iconUrl ? 1 : 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
"align-items": "center",
|
"align-items": "center",
|
||||||
"justify-content": "center",
|
"justify-content": "center",
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { markReleaseNotesSeen } from "@/lib/release-notes"
|
||||||
|
|
||||||
|
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getText(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const text = value.trim()
|
||||||
|
return text.length > 0 ? text : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(value)) return
|
||||||
|
const parts = value.map((item) => (typeof item === "string" ? item.trim() : "")).filter((item) => item.length > 0)
|
||||||
|
if (parts.length === 0) return
|
||||||
|
return parts.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRemoteUrl(url: string): string {
|
||||||
|
if (url.startsWith("https://") || url.startsWith("http://")) return url
|
||||||
|
if (url.startsWith("/")) return `https://opencode.ai${url}`
|
||||||
|
return `https://opencode.ai/${url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMedia(value: unknown): ReleaseFeature["media"] | undefined {
|
||||||
|
if (!isRecord(value)) return
|
||||||
|
|
||||||
|
const type = getText(value.type)?.toLowerCase()
|
||||||
|
const src = getText(value.src)
|
||||||
|
if (!src) return
|
||||||
|
if (type !== "image" && type !== "video") return
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
src: normalizeRemoteUrl(src),
|
||||||
|
alt: getText(value.alt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFeature(value: unknown): ReleaseFeature | undefined {
|
||||||
|
if (!isRecord(value)) return
|
||||||
|
|
||||||
|
const title = getText(value.title) ?? getText(value.name) ?? getText(value.heading)
|
||||||
|
const description = getText(value.description) ?? getText(value.body) ?? getText(value.text)
|
||||||
|
|
||||||
|
if (!title) return
|
||||||
|
if (!description) return
|
||||||
|
|
||||||
|
const tag = getText(value.tag) ?? getText(value.label) ?? "New"
|
||||||
|
|
||||||
|
const media = (() => {
|
||||||
|
const parsed = parseMedia(value.media)
|
||||||
|
if (parsed) return parsed
|
||||||
|
|
||||||
|
const alt = getText(value.alt)
|
||||||
|
const image = getText(value.image)
|
||||||
|
if (image) return { type: "image" as const, src: normalizeRemoteUrl(image), alt }
|
||||||
|
|
||||||
|
const video = getText(value.video)
|
||||||
|
if (video) return { type: "video" as const, src: normalizeRemoteUrl(video), alt }
|
||||||
|
})()
|
||||||
|
|
||||||
|
return { title, description, tag, media }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChangelog(value: unknown): ReleaseNote | undefined {
|
||||||
|
const releases = (() => {
|
||||||
|
if (Array.isArray(value)) return value
|
||||||
|
if (!isRecord(value)) return
|
||||||
|
if (Array.isArray(value.releases)) return value.releases
|
||||||
|
if (Array.isArray(value.versions)) return value.versions
|
||||||
|
if (Array.isArray(value.changelog)) return value.changelog
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (!releases) {
|
||||||
|
if (!isRecord(value)) return
|
||||||
|
if (!Array.isArray(value.highlights)) return
|
||||||
|
const features = value.highlights.map(parseFeature).filter((item): item is ReleaseFeature => item !== undefined)
|
||||||
|
if (features.length === 0) return
|
||||||
|
return { version: CURRENT_RELEASE.version, features: features.slice(0, 3) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = (() => {
|
||||||
|
const head = releases[0]
|
||||||
|
if (!isRecord(head)) return
|
||||||
|
return getText(head.version) ?? getText(head.tag_name) ?? getText(head.tag) ?? getText(head.name)
|
||||||
|
})()
|
||||||
|
|
||||||
|
const features = releases
|
||||||
|
.flatMap((item) => {
|
||||||
|
if (!isRecord(item)) return []
|
||||||
|
const highlights = item.highlights
|
||||||
|
if (!Array.isArray(highlights)) return []
|
||||||
|
return highlights.map(parseFeature).filter((feature): feature is ReleaseFeature => feature !== undefined)
|
||||||
|
})
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
if (features.length === 0) return
|
||||||
|
return { version: version ?? CURRENT_RELEASE.version, features }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseFeature {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
tag?: string
|
||||||
|
media?: {
|
||||||
|
type: "image" | "video"
|
||||||
|
src: string
|
||||||
|
alt?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseNote {
|
||||||
|
version: string
|
||||||
|
features: ReleaseFeature[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current release notes - update this with each release
|
||||||
|
export const CURRENT_RELEASE: ReleaseNote = {
|
||||||
|
version: "1.0.0",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
title: "Cleaner tab experience",
|
||||||
|
description: "Chat is now fixed to the side of your tabs, and review is now available as a dedicated tab. ",
|
||||||
|
tag: "New",
|
||||||
|
media: {
|
||||||
|
type: "video",
|
||||||
|
src: "/release/release-example.mp4",
|
||||||
|
alt: "Cleaner tab experience",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Share with control",
|
||||||
|
description: "Keep your sessions private by default, or publish them to the web with a shareable URL.",
|
||||||
|
tag: "New",
|
||||||
|
media: {
|
||||||
|
type: "image",
|
||||||
|
src: "/release/release-share.png",
|
||||||
|
alt: "Share with control",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Improved attachment management",
|
||||||
|
description: "Upload and manage attachments more easily, to help build and maintain context.",
|
||||||
|
tag: "New",
|
||||||
|
media: {
|
||||||
|
type: "video",
|
||||||
|
src: "/release/release-example.mp4",
|
||||||
|
alt: "Improved attachment management",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogReleaseNotes(props: { release?: ReleaseNote }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const [note, setNote] = createSignal(props.release ?? CURRENT_RELEASE)
|
||||||
|
const [index, setIndex] = createSignal(0)
|
||||||
|
|
||||||
|
const feature = () => note().features[index()] ?? note().features[0] ?? CURRENT_RELEASE.features[0]!
|
||||||
|
const total = () => note().features.length
|
||||||
|
const isFirst = () => index() === 0
|
||||||
|
const isLast = () => index() === total() - 1
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (!isLast()) setIndex(index() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
if (!isFirst()) setIndex(index() - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
markReleaseNotesSeen()
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
let focusTrap: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowLeft" && !isFirst()) {
|
||||||
|
e.preventDefault()
|
||||||
|
setIndex(index() - 1)
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowRight" && !isLast()) {
|
||||||
|
e.preventDefault()
|
||||||
|
setIndex(index() + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
focusTrap?.focus()
|
||||||
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
fetch(CHANGELOG_URL, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
})
|
||||||
|
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
|
||||||
|
.then((json) => {
|
||||||
|
if (!json) return
|
||||||
|
const parsed = parseChangelog(json)
|
||||||
|
if (!parsed) return
|
||||||
|
setNote({
|
||||||
|
version: parsed.version,
|
||||||
|
features: parsed.features,
|
||||||
|
})
|
||||||
|
setIndex(0)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
|
||||||
|
onCleanup(() => controller.abort())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refocus the trap when index changes to ensure escape always works
|
||||||
|
createEffect(() => {
|
||||||
|
index() // track index
|
||||||
|
focusTrap?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog class="dialog-release-notes">
|
||||||
|
{/* Hidden element to capture initial focus and handle escape */}
|
||||||
|
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
|
||||||
|
{/* Left side - Text content */}
|
||||||
|
<div class="flex flex-col flex-1 min-w-0 p-8">
|
||||||
|
{/* Top section - feature content (fixed position from top) */}
|
||||||
|
<div class="flex flex-col gap-2 pt-22">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="text-16-medium text-text-strong">{feature().title}</h1>
|
||||||
|
{feature().tag && (
|
||||||
|
<span
|
||||||
|
class="text-12-medium text-text-weak px-1.5 py-0.5 bg-surface-base rounded-sm border border-border-weak-base"
|
||||||
|
style={{ "border-width": "0.5px" }}
|
||||||
|
>
|
||||||
|
{feature().tag}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p class="text-14-regular text-text-base">{feature().description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer to push buttons to bottom */}
|
||||||
|
<div class="flex-1" />
|
||||||
|
|
||||||
|
{/* Bottom section - buttons and indicators (fixed position) */}
|
||||||
|
<div class="flex flex-col gap-12">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{isLast() ? (
|
||||||
|
<Button variant="primary" size="large" onClick={handleClose}>
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total() > 1 && (
|
||||||
|
<div class="flex items-center gap-1.5 -my-2.5">
|
||||||
|
{note().features.map((_, i) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
||||||
|
classList={{
|
||||||
|
"w-8": i === index(),
|
||||||
|
"w-3": i !== index(),
|
||||||
|
}}
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
||||||
|
classList={{
|
||||||
|
"bg-icon-strong-base": i === index(),
|
||||||
|
"bg-icon-weak-base": i !== index(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Media content (edge to edge) */}
|
||||||
|
{feature().media && (
|
||||||
|
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||||
|
{feature().media!.type === "image" ? (
|
||||||
|
<img
|
||||||
|
src={feature().media!.src}
|
||||||
|
alt={feature().media!.alt ?? "Release preview"}
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video src={feature().media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -155,7 +155,7 @@ export function DialogSelectServer() {
|
|||||||
},
|
},
|
||||||
{ initialValue: null },
|
{ initialValue: null },
|
||||||
)
|
)
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
const isDesktop = platform.platform === "desktop"
|
||||||
|
|
||||||
const looksComplete = (value: string) => {
|
const looksComplete = (value: string) => {
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
@@ -505,7 +505,7 @@ export function DialogSelectServer() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<Show when={canDefault() && defaultUrl() !== i}>
|
<Show when={isDesktop && defaultUrl() !== i}>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={async () => {
|
onSelect={async () => {
|
||||||
await platform.setDefaultServerUrl?.(i)
|
await platform.setDefaultServerUrl?.(i)
|
||||||
@@ -517,7 +517,7 @@ export function DialogSelectServer() {
|
|||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={canDefault() && defaultUrl() === i}>
|
<Show when={isDesktop && defaultUrl() === i}>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={async () => {
|
onSelect={async () => {
|
||||||
await platform.setDefaultServerUrl?.(null)
|
await platform.setDefaultServerUrl?.(null)
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { SettingsGeneral } from "./settings-general"
|
import { SettingsGeneral } from "./settings-general"
|
||||||
import { SettingsKeybinds } from "./settings-keybinds"
|
import { SettingsKeybinds } from "./settings-keybinds"
|
||||||
|
import { SettingsPermissions } from "./settings-permissions"
|
||||||
import { SettingsProviders } from "./settings-providers"
|
import { SettingsProviders } from "./settings-providers"
|
||||||
import { SettingsModels } from "./settings-models"
|
import { SettingsModels } from "./settings-models"
|
||||||
|
import { SettingsAgents } from "./settings-agents"
|
||||||
|
import { SettingsCommands } from "./settings-commands"
|
||||||
|
import { SettingsMcp } from "./settings-mcp"
|
||||||
|
|
||||||
export const DialogSettings: Component = () => {
|
export const DialogSettings: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -41,10 +45,6 @@ export const DialogSettings: Component = () => {
|
|||||||
<Icon name="server" />
|
<Icon name="server" />
|
||||||
{language.t("settings.providers.title")}
|
{language.t("settings.providers.title")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="models">
|
|
||||||
<Icon name="server" />
|
|
||||||
{language.t("settings.models.title")}
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,9 +64,9 @@ export const DialogSettings: Component = () => {
|
|||||||
<Tabs.Content value="providers" class="no-scrollbar">
|
<Tabs.Content value="providers" class="no-scrollbar">
|
||||||
<SettingsProviders />
|
<SettingsProviders />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="models" class="no-scrollbar">
|
{/* <Tabs.Content value="models" class="no-scrollbar"> */}
|
||||||
<SettingsModels />
|
{/* <SettingsModels /> */}
|
||||||
</Tabs.Content>
|
{/* </Tabs.Content> */}
|
||||||
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
|
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
|
||||||
{/* <SettingsAgents /> */}
|
{/* <SettingsAgents /> */}
|
||||||
{/* </Tabs.Content> */}
|
{/* </Tabs.Content> */}
|
||||||
|
|||||||
@@ -1,207 +1,111 @@
|
|||||||
import { useFile } from "@/context/file"
|
import { useLocal, type LocalFile } from "@/context/local"
|
||||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import {
|
import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js"
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
For,
|
|
||||||
Match,
|
|
||||||
splitProps,
|
|
||||||
Switch,
|
|
||||||
untrack,
|
|
||||||
type ComponentProps,
|
|
||||||
type ParentProps,
|
|
||||||
} from "solid-js"
|
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
|
||||||
|
|
||||||
export default function FileTree(props: {
|
export default function FileTree(props: {
|
||||||
path: string
|
path: string
|
||||||
class?: string
|
class?: string
|
||||||
nodeClass?: string
|
nodeClass?: string
|
||||||
level?: number
|
level?: number
|
||||||
allowed?: readonly string[]
|
onFileClick?: (file: LocalFile) => void
|
||||||
modified?: readonly string[]
|
|
||||||
draggable?: boolean
|
|
||||||
tooltip?: boolean
|
|
||||||
onFileClick?: (file: FileNode) => void
|
|
||||||
}) {
|
}) {
|
||||||
const file = useFile()
|
const local = useLocal()
|
||||||
const level = props.level ?? 0
|
const level = props.level ?? 0
|
||||||
const draggable = () => props.draggable ?? true
|
|
||||||
const tooltip = () => props.tooltip ?? true
|
|
||||||
|
|
||||||
const filter = createMemo(() => {
|
const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => (
|
||||||
const allowed = props.allowed
|
<Dynamic
|
||||||
if (!allowed) return
|
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 files = new Set(allowed)
|
// Create custom drag image without margins
|
||||||
const dirs = new Set<string>()
|
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"
|
||||||
|
|
||||||
for (const item of allowed) {
|
// Copy only the icon and text content without padding
|
||||||
const parts = item.split("/")
|
const icon = e.currentTarget.querySelector("svg")
|
||||||
const parents = parts.slice(0, -1)
|
const text = e.currentTarget.querySelector("span")
|
||||||
for (const [idx] of parents.entries()) {
|
if (icon && text) {
|
||||||
const dir = parents.slice(0, idx + 1).join("/")
|
dragImage.innerHTML = icon.outerHTML + text.outerHTML
|
||||||
if (dir) dirs.add(dir)
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { files, dirs }
|
document.body.appendChild(dragImage)
|
||||||
})
|
evt.dataTransfer!.setDragImage(dragImage, 0, 12)
|
||||||
|
setTimeout(() => document.body.removeChild(dragImage), 0)
|
||||||
const marks = createMemo(() => {
|
}}
|
||||||
const modified = props.modified
|
{...p}
|
||||||
if (!modified || modified.length === 0) return
|
>
|
||||||
return new Set(modified)
|
{p.children}
|
||||||
})
|
<span
|
||||||
|
|
||||||
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={{
|
classList={{
|
||||||
"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,
|
"text-xs whitespace-nowrap truncate": true,
|
||||||
...(local.classList ?? {}),
|
"text-text-muted/40": p.node.ignored,
|
||||||
[local.class ?? ""]: !!local.class,
|
"text-text-muted/80": !p.node.ignored,
|
||||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
// "!text-text": local.file.active()?.path === p.node.path,
|
||||||
|
// "!text-primary": local.file.changed(p.node.path),
|
||||||
}}
|
}}
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
{local.children}
|
{p.node.name}
|
||||||
<span
|
</span>
|
||||||
classList={{
|
{/* <Show when={local.file.changed(p.node.path)}> */}
|
||||||
"flex-1 min-w-0 text-12-regular whitespace-nowrap truncate": true,
|
{/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
|
||||||
"text-text-weaker": local.node.ignored,
|
{/* </Show> */}
|
||||||
"text-text-weak": !local.node.ignored,
|
</Dynamic>
|
||||||
}}
|
)
|
||||||
>
|
|
||||||
{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 (
|
return (
|
||||||
<div class={`flex flex-col ${props.class ?? ""}`}>
|
<div class={`flex flex-col ${props.class}`}>
|
||||||
<For each={nodes()}>
|
<For each={local.file.children(props.path)}>
|
||||||
{(node) => {
|
{(node) => (
|
||||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
<Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right">
|
||||||
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>
|
<Switch>
|
||||||
<Match when={node.type === "directory"}>
|
<Match when={node.type === "directory"}>
|
||||||
<Collapsible
|
<Collapsible
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
forceMount={false}
|
forceMount={false}
|
||||||
open={expanded()}
|
// open={local.file.node(node.path)?.expanded}
|
||||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<Wrapper>
|
<Node node={node}>
|
||||||
<Node node={node}>
|
<Collapsible.Arrow class="text-text-muted/60 ml-1" />
|
||||||
<Collapsible.Arrow class="text-icon-weak ml-1" />
|
<FileIcon
|
||||||
<FileIcon node={node} expanded={expanded()} class="text-icon-weak -ml-1 size-4" />
|
node={node}
|
||||||
</Node>
|
// expanded={local.file.node(node.path).expanded}
|
||||||
</Wrapper>
|
class="text-text-muted/60 -ml-1"
|
||||||
|
/>
|
||||||
|
</Node>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
<FileTree
|
<FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} />
|
||||||
path={node.path}
|
|
||||||
level={level + 1}
|
|
||||||
allowed={props.allowed}
|
|
||||||
modified={props.modified}
|
|
||||||
draggable={props.draggable}
|
|
||||||
tooltip={props.tooltip}
|
|
||||||
onFileClick={props.onFileClick}
|
|
||||||
/>
|
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={node.type === "file"}>
|
<Match when={node.type === "file"}>
|
||||||
<Wrapper>
|
<Node node={node} as="button" onClick={() => props.onFileClick?.(node)}>
|
||||||
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
|
<div class="w-4 shrink-0" />
|
||||||
<div class="w-4 shrink-0" />
|
<FileIcon node={node} class="text-primary" />
|
||||||
<FileIcon node={node} class="text-icon-weak size-4" />
|
</Node>
|
||||||
</Node>
|
|
||||||
</Wrapper>
|
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
</Tooltip>
|
||||||
}}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { onMount } from "solid-js"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { DialogReleaseNotes } from "./dialog-release-notes"
|
||||||
|
import { shouldShowReleaseNotes, markReleaseNotesSeen } from "@/lib/release-notes"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that handles showing release notes modal on app startup.
|
||||||
|
* Shows the modal if:
|
||||||
|
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true in lib/release-notes.ts
|
||||||
|
* - OR the user hasn't seen the current version's release notes yet
|
||||||
|
*
|
||||||
|
* To disable the dev mode behavior, set DEV_ALWAYS_SHOW_RELEASE_NOTES to false
|
||||||
|
* in packages/app/src/lib/release-notes.ts
|
||||||
|
*/
|
||||||
|
export function ReleaseNotesHandler() {
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Small delay to ensure app is fully loaded before showing modal
|
||||||
|
setTimeout(() => {
|
||||||
|
if (shouldShowReleaseNotes()) {
|
||||||
|
dialog.show(
|
||||||
|
() => <DialogReleaseNotes />,
|
||||||
|
() => markReleaseNotesSeen(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -131,7 +131,7 @@ export function SessionHeader() {
|
|||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
<button
|
<button
|
||||||
type="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-visible: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:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
||||||
onClick={() => command.trigger("file.open")}
|
onClick={() => command.trigger("file.open")}
|
||||||
aria-label={language.t("session.header.searchFiles")}
|
aria-label={language.t("session.header.searchFiles")}
|
||||||
>
|
>
|
||||||
@@ -280,32 +280,6 @@ export function SessionHeader() {
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</div>
|
</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">
|
<div class="hidden md:block shrink-0">
|
||||||
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { Show } from "solid-js"
|
import { createSignal, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
@@ -13,13 +12,11 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sortable = createSortable(props.terminal.id)
|
const sortable = createSortable(props.terminal.id)
|
||||||
const [store, setStore] = createStore({
|
const [editing, setEditing] = createSignal(false)
|
||||||
editing: false,
|
const [title, setTitle] = createSignal(props.terminal.title)
|
||||||
title: props.terminal.title,
|
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||||
menuOpen: false,
|
const [menuPosition, setMenuPosition] = createSignal({ x: 0, y: 0 })
|
||||||
menuPosition: { x: 0, y: 0 },
|
const [blurEnabled, setBlurEnabled] = createSignal(false)
|
||||||
blurEnabled: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDefaultTitle = () => {
|
const isDefaultTitle = () => {
|
||||||
const number = props.terminal.titleNumber
|
const number = props.terminal.titleNumber
|
||||||
@@ -50,7 +47,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const focus = () => {
|
const focus = () => {
|
||||||
if (store.editing) return
|
if (editing()) return
|
||||||
|
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
document.activeElement.blur()
|
document.activeElement.blur()
|
||||||
@@ -74,26 +71,26 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("blurEnabled", false)
|
setBlurEnabled(false)
|
||||||
setStore("title", props.terminal.title)
|
setTitle(props.terminal.title)
|
||||||
setStore("editing", true)
|
setEditing(true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
|
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
|
||||||
if (!input) return
|
if (!input) return
|
||||||
input.focus()
|
input.focus()
|
||||||
input.select()
|
input.select()
|
||||||
setTimeout(() => setStore("blurEnabled", true), 100)
|
setTimeout(() => setBlurEnabled(true), 100)
|
||||||
}, 10)
|
}, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (!store.blurEnabled) return
|
if (!blurEnabled()) return
|
||||||
|
|
||||||
const value = store.title.trim()
|
const value = title().trim()
|
||||||
if (value && value !== props.terminal.title) {
|
if (value && value !== props.terminal.title) {
|
||||||
terminal.update({ id: props.terminal.id, title: value })
|
terminal.update({ id: props.terminal.id, title: value })
|
||||||
}
|
}
|
||||||
setStore("editing", false)
|
setEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const keydown = (e: KeyboardEvent) => {
|
const keydown = (e: KeyboardEvent) => {
|
||||||
@@ -104,14 +101,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
}
|
}
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStore("editing", false)
|
setEditing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const menu = (e: MouseEvent) => {
|
const menu = (e: MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStore("menuPosition", { x: e.clientX, y: e.clientY })
|
setMenuPosition({ x: e.clientX, y: e.clientY })
|
||||||
setStore("menuOpen", true)
|
setMenuOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,17 +143,17 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span onDblClick={edit} style={{ visibility: store.editing ? "hidden" : "visible" }}>
|
<span onDblClick={edit} style={{ visibility: editing() ? "hidden" : "visible" }}>
|
||||||
{label()}
|
{label()}
|
||||||
</span>
|
</span>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Show when={store.editing}>
|
<Show when={editing()}>
|
||||||
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
|
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
|
||||||
<input
|
<input
|
||||||
id={`terminal-title-input-${props.terminal.id}`}
|
id={`terminal-title-input-${props.terminal.id}`}
|
||||||
type="text"
|
type="text"
|
||||||
value={store.title}
|
value={title()}
|
||||||
onInput={(e) => setStore("title", e.currentTarget.value)}
|
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||||
onBlur={save}
|
onBlur={save}
|
||||||
onKeyDown={keydown}
|
onKeyDown={keydown}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
@@ -164,13 +161,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
|
<DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
left: `${store.menuPosition.x}px`,
|
left: `${menuPosition().x}px`,
|
||||||
top: `${store.menuPosition.y}px`,
|
top: `${menuPosition().y}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item onSelect={edit}>
|
<DropdownMenu.Item onSelect={edit}>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
|
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -112,26 +111,24 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [active, setActive] = createSignal<string | null>(null)
|
||||||
active: null as string | null,
|
const [filter, setFilter] = createSignal("")
|
||||||
filter: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
if (!store.active) return
|
if (!active()) return
|
||||||
setStore("active", null)
|
setActive(null)
|
||||||
command.keybinds(true)
|
command.keybinds(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = (id: string) => {
|
const start = (id: string) => {
|
||||||
if (store.active === id) {
|
if (active() === id) {
|
||||||
stop()
|
stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (store.active) stop()
|
if (active()) stop()
|
||||||
|
|
||||||
setStore("active", id)
|
setActive(id)
|
||||||
command.keybinds(false)
|
command.keybinds(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +203,7 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const query = store.filter.toLowerCase().trim()
|
const query = filter().toLowerCase().trim()
|
||||||
if (!query) return grouped()
|
if (!query) return grouped()
|
||||||
|
|
||||||
const map = list()
|
const map = list()
|
||||||
@@ -288,7 +285,7 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const handle = (event: KeyboardEvent) => {
|
const handle = (event: KeyboardEvent) => {
|
||||||
const id = store.active
|
const id = active()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -348,7 +345,7 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (store.active) command.keybinds(true)
|
if (active()) command.keybinds(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -373,8 +370,8 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
<TextField
|
<TextField
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
type="text"
|
type="text"
|
||||||
value={store.filter}
|
value={filter()}
|
||||||
onChange={(v) => setStore("filter", v)}
|
onChange={setFilter}
|
||||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
@@ -382,8 +379,8 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
<Show when={store.filter}>
|
<Show when={filter()}>
|
||||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
<IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -405,13 +402,13 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
classList={{
|
classList={{
|
||||||
"h-8 px-3 rounded-md text-12-regular": true,
|
"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":
|
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
||||||
store.active !== id,
|
active() !== id,
|
||||||
"border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id,
|
"border border-border-weak-base bg-surface-inset-base text-text-weak": active() === id,
|
||||||
}}
|
}}
|
||||||
onClick={() => start(id)}
|
onClick={() => start(id)}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={store.active === id}
|
when={active() === id}
|
||||||
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||||
>
|
>
|
||||||
{language.t("settings.shortcuts.pressKeys")}
|
{language.t("settings.shortcuts.pressKeys")}
|
||||||
@@ -426,11 +423,11 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={store.filter && !hasResults()}>
|
<Show when={filter() && !hasResults()}>
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
<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>
|
<span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span>
|
||||||
<Show when={store.filter}>
|
<Show when={filter()}>
|
||||||
<span class="text-14-regular text-text-strong mt-1">"{store.filter}"</span>
|
<span class="text-14-regular text-text-strong mt-1">"{filter()}"</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,135 +1,14 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { Component } from "solid-js"
|
||||||
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 { 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 = () => {
|
export const SettingsModels: Component = () => {
|
||||||
const language = useLanguage()
|
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 (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
|
<div class="flex flex-col h-full overflow-y-auto">
|
||||||
<div
|
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||||
class="sticky top-0 z-10"
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
||||||
style={{
|
<p class="text-14-regular text-text-weak">{language.t("settings.models.description")}</p>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,10 +39,9 @@ export function StatusPopover() {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [loading, setLoading] = createSignal<string | null>(null)
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<string, ServerStatus | undefined>,
|
status: {} as Record<string, ServerStatus | undefined>,
|
||||||
loading: null as string | null,
|
|
||||||
defaultServerUrl: undefined as string | undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const servers = createMemo(() => {
|
const servers = createMemo(() => {
|
||||||
@@ -98,8 +97,8 @@ export function StatusPopover() {
|
|||||||
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
|
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
|
||||||
|
|
||||||
const toggleMcp = async (name: string) => {
|
const toggleMcp = async (name: string) => {
|
||||||
if (store.loading) return
|
if (loading()) return
|
||||||
setStore("loading", name)
|
setLoading(name)
|
||||||
const status = sync.data.mcp[name]
|
const status = sync.data.mcp[name]
|
||||||
if (status?.status === "connected") {
|
if (status?.status === "connected") {
|
||||||
await sdk.client.mcp.disconnect({ name })
|
await sdk.client.mcp.disconnect({ name })
|
||||||
@@ -108,7 +107,7 @@ export function StatusPopover() {
|
|||||||
}
|
}
|
||||||
const result = await sdk.client.mcp.status()
|
const result = await sdk.client.mcp.status()
|
||||||
if (result.data) sync.set("mcp", result.data)
|
if (result.data) sync.set("mcp", result.data)
|
||||||
setStore("loading", null)
|
setLoading(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||||
@@ -124,21 +123,15 @@ export function StatusPopover() {
|
|||||||
|
|
||||||
const serverCount = createMemo(() => sortedServers().length)
|
const serverCount = createMemo(() => sortedServers().length)
|
||||||
|
|
||||||
const refreshDefaultServerUrl = () => {
|
const [defaultServerUrl, setDefaultServerUrl] = createSignal<string | undefined>()
|
||||||
const result = platform.getDefaultServerUrl?.()
|
|
||||||
if (!result) {
|
|
||||||
setStore("defaultServerUrl", undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("defaultServerUrl", normalizeServerUrl(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
refreshDefaultServerUrl()
|
const result = platform.getDefaultServerUrl?.()
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result) setDefaultServerUrl(normalizeServerUrl(result))
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -219,7 +212,7 @@ export function StatusPopover() {
|
|||||||
<For each={sortedServers()}>
|
<For each={sortedServers()}>
|
||||||
{(url) => {
|
{(url) => {
|
||||||
const isActive = () => url === server.url
|
const isActive = () => url === server.url
|
||||||
const isDefault = () => url === store.defaultServerUrl
|
const isDefault = () => url === defaultServerUrl()
|
||||||
const status = () => store.status[url]
|
const status = () => store.status[url]
|
||||||
const isBlocked = () => status()?.healthy === false
|
const isBlocked = () => status()?.healthy === false
|
||||||
const [truncated, setTruncated] = createSignal(false)
|
const [truncated, setTruncated] = createSignal(false)
|
||||||
@@ -301,7 +294,7 @@ export function StatusPopover() {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||||
onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)}
|
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||||
>
|
>
|
||||||
{language.t("status.popover.action.manageServers")}
|
{language.t("status.popover.action.manageServers")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -328,7 +321,7 @@ export function StatusPopover() {
|
|||||||
type="button"
|
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"
|
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)}
|
onClick={() => toggleMcp(item.name)}
|
||||||
disabled={store.loading === item.name}
|
disabled={loading() === item.name}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
@@ -344,7 +337,7 @@ export function StatusPopover() {
|
|||||||
<div onClick={(event) => event.stopPropagation()}>
|
<div onClick={(event) => event.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={store.loading === item.name}
|
disabled={loading() === item.name}
|
||||||
onChange={() => toggleMcp(item.name)}
|
onChange={() => toggleMcp(item.name)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -24,15 +24,6 @@ function normalizeKey(key: string) {
|
|||||||
return key.toLowerCase()
|
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 type KeybindConfig = string
|
||||||
|
|
||||||
export interface Keybind {
|
export interface Keybind {
|
||||||
@@ -165,10 +156,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [store, setStore] = createStore({
|
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||||
registrations: [] as Accessor<CommandOption[]>[],
|
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||||
suspendCount: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||||
Persist.global("command.catalog.v1"),
|
Persist.global("command.catalog.v1"),
|
||||||
@@ -186,7 +175,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const all: CommandOption[] = []
|
const all: CommandOption[] = []
|
||||||
|
|
||||||
for (const reg of store.registrations) {
|
for (const reg of registrations()) {
|
||||||
for (const opt of reg()) {
|
for (const opt of reg()) {
|
||||||
if (seen.has(opt.id)) continue
|
if (seen.has(opt.id)) continue
|
||||||
seen.add(opt.id)
|
seen.add(opt.id)
|
||||||
@@ -232,31 +221,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const suspended = () => store.suspendCount > 0
|
const suspended = () => 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") => {
|
const run = (id: string, source?: "palette" | "keybind" | "slash") => {
|
||||||
for (const option of options()) {
|
for (const option of options()) {
|
||||||
@@ -274,18 +239,24 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (suspended() || dialog.active) return
|
if (suspended() || dialog.active) return
|
||||||
|
|
||||||
const sig = signatureFromEvent(event)
|
const paletteKeybinds = parseKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
|
||||||
|
if (matchKeybind(paletteKeybinds, event)) {
|
||||||
if (palette().has(sig)) {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
showPalette()
|
showPalette()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const option = keymap().get(sig)
|
for (const option of options()) {
|
||||||
if (!option) return
|
if (option.disabled) continue
|
||||||
event.preventDefault()
|
if (!option.keybind) continue
|
||||||
option.onSelect?.("keybind")
|
|
||||||
|
const keybinds = parseKeybind(option.keybind)
|
||||||
|
if (matchKeybind(keybinds, event)) {
|
||||||
|
event.preventDefault()
|
||||||
|
option.onSelect?.("keybind")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -299,9 +270,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
return {
|
return {
|
||||||
register(cb: () => CommandOption[]) {
|
register(cb: () => CommandOption[]) {
|
||||||
const results = createMemo(cb)
|
const results = createMemo(cb)
|
||||||
setStore("registrations", (arr) => [results, ...arr])
|
setRegistrations((arr) => [results, ...arr])
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
setStore("registrations", (arr) => arr.filter((x) => x !== results))
|
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
||||||
@@ -323,7 +294,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
},
|
},
|
||||||
show: showPalette,
|
show: showPalette,
|
||||||
keybinds(enabled: boolean) {
|
keybinds(enabled: boolean) {
|
||||||
setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
|
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||||
},
|
},
|
||||||
suspended,
|
suspended,
|
||||||
get catalog() {
|
get catalog() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
@@ -37,16 +37,8 @@ function createCommentSession(dir: string, id: string | undefined) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const [focus, setFocus] = createSignal<CommentFocus | null>(null)
|
||||||
focus: null as CommentFocus | null,
|
const [active, setActive] = createSignal<CommentFocus | null>(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] ?? []
|
const list = (file: string) => store.comments[file] ?? []
|
||||||
|
|
||||||
@@ -82,10 +74,10 @@ function createCommentSession(dir: string, id: string | undefined) {
|
|||||||
all,
|
all,
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
focus: createMemo(() => state.focus),
|
focus: createMemo(() => focus()),
|
||||||
setFocus,
|
setFocus,
|
||||||
clearFocus: () => setFocus(null),
|
clearFocus: () => setFocus(null),
|
||||||
active: createMemo(() => state.active),
|
active: createMemo(() => active()),
|
||||||
setActive,
|
setActive,
|
||||||
clearActive: () => setActive(null),
|
clearActive: () => setActive(null),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2"
|
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
@@ -39,14 +39,6 @@ export type FileState = {
|
|||||||
content?: FileContent
|
content?: FileContent
|
||||||
}
|
}
|
||||||
|
|
||||||
type DirectoryState = {
|
|
||||||
expanded: boolean
|
|
||||||
loaded?: boolean
|
|
||||||
loading?: boolean
|
|
||||||
error?: string
|
|
||||||
children?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripFileProtocol(input: string) {
|
function stripFileProtocol(input: string) {
|
||||||
if (!input.startsWith("file://")) return input
|
if (!input.startsWith("file://")) return input
|
||||||
return input.slice("file://".length)
|
return input.slice("file://".length)
|
||||||
@@ -65,62 +57,6 @@ function stripQueryAndHash(input: string) {
|
|||||||
return input
|
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 {
|
export function selectionFromLines(range: SelectedLineRange): FileSelection {
|
||||||
const startLine = Math.min(range.start, range.end)
|
const startLine = Math.min(range.start, range.end)
|
||||||
const endLine = Math.max(range.start, range.end)
|
const endLine = Math.max(range.start, range.end)
|
||||||
@@ -261,7 +197,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
const root = directory()
|
const root = directory()
|
||||||
const prefix = root.endsWith("/") ? root : root + "/"
|
const prefix = root.endsWith("/") ? root : root + "/"
|
||||||
|
|
||||||
let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
|
let path = stripQueryAndHash(stripFileProtocol(input))
|
||||||
|
|
||||||
if (path.startsWith(prefix)) {
|
if (path.startsWith(prefix)) {
|
||||||
path = path.slice(prefix.length)
|
path = path.slice(prefix.length)
|
||||||
@@ -293,7 +229,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const treeInflight = new Map<string, Promise<void>>()
|
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
file: Record<string, FileState>
|
file: Record<string, FileState>
|
||||||
@@ -301,21 +236,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
file: {},
|
file: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const [tree, setTree] = createStore<{
|
|
||||||
node: Record<string, FileNode>
|
|
||||||
dir: Record<string, DirectoryState>
|
|
||||||
}>({
|
|
||||||
node: {},
|
|
||||||
dir: { "": { expanded: true } },
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
scope()
|
scope()
|
||||||
inflight.clear()
|
inflight.clear()
|
||||||
treeInflight.clear()
|
|
||||||
setStore("file", {})
|
setStore("file", {})
|
||||||
setTree("node", {})
|
|
||||||
setTree("dir", { "": { expanded: true } })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewCache = new Map<string, ViewCacheEntry>()
|
const viewCache = new Map<string, ViewCacheEntry>()
|
||||||
@@ -427,156 +351,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
return promise
|
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 stop = sdk.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event.type !== "file.watcher.updated") return
|
if (event.type !== "file.watcher.updated") return
|
||||||
const path = normalize(event.properties.file)
|
const path = normalize(event.properties.file)
|
||||||
if (!path) return
|
if (!path) return
|
||||||
if (path.startsWith(".git/")) return
|
if (path.startsWith(".git/")) return
|
||||||
|
if (!store.file[path]) return
|
||||||
if (store.file[path]) {
|
load(path, { force: true })
|
||||||
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)]
|
const get = (input: string) => store.file[normalize(input)]
|
||||||
@@ -610,21 +392,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
normalize,
|
normalize,
|
||||||
tab,
|
tab,
|
||||||
pathFromTab,
|
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,
|
get,
|
||||||
load,
|
load,
|
||||||
scrollTop,
|
scrollTop,
|
||||||
|
|||||||
@@ -82,10 +82,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
diffStyle: "split" as ReviewDiffStyle,
|
diffStyle: "split" as ReviewDiffStyle,
|
||||||
panelOpened: true,
|
panelOpened: true,
|
||||||
},
|
},
|
||||||
fileTree: {
|
|
||||||
opened: false,
|
|
||||||
width: 260,
|
|
||||||
},
|
|
||||||
session: {
|
session: {
|
||||||
width: 600,
|
width: 600,
|
||||||
},
|
},
|
||||||
@@ -222,7 +218,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||||
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
|
const [childStore] = globalSync.child(project.worktree)
|
||||||
const projectID = childStore.project
|
const projectID = childStore.project
|
||||||
const metadata = projectID
|
const metadata = projectID
|
||||||
? globalSync.data.project.find((x) => x.id === projectID)
|
? globalSync.data.project.find((x) => x.id === projectID)
|
||||||
@@ -453,38 +449,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
setStore("review", "diffStyle", diffStyle)
|
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: {
|
session: {
|
||||||
width: createMemo(() => store.session?.width ?? 600),
|
width: createMemo(() => store.session?.width ?? 600),
|
||||||
resize(width: number) {
|
resize(width: number) {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
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 type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useModels } from "@/context/models"
|
import { DateTime } from "luxon"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
@@ -110,7 +112,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
const model = (() => {
|
const model = (() => {
|
||||||
const models = useModels()
|
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 [ephemeral, setEphemeral] = createStore<{
|
const [ephemeral, setEphemeral] = createStore<{
|
||||||
model: Record<string, ModelKey | undefined>
|
model: Record<string, ModelKey | undefined>
|
||||||
@@ -118,6 +131,57 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
model: {},
|
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>(() => {
|
const fallbackModel = createMemo<ModelKey | undefined>(() => {
|
||||||
if (sync.data.config.model) {
|
if (sync.data.config.model) {
|
||||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||||
@@ -129,7 +193,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of models.recent.list()) {
|
for (const item of store.recent) {
|
||||||
if (isModelValid(item)) {
|
if (isModelValid(item)) {
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
@@ -161,10 +225,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
fallbackModel,
|
fallbackModel,
|
||||||
)
|
)
|
||||||
if (!key) return undefined
|
if (!key) return undefined
|
||||||
return models.find(key)
|
return find(key)
|
||||||
})
|
})
|
||||||
|
|
||||||
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
|
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
||||||
|
|
||||||
const cycle = (direction: 1 | -1) => {
|
const cycle = (direction: 1 | -1) => {
|
||||||
const recentList = recent()
|
const recentList = recent()
|
||||||
@@ -189,32 +253,54 @@ 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 {
|
return {
|
||||||
ready: models.ready,
|
ready: modelReady,
|
||||||
current,
|
current,
|
||||||
recent,
|
recent,
|
||||||
list: models.list,
|
list,
|
||||||
cycle,
|
cycle,
|
||||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
const currentAgent = agent.current()
|
const currentAgent = agent.current()
|
||||||
const next = model ?? fallbackModel()
|
const next = model ?? fallbackModel()
|
||||||
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
||||||
if (model) models.setVisibility(model, true)
|
if (model) updateVisibility(model, "show")
|
||||||
if (options?.recent && model) models.recent.push(model)
|
if (options?.recent && model) {
|
||||||
|
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||||
|
if (uniq.length > 5) uniq.pop()
|
||||||
|
setStore("recent", uniq)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
visible(model: ModelKey) {
|
visible(model: ModelKey) {
|
||||||
return models.visible(model)
|
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
|
||||||
},
|
},
|
||||||
setVisibility(model: ModelKey, visible: boolean) {
|
setVisibility(model: ModelKey, visible: boolean) {
|
||||||
models.setVisibility(model, visible)
|
updateVisibility(model, visible ? "show" : "hide")
|
||||||
},
|
},
|
||||||
variant: {
|
variant: {
|
||||||
current() {
|
current() {
|
||||||
const m = current()
|
const m = current()
|
||||||
if (!m) return undefined
|
if (!m) return undefined
|
||||||
return models.variant.get({ providerID: m.provider.id, modelID: m.id })
|
const key = `${m.provider.id}/${m.id}`
|
||||||
|
return store.variant?.[key]
|
||||||
},
|
},
|
||||||
list() {
|
list() {
|
||||||
const m = current()
|
const m = current()
|
||||||
@@ -225,7 +311,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
set(value: string | undefined) {
|
set(value: string | undefined) {
|
||||||
const m = current()
|
const m = current()
|
||||||
if (!m) return
|
if (!m) return
|
||||||
models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
|
const key = `${m.provider.id}/${m.id}`
|
||||||
|
if (!store.variant) {
|
||||||
|
setStore("variant", { [key]: value })
|
||||||
|
} else {
|
||||||
|
setStore("variant", key, value)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
cycle() {
|
cycle() {
|
||||||
const variants = this.list()
|
const variants = this.list()
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
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 { createStore } from "solid-js/store"
|
||||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
import { createEffect, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
@@ -52,15 +52,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
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(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("notification", ["notification.v1"]),
|
Persist.global("notification", ["notification.v1"]),
|
||||||
createStore({
|
createStore({
|
||||||
@@ -81,59 +72,13 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
setStore("list", (list) => pruneNotifications([...list, notification]))
|
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 unsub = globalSDK.event.listen((e) => {
|
||||||
const event = e.details
|
|
||||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
|
||||||
|
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
|
const event = e.details
|
||||||
const time = Date.now()
|
const time = Date.now()
|
||||||
|
const activeDirectory = params.dir ? base64Decode(params.dir) : undefined
|
||||||
|
const activeSession = params.id
|
||||||
const viewed = (sessionID?: string) => {
|
const viewed = (sessionID?: string) => {
|
||||||
const activeDirectory = currentDirectory()
|
|
||||||
const activeSession = currentSession()
|
|
||||||
if (!activeDirectory) return false
|
if (!activeDirectory) return false
|
||||||
if (!activeSession) return false
|
if (!activeSession) return false
|
||||||
if (!sessionID) return false
|
if (!sessionID) return false
|
||||||
@@ -143,7 +88,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.idle": {
|
case "session.idle": {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.properties.sessionID
|
||||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
const [syncStore] = globalSync.child(directory)
|
||||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||||
const session = match.found ? syncStore.session[match.index] : undefined
|
const session = match.found ? syncStore.session[match.index] : undefined
|
||||||
if (session?.parentID) break
|
if (session?.parentID) break
|
||||||
@@ -170,7 +115,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
}
|
}
|
||||||
case "session.error": {
|
case "session.error": {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.properties.sessionID
|
||||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
const [syncStore] = globalSync.child(directory)
|
||||||
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
|
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
|
||||||
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
|
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
|
||||||
if (session?.parentID) break
|
if (session?.parentID) break
|
||||||
@@ -203,10 +148,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
ready,
|
ready,
|
||||||
session: {
|
session: {
|
||||||
all(session: string) {
|
all(session: string) {
|
||||||
return index().session.all.get(session) ?? empty
|
return store.list.filter((n) => n.session === session)
|
||||||
},
|
},
|
||||||
unseen(session: string) {
|
unseen(session: string) {
|
||||||
return index().session.unseen.get(session) ?? empty
|
return store.list.filter((n) => n.session === session && !n.viewed)
|
||||||
},
|
},
|
||||||
markViewed(session: string) {
|
markViewed(session: string) {
|
||||||
setStore("list", (n) => n.session === session, "viewed", true)
|
setStore("list", (n) => n.session === session, "viewed", true)
|
||||||
@@ -214,10 +159,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
all(directory: string) {
|
all(directory: string) {
|
||||||
return index().project.all.get(directory) ?? empty
|
return store.list.filter((n) => n.directory === directory)
|
||||||
},
|
},
|
||||||
unseen(directory: string) {
|
unseen(directory: string) {
|
||||||
return index().project.unseen.get(directory) ?? empty
|
return store.list.filter((n) => n.directory === directory && !n.viewed)
|
||||||
},
|
},
|
||||||
markViewed(directory: string) {
|
markViewed(directory: string) {
|
||||||
setStore("list", (n) => n.directory === directory, "viewed", true)
|
setStore("list", (n) => n.directory === directory, "viewed", true)
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ export type Platform = {
|
|||||||
/** Fetch override */
|
/** Fetch override */
|
||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
|
|
||||||
/** Get the configured default server URL (platform-specific) */
|
/** Get the configured default server URL (desktop only) */
|
||||||
getDefaultServerUrl?(): Promise<string | null> | string | null
|
getDefaultServerUrl?(): Promise<string | null>
|
||||||
|
|
||||||
/** Set the default server URL to use on app startup (platform-specific) */
|
/** Set the default server URL to use on app startup (desktop only) */
|
||||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
setDefaultServerUrl?(url: string | null): Promise<void>
|
||||||
|
|
||||||
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
||||||
parseMarkdown?(markdown: string): Promise<string>
|
parseMarkdown?(markdown: string): Promise<string>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -40,17 +40,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const [active, setActiveRaw] = createSignal("")
|
||||||
active: "",
|
|
||||||
healthy: undefined as boolean | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const healthy = () => state.healthy
|
|
||||||
|
|
||||||
function setActive(input: string) {
|
function setActive(input: string) {
|
||||||
const url = normalizeServerUrl(input)
|
const url = normalizeServerUrl(input)
|
||||||
if (!url) return
|
if (!url) return
|
||||||
setState("active", url)
|
setActiveRaw(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
function add(input: string) {
|
function add(input: string) {
|
||||||
@@ -59,7 +54,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const fallback = normalizeServerUrl(props.defaultUrl)
|
const fallback = normalizeServerUrl(props.defaultUrl)
|
||||||
if (fallback && url === fallback) {
|
if (fallback && url === fallback) {
|
||||||
setState("active", url)
|
setActiveRaw(url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +62,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
if (!store.list.includes(url)) {
|
if (!store.list.includes(url)) {
|
||||||
setStore("list", store.list.length, url)
|
setStore("list", store.list.length, url)
|
||||||
}
|
}
|
||||||
setState("active", url)
|
setActiveRaw(url)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,23 +71,25 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
if (!url) return
|
if (!url) return
|
||||||
|
|
||||||
const list = store.list.filter((x) => x !== url)
|
const list = store.list.filter((x) => x !== url)
|
||||||
const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
|
const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active()
|
||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("list", list)
|
setStore("list", list)
|
||||||
setState("active", next)
|
setActiveRaw(next)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready()) return
|
if (!ready()) return
|
||||||
if (state.active) return
|
if (active()) return
|
||||||
const url = normalizeServerUrl(props.defaultUrl)
|
const url = normalizeServerUrl(props.defaultUrl)
|
||||||
if (!url) return
|
if (!url) return
|
||||||
setState("active", url)
|
setActiveRaw(url)
|
||||||
})
|
})
|
||||||
|
|
||||||
const isReady = createMemo(() => ready() && !!state.active)
|
const isReady = createMemo(() => ready() && !!active())
|
||||||
|
|
||||||
|
const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
|
||||||
|
|
||||||
const check = (url: string) => {
|
const check = (url: string) => {
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
@@ -107,10 +104,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const url = state.active
|
const url = active()
|
||||||
if (!url) return
|
if (!url) return
|
||||||
|
|
||||||
setState("healthy", undefined)
|
setHealthy(undefined)
|
||||||
|
|
||||||
let alive = true
|
let alive = true
|
||||||
let busy = false
|
let busy = false
|
||||||
@@ -121,7 +118,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
void check(url)
|
void check(url)
|
||||||
.then((next) => {
|
.then((next) => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
setState("healthy", next)
|
setHealthy(next)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
busy = false
|
busy = false
|
||||||
@@ -137,7 +134,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const origin = createMemo(() => projectsKey(state.active))
|
const origin = createMemo(() => projectsKey(active()))
|
||||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||||
const isLocal = createMemo(() => origin() === "local")
|
const isLocal = createMemo(() => origin() === "local")
|
||||||
|
|
||||||
@@ -146,10 +143,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
healthy,
|
healthy,
|
||||||
isLocal,
|
isLocal,
|
||||||
get url() {
|
get url() {
|
||||||
return state.active
|
return active()
|
||||||
},
|
},
|
||||||
get name() {
|
get name() {
|
||||||
return serverDisplayName(state.active)
|
return serverDisplayName(active())
|
||||||
},
|
},
|
||||||
get list() {
|
get list() {
|
||||||
return store.list
|
return store.list
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { dict as en } from "@/i18n/en"
|
|||||||
import { dict as zh } from "@/i18n/zh"
|
import { dict as zh } from "@/i18n/zh"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
|
|
||||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
|
||||||
|
|
||||||
const root = document.getElementById("root")
|
const root = document.getElementById("root")
|
||||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||||
const locale = (() => {
|
const locale = (() => {
|
||||||
@@ -64,26 +62,6 @@ const platform: Platform = {
|
|||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.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(
|
render(
|
||||||
|
|||||||
@@ -55,3 +55,30 @@
|
|||||||
scrollbar-width: thin !important;
|
scrollbar-width: thin !important;
|
||||||
scrollbar-color: var(--border-weak-base) transparent !important;
|
scrollbar-color: var(--border-weak-base) transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wider dialog variant for release notes modal */
|
||||||
|
[data-component="dialog"]:has(.dialog-release-notes) {
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
[data-slot="dialog-container"] {
|
||||||
|
width: min(100%, 720px);
|
||||||
|
height: min(100%, 400px);
|
||||||
|
margin-top: -80px;
|
||||||
|
|
||||||
|
[data-slot="dialog-content"] {
|
||||||
|
min-height: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
box-shadow: var(--shadow-lg-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="dialog-body"] {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { CURRENT_RELEASE } from "@/components/dialog-release-notes"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "opencode:last-seen-version"
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DEV MODE: Set this to true to always show the release notes modal on startup
|
||||||
|
// Set to false for production behavior (only shows after updates)
|
||||||
|
// ============================================================================
|
||||||
|
const DEV_ALWAYS_SHOW_RELEASE_NOTES = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if release notes should be shown
|
||||||
|
* Returns true if:
|
||||||
|
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true (for development)
|
||||||
|
* - OR the current version is newer than the last seen version
|
||||||
|
*/
|
||||||
|
export function shouldShowReleaseNotes(): boolean {
|
||||||
|
if (DEV_ALWAYS_SHOW_RELEASE_NOTES) {
|
||||||
|
console.log("[ReleaseNotes] DEV mode: always showing release notes")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeen = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!lastSeen) {
|
||||||
|
// First time user - show release notes
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare versions - show if current is newer
|
||||||
|
return CURRENT_RELEASE.version !== lastSeen
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the current release notes as seen
|
||||||
|
* Call this when the user closes the release notes modal
|
||||||
|
*/
|
||||||
|
export function markReleaseNotesSeen(): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, CURRENT_RELEASE.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current version
|
||||||
|
*/
|
||||||
|
export function getCurrentVersion(): string {
|
||||||
|
return CURRENT_RELEASE.version
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the seen status (useful for testing)
|
||||||
|
*/
|
||||||
|
export function resetReleaseNotesSeen(): void {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
}
|
||||||
@@ -23,11 +23,6 @@ export default function Home() {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const homedir = createMemo(() => sync.data.path.home)
|
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) {
|
function openProject(directory: string) {
|
||||||
layout.projects.open(directory)
|
layout.projects.open(directory)
|
||||||
@@ -89,7 +84,11 @@ export default function Home() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="flex flex-col gap-2">
|
<ul class="flex flex-col gap-2">
|
||||||
<For each={recent()}>
|
<For
|
||||||
|
each={sync.data.project
|
||||||
|
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||||
|
.slice(0, 5)}
|
||||||
|
>
|
||||||
{(project) => (
|
{(project) => (
|
||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
|
|||||||
+435
-558
File diff suppressed because it is too large
Load Diff
+640
-935
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
import { onCleanup } from "solid-js"
|
import { createSignal, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
|
|
||||||
// Minimal types to avoid relying on non-standard DOM typings
|
// Minimal types to avoid relying on non-standard DOM typings
|
||||||
type RecognitionResult = {
|
type RecognitionResult = {
|
||||||
@@ -60,15 +59,9 @@ export function createSpeechRecognition(opts?: {
|
|||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
|
Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [isRecording, setIsRecording] = createSignal(false)
|
||||||
isRecording: false,
|
const [committed, setCommitted] = createSignal("")
|
||||||
committed: "",
|
const [interim, setInterim] = createSignal("")
|
||||||
interim: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
const isRecording = () => store.isRecording
|
|
||||||
const committed = () => store.committed
|
|
||||||
const interim = () => store.interim
|
|
||||||
|
|
||||||
let recognition: Recognition | undefined
|
let recognition: Recognition | undefined
|
||||||
let shouldContinue = false
|
let shouldContinue = false
|
||||||
@@ -89,7 +82,7 @@ export function createSpeechRecognition(opts?: {
|
|||||||
const nextCommitted = appendSegment(committedText, segment)
|
const nextCommitted = appendSegment(committedText, segment)
|
||||||
if (nextCommitted === committedText) return
|
if (nextCommitted === committedText) return
|
||||||
committedText = nextCommitted
|
committedText = nextCommitted
|
||||||
setStore("committed", committedText)
|
setCommitted(committedText)
|
||||||
if (opts?.onFinal) opts.onFinal(segment.trim())
|
if (opts?.onFinal) opts.onFinal(segment.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +98,7 @@ export function createSpeechRecognition(opts?: {
|
|||||||
pendingHypothesis = ""
|
pendingHypothesis = ""
|
||||||
lastInterimSuffix = ""
|
lastInterimSuffix = ""
|
||||||
shrinkCandidate = undefined
|
shrinkCandidate = undefined
|
||||||
setStore("interim", "")
|
setInterim("")
|
||||||
if (opts?.onInterim) opts.onInterim("")
|
if (opts?.onInterim) opts.onInterim("")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +107,7 @@ export function createSpeechRecognition(opts?: {
|
|||||||
pendingHypothesis = hypothesis
|
pendingHypothesis = hypothesis
|
||||||
lastInterimSuffix = suffix
|
lastInterimSuffix = suffix
|
||||||
shrinkCandidate = undefined
|
shrinkCandidate = undefined
|
||||||
setStore("interim", suffix)
|
setInterim(suffix)
|
||||||
if (opts?.onInterim) {
|
if (opts?.onInterim) {
|
||||||
opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
|
opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
|
||||||
}
|
}
|
||||||
@@ -129,7 +122,7 @@ export function createSpeechRecognition(opts?: {
|
|||||||
pendingHypothesis = ""
|
pendingHypothesis = ""
|
||||||
lastInterimSuffix = ""
|
lastInterimSuffix = ""
|
||||||
shrinkCandidate = undefined
|
shrinkCandidate = undefined
|
||||||
setStore("interim", "")
|
setInterim("")
|
||||||
if (opts?.onInterim) opts.onInterim("")
|
if (opts?.onInterim) opts.onInterim("")
|
||||||
}, COMMIT_DELAY)
|
}, COMMIT_DELAY)
|
||||||
}
|
}
|
||||||
@@ -169,7 +162,7 @@ export function createSpeechRecognition(opts?: {
|
|||||||
pendingHypothesis = ""
|
pendingHypothesis = ""
|
||||||
lastInterimSuffix = ""
|
lastInterimSuffix = ""
|
||||||
shrinkCandidate = undefined
|
shrinkCandidate = undefined
|
||||||
setStore("interim", "")
|
setInterim("")
|
||||||
if (opts?.onInterim) opts.onInterim("")
|
if (opts?.onInterim) opts.onInterim("")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -218,7 +211,7 @@ export function createSpeechRecognition(opts?: {
|
|||||||
lastInterimSuffix = ""
|
lastInterimSuffix = ""
|
||||||
shrinkCandidate = undefined
|
shrinkCandidate = undefined
|
||||||
if (e.error === "no-speech" && shouldContinue) {
|
if (e.error === "no-speech" && shouldContinue) {
|
||||||
setStore("interim", "")
|
setInterim("")
|
||||||
if (opts?.onInterim) opts.onInterim("")
|
if (opts?.onInterim) opts.onInterim("")
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
@@ -228,7 +221,7 @@ export function createSpeechRecognition(opts?: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
shouldContinue = false
|
shouldContinue = false
|
||||||
setStore("isRecording", false)
|
setIsRecording(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
recognition.onstart = () => {
|
recognition.onstart = () => {
|
||||||
@@ -237,16 +230,16 @@ export function createSpeechRecognition(opts?: {
|
|||||||
cancelPendingCommit()
|
cancelPendingCommit()
|
||||||
lastInterimSuffix = ""
|
lastInterimSuffix = ""
|
||||||
shrinkCandidate = undefined
|
shrinkCandidate = undefined
|
||||||
setStore("interim", "")
|
setInterim("")
|
||||||
if (opts?.onInterim) opts.onInterim("")
|
if (opts?.onInterim) opts.onInterim("")
|
||||||
setStore("isRecording", true)
|
setIsRecording(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
recognition.onend = () => {
|
recognition.onend = () => {
|
||||||
cancelPendingCommit()
|
cancelPendingCommit()
|
||||||
lastInterimSuffix = ""
|
lastInterimSuffix = ""
|
||||||
shrinkCandidate = undefined
|
shrinkCandidate = undefined
|
||||||
setStore("isRecording", false)
|
setIsRecording(false)
|
||||||
if (shouldContinue) {
|
if (shouldContinue) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
@@ -265,7 +258,7 @@ export function createSpeechRecognition(opts?: {
|
|||||||
cancelPendingCommit()
|
cancelPendingCommit()
|
||||||
lastInterimSuffix = ""
|
lastInterimSuffix = ""
|
||||||
shrinkCandidate = undefined
|
shrinkCandidate = undefined
|
||||||
setStore("interim", "")
|
setInterim("")
|
||||||
try {
|
try {
|
||||||
recognition.start()
|
recognition.start()
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -278,7 +271,7 @@ export function createSpeechRecognition(opts?: {
|
|||||||
cancelPendingCommit()
|
cancelPendingCommit()
|
||||||
lastInterimSuffix = ""
|
lastInterimSuffix = ""
|
||||||
shrinkCandidate = undefined
|
shrinkCandidate = undefined
|
||||||
setStore("interim", "")
|
setInterim("")
|
||||||
if (opts?.onInterim) opts.onInterim("")
|
if (opts?.onInterim) opts.onInterim("")
|
||||||
try {
|
try {
|
||||||
recognition.stop()
|
recognition.stop()
|
||||||
@@ -291,7 +284,7 @@ export function createSpeechRecognition(opts?: {
|
|||||||
cancelPendingCommit()
|
cancelPendingCommit()
|
||||||
lastInterimSuffix = ""
|
lastInterimSuffix = ""
|
||||||
shrinkCandidate = undefined
|
shrinkCandidate = undefined
|
||||||
setStore("interim", "")
|
setInterim("")
|
||||||
if (opts?.onInterim) opts.onInterim("")
|
if (opts?.onInterim) opts.onInterim("")
|
||||||
try {
|
try {
|
||||||
recognition?.stop()
|
recognition?.stop()
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ type HighlightGroup = {
|
|||||||
items: HighlightItem[]
|
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[] {
|
function parseHighlights(body: string): HighlightGroup[] {
|
||||||
const groups = new Map<string, HighlightItem[]>()
|
const groups = new Map<string, HighlightItem[]>()
|
||||||
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
|
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
|
||||||
@@ -93,48 +90,25 @@ export async function GET() {
|
|||||||
Accept: "application/vnd.github.v3+json",
|
Accept: "application/vnd.github.v3+json",
|
||||||
"User-Agent": "OpenCode-Console",
|
"User-Agent": "OpenCode-Console",
|
||||||
},
|
},
|
||||||
cf: {
|
})
|
||||||
// best-effort edge caching (ignored outside Cloudflare)
|
|
||||||
cacheTtl: 60 * 5,
|
|
||||||
cacheEverything: true,
|
|
||||||
},
|
|
||||||
} as any).catch(() => undefined)
|
|
||||||
|
|
||||||
const fail = () =>
|
if (!response.ok) {
|
||||||
new Response(JSON.stringify({ releases: [] }), {
|
return { releases: [] }
|
||||||
status: 503,
|
}
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Cache-Control": error,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response?.ok) return fail()
|
const releases = (await response.json()) as Release[]
|
||||||
|
|
||||||
const data = await response.json().catch(() => undefined)
|
return {
|
||||||
if (!Array.isArray(data)) return fail()
|
releases: releases.map((release) => {
|
||||||
|
const parsed = parseMarkdown(release.body || "")
|
||||||
const releases = data as Release[]
|
return {
|
||||||
|
tag: release.tag_name,
|
||||||
return new Response(
|
name: release.name,
|
||||||
JSON.stringify({
|
date: release.published_at,
|
||||||
releases: releases.map((release) => {
|
url: release.html_url,
|
||||||
const parsed = parseMarkdown(release.body || "")
|
highlights: parsed.highlights,
|
||||||
return {
|
sections: parsed.sections,
|
||||||
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;
|
top: 80px;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
padding: 44px 0 8px;
|
padding: 8px 0;
|
||||||
|
|
||||||
@media (max-width: 50rem) {
|
@media (max-width: 50rem) {
|
||||||
position: static;
|
position: static;
|
||||||
|
|||||||
@@ -1,12 +1,44 @@
|
|||||||
import "./index.css"
|
import "./index.css"
|
||||||
import { Title, Meta, Link } from "@solidjs/meta"
|
import { Title, Meta, Link } from "@solidjs/meta"
|
||||||
import { createAsync } from "@solidjs/router"
|
import { createAsync, query } from "@solidjs/router"
|
||||||
import { Header } from "~/component/header"
|
import { Header } from "~/component/header"
|
||||||
import { Footer } from "~/component/footer"
|
import { Footer } from "~/component/footer"
|
||||||
import { Legal } from "~/component/legal"
|
import { Legal } from "~/component/legal"
|
||||||
import { config } from "~/config"
|
import { config } from "~/config"
|
||||||
import { For, Show, createSignal } from "solid-js"
|
import { For, Show, createSignal } from "solid-js"
|
||||||
import { getRequestEvent } from "solid-js/web"
|
|
||||||
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
|
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
|
||||||
|
|
||||||
@@ -22,33 +54,68 @@ type HighlightGroup = {
|
|||||||
items: HighlightItem[]
|
items: HighlightItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangelogRelease = {
|
function parseHighlights(body: string): HighlightGroup[] {
|
||||||
tag: string
|
const groups = new Map<string, HighlightItem[]>()
|
||||||
name: string
|
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
|
||||||
date: string
|
let match
|
||||||
url: string
|
|
||||||
highlights: HighlightGroup[]
|
while ((match = regex.exec(body)) !== null) {
|
||||||
sections: { title: string; items: string[] }[]
|
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 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getReleases() {
|
function parseMarkdown(body: string) {
|
||||||
const event = getRequestEvent()
|
const lines = body.split("\n")
|
||||||
const url = event ? new URL("/changelog.json", event.request.url).toString() : "/changelog.json"
|
const sections: { title: string; items: string[] }[] = []
|
||||||
|
let current: { title: string; items: string[] } | null = null
|
||||||
|
let skip = false
|
||||||
|
|
||||||
const response = await fetch(url).catch(() => undefined)
|
for (const line of lines) {
|
||||||
if (!response?.ok) return []
|
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 json = await response.json().catch(() => undefined)
|
const highlights = parseHighlights(body)
|
||||||
return Array.isArray(json?.releases) ? (json.releases as ChangelogRelease[]) : []
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString: string) {
|
return { sections, highlights }
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReleaseItem(props: { item: string }) {
|
function ReleaseItem(props: { item: string }) {
|
||||||
@@ -150,27 +217,28 @@ export default function Changelog() {
|
|||||||
<section data-component="releases">
|
<section data-component="releases">
|
||||||
<For each={releases()}>
|
<For each={releases()}>
|
||||||
{(release) => {
|
{(release) => {
|
||||||
|
const parsed = () => parseMarkdown(release.body || "")
|
||||||
return (
|
return (
|
||||||
<article data-component="release">
|
<article data-component="release">
|
||||||
<header>
|
<header>
|
||||||
<div data-slot="version">
|
<div data-slot="version">
|
||||||
<a href={release.url} target="_blank" rel="noopener noreferrer">
|
<a href={release.html_url} target="_blank" rel="noopener noreferrer">
|
||||||
{release.tag}
|
{release.tag_name}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<time dateTime={release.date}>{formatDate(release.date)}</time>
|
<time dateTime={release.published_at}>{formatDate(release.published_at)}</time>
|
||||||
</header>
|
</header>
|
||||||
<div data-slot="content">
|
<div data-slot="content">
|
||||||
<Show when={release.highlights.length > 0}>
|
<Show when={parsed().highlights.length > 0}>
|
||||||
<div data-component="highlights">
|
<div data-component="highlights">
|
||||||
<For each={release.highlights}>{(group) => <HighlightSection group={group} />}</For>
|
<For each={parsed().highlights}>{(group) => <HighlightSection group={group} />}</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={release.highlights.length > 0 && release.sections.length > 0}>
|
<Show when={parsed().highlights.length > 0 && parsed().sections.length > 0}>
|
||||||
<CollapsibleSections sections={release.sections} />
|
<CollapsibleSections sections={parsed().sections} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={release.highlights.length === 0}>
|
<Show when={parsed().highlights.length === 0}>
|
||||||
<For each={release.sections}>
|
<For each={parsed().sections}>
|
||||||
{(section) => (
|
{(section) => (
|
||||||
<div data-component="section">
|
<div data-component="section">
|
||||||
<h3>{section.title}</h3>
|
<h3>{section.title}</h3>
|
||||||
@@ -187,9 +255,9 @@ export default function Changelog() {
|
|||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Legal />
|
<Legal />
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root" class="flex flex-col h-dvh p-px"></div>
|
<div id="root" class="flex flex-col h-dvh"></div>
|
||||||
<div data-tauri-decorum-tb class="w-0 h-0 hidden" />
|
<div data-tauri-decorum-tb class="w-0 h-0 hidden" />
|
||||||
<script src="/src/index.tsx" type="module"></script>
|
<script src="/src/index.tsx" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -328,23 +328,18 @@ render(() => {
|
|||||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||||
const platform = createPlatform(() => serverPassword())
|
const platform = createPlatform(() => serverPassword())
|
||||||
|
|
||||||
onMount(() => {
|
function handleClick(e: MouseEvent) {
|
||||||
// Handle external links - open in system browser instead of webview
|
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||||
const handleClick = (e: MouseEvent) => {
|
if (link?.href) {
|
||||||
const target = e.target as HTMLElement
|
e.preventDefault()
|
||||||
const link = target.closest("a") as HTMLAnchorElement | null
|
platform.openLink(link.href)
|
||||||
|
|
||||||
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)
|
onMount(() => {
|
||||||
|
document.addEventListener("click", handleClick)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
document.removeEventListener("click", handleClick, true)
|
document.removeEventListener("click", handleClick)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { createStore } from "solid-js/store"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import NotFound from "../[...404]"
|
import NotFound from "../[...404]"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
|
||||||
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||||
import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
|
import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
|
||||||
import { clientOnly } from "@solidjs/start"
|
import { clientOnly } from "@solidjs/start"
|
||||||
@@ -363,15 +362,6 @@ export default function () {
|
|||||||
{title()}
|
{title()}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start justify-start h-full min-h-0">
|
<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
|
<SessionTurn
|
||||||
sessionID={data().sessionID}
|
sessionID={data().sessionID}
|
||||||
messageID={store.messageId ?? firstUserMessage()!.id!}
|
messageID={store.messageId ?? firstUserMessage()!.id!}
|
||||||
|
|||||||
@@ -82,8 +82,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||||
"@opentui/core": "0.1.75",
|
"@opentui/core": "0.1.74",
|
||||||
"@opentui/solid": "0.1.75",
|
"@opentui/solid": "0.1.74",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export namespace Agent {
|
|||||||
const result: Record<string, Info> = {
|
const result: Record<string, Info> = {
|
||||||
build: {
|
build: {
|
||||||
name: "build",
|
name: "build",
|
||||||
description: "The default agent. Executes tools based on configured permissions.",
|
|
||||||
options: {},
|
options: {},
|
||||||
permission: PermissionNext.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
@@ -88,7 +87,6 @@ export namespace Agent {
|
|||||||
},
|
},
|
||||||
plan: {
|
plan: {
|
||||||
name: "plan",
|
name: "plan",
|
||||||
description: "Plan mode. Disallows all edit tools.",
|
|
||||||
options: {},
|
options: {},
|
||||||
permission: PermissionNext.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ async function createToolContext(agent: Agent.Info) {
|
|||||||
callID: Identifier.ascending("part"),
|
callID: Identifier.ascending("part"),
|
||||||
agent: agent.name,
|
agent: agent.name,
|
||||||
abort: new AbortController().signal,
|
abort: new AbortController().signal,
|
||||||
messages: [],
|
|
||||||
metadata: () => {},
|
metadata: () => {},
|
||||||
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
||||||
for (const pattern of req.patterns) {
|
for (const pattern of req.patterns) {
|
||||||
|
|||||||
@@ -570,16 +570,6 @@ function App() {
|
|||||||
dialog.clear()
|
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(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function Session() {
|
|||||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||||
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
||||||
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
|
||||||
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
||||||
|
|
||||||
const wide = createMemo(() => dimensions().width > 120)
|
const wide = createMemo(() => dimensions().width > 120)
|
||||||
@@ -503,7 +503,7 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
title: "Toggle code concealment",
|
||||||
value: "session.toggle.conceal",
|
value: "session.toggle.conceal",
|
||||||
keybind: "messages_toggle_conceal" as any,
|
keybind: "messages_toggle_conceal" as any,
|
||||||
category: "Session",
|
category: "Session",
|
||||||
@@ -538,6 +538,18 @@ export function Session() {
|
|||||||
dialog.clear()
|
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",
|
title: showDetails() ? "Hide tool details" : "Show tool details",
|
||||||
value: "session.toggle.actions",
|
value: "session.toggle.actions",
|
||||||
@@ -1693,29 +1705,10 @@ function Glob(props: ToolProps<typeof GlobTool>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Read(props: ToolProps<typeof ReadTool>) {
|
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 (
|
return (
|
||||||
<>
|
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
|
||||||
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
|
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
</InlineTool>
|
||||||
</InlineTool>
|
|
||||||
<For each={loaded()}>
|
|
||||||
{(filepath) => (
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<text paddingLeft={3} fg={theme.textMuted}>
|
|
||||||
↳ Loaded {normalizePath(filepath)}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,11 +206,7 @@ export namespace File {
|
|||||||
const project = Instance.project
|
const project = Instance.project
|
||||||
if (project.vcs !== "git") return []
|
if (project.vcs !== "git") return []
|
||||||
|
|
||||||
const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD`
|
const diffOutput = await $`git diff --numstat HEAD`.cwd(Instance.directory).quiet().nothrow().text()
|
||||||
.cwd(Instance.directory)
|
|
||||||
.quiet()
|
|
||||||
.nothrow()
|
|
||||||
.text()
|
|
||||||
|
|
||||||
const changedFiles: Info[] = []
|
const changedFiles: Info[] = []
|
||||||
|
|
||||||
@@ -227,7 +223,7 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard`
|
const untrackedOutput = await $`git ls-files --others --exclude-standard`
|
||||||
.cwd(Instance.directory)
|
.cwd(Instance.directory)
|
||||||
.quiet()
|
.quiet()
|
||||||
.nothrow()
|
.nothrow()
|
||||||
@@ -252,7 +248,7 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get deleted files
|
// Get deleted files
|
||||||
const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
|
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
|
||||||
.cwd(Instance.directory)
|
.cwd(Instance.directory)
|
||||||
.quiet()
|
.quiet()
|
||||||
.nothrow()
|
.nothrow()
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
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"),
|
sessionID: Identifier.schema("session"),
|
||||||
messageID: Identifier.schema("message"),
|
messageID: Identifier.schema("message"),
|
||||||
}),
|
}),
|
||||||
async (input): Promise<WithParts> => {
|
async (input) => {
|
||||||
return {
|
return {
|
||||||
info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
|
info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
|
||||||
parts: await parts(input.messageID),
|
parts: await parts(input.messageID),
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { Instance } from "../project/instance"
|
|||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { ProviderTransform } from "../provider/transform"
|
import { ProviderTransform } from "../provider/transform"
|
||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { InstructionPrompt } from "./instruction"
|
|
||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||||
@@ -387,7 +386,6 @@ export namespace SessionPrompt {
|
|||||||
abort,
|
abort,
|
||||||
callID: part.callID,
|
callID: part.callID,
|
||||||
extra: { bypassAgentCheck: true },
|
extra: { bypassAgentCheck: true },
|
||||||
messages: msgs,
|
|
||||||
async metadata(input) {
|
async metadata(input) {
|
||||||
await Session.updatePart({
|
await Session.updatePart({
|
||||||
...part,
|
...part,
|
||||||
@@ -563,7 +561,6 @@ export namespace SessionPrompt {
|
|||||||
tools: lastUser.tools,
|
tools: lastUser.tools,
|
||||||
processor,
|
processor,
|
||||||
bypassAgentCheck,
|
bypassAgentCheck,
|
||||||
messages: msgs,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (step === 1) {
|
if (step === 1) {
|
||||||
@@ -601,7 +598,7 @@ export namespace SessionPrompt {
|
|||||||
agent,
|
agent,
|
||||||
abort,
|
abort,
|
||||||
sessionID,
|
sessionID,
|
||||||
system: [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())],
|
system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
|
||||||
messages: [
|
messages: [
|
||||||
...MessageV2.toModelMessages(sessionMessages, model),
|
...MessageV2.toModelMessages(sessionMessages, model),
|
||||||
...(isLastStep
|
...(isLastStep
|
||||||
@@ -653,7 +650,6 @@ export namespace SessionPrompt {
|
|||||||
tools?: Record<string, boolean>
|
tools?: Record<string, boolean>
|
||||||
processor: SessionProcessor.Info
|
processor: SessionProcessor.Info
|
||||||
bypassAgentCheck: boolean
|
bypassAgentCheck: boolean
|
||||||
messages: MessageV2.WithParts[]
|
|
||||||
}) {
|
}) {
|
||||||
using _ = log.time("resolveTools")
|
using _ = log.time("resolveTools")
|
||||||
const tools: Record<string, AITool> = {}
|
const tools: Record<string, AITool> = {}
|
||||||
@@ -665,7 +661,6 @@ export namespace SessionPrompt {
|
|||||||
callID: options.toolCallId,
|
callID: options.toolCallId,
|
||||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
|
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
|
||||||
agent: input.agent.name,
|
agent: input.agent.name,
|
||||||
messages: input.messages,
|
|
||||||
metadata: async (val: { title?: string; metadata?: any }) => {
|
metadata: async (val: { title?: string; metadata?: any }) => {
|
||||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||||
if (match && match.state.status === "running") {
|
if (match && match.state.status === "running") {
|
||||||
@@ -1013,7 +1008,6 @@ export namespace SessionPrompt {
|
|||||||
agent: input.agent!,
|
agent: input.agent!,
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
extra: { bypassCwdCheck: true, model },
|
extra: { bypassCwdCheck: true, model },
|
||||||
messages: [],
|
|
||||||
metadata: async () => {},
|
metadata: async () => {},
|
||||||
ask: async () => {},
|
ask: async () => {},
|
||||||
}
|
}
|
||||||
@@ -1075,7 +1069,6 @@ export namespace SessionPrompt {
|
|||||||
agent: input.agent!,
|
agent: input.agent!,
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
extra: { bypassCwdCheck: true },
|
extra: { bypassCwdCheck: true },
|
||||||
messages: [],
|
|
||||||
metadata: async () => {},
|
metadata: async () => {},
|
||||||
ask: async () => {},
|
ask: async () => {},
|
||||||
}
|
}
|
||||||
@@ -1356,7 +1349,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
|
|
||||||
const session = await Session.get(input.sessionID)
|
const session = await Session.get(input.sessionID)
|
||||||
if (session.revert) {
|
if (session.revert) {
|
||||||
await SessionRevert.cleanup(session)
|
SessionRevert.cleanup(session)
|
||||||
}
|
}
|
||||||
const agent = await Agent.get(input.agent)
|
const agent = await Agent.get(input.agent)
|
||||||
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
|
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
|
||||||
|
|||||||
@@ -20,62 +20,6 @@ import { Agent } from "@/agent/agent"
|
|||||||
export namespace SessionSummary {
|
export namespace SessionSummary {
|
||||||
const log = Log.create({ service: "session.summary" })
|
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(
|
export const summarize = fn(
|
||||||
z.object({
|
z.object({
|
||||||
sessionID: z.string(),
|
sessionID: z.string(),
|
||||||
@@ -172,18 +116,7 @@ export namespace SessionSummary {
|
|||||||
messageID: Identifier.schema("message").optional(),
|
messageID: Identifier.schema("message").optional(),
|
||||||
}),
|
}),
|
||||||
async (input) => {
|
async (input) => {
|
||||||
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
|
return 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,14 +1,37 @@
|
|||||||
import { Ripgrep } from "../file/ripgrep"
|
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 { Instance } from "../project/instance"
|
||||||
|
import path from "path"
|
||||||
|
import os from "os"
|
||||||
|
|
||||||
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||||
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
|
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
|
||||||
import PROMPT_BEAST from "./prompt/beast.txt"
|
import PROMPT_BEAST from "./prompt/beast.txt"
|
||||||
import PROMPT_GEMINI from "./prompt/gemini.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 PROMPT_CODEX from "./prompt/codex_header.txt"
|
||||||
import type { Provider } from "@/provider/provider"
|
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 namespace SystemPrompt {
|
||||||
export function instructions() {
|
export function instructions() {
|
||||||
@@ -49,4 +72,81 @@ export namespace SystemPrompt {
|
|||||||
].join("\n"),
|
].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()
|
const git = gitdir()
|
||||||
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
|
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
|
||||||
const result =
|
const result =
|
||||||
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
|
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
|
||||||
.quiet()
|
.quiet()
|
||||||
.cwd(Instance.worktree)
|
.cwd(Instance.worktree)
|
||||||
.nothrow()
|
.nothrow()
|
||||||
@@ -196,7 +196,7 @@ export namespace Snapshot {
|
|||||||
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
|
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
|
||||||
const git = gitdir()
|
const git = gitdir()
|
||||||
const result: FileDiff[] = []
|
const result: FileDiff[] = []
|
||||||
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} -- .`
|
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} -- .`
|
||||||
.quiet()
|
.quiet()
|
||||||
.cwd(Instance.directory)
|
.cwd(Instance.directory)
|
||||||
.nothrow()
|
.nothrow()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import DESCRIPTION from "./read.txt"
|
|||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
import { assertExternalDirectory } from "./external-directory"
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
import { InstructionPrompt } from "../session/instruction"
|
|
||||||
|
|
||||||
const DEFAULT_READ_LIMIT = 2000
|
const DEFAULT_READ_LIMIT = 2000
|
||||||
const MAX_LINE_LENGTH = 2000
|
const MAX_LINE_LENGTH = 2000
|
||||||
@@ -60,8 +59,6 @@ export const ReadTool = Tool.define("read", {
|
|||||||
throw new Error(`File not found: ${filepath}`)
|
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)
|
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
||||||
const isImage =
|
const isImage =
|
||||||
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
|
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
|
||||||
@@ -75,7 +72,6 @@ export const ReadTool = Tool.define("read", {
|
|||||||
metadata: {
|
metadata: {
|
||||||
preview: msg,
|
preview: msg,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
|
||||||
},
|
},
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
@@ -137,17 +133,12 @@ export const ReadTool = Tool.define("read", {
|
|||||||
LSP.touchFile(filepath, false)
|
LSP.touchFile(filepath, false)
|
||||||
FileTime.read(ctx.sessionID, filepath)
|
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 {
|
return {
|
||||||
title,
|
title,
|
||||||
output,
|
output,
|
||||||
metadata: {
|
metadata: {
|
||||||
preview,
|
preview,
|
||||||
truncated,
|
truncated,
|
||||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export namespace Tool {
|
|||||||
abort: AbortSignal
|
abort: AbortSignal
|
||||||
callID?: string
|
callID?: string
|
||||||
extra?: { [key: string]: any }
|
extra?: { [key: string]: any }
|
||||||
messages: MessageV2.WithParts[]
|
|
||||||
metadata(input: { title?: string; metadata?: M }): void
|
metadata(input: { title?: string; metadata?: M }): void
|
||||||
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
|
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
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,7 +11,6 @@ const baseCtx = {
|
|||||||
callID: "",
|
callID: "",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
abort: AbortSignal.any([]),
|
abort: AbortSignal.any([]),
|
||||||
messages: [],
|
|
||||||
metadata: () => {},
|
metadata: () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const ctx = {
|
|||||||
callID: "",
|
callID: "",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
abort: AbortSignal.any([]),
|
abort: AbortSignal.any([]),
|
||||||
messages: [],
|
|
||||||
metadata: () => {},
|
metadata: () => {},
|
||||||
ask: async () => {},
|
ask: async () => {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
|
|||||||
callID: "",
|
callID: "",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
abort: AbortSignal.any([]),
|
abort: AbortSignal.any([]),
|
||||||
messages: [],
|
|
||||||
metadata: () => {},
|
metadata: () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const ctx = {
|
|||||||
callID: "",
|
callID: "",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
abort: AbortSignal.any([]),
|
abort: AbortSignal.any([]),
|
||||||
messages: [],
|
|
||||||
metadata: () => {},
|
metadata: () => {},
|
||||||
ask: async () => {},
|
ask: async () => {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const ctx = {
|
|||||||
callID: "test-call",
|
callID: "test-call",
|
||||||
agent: "test-agent",
|
agent: "test-agent",
|
||||||
abort: AbortSignal.any([]),
|
abort: AbortSignal.any([]),
|
||||||
messages: [],
|
|
||||||
metadata: () => {},
|
metadata: () => {},
|
||||||
ask: async () => {},
|
ask: async () => {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const ctx = {
|
|||||||
callID: "",
|
callID: "",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
abort: AbortSignal.any([]),
|
abort: AbortSignal.any([]),
|
||||||
messages: [],
|
|
||||||
metadata: () => {},
|
metadata: () => {},
|
||||||
ask: async () => {},
|
ask: async () => {},
|
||||||
}
|
}
|
||||||
@@ -331,26 +330,3 @@ 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) {
|
&:hover:not(:disabled) {
|
||||||
background-color: var(--surface-raised-base-hover);
|
background-color: var(--surface-raised-base-hover);
|
||||||
}
|
}
|
||||||
&:focus-visible:not(:disabled) {
|
&:focus:not(:disabled) {
|
||||||
background-color: var(--surface-raised-base-hover);
|
background-color: var(--surface-raised-base-hover);
|
||||||
}
|
}
|
||||||
&:active:not(:disabled) {
|
&:active:not(:disabled) {
|
||||||
|
|||||||
@@ -128,56 +128,20 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = () => {
|
const notifyRendered = () => {
|
||||||
|
if (!local.onRendered) return
|
||||||
|
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
observer = undefined
|
observer = undefined
|
||||||
renderToken++
|
renderToken++
|
||||||
|
|
||||||
const token = renderToken
|
const token = renderToken
|
||||||
|
|
||||||
const lines = lineCount()
|
const lines = (() => {
|
||||||
|
const text = local.file.contents
|
||||||
|
const total = text.split("\n").length - (text.endsWith("\n") ? 1 : 0)
|
||||||
|
return Math.max(1, total)
|
||||||
|
})()
|
||||||
|
|
||||||
const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
|
const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
|
||||||
|
|
||||||
@@ -188,7 +152,6 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
observer = undefined
|
observer = undefined
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (token !== renderToken) return
|
if (token !== renderToken) return
|
||||||
applySelection(lastSelection)
|
|
||||||
local.onRendered?.()
|
local.onRendered?.()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -278,7 +241,7 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
|
|
||||||
const setSelectedLines = (range: SelectedLineRange | null) => {
|
const setSelectedLines = (range: SelectedLineRange | null) => {
|
||||||
lastSelection = range
|
lastSelection = range
|
||||||
applySelection(range)
|
file().setSelectedLines(range)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleSelectionUpdate = () => {
|
const scheduleSelectionUpdate = () => {
|
||||||
|
|||||||
@@ -38,77 +38,6 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
|||||||
fileDiffRef.removeAttribute("data-color-scheme")
|
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 findSide = (element: HTMLElement): "additions" | "deletions" => {
|
||||||
const line = element.closest("[data-line], [data-alt-line]")
|
const line = element.closest("[data-line], [data-alt-line]")
|
||||||
if (line instanceof HTMLElement) {
|
if (line instanceof HTMLElement) {
|
||||||
@@ -230,14 +159,14 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
|||||||
containerWrapper: container,
|
containerWrapper: container,
|
||||||
})
|
})
|
||||||
|
|
||||||
setSelectedLines(local.selectedLines ?? null)
|
fileDiffInstance.setSelectedLines(local.selectedLines ?? null)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
|
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setSelectedLines(local.selectedLines ?? null)
|
fileDiffInstance?.setSelectedLines(local.selectedLines ?? null)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -115,64 +115,9 @@ export function Diff<T>(props: DiffProps<T>) {
|
|||||||
host.removeAttribute("data-color-scheme")
|
host.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: 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 = () => {
|
const notifyRendered = () => {
|
||||||
|
if (!local.onRendered) return
|
||||||
|
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
observer = undefined
|
observer = undefined
|
||||||
renderToken++
|
renderToken++
|
||||||
@@ -189,7 +134,6 @@ export function Diff<T>(props: DiffProps<T>) {
|
|||||||
observer = undefined
|
observer = undefined
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (token !== renderToken) return
|
if (token !== renderToken) return
|
||||||
setSelectedLines(lastSelection)
|
|
||||||
local.onRendered?.()
|
local.onRendered?.()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -229,8 +173,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
|||||||
const root = getRoot()
|
const root = getRoot()
|
||||||
if (typeof MutationObserver === "undefined") {
|
if (typeof MutationObserver === "undefined") {
|
||||||
if (!root || !isReady(root)) return
|
if (!root || !isReady(root)) return
|
||||||
setSelectedLines(lastSelection)
|
local.onRendered()
|
||||||
local.onRendered?.()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,14 +214,41 @@ export function Diff<T>(props: DiffProps<T>) {
|
|||||||
)
|
)
|
||||||
if (code.length === 0) return
|
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) {
|
for (const range of ranges) {
|
||||||
const start = rowIndex(root, split, range.start, range.side)
|
const start = rowIndex(range.start, range.side)
|
||||||
if (start === undefined) continue
|
if (start === undefined) continue
|
||||||
|
|
||||||
const end = (() => {
|
const end = (() => {
|
||||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||||
if (same) return start
|
if (same) return start
|
||||||
return rowIndex(root, split, range.end, range.endSide ?? range.side)
|
return rowIndex(range.end, range.endSide ?? range.side)
|
||||||
})()
|
})()
|
||||||
if (end === undefined) continue
|
if (end === undefined) continue
|
||||||
|
|
||||||
@@ -288,7 +258,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
|||||||
for (const block of code) {
|
for (const block of code) {
|
||||||
for (const element of Array.from(block.children)) {
|
for (const element of Array.from(block.children)) {
|
||||||
if (!(element instanceof HTMLElement)) continue
|
if (!(element instanceof HTMLElement)) continue
|
||||||
const idx = lineIndex(split, element)
|
const idx = lineIndex(element)
|
||||||
if (idx === undefined) continue
|
if (idx === undefined) continue
|
||||||
if (idx > last) break
|
if (idx > last) break
|
||||||
if (idx < first) continue
|
if (idx < first) continue
|
||||||
@@ -305,15 +275,8 @@ export function Diff<T>(props: DiffProps<T>) {
|
|||||||
const setSelectedLines = (range: SelectedLineRange | null) => {
|
const setSelectedLines = (range: SelectedLineRange | null) => {
|
||||||
const active = current()
|
const active = current()
|
||||||
if (!active) return
|
if (!active) return
|
||||||
|
lastSelection = range
|
||||||
const fixed = fixSelection(range)
|
active.setSelectedLines(range)
|
||||||
if (fixed === undefined) {
|
|
||||||
lastSelection = range
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSelection = fixed
|
|
||||||
active.setSelectedLines(fixed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSelection = () => {
|
const updateSelection = () => {
|
||||||
|
|||||||
@@ -3,20 +3,19 @@ import { ComponentProps, JSXElement, ParentProps, splitProps } from "solid-js"
|
|||||||
|
|
||||||
export interface HoverCardProps extends ParentProps, Omit<ComponentProps<typeof Kobalte>, "children"> {
|
export interface HoverCardProps extends ParentProps, Omit<ComponentProps<typeof Kobalte>, "children"> {
|
||||||
trigger: JSXElement
|
trigger: JSXElement
|
||||||
mount?: HTMLElement
|
|
||||||
class?: ComponentProps<"div">["class"]
|
class?: ComponentProps<"div">["class"]
|
||||||
classList?: ComponentProps<"div">["classList"]
|
classList?: ComponentProps<"div">["classList"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HoverCard(props: HoverCardProps) {
|
export function HoverCard(props: HoverCardProps) {
|
||||||
const [local, rest] = splitProps(props, ["trigger", "mount", "class", "classList", "children"])
|
const [local, rest] = splitProps(props, ["trigger", "class", "classList", "children"])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Kobalte gutter={4} {...rest}>
|
<Kobalte gutter={4} {...rest}>
|
||||||
<Kobalte.Trigger as="div" data-slot="hover-card-trigger">
|
<Kobalte.Trigger as="div" data-slot="hover-card-trigger">
|
||||||
{local.trigger}
|
{local.trigger}
|
||||||
</Kobalte.Trigger>
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal mount={local.mount}>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
data-component="hover-card-content"
|
data-component="hover-card-content"
|
||||||
classList={{
|
classList={{
|
||||||
|
|||||||
@@ -90,8 +90,8 @@
|
|||||||
/* color: var(--icon-hover); */
|
/* color: var(--icon-hover); */
|
||||||
/* } */
|
/* } */
|
||||||
}
|
}
|
||||||
&:focus-visible:not(:disabled) {
|
&:focus:not(:disabled) {
|
||||||
background-color: var(--surface-raised-base-hover);
|
background-color: var(--surface-focus);
|
||||||
}
|
}
|
||||||
&:active:not(:disabled) {
|
&:active:not(:disabled) {
|
||||||
background-color: var(--surface-raised-base-active);
|
background-color: var(--surface-raised-base-active);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
&:hover:not(:disabled),
|
&:hover:not(:disabled),
|
||||||
&:focus-visible:not(:disabled),
|
&:focus:not(:disabled),
|
||||||
&:active:not(:disabled) {
|
&:active:not(:disabled) {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
&:hover:not(:disabled),
|
&:hover:not(:disabled),
|
||||||
&:focus-visible:not(:disabled),
|
&:focus:not(:disabled),
|
||||||
&:active:not(:disabled) {
|
&:active:not(:disabled) {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@@ -105,6 +105,24 @@
|
|||||||
color: var(--icon-active);
|
color: var(--icon-active);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> [data-component="icon-button"] {
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
&:hover:not(:disabled),
|
||||||
|
&:focus:not(:disabled),
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) [data-slot="icon-svg"] {
|
||||||
|
color: var(--icon-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) [data-slot="icon-svg"] {
|
||||||
|
color: var(--icon-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="list-scroll"] {
|
[data-slot="list-scroll"] {
|
||||||
|
|||||||
@@ -21,12 +21,6 @@
|
|||||||
transform: translateX(50%);
|
transform: translateX(50%);
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
|
|
||||||
&[data-edge="start"] {
|
|
||||||
inset-inline-start: 0;
|
|
||||||
inset-inline-end: auto;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
width: 3px;
|
width: 3px;
|
||||||
inset-block: 0;
|
inset-block: 0;
|
||||||
@@ -42,12 +36,6 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
cursor: row-resize;
|
cursor: row-resize;
|
||||||
|
|
||||||
&[data-edge="end"] {
|
|
||||||
inset-block-start: auto;
|
|
||||||
inset-block-end: 0;
|
|
||||||
transform: translateY(50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
height: 3px;
|
height: 3px;
|
||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { splitProps, type JSX } from "solid-js"
|
|||||||
|
|
||||||
export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "onResize"> {
|
export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "onResize"> {
|
||||||
direction: "horizontal" | "vertical"
|
direction: "horizontal" | "vertical"
|
||||||
edge?: "start" | "end"
|
|
||||||
size: number
|
size: number
|
||||||
min: number
|
min: number
|
||||||
max: number
|
max: number
|
||||||
@@ -14,7 +13,6 @@ export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElemen
|
|||||||
export function ResizeHandle(props: ResizeHandleProps) {
|
export function ResizeHandle(props: ResizeHandleProps) {
|
||||||
const [local, rest] = splitProps(props, [
|
const [local, rest] = splitProps(props, [
|
||||||
"direction",
|
"direction",
|
||||||
"edge",
|
|
||||||
"size",
|
"size",
|
||||||
"min",
|
"min",
|
||||||
"max",
|
"max",
|
||||||
@@ -27,7 +25,6 @@ export function ResizeHandle(props: ResizeHandleProps) {
|
|||||||
|
|
||||||
const handleMouseDown = (e: MouseEvent) => {
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end")
|
|
||||||
const start = local.direction === "horizontal" ? e.clientX : e.clientY
|
const start = local.direction === "horizontal" ? e.clientX : e.clientY
|
||||||
const startSize = local.size
|
const startSize = local.size
|
||||||
let current = startSize
|
let current = startSize
|
||||||
@@ -37,14 +34,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
|
|||||||
|
|
||||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||||
const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY
|
const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY
|
||||||
const delta =
|
const delta = local.direction === "vertical" ? start - pos : pos - start
|
||||||
local.direction === "vertical"
|
|
||||||
? edge === "end"
|
|
||||||
? pos - start
|
|
||||||
: start - pos
|
|
||||||
: edge === "start"
|
|
||||||
? start - pos
|
|
||||||
: pos - start
|
|
||||||
current = startSize + delta
|
current = startSize + delta
|
||||||
const clamped = Math.min(local.max, Math.max(local.min, current))
|
const clamped = Math.min(local.max, Math.max(local.min, current))
|
||||||
local.onResize(clamped)
|
local.onResize(clamped)
|
||||||
@@ -71,7 +61,6 @@ export function ResizeHandle(props: ResizeHandleProps) {
|
|||||||
{...rest}
|
{...rest}
|
||||||
data-component="resize-handle"
|
data-component="resize-handle"
|
||||||
data-direction={local.direction}
|
data-direction={local.direction}
|
||||||
data-edge={local.edge ?? (local.direction === "vertical" ? "start" : "end")}
|
|
||||||
classList={{
|
classList={{
|
||||||
...(local.classList ?? {}),
|
...(local.classList ?? {}),
|
||||||
[local.class ?? ""]: !!local.class,
|
[local.class ?? ""]: !!local.class,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not([data-expanded]):focus-visible {
|
&:not([data-expanded]):focus {
|
||||||
&[data-variant="secondary"] {
|
&[data-variant="secondary"] {
|
||||||
background-color: var(--button-secondary-base);
|
background-color: var(--button-secondary-base);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { StickyAccordionHeader } from "./sticky-accordion-header"
|
|||||||
import { useDiffComponent } from "../context/diff"
|
import { useDiffComponent } from "../context/diff"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
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 { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
||||||
@@ -119,12 +118,6 @@ function dataUrlFromValue(value: unknown): string | undefined {
|
|||||||
return `data:${mime};base64,${content}`
|
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 = {
|
type SessionReviewSelection = {
|
||||||
file: string
|
file: string
|
||||||
range: SelectedLineRange
|
range: SelectedLineRange
|
||||||
@@ -496,12 +489,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion.Item
|
<Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
|
||||||
value={diff.file}
|
|
||||||
id={diffId(diff.file)}
|
|
||||||
data-file={diff.file}
|
|
||||||
data-slot="session-review-accordion-item"
|
|
||||||
>
|
|
||||||
<StickyAccordionHeader>
|
<StickyAccordionHeader>
|
||||||
<Accordion.Trigger>
|
<Accordion.Trigger>
|
||||||
<div data-slot="session-review-trigger-content">
|
<div data-slot="session-review-trigger-content">
|
||||||
|
|||||||
@@ -212,58 +212,6 @@
|
|||||||
/* } */
|
/* } */
|
||||||
}
|
}
|
||||||
|
|
||||||
&[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"] {
|
&[data-orientation="vertical"] {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Show, splitProps, type JSX } from "solid-js"
|
|||||||
import type { ComponentProps, ParentProps, Component } from "solid-js"
|
import type { ComponentProps, ParentProps, Component } from "solid-js"
|
||||||
|
|
||||||
export interface TabsProps extends ComponentProps<typeof Kobalte> {
|
export interface TabsProps extends ComponentProps<typeof Kobalte> {
|
||||||
variant?: "normal" | "alt" | "pill" | "settings"
|
variant?: "normal" | "alt" | "settings"
|
||||||
orientation?: "horizontal" | "vertical"
|
orientation?: "horizontal" | "vertical"
|
||||||
}
|
}
|
||||||
export interface TabsListProps extends ComponentProps<typeof Kobalte.List> {}
|
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> = {
|
export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
|
||||||
"oc-1": oc1Theme,
|
"oc-1": oc1Theme,
|
||||||
aura: auraTheme,
|
tokyonight: tokyonightTheme,
|
||||||
ayu: ayuTheme,
|
|
||||||
carbonfox: carbonfoxTheme,
|
|
||||||
catppuccin: catppuccinTheme,
|
|
||||||
dracula: draculaTheme,
|
dracula: draculaTheme,
|
||||||
gruvbox: gruvboxTheme,
|
|
||||||
monokai: monokaiTheme,
|
monokai: monokaiTheme,
|
||||||
nightowl: nightowlTheme,
|
solarized: solarizedTheme,
|
||||||
nord: nordTheme,
|
nord: nordTheme,
|
||||||
|
catppuccin: catppuccinTheme,
|
||||||
|
ayu: ayuTheme,
|
||||||
onedarkpro: oneDarkProTheme,
|
onedarkpro: oneDarkProTheme,
|
||||||
shadesofpurple: shadesOfPurpleTheme,
|
shadesofpurple: shadesOfPurpleTheme,
|
||||||
solarized: solarizedTheme,
|
nightowl: nightowlTheme,
|
||||||
tokyonight: tokyonightTheme,
|
|
||||||
vesper: vesperTheme,
|
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:
|
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`)
|
1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`, or `CONTEXT.md`)
|
||||||
2. **Global file** at `~/.config/opencode/AGENTS.md`
|
2. **Global file** at `~/.config/opencode/AGENTS.md`
|
||||||
3. **Claude Code file** at `~/.claude/CLAUDE.md` (unless disabled)
|
3. **Claude Code file** at `~/.claude/CLAUDE.md` (unless disabled)
|
||||||
|
|
||||||
|
|||||||
@@ -4,35 +4,6 @@ import { $ } from "bun"
|
|||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
import { buildNotes, getLatestRelease } from "./changelog"
|
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[] = []
|
let notes: string[] = []
|
||||||
|
|
||||||
console.log("=== publishing ===\n")
|
console.log("=== publishing ===\n")
|
||||||
@@ -40,7 +11,6 @@ console.log("=== publishing ===\n")
|
|||||||
if (!Script.preview) {
|
if (!Script.preview) {
|
||||||
const previous = await getLatestRelease()
|
const previous = await getLatestRelease()
|
||||||
notes = await buildNotes(previous, "HEAD")
|
notes = await buildNotes(previous, "HEAD")
|
||||||
notes.unshift(highlightsTemplate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkgjsons = await Array.fromAsync(
|
const pkgjsons = await Array.fromAsync(
|
||||||
|
|||||||
Reference in New Issue
Block a user