mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 04:27:20 +03:00
feat(plugin): add a home.main slot to Home
This commit is contained in:
@@ -53,6 +53,7 @@ import { LanguageProvider } from "../src/runtime/i18n/language"
|
||||
import { ExtensionPlugins } from "../src/runtime/plugin/extension-plugins"
|
||||
import { SessionPluginPills } from "../src/session/composer/plugin-pills"
|
||||
import { ui } from "../src/runtime/plugin/ui"
|
||||
import { HomePluginSlot } from "../src/home/plugin-slot"
|
||||
|
||||
export {
|
||||
bindPlugin,
|
||||
@@ -702,6 +703,63 @@ export function mountSessionRegion(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the real host and Home's plugin slot, 600px wide, for the server and project Home shows. `show(server,
|
||||
* directory)` changes them as the user's picks on Home do. `height()` is the slot's height.
|
||||
*/
|
||||
export function mountHomeSlot(input: { definitions: readonly Entry[] }) {
|
||||
const container = document.createElement("div")
|
||||
container.style.width = "600px"
|
||||
document.body.appendChild(container)
|
||||
const hosts: PluginHost[] = []
|
||||
|
||||
const [shown, setShown] = createStore<{ server: string; directory: string | undefined }>({
|
||||
server: "local",
|
||||
directory: undefined,
|
||||
})
|
||||
|
||||
function Home() {
|
||||
const host = usePluginHost()
|
||||
hosts.push(host)
|
||||
|
||||
return (
|
||||
<Show when={host.ready()}>
|
||||
<HomePluginSlot server={shown.server} directory={shown.directory} />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const dispose = render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<DialogProvider>
|
||||
<PluginHostProvider
|
||||
definitions={() => input.definitions}
|
||||
disabled={() => new Set<string>()}
|
||||
apis={fakeApis(unused)}
|
||||
whenMounted={mounted}
|
||||
>
|
||||
<Home />
|
||||
</PluginHostProvider>
|
||||
</DialogProvider>
|
||||
</LanguageProvider>
|
||||
),
|
||||
container,
|
||||
)
|
||||
|
||||
return {
|
||||
container,
|
||||
show: (server: string, directory: string | undefined) => setShown({ server, directory }),
|
||||
height: () => container.firstElementChild?.getBoundingClientRect().height,
|
||||
status: (id: string) => hosts[0]?.status(id),
|
||||
failure: (id: string) => hosts[0]?.failure(id),
|
||||
unmount: () => {
|
||||
dispose()
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the real host's theme contributions into the app's theme provider, as the window does. `options` reads the
|
||||
* theme picker's choices as Settings › Appearance lists them; `disable` and `enable` toggle a plugin as
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
Live,
|
||||
SessionRef,
|
||||
SessionScreen,
|
||||
SlotMap,
|
||||
} from "@opencode/plugin/gui"
|
||||
import type { BuiltinPanelTab } from "@opencode/plugin/gui/builtin"
|
||||
import type { Bridge, BridgeMessage, EnableState, Installed } from "@opencode/plugin/gui/host"
|
||||
@@ -985,6 +986,122 @@ story("a contribution that throws renders nothing and records the error; the oth
|
||||
})
|
||||
})
|
||||
|
||||
story(
|
||||
"Home's slot renders in order, follows the shown server and project without a remount, and isolates a throw",
|
||||
async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountHomeSlot, until, Slot } = await import(fixture)
|
||||
const mounts = { count: 0 }
|
||||
|
||||
const host = mountHomeSlot({
|
||||
definitions: [
|
||||
{
|
||||
id: "fixture",
|
||||
windowEntry: async () => ({
|
||||
default: (ctx: Context) => {
|
||||
ctx.add(Slot, {
|
||||
at: "home.main",
|
||||
render: () => {
|
||||
throw new Error("broken home card")
|
||||
},
|
||||
})
|
||||
ctx.add(Slot, {
|
||||
at: "home.main",
|
||||
render: (input: SlotMap["home.main"]) => {
|
||||
mounts.count++
|
||||
|
||||
// A render runs once; its own reactivity follows the input's getters.
|
||||
return () => `[${input.server} ${input.directory ?? "all"}]`
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
windowEntry: async () => ({
|
||||
default: (ctx: Context) =>
|
||||
void ctx.add(Slot, {
|
||||
at: "home.main",
|
||||
order: -1,
|
||||
render: () => "[first]",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await until(
|
||||
() => !!host.failure("fixture") && mounts.count > 0 && !!host.container.textContent?.includes("first"),
|
||||
)
|
||||
const seen = () => host.container.textContent
|
||||
const before = seen()
|
||||
host.show("http://remote:4096", "/repo")
|
||||
const after = seen()
|
||||
const failure = host.failure("fixture")
|
||||
|
||||
const outcome = {
|
||||
before,
|
||||
after,
|
||||
mounts: mounts.count,
|
||||
status: [host.status("fixture"), host.status("other")],
|
||||
failure: { phase: failure?.phase, named: !!failure?.error.includes("broken home card") },
|
||||
}
|
||||
|
||||
host.unmount()
|
||||
|
||||
return outcome
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({
|
||||
before: "[first][local all]",
|
||||
after: "[first][http://remote:4096 /repo]",
|
||||
mounts: 1,
|
||||
status: ["active", "active"],
|
||||
failure: { phase: "render", named: true },
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
story("Home's slot takes no room until a contribution renders something", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountHomeSlot, until, Slot, createSignal } = await import(fixture)
|
||||
const [loaded, setLoaded] = createSignal(false)
|
||||
const rendered = { count: 0 }
|
||||
|
||||
const host = mountHomeSlot({
|
||||
definitions: [
|
||||
{
|
||||
id: "fixture",
|
||||
windowEntry: async () => ({
|
||||
default: (ctx: Context) =>
|
||||
void ctx.add(Slot, {
|
||||
at: "home.main",
|
||||
// Nothing until its data loads, as a card that fetches does.
|
||||
render: () => {
|
||||
rendered.count++
|
||||
|
||||
return () => (loaded() ? Object.assign(document.createElement("p"), { textContent: "card" }) : null)
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await until(() => rendered.count > 0)
|
||||
const empty = host.height()
|
||||
setLoaded(true)
|
||||
await until(() => !!host.container.textContent?.includes("card"))
|
||||
const filled = host.height()
|
||||
host.unmount()
|
||||
|
||||
return { empty, filled: (filled ?? 0) > 0 }
|
||||
}, fixture)
|
||||
|
||||
expect(result).toEqual({ empty: 0, filled: true })
|
||||
})
|
||||
|
||||
story(
|
||||
"plugin GUIs join, hot-reload in place, run their returned cleanup and leave, without holding startup",
|
||||
async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PluginSlot } from "@/runtime/plugin/render"
|
||||
|
||||
/** Plugin content in Home's main column; it takes no room while no contribution renders anything. */
|
||||
export function HomePluginSlot(props: { server: string; directory: string | undefined }) {
|
||||
return (
|
||||
<div class="flex flex-col gap-2 py-2 empty:hidden md:py-1">
|
||||
<PluginSlot
|
||||
at="home.main"
|
||||
input={{
|
||||
get server() {
|
||||
return props.server
|
||||
},
|
||||
get directory() {
|
||||
return props.directory
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -501,6 +501,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
session: {
|
||||
showProjectName: () => !home.project.selected(),
|
||||
server: () => home.selection.value().server,
|
||||
directory: () => home.selection.value().directory,
|
||||
canCreate: () => !!home.project.newSession(),
|
||||
lookup: async (sessionID: string, signal?: AbortSignal) => {
|
||||
const ctx = home.server.focusedContext()
|
||||
|
||||
@@ -22,6 +22,7 @@ export function HomeSessions(props: {
|
||||
onRevealLocations={props.sessions.location.sync}
|
||||
showProjectName={props.sessions.session.showProjectName()}
|
||||
server={server()}
|
||||
directory={props.sessions.session.directory()}
|
||||
canCreateSession={props.sessions.session.canCreate()}
|
||||
searchValue={props.search.query.value()}
|
||||
searchPlaceholder={props.search.query.placeholder()}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { showToast } from "@/shell/notifications/toast"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { shouldOpenSessionInBackground } from "./open"
|
||||
import { createAltHold } from "./alt-hold"
|
||||
import { HomePluginSlot } from "../plugin-slot"
|
||||
import "./view.css"
|
||||
import {
|
||||
HomeSessionStatusController,
|
||||
@@ -76,6 +77,7 @@ export type HomeSessionsViewProps = {
|
||||
onRevealLocations: (record?: HomeSessionRecord) => void
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
directory: string | undefined
|
||||
canCreateSession: boolean
|
||||
searchValue: string
|
||||
searchPlaceholder: string
|
||||
@@ -228,20 +230,6 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
hoveredSession={hover.sessionID}
|
||||
onHoverSession={(sessionID) => setHover("sessionID", sessionID)}
|
||||
/>
|
||||
<Show when={props.groups.length > 0 && props.canCreateSession}>
|
||||
<div class="pointer-events-none absolute right-0 top-[68px] z-20 flex md:top-[84px] lg:top-[108px]">
|
||||
<Button
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={props.onCreateSession}
|
||||
>
|
||||
{props.language.t("command.session.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pointer-events-none sticky top-[68px] z-40 h-0 -mr-3 md:top-[84px] lg:top-[108px]">
|
||||
<div
|
||||
@@ -250,6 +238,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
class="relative ml-auto h-[calc(100cqh-68px)] w-3 md:h-[calc(100cqh-84px)] lg:h-[calc(100cqh-108px)]"
|
||||
/>
|
||||
</div>
|
||||
<HomePluginSlot server={props.server} directory={props.directory} />
|
||||
<div
|
||||
data-slot="home-sessions-list"
|
||||
data-reveal={skeleton() || undefined}
|
||||
@@ -293,6 +282,23 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
</Match>
|
||||
<Match when={props.listView.status === "ready"}>
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-1 pr-3 pb-16 md:pt-3">
|
||||
{/* Pinned beside the group header row, so content above the list never covers it. */}
|
||||
<Show when={props.canCreateSession}>
|
||||
<div class="pointer-events-none sticky top-[68px] z-20 h-0 md:top-[84px] lg:top-[108px]">
|
||||
<div class="absolute right-0 top-0 flex">
|
||||
<Button
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={props.onCreateSession}
|
||||
>
|
||||
{props.language.t("command.session.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Index keeps group subtrees mounted when the group arrays are
|
||||
rebuilt, so store updates cannot recreate rows mid-gesture. */}
|
||||
<Index each={props.groups}>
|
||||
|
||||
@@ -252,28 +252,32 @@ SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the
|
||||
|
||||
### Registries
|
||||
|
||||
| Registry | Process | What an item is |
|
||||
| ---------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [`Command`](../plugin/src/gui/registries.ts) | window | A palette command with an optional keybind and slash command |
|
||||
| [`MenuItem`](../plugin/src/gui/registries.ts) | window | An item of a host menu: the side panel + menu, Add server, a server row |
|
||||
| [`Panel`](../plugin/src/gui/registries.ts) | window | Tabs in the session's side region |
|
||||
| [`SettingsPage`](../plugin/src/gui/registries.ts) | window | A settings page, a section on a host page, or rows in a host section |
|
||||
| [`Server`](../plugin/src/gui/registries.ts) | window | A source of servers, such as SSH hosts |
|
||||
| [`LinkHandler`](../plugin/src/gui/registries.ts) | window | Opens local links, such as file paths in messages, and says whether their target exists |
|
||||
| [`TitlebarItem`](../plugin/src/gui/registries.ts) | window | A pill in the titlebar or the tabs footer |
|
||||
| [`ComposerPill`](../plugin/src/gui/registries.ts) | window | A pill in the row above a session's composer, optionally opening a menu of rows or a popover of your own content |
|
||||
| [`CodeBlock`](../plugin/src/gui/registries.ts) | window | Your UI in place of the highlighted code of complete fenced code blocks of the languages you list, such as a diagram or a table |
|
||||
| [`CodeBlockAction`](../plugin/src/gui/registries.ts) | window | A button beside the Copy button of complete fenced code blocks of the languages you list, such as "Run in terminal" |
|
||||
| [`Slot`](../plugin/src/gui/registries.ts) | window | Content for `window.bottom`, `session.header`, `session.location.menu`, `session.panel.end`; built-ins also fill `session.location.workspace`, `session.location.branch.menu` and `session.location.path.menu` through `BuiltinSlot` |
|
||||
| [`Style`](../plugin/src/gui/registries.ts) | window | CSS imported with `?inline` |
|
||||
| [`Theme`](../plugin/src/gui/registries.ts) | window | A color theme in the app's theme file format, listed in Settings › Appearance › Theme |
|
||||
| [`MenubarItem`](../plugin/src/gui/main.ts) | main | An item of the native app menu |
|
||||
| Registry | Process | What an item is |
|
||||
| ---------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`Command`](../plugin/src/gui/registries.ts) | window | A palette command with an optional keybind and slash command |
|
||||
| [`MenuItem`](../plugin/src/gui/registries.ts) | window | An item of a host menu: the side panel + menu, Add server, a server row |
|
||||
| [`Panel`](../plugin/src/gui/registries.ts) | window | Tabs in the session's side region |
|
||||
| [`SettingsPage`](../plugin/src/gui/registries.ts) | window | A settings page, a section on a host page, or rows in a host section |
|
||||
| [`Server`](../plugin/src/gui/registries.ts) | window | A source of servers, such as SSH hosts |
|
||||
| [`LinkHandler`](../plugin/src/gui/registries.ts) | window | Opens local links, such as file paths in messages, and says whether their target exists |
|
||||
| [`TitlebarItem`](../plugin/src/gui/registries.ts) | window | A pill in the titlebar or the tabs footer |
|
||||
| [`ComposerPill`](../plugin/src/gui/registries.ts) | window | A pill in the row above a session's composer, optionally opening a menu of rows or a popover of your own content |
|
||||
| [`CodeBlock`](../plugin/src/gui/registries.ts) | window | Your UI in place of the highlighted code of complete fenced code blocks of the languages you list, such as a diagram or a table |
|
||||
| [`CodeBlockAction`](../plugin/src/gui/registries.ts) | window | A button beside the Copy button of complete fenced code blocks of the languages you list, such as "Run in terminal" |
|
||||
| [`Slot`](../plugin/src/gui/registries.ts) | window | Content for `window.bottom`, `session.header`, `session.location.menu`, `session.panel.end`, `home.main`; built-ins also fill `session.location.workspace`, `session.location.branch.menu` and `session.location.path.menu` through `BuiltinSlot` |
|
||||
| [`Style`](../plugin/src/gui/registries.ts) | window | CSS imported with `?inline` |
|
||||
| [`Theme`](../plugin/src/gui/registries.ts) | window | A color theme in the app's theme file format, listed in Settings › Appearance › Theme |
|
||||
| [`MenubarItem`](../plugin/src/gui/main.ts) | main | An item of the native app menu |
|
||||
|
||||
`session.location.menu` contributes items after the project menu's location details. It receives the owning `screen`,
|
||||
the live `session`, and `shown`; keep data subscriptions active only while `shown` is true. Loading headers without an
|
||||
owning screen show the host's location details without this slot. Render its rows with `ctx.ui.MenuItem`,
|
||||
`ctx.ui.MenuSub`, `ctx.ui.MenuGroup` and `ctx.ui.MenuSeparator`, so they navigate and close like the host's rows.
|
||||
|
||||
`home.main` renders in Home's main column, between the session search and the session list, before any session is
|
||||
open. It receives no `screen` or `session`: only the `server` Home shows (a `ServerRef.id` for `ctx.servers.get`) and
|
||||
the project `directory` it filters to, undefined for every project. It takes no room while nothing renders into it.
|
||||
|
||||
`session.location.branch.menu` (built-ins only, through `BuiltinSlot`) contributes items after Copy branch name in
|
||||
the branch submenu, with the same `screen`, live `session`, and project-menu `shown` context. It renders only when a
|
||||
branch name and owning screen are available. Use it for actions on the branch's working directory, such as opening
|
||||
|
||||
@@ -897,6 +897,21 @@ export interface SlotMap {
|
||||
/** The routed session; a new object when another session is routed. */
|
||||
readonly session: MountedSession
|
||||
}
|
||||
/**
|
||||
* Home's main column, between the session search and the session list, before any session is open: onboarding, a
|
||||
* dashboard card, help or a status summary. It renders while Home shows a server's sessions, whether they load, fail
|
||||
* or are empty. It spans the column's width at every window size and scrolls away with the list. Contributions stack in `order` with a small gap, and the slot takes no room while none renders anything.
|
||||
* The fields are reactive getters: read them where you use them.
|
||||
*/
|
||||
readonly "home.main": {
|
||||
/**
|
||||
* The `ServerRef.id` of the server Home shows; read its ref with `ctx.servers.get`. Changes without a remount when
|
||||
* the user picks another server.
|
||||
*/
|
||||
readonly server: string
|
||||
/** The root directory of the project Home filters its sessions to; undefined while Home lists every project. */
|
||||
readonly directory: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Content for one of the host's slots, typed by `at`. */
|
||||
|
||||
@@ -996,7 +996,7 @@ interface SettingEntry {
|
||||
|
||||
### Slots
|
||||
|
||||
`Slot` renders UI into one of four window slots.
|
||||
`Slot` renders UI into one of the window's slots.
|
||||
|
||||
```tsx
|
||||
import { Slot } from "@opencode/plugin/gui"
|
||||
@@ -1013,6 +1013,7 @@ ctx.add(Slot, {
|
||||
`false`.
|
||||
- `session.location.menu` adds items to the project menu, after its location details.
|
||||
- `session.panel.end` holds actions at the trailing end of the side region's tab strip.
|
||||
- `home.main` is Home's main column, between the session search and the session list. It takes no room while empty.
|
||||
- `order` sorts the items in one slot.
|
||||
|
||||
`session.location.menu` receives `shown`, which is `true` while the project menu is open. Subscribe to location data
|
||||
@@ -1035,6 +1036,16 @@ ctx.add(Slot, {
|
||||
})
|
||||
```
|
||||
|
||||
`home.main` shows content before a session is open, such as onboarding or a dashboard card. It receives the `server`
|
||||
Home shows, as a `ServerRef.id`, and the project `directory` it filters to, or `undefined` for every project:
|
||||
|
||||
```tsx
|
||||
ctx.add(Slot, {
|
||||
at: "home.main",
|
||||
render: (input) => <TodayCard server={ctx.servers.get(input.server)} directory={input.directory} />,
|
||||
})
|
||||
```
|
||||
|
||||
#### Reference
|
||||
|
||||
```ts
|
||||
@@ -1047,6 +1058,7 @@ interface SlotMap {
|
||||
"session.header": { screen: SessionScreen; session: MountedSession; active: boolean }
|
||||
"session.location.menu": { screen: SessionScreen; session: MountedSession; shown: boolean }
|
||||
"session.panel.end": { screen: SessionScreen; session: MountedSession }
|
||||
"home.main": { server: string; directory: string | undefined }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user