Skip to content
91 changes: 66 additions & 25 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from
import { SidebarContent } from "./layout/sidebar-shell"

export default function Layout(props: ParentProps) {
const safe = new Set(["http:", "https:", "mailto:"])

const external = (href: string) => {
if (typeof URL.canParse === "function" && !URL.canParse(href)) return

try {
const url = new URL(href)
if (!safe.has(url.protocol)) return
return url.href
} catch {
return
}
}

const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]),
createStore({
Expand Down Expand Up @@ -146,6 +160,17 @@ export default function Layout(props: ParentProps) {
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => route().dir)

const [pluginItems] = createResource(
() => currentDir(),
async (directory) => {
if (!directory) return []
return globalSDK.client.plugin
.sidebar({ directory })
.then((x) => x.data?.items ?? [])
.catch(() => [])
},
)

const [state, setState] = createStore({
autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>,
Expand Down Expand Up @@ -2073,7 +2098,9 @@ export default function Layout(props: ParentProps) {
const clearNotifications = () =>
workspaces()
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
.forEach((directory) => {
notification.project.markViewed(directory)
})
const workspacesEnabled = createMemo(() => {
const item = project()
if (!item) return false
Expand Down Expand Up @@ -2371,6 +2398,17 @@ export default function Layout(props: ParentProps) {
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
pluginItems={pluginItems}
onOpenPluginItem={(href: string) => {
if (mobile) layout.mobileSidebar.hide()
if (href.startsWith("/")) {
navigate(href)
return
}

const url = external(href)
if (url) platform.openLink(url)
}}
renderPanel={() =>
mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
}
Expand Down Expand Up @@ -2435,7 +2473,11 @@ export default function Layout(props: ParentProps) {
/>

<div class="xl:hidden">
<div
<button
type="button"
aria-label={language.t("common.dismiss")}
aria-hidden={!layout.mobileSidebar.opened()}
tabIndex={layout.mobileSidebar.opened() ? 0 : -1}
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
Expand All @@ -2453,7 +2495,6 @@ export default function Layout(props: ParentProps) {
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
Expand Down Expand Up @@ -2482,29 +2523,29 @@ export default function Layout(props: ParentProps) {
</main>
</div>

<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<Show when={peekProject() && state.peeked && !layout.sidebar.opened()}>
<aside
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
</aside>
</Show>

<div
classList={{
Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/pages/layout/sidebar-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "@thisbeyond/solid-dnd"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { getIconName } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { type LocalProject } from "@/context/layout"

Expand All @@ -30,6 +31,8 @@ export const SidebarContent = (props: {
onOpenSettings: () => void
helpLabel: Accessor<string>
onOpenHelp: () => void
pluginItems: Accessor<{ id: string; label: string; icon: string; href: string }[] | undefined>
onOpenPluginItem: (href: string) => void
renderPanel: () => JSX.Element
}): JSX.Element => {
const expanded = createMemo(() => !!props.mobile || props.opened())
Expand Down Expand Up @@ -90,6 +93,21 @@ export const SidebarContent = (props: {
</DragDropProvider>
</div>
<div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
<div class="flex max-h-40 w-full flex-col items-center gap-2 overflow-y-auto no-scrollbar">
<For each={props.pluginItems() || []}>
{(item) => (
<Tooltip placement={placement()} value={item.label}>
<IconButton
icon={getIconName(item.icon)}
variant="ghost"
size="large"
onClick={() => props.onOpenPluginItem(item.href)}
aria-label={item.label}
/>
</Tooltip>
)}
</For>
</div>
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
<IconButton
icon="settings-gear"
Expand Down
14 changes: 8 additions & 6 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,12 +497,14 @@ export namespace Config {
async function loadPlugin(dir: string) {
const plugins: string[] = []

for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
for (const item of (
await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})
).sort((a, b) => a.localeCompare(b))) {
plugins.push(pathToFileURL(item).href)
}
return plugins
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/semver.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
declare module "semver" {
const semver: {
satisfies(version: string, range: string): boolean
lt(lhs: string, rhs: string): boolean
gt(lhs: string, rhs: string): boolean
major(version: string): number
minor(version: string): number
}

export default semver
}
63 changes: 63 additions & 0 deletions packages/opencode/src/server/routes/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { SidebarItem } from "@opencode-ai/plugin"
import { Hono } from "hono"
import { describeRoute, resolver } from "hono-openapi"
import z from "zod"
import { Plugin } from "../../plugin"
import { Log } from "../../util/log"
import { lazy } from "../../util/lazy"

const log = Log.create({ service: "server" })

const sidebarItem = z.object({
id: z.string(),
label: z.string(),
icon: z.string(),
href: z.string(),
order: z.number().optional(),
})

const result = z.object({
items: sidebarItem.array(),
})

function sort(a: SidebarItem, b: SidebarItem) {
const order = (a.order ?? 0) - (b.order ?? 0)
if (order) return order
return a.id.localeCompare(b.id)
}

export const PluginRoutes = lazy(() =>
new Hono().get(
"/sidebar",
describeRoute({
summary: "List plugin sidebar items",
description: "Retrieve sidebar items contributed by loaded plugins.",
operationId: "plugin.sidebar",
responses: {
200: {
description: "Plugin sidebar items",
content: {
"application/json": {
schema: resolver(result),
},
},
},
},
}),
async (c) => {
const body = await Plugin.trigger("ui.sidebar", {}, { items: [] as SidebarItem[] })
.then((output) => {
const map = new Map<string, SidebarItem>()
for (const sidebarItem of output.items) map.set(sidebarItem.id, sidebarItem)
return {
items: [...map.values()].sort(sort),
}
})
.catch((error) => {
log.error("plugin sidebar failed", { error })
return { items: [] as SidebarItem[] }
})
return c.json(body)
},
),
)
2 changes: 2 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { Snapshot } from "@/snapshot"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
import { PluginRoutes } from "./routes/plugin"
import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
import { initProjectors } from "./projectors"
Expand Down Expand Up @@ -255,6 +256,7 @@ export namespace Server {
.route("/permission", PermissionRoutes())
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
.route("/plugin", PluginRoutes())
.route("/", FileRoutes())
.route("/", EventRoutes())
.route("/mcp", McpRoutes())
Expand Down
Loading
Loading