mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-14 04:46:23 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea5ae23295 |
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-euVUyj0CzjCA1nYbN2vKctEPzLkUlNGTK2dMNbackqM=",
|
||||
"aarch64-linux": "sha256-qQkjqaxpjAae+rohoWI601QnrgKYghJ+ttqeiQBTwCM=",
|
||||
"aarch64-darwin": "sha256-HYWs31TJlDZsDBNmbPARo16r7zNKy9x840uHGcUMYsk=",
|
||||
"x86_64-darwin": "sha256-89FOrX813FENk3u8RAHCfyD7voaZWW++Z4Gpa3SkOJs="
|
||||
"x86_64-linux": "sha256-yzCk746pospz8EVakHRcDhYJkhGYGSt9dHOPbzO4OYo=",
|
||||
"aarch64-linux": "sha256-MFJVLos4v2r9jazmmr3ldVCJKLrO+Qp/BpGpyM/1lf8=",
|
||||
"aarch64-darwin": "sha256-k8r/HVSgdRSTlJ1lI7EebqbxeA3AElnaw1sDYOPEdQw=",
|
||||
"x86_64-darwin": "sha256-ACJdJfz12xLBQvWIkbuve86znSoGJ2PLDQbgh0cT6/g="
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,22 +33,19 @@ for (const rtl of [false, true]) {
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveAttribute("aria-expanded", "true")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await trigger.boundingBox()
|
||||
const view = await page.locator('[data-component="new-session"]').boundingBox()
|
||||
const project = await summary.locator('[data-section="project"]').boundingBox()
|
||||
const server = await summary.locator('[data-section="server"]').boundingBox()
|
||||
if (!button || !project || !server) return
|
||||
if (!view || !project || !server) return
|
||||
return {
|
||||
top: project.y - button.y - button.height,
|
||||
top: project.y - view.y - 48,
|
||||
cards: server.y - project.y - project.height,
|
||||
}
|
||||
})
|
||||
.toEqual({ top: 12, cards: 8 })
|
||||
.toEqual({ top: 6, cards: 8 })
|
||||
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
|
||||
@@ -248,7 +248,7 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
expect(messageAfter).toEqual(messageBefore)
|
||||
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -55,8 +55,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
"href",
|
||||
`#opencode-v2-icon-${workspace ? "outline-worktree" : "monitor"}`,
|
||||
)
|
||||
// Initial layout scrolls this sticky header's ancestor and dismisses its tooltip.
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
await trigger.hover()
|
||||
await expect(trigger).not.toHaveCSS("background-color", background)
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const server = "http://summary-remote.test:4096"
|
||||
const path = "/home/remote/.config/opencode/opencode.jsonc"
|
||||
|
||||
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
|
||||
|
||||
for (const service of [
|
||||
{ name: "MCP", config: { mcp: { servers: {} } } },
|
||||
{ name: "Plugins", config: { plugins: [] } },
|
||||
{ name: "Skills", config: { skills: [] } },
|
||||
{ name: "LSP", config: { lsp: false } },
|
||||
]) {
|
||||
test(`remote ${service.name} copies its configuration path with timeline copy feedback`, async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.route("**/api/config**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{ type: "document", path, info: service.config },
|
||||
{ type: "document", path: `${fixture.directory}/opencode.json`, info: {} },
|
||||
{ type: "document", path: `${fixture.directory}/.opencode/agents/review.md`, info: {} },
|
||||
],
|
||||
})
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${fixture.targetID}`)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page
|
||||
.getByRole("dialog", { name: "Session details", exact: true })
|
||||
.getByRole("button", { name: service.name, exact: true })
|
||||
.click()
|
||||
const menu = page.getByRole("dialog", { name: service.name, exact: true })
|
||||
const copy = menu.getByRole("button", { name: "Copy configuration file path", exact: true })
|
||||
const tooltipOffset = async () => {
|
||||
const icon = await copy.locator("svg").boundingBox()
|
||||
const tooltip = await page.getByRole("tooltip").boundingBox()
|
||||
if (!icon || !tooltip) return Infinity
|
||||
return Math.abs(tooltip.x + tooltip.width / 2 - icon.x - icon.width / 2)
|
||||
}
|
||||
await expect(copy).toBeEnabled()
|
||||
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
|
||||
await expect(copy.locator(".session-service-config-arrow")).toHaveCount(0)
|
||||
await copy.hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Copy")
|
||||
await expect.poll(tooltipOffset).toBeLessThanOrEqual(1)
|
||||
await copy.click()
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(path)
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Copied")
|
||||
await expect.poll(tooltipOffset).toBeLessThanOrEqual(1)
|
||||
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-check")
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
|
||||
})
|
||||
}
|
||||
|
||||
test("remote configuration without a file path reports the problem instead of copying a directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setup(page)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${fixture.targetID}`)
|
||||
await page.evaluate(() => navigator.clipboard.writeText("original clipboard"))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page
|
||||
.getByRole("dialog", { name: "Session details", exact: true })
|
||||
.getByRole("button", { name: "Skills", exact: true })
|
||||
.click()
|
||||
const copy = page
|
||||
.getByRole("dialog", { name: "Skills", exact: true })
|
||||
.getByRole("button", { name: "Copy configuration file path", exact: true })
|
||||
await copy.click()
|
||||
await expect(page.getByText("No configuration file found", { exact: true })).toBeVisible()
|
||||
await expect(copy).toBeEnabled()
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("original clipboard")
|
||||
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
|
||||
})
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
server,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.addInitScript((server) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
list: [{ type: "http", http: { url: server }, displayName: "Remote server" }],
|
||||
projects: {},
|
||||
lastProject: {},
|
||||
}),
|
||||
)
|
||||
}, server)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(trigger).toBeFocused()
|
||||
|
||||
await trigger.click()
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
|
||||
// Cross the actual chat-panel breakpoint, including any surrounding shell width.
|
||||
const shell = 1440 - (await panel.boundingBox())!.width
|
||||
await page.setViewportSize({ width: 1320 + shell, height: 900 })
|
||||
@@ -124,53 +124,6 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`summary truncates long copy before the indicator column in ${direction}`, async ({ page }) => {
|
||||
const branch = `feature/${"long-branch-name-".repeat(12)}`
|
||||
await mockStressTimeline(page)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/vcs",
|
||||
(route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: { location: { directory: fixture.directory }, data: { branch: { current: branch, default: "main" } } },
|
||||
})
|
||||
},
|
||||
)
|
||||
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", direction)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const text = summary.getByText(branch, { exact: true })
|
||||
await expect(text).toBeVisible()
|
||||
await expect(text).toHaveCSS("text-overflow", "ellipsis")
|
||||
await expect.poll(() => text.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
|
||||
const arrow = summary
|
||||
.getByRole("button", { name: "Local repository", exact: true })
|
||||
.locator(".session-summary-menu-indicator")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const label = await text.boundingBox()
|
||||
const icon = await arrow.boundingBox()
|
||||
if (!label || !icon) return 0
|
||||
return direction === "ltr" ? icon.x - label.x - label.width : label.x - icon.x - icon.width
|
||||
})
|
||||
.toBeGreaterThanOrEqual(12)
|
||||
for (const name of ["Local repository", "MCP", "Plugins", "Skills", "LSP"]) {
|
||||
const row = summary.getByRole("button", { name, exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const label = await row.locator(".session-summary-label").boundingBox()
|
||||
const icon = await row.locator(".session-summary-menu-indicator").boundingBox()
|
||||
if (!label || !icon) return 0
|
||||
return direction === "ltr" ? icon.x - label.x - label.width : label.x - icon.x - icon.width
|
||||
})
|
||||
.toBeGreaterThanOrEqual(12)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 })
|
||||
|
||||
@@ -164,5 +164,5 @@ test("multiple desktop connections show the session's server name", async ({ pag
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveCount(0)
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -3,10 +3,10 @@ import { fixture } from "../performance/timeline/session-timeline-stress.fixture
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
const services = [
|
||||
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured", item: "summary-mcp" },
|
||||
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured", item: "summary-plugin" },
|
||||
{ name: "Skills", path: "/api/skill", empty: "No skills configured", item: "summary-skill" },
|
||||
{ name: "LSP", path: "/api/config", empty: "No LSP servers configured", item: "summary-lsp" },
|
||||
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured yet", item: "summary-mcp" },
|
||||
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured yet", item: "summary-plugin" },
|
||||
{ name: "Skills", path: "/api/skill", empty: "No skills configured yet", item: "summary-skill" },
|
||||
{ name: "LSP", path: "/api/config", empty: "No LSP servers explicitly configured", item: "summary-lsp" },
|
||||
] as const
|
||||
|
||||
for (const service of services) {
|
||||
@@ -56,16 +56,15 @@ for (const service of services) {
|
||||
const content = menu.getByText(empty ? service.empty : service.item, { exact: true })
|
||||
await expect(content).toBeVisible()
|
||||
await expect(menu).toHaveAttribute("aria-busy", "false")
|
||||
await expect(menu).toHaveCSS("width", empty ? "232px" : "280px")
|
||||
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
|
||||
if (empty) {
|
||||
const message = menu.locator(".session-service-empty")
|
||||
await expect(message).toHaveCSS("padding", "0px")
|
||||
await expect(message).toHaveCSS("gap", "0px")
|
||||
await expect(message.locator("strong")).toHaveCSS("padding", "8px 12px")
|
||||
await expect(message).toHaveCSS("font-size", "13px")
|
||||
await expect(message).toHaveCSS("padding", "8px 12px")
|
||||
await expect(message).toHaveCSS("gap", "8px")
|
||||
await expect(message).toHaveCSS("font-size", "11px")
|
||||
await expect(message).toHaveCSS("line-height", "16px")
|
||||
await expect(message.locator("strong")).toHaveCSS("font-weight", "530")
|
||||
await expect(message.locator(".session-service-footer")).toHaveCSS("font-weight", "440")
|
||||
await expect(message.locator("p")).toHaveCSS("font-weight", "440")
|
||||
await testInfo.attach(`${service.name}-empty`, { body: await menu.screenshot(), contentType: "image/png" })
|
||||
}
|
||||
await page.keyboard.press("Escape")
|
||||
@@ -80,7 +79,7 @@ for (const service of services) {
|
||||
await expect(menu).toHaveAttribute("aria-busy", "true")
|
||||
await expect(content).toBeVisible()
|
||||
await expect(menu.getByRole("status")).toHaveCount(0)
|
||||
await expect(menu).toHaveCSS("width", empty ? "232px" : "280px")
|
||||
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
|
||||
await expect(summary).toBeVisible()
|
||||
} finally {
|
||||
response.resolve()
|
||||
@@ -111,16 +110,16 @@ test("prefetching plugins does not suspend the summary or report an empty catalo
|
||||
try {
|
||||
await expect.poll(() => state.requested).toBe(true)
|
||||
await expect(summary.getByRole("button", { name: fixture.project.name, exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "Plugins", exact: true })
|
||||
await expect(menu.getByRole("status")).toContainText("Loading")
|
||||
await expect(menu.getByText("No plugins configured", { exact: true })).toHaveCount(0)
|
||||
await expect(menu.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
|
||||
await expect(summary).toBeVisible()
|
||||
} finally {
|
||||
response.resolve()
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured", { exact: true }),
|
||||
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured yet", { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ for (const custom of [false, true]) {
|
||||
).toBeInViewport()
|
||||
await page.keyboard.press(shortcut)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
|
||||
await expect.poll(() => summary.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
await expect(tooltip).toBeHidden()
|
||||
await page.keyboard.press(shortcut)
|
||||
@@ -74,14 +74,14 @@ for (const layout of ["horizontal", "vertical"] as const) {
|
||||
await trigger.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const project = summary.getByRole("button", { name: fixture.project.name, exact: true })
|
||||
const server = summary.getByRole("button", { name: "Extensions", exact: true })
|
||||
const server = summary.getByRole("button", { name: "Server", exact: true })
|
||||
await expect(project).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(server).toHaveAttribute("aria-expanded", "true")
|
||||
for (const heading of [project, server]) {
|
||||
await expect(heading).toHaveCSS("column-gap", "8px")
|
||||
await expect(heading.locator(".session-summary-label")).toHaveCSS("flex-grow", "0")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "14")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "14")
|
||||
await expect(heading.locator(".session-summary-heading-label")).toHaveCSS("column-gap", "4px")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "16")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "16")
|
||||
}
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await testInfo.attach(`summary-${layout}`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
@@ -134,7 +134,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await mcp.hover()
|
||||
await expect(submenu).toHaveCount(0)
|
||||
await mcp.click()
|
||||
await expect(submenu.getByText("No MCP servers configured", { exact: true })).toBeVisible()
|
||||
await expect(submenu.getByText("No MCP servers configured yet", { exact: true })).toBeVisible()
|
||||
await expect(summary).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -150,20 +150,20 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(summary).toBeVisible()
|
||||
await expect(mcp).toBeFocused()
|
||||
await mcp.press("Enter")
|
||||
await expect(submenu.getByText("Configuration file")).toBeVisible()
|
||||
await expect(submenu.getByText("Add servers in opencode.json")).toBeVisible()
|
||||
await mcp.click()
|
||||
await expect(submenu).toBeHidden()
|
||||
|
||||
for (const [name, text] of [
|
||||
["Plugins", "No plugins configured"],
|
||||
["Skills", "No skills configured"],
|
||||
["LSP", "No LSP servers configured"],
|
||||
["Plugins", "No plugins configured yet"],
|
||||
["Skills", "No skills configured yet"],
|
||||
["LSP", "No LSP servers explicitly configured"],
|
||||
]) {
|
||||
await summary.getByRole("button", { name, exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name, exact: true }).getByText(text, { exact: true })).toBeVisible()
|
||||
await expect(submenu).toBeHidden()
|
||||
}
|
||||
await summary.getByRole("button", { name: "Extensions", exact: true }).click()
|
||||
await summary.getByRole("button", { name: "Server", exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(summary).toBeHidden()
|
||||
@@ -269,7 +269,7 @@ test("catalog submenus show project plugins and skills, refresh on reopen, and d
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
|
||||
await expect(plugins.getByRole("alert")).toContainText("Request failed")
|
||||
await expect(plugins.getByText("No plugins configured", { exact: true })).toHaveCount(0)
|
||||
await expect(plugins.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
|
||||
state.fail = false
|
||||
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
|
||||
await expect(plugins.getByText("supermemory", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -6,7 +6,6 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
|
||||
|
||||
export interface MockServerConfig {
|
||||
server?: string
|
||||
provider: unknown | (() => unknown)
|
||||
integrationMethods?: Record<string, unknown[]>
|
||||
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||
@@ -51,9 +50,7 @@ type MockStreamWindow = Window & {
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const server =
|
||||
config.server ??
|
||||
`http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
await page.addInitScript(
|
||||
({ server, retry }) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ export function NewSessionSummary(props: {
|
||||
<div class="session-summary-card">
|
||||
<button type="button" class="session-summary-row" onClick={props.onChooseProject}>
|
||||
<Icon name="folder" class="text-v2-icon-icon-muted" />
|
||||
<span class="session-summary-label">{language.t("session.summary.chooseProject")}</span>
|
||||
{language.t("session.summary.chooseProject")}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Icon
|
||||
name={summary() ? "fill-triangle-down" : "chevron-down"}
|
||||
size={summary() ? "normal" : "small"}
|
||||
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
@@ -294,7 +294,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Icon
|
||||
name={summary() ? "fill-triangle-down" : "chevron-down"}
|
||||
size={summary() ? "normal" : "small"}
|
||||
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
|
||||
@@ -1431,7 +1431,7 @@ export const dict = {
|
||||
"session.summary.tooltip": "Summary",
|
||||
"session.summary.noBranch": "No branch",
|
||||
"session.summary.basedOn": "Based on {{branch}}",
|
||||
"session.summary.server": "Extensions",
|
||||
"session.summary.server": "Server",
|
||||
"session.summary.chooseProject": "Choose a project",
|
||||
"session.summary.mcp.onCreation": "Applies when the worktree is created",
|
||||
"session.summary.mcp.prepareFailed": "Could not prepare MCP servers",
|
||||
@@ -1439,29 +1439,23 @@ export const dict = {
|
||||
"session.summary.mcp.signInBeforeSend": "Sign in to {{name}} before sending the prompt.",
|
||||
"session.summary.mcp.notReady": "MCP server {{name}} is not ready. Resolve its connection before sending the prompt.",
|
||||
"session.summary.mcp": "MCP",
|
||||
"session.summary.mcp.title": "Configured MCP servers",
|
||||
"session.summary.plugins": "Plugins",
|
||||
"session.summary.plugins.configured": "Configured plugins",
|
||||
"session.summary.skills": "Skills",
|
||||
"session.summary.skills.configured": "Configured skills",
|
||||
"session.summary.lsp": "LSP",
|
||||
"session.summary.failed": "Failed",
|
||||
"session.summary.retry": "Retry",
|
||||
"session.summary.connecting": "Connecting…",
|
||||
"session.summary.needsAuth": "Sign in required",
|
||||
"session.summary.configure": "Configuration file",
|
||||
"session.summary.copyConfigPath": "Copy configuration file path",
|
||||
"session.summary.configFileMissing": "No configuration file found",
|
||||
"session.summary.mcp.empty": "No MCP servers configured",
|
||||
"session.summary.mcp.empty": "No MCP servers configured yet",
|
||||
"session.summary.mcp.add": "Add servers in opencode.json",
|
||||
"session.summary.plugins.manage": "Manage plugins in opencode.json",
|
||||
"session.summary.plugins.empty": "No plugins configured",
|
||||
"session.summary.plugins.empty": "No plugins configured yet",
|
||||
"session.summary.plugins.add": "Add plugins in opencode.json",
|
||||
"session.summary.skills.manage": "Manage skills in opencode.json",
|
||||
"session.summary.skills.empty": "No skills configured",
|
||||
"session.summary.skills.empty": "No skills configured yet",
|
||||
"session.summary.skills.add": "Add skills in opencode.json",
|
||||
"session.summary.lsp.configured": "Configured LSPs",
|
||||
"session.summary.lsp.empty": "No LSP servers configured",
|
||||
"session.summary.lsp.empty": "No LSP servers explicitly configured",
|
||||
"session.summary.lsp.manage": "Manage LSP in opencode.json",
|
||||
"workspace.type.local": "local",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
|
||||
@@ -58,7 +58,7 @@ export function SessionSummaryPanel(props: {
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{location()}
|
||||
</span>
|
||||
<Icon name="fill-triangle-down" class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon name="fill-triangle-down" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</SessionWorkspaceMenu>
|
||||
<div class="session-summary-row">
|
||||
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
@@ -87,29 +87,15 @@ export function SessionSummaryPanel(props: {
|
||||
</div>
|
||||
<button type="button" class="session-summary-row" onClick={props.onReview}>
|
||||
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="session-summary-label flex items-center gap-2">
|
||||
<Show
|
||||
when={props.diffs}
|
||||
fallback={
|
||||
<span class="truncate text-v2-text-text-muted">{language.t("session.review.loadingChanges")}</span>
|
||||
}
|
||||
>
|
||||
{(diffs) => (
|
||||
<Show
|
||||
when={diffs().length > 0}
|
||||
fallback={
|
||||
<span class="truncate text-v2-text-text-muted">{language.t("session.review.noChanges")}</span>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">
|
||||
{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}
|
||||
</span>
|
||||
<span class="shrink-0 text-v2-text-text-muted">·</span>
|
||||
<DiffChanges appearance="standard" changes={diffs()} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={props.diffs} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
|
||||
{(diffs) => (
|
||||
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
|
||||
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<DiffChanges appearance="standard" changes={diffs()} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
|
||||
</ProjectSummaryCard>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Show, type ParentProps } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import "./summary.css"
|
||||
|
||||
export function SummaryPopover(
|
||||
props: ParentProps<{ active?: boolean; open: boolean; onOpenChange: (open: boolean) => void }>,
|
||||
@@ -29,9 +28,8 @@ export function SummaryPopover(
|
||||
)
|
||||
const keybind = () => command.keybindParts("session.summary.toggle")
|
||||
return (
|
||||
<Popover open={props.open} placement="bottom-end" gutter={8} overflowPadding={16} onOpenChange={props.onOpenChange}>
|
||||
{/* Match the button's vertical bounds; the 8px gutter plus 4px content padding gives a 12px card gap. */}
|
||||
<Popover.Anchor class="pointer-events-none absolute end-3 top-2.5 h-7 w-0" aria-hidden="true" />
|
||||
<Popover open={props.open} placement="bottom-end" gutter={2} overflowPadding={16} onOpenChange={props.onOpenChange}>
|
||||
<Popover.Anchor class="pointer-events-none absolute end-3 top-0 h-12 w-0" aria-hidden="true" />
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={
|
||||
@@ -55,7 +53,7 @@ export function SummaryPopover(
|
||||
</Tooltip>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="session-summary-popover z-50 max-h-[calc(100dvh-96px)] overflow-y-auto border-0 bg-transparent p-1 outline-none"
|
||||
class="z-50 max-h-[calc(100dvh-96px)] overflow-y-auto border-0 bg-transparent p-1 outline-none"
|
||||
aria-label={language.t("session.summary.title")}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -33,10 +33,12 @@ export function ProjectSummaryCard(
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)}
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{displayName(props.project)}
|
||||
<span class="session-summary-heading-label">
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="session-summary-disclosure" />
|
||||
</button>
|
||||
<Show when={expanded()}>
|
||||
<div id={contentID} class="session-summary-rows">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { getDirectory } from "@opencode/util/path"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
@@ -17,13 +15,11 @@ import {
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { pluginLabel } from "@/providers/catalog/plugin"
|
||||
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
|
||||
import { configuredLsps } from "./configured-lsp"
|
||||
@@ -31,8 +27,8 @@ import { configuredLsps } from "./configured-lsp"
|
||||
const services = [
|
||||
{ type: "mcp", icon: "mcp", label: "session.summary.mcp" },
|
||||
{ type: "plugins", icon: "cube", label: "session.summary.plugins" },
|
||||
{ type: "skills", icon: "graduation-cap", label: "session.summary.skills" },
|
||||
{ type: "lsp", icon: "code-slash", label: "session.summary.lsp" },
|
||||
{ type: "skills", icon: "post-skill", label: "session.summary.skills" },
|
||||
{ type: "lsp", icon: "code", label: "session.summary.lsp" },
|
||||
] as const
|
||||
|
||||
type Service = (typeof services)[number]["type"]
|
||||
@@ -72,10 +68,12 @@ export function SessionServerPanel(props: { directory: string; shown: boolean; m
|
||||
onClick={() => settings.sessionSummary.setServerExpanded(!expanded())}
|
||||
>
|
||||
<Icon name="server" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{name()}
|
||||
<span class="session-summary-heading-label">
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{name()}
|
||||
</span>
|
||||
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="session-summary-disclosure" />
|
||||
</button>
|
||||
<Show when={expanded() ? props.directory : undefined} keyed>
|
||||
{(directory) => (
|
||||
@@ -133,10 +131,13 @@ function LspMenu(props: ServiceMenuProps) {
|
||||
<Show
|
||||
when={names().length}
|
||||
fallback={
|
||||
<ServiceEmpty title={language.t("session.summary.lsp.empty")} directory={props.directory} service="lsp" />
|
||||
<ServiceEmpty
|
||||
title={language.t("session.summary.lsp.empty")}
|
||||
description={language.t("session.summary.lsp.manage")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<h3 class="session-service-title">{language.t("session.summary.lsp.configured")}</h3>
|
||||
<div class="session-service-message">{language.t("session.summary.lsp.configured")}</div>
|
||||
<For each={names()}>
|
||||
{(name) => (
|
||||
<div class="session-service-row">
|
||||
@@ -146,9 +147,7 @@ function LspMenu(props: ServiceMenuProps) {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="session-service-footer">
|
||||
<ServiceConfigLink directory={props.directory} service="lsp" />
|
||||
</div>
|
||||
<div class="session-service-message">{language.t("session.summary.lsp.manage")}</div>
|
||||
</Show>
|
||||
</ServicePopover>
|
||||
)
|
||||
@@ -198,10 +197,12 @@ function McpMenu(props: ServiceMenuProps) {
|
||||
<Show
|
||||
when={servers().length}
|
||||
fallback={
|
||||
<ServiceEmpty title={language.t("session.summary.mcp.empty")} directory={props.directory} service="mcp" />
|
||||
<ServiceEmpty
|
||||
title={language.t("session.summary.mcp.empty")}
|
||||
description={language.t("session.summary.mcp.add")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<h3 class="session-service-title">{language.t("session.summary.mcp.title")}</h3>
|
||||
<Show when={props.mcp?.preview}>
|
||||
<div class="session-service-message" data-slot="mcp-preview-hint">
|
||||
{language.t("session.summary.mcp.onCreation")}
|
||||
@@ -266,9 +267,6 @@ function McpMenu(props: ServiceMenuProps) {
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
<div class="session-service-footer">
|
||||
<ServiceConfigLink directory={props.directory} service="mcp" />
|
||||
</div>
|
||||
</Show>
|
||||
</ServicePopover>
|
||||
)
|
||||
@@ -337,18 +335,17 @@ function ServiceCatalog(props: ServiceMenuProps) {
|
||||
title={language.t(
|
||||
props.service.type === "plugins" ? "session.summary.plugins.empty" : "session.summary.skills.empty",
|
||||
)}
|
||||
directory={props.directory}
|
||||
service={props.service.type}
|
||||
description={language.t(
|
||||
props.service.type === "plugins" ? "session.summary.plugins.add" : "session.summary.skills.add",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<h3 class="session-service-title">
|
||||
<div class="session-service-message">
|
||||
{language.t(
|
||||
props.service.type === "plugins"
|
||||
? "session.summary.plugins.configured"
|
||||
: "session.summary.skills.configured",
|
||||
props.service.type === "plugins" ? "session.summary.plugins.manage" : "session.summary.skills.manage",
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<For each={list()}>
|
||||
{(item) => (
|
||||
<div class="session-service-row" title={item.error ?? item.name}>
|
||||
@@ -362,9 +359,6 @@ function ServiceCatalog(props: ServiceMenuProps) {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="session-service-footer">
|
||||
<ServiceConfigLink directory={props.directory} service={props.service.type} />
|
||||
</div>
|
||||
</Show>
|
||||
</ServicePopover>
|
||||
)
|
||||
@@ -399,7 +393,7 @@ function ServicePopover(
|
||||
<Popover.Trigger as="button" type="button" class="session-summary-row">
|
||||
<Icon name={props.service.icon} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="session-summary-label">{language.t(props.service.label)}</span>
|
||||
<Icon name="fill-triangle-down" class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon name="fill-triangle-down" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
@@ -437,100 +431,11 @@ function ServicePopover(
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceConfigLink(props: { directory: string; service: Service }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const [store, setStore] = createStore({ opening: false, copied: false })
|
||||
const label = () => language.t(server.isLocal ? "session.summary.configure" : "session.summary.copyConfigPath")
|
||||
createEffect(() => {
|
||||
if (!store.copied) return
|
||||
const timeout = setTimeout(() => setStore("copied", false), 2000)
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
})
|
||||
const activate = async () => {
|
||||
const revealPath = platform.revealPath
|
||||
if (store.opening || (server.isLocal && !revealPath)) return
|
||||
setStore({ opening: true, copied: false })
|
||||
const directory = props.directory
|
||||
await sdk.api.config
|
||||
.get({ location: { directory } })
|
||||
.then(async (entries) => {
|
||||
const documents = entries
|
||||
.filter((entry) => entry.type === "document")
|
||||
.filter((entry) => entry.path !== undefined && /\.jsonc?$/.test(entry.path))
|
||||
const path =
|
||||
documents.findLast((entry) => entry.info[props.service] !== undefined)?.path ?? documents.at(-1)?.path
|
||||
if (!server.isLocal) {
|
||||
if (!path) throw new Error(language.t("session.summary.configFileMissing"))
|
||||
await (platform.writeClipboardText?.(path) ?? navigator.clipboard.writeText(path))
|
||||
setStore("copied", true)
|
||||
return
|
||||
}
|
||||
if (path && (await revealPath?.(path))) return
|
||||
await platform.openPath?.(path ? getDirectory(path) : directory)
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
)
|
||||
.finally(() => setStore("opening", false))
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span class="session-service-config-separator" role="separator" />
|
||||
<Show
|
||||
when={!server.isLocal || platform.revealPath}
|
||||
fallback={
|
||||
<span class="session-service-row">
|
||||
<Icon name="settings-gear" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
{label()}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
inactive={server.isLocal}
|
||||
value={language.t(store.copied ? "ui.message.copied" : "ui.message.copy")}
|
||||
placement="top"
|
||||
getAnchorRect={(anchor) => anchor?.querySelector("svg")?.getBoundingClientRect()}
|
||||
forceOpen={store.copied ? true : undefined}
|
||||
class="w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="session-service-config"
|
||||
disabled={store.opening}
|
||||
onMouseDown={(event) => {
|
||||
if (!server.isLocal) event.preventDefault()
|
||||
}}
|
||||
onClick={() => void activate()}
|
||||
>
|
||||
<Icon
|
||||
name={server.isLocal ? "settings-gear" : store.copied ? "check" : "outline-copy"}
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<span class="session-summary-label">{label()}</span>
|
||||
<Show when={server.isLocal}>
|
||||
<Icon name="arrow-up-right" class="session-service-config-arrow shrink-0" />
|
||||
</Show>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceEmpty(props: { title: string; directory: string; service: Service }) {
|
||||
function ServiceEmpty(props: { title: string; description: string }) {
|
||||
return (
|
||||
<div class="session-service-empty">
|
||||
<strong>{props.title}</strong>
|
||||
<div class="session-service-footer">
|
||||
<ServiceConfigLink directory={props.directory} service={props.service} />
|
||||
</div>
|
||||
<p>{props.description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
.session-summary-popover {
|
||||
transform-origin: var(--kb-popover-content-transform-origin);
|
||||
animation: menu-v2-in 120ms ease-out;
|
||||
}
|
||||
|
||||
[data-component="session-summary-panel"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -19,7 +14,7 @@
|
||||
.session-summary-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 2px;
|
||||
padding: 4px 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
@@ -29,16 +24,13 @@
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
.session-summary-card,
|
||||
.session-summary-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.session-summary-row,
|
||||
.session-service-row,
|
||||
.session-service-config,
|
||||
[data-component="switch"].session-mcp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -54,22 +46,7 @@
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.session-summary-card .session-summary-row {
|
||||
/* Keep copy out of the trailing indicator column, including rows without an indicator. */
|
||||
padding-inline-end: 36px;
|
||||
}
|
||||
|
||||
.session-summary-card .session-summary-row:has(> .session-summary-menu-indicator) {
|
||||
/* Align trailing 16px indicators with the move row's 20px dismiss button. */
|
||||
padding-inline-end: 8px;
|
||||
}
|
||||
|
||||
.session-summary-card .session-summary-menu-indicator {
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
.session-summary-row:focus-visible,
|
||||
.session-service-config:focus-visible,
|
||||
.session-mcp-row:focus-within {
|
||||
outline: none;
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
@@ -77,8 +54,6 @@
|
||||
|
||||
@media (hover: hover) {
|
||||
button.session-summary-row:hover,
|
||||
.session-service-config:not(:disabled):hover,
|
||||
.session-summary-move:has(> .session-summary-dismiss:hover) > .session-summary-row,
|
||||
.session-mcp-row:not([data-disabled]):hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
@@ -93,6 +68,13 @@
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
.session-summary-heading-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.session-summary-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -101,9 +83,6 @@
|
||||
white-space: nowrap;
|
||||
text-align: start;
|
||||
}
|
||||
.session-summary-heading .session-summary-label {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.session-summary-disclosure {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
@@ -122,59 +101,35 @@
|
||||
max-height: min(480px, calc(100dvh - 32px));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 2px;
|
||||
padding: 4px 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
box-shadow: var(--v2-elevation-floating);
|
||||
outline: none;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: var(--line-height-base);
|
||||
transform-origin: var(--kb-popover-content-transform-origin);
|
||||
animation: menu-v2-in 120ms ease-out;
|
||||
|
||||
&[data-empty] {
|
||||
width: 232px;
|
||||
width: 200px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.session-service-menu .session-summary-row,
|
||||
.session-service-menu .session-service-row,
|
||||
.session-service-menu .session-service-config,
|
||||
.session-service-menu [data-component="switch"].session-mcp-row {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.session-service-menu > :is(.session-service-row, .session-mcp-row) + :is(.session-service-row, .session-mcp-row) {
|
||||
margin-block-start: 2px;
|
||||
}
|
||||
|
||||
.session-service-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: inherit;
|
||||
font-weight: 530;
|
||||
letter-spacing: 0.05px;
|
||||
}
|
||||
|
||||
.session-service-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
strong {
|
||||
padding: 8px 12px;
|
||||
font-weight: 530;
|
||||
}
|
||||
}
|
||||
@@ -184,72 +139,28 @@
|
||||
color: var(--v2-text-text-faint);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.session-service-menu > .session-service-footer {
|
||||
padding-block-start: 6px;
|
||||
}
|
||||
.session-service-config-separator {
|
||||
display: block;
|
||||
height: 1px;
|
||||
width: calc(100% + 4px);
|
||||
margin: 2px -2px;
|
||||
background: var(--v2-border-border-muted);
|
||||
}
|
||||
.session-service-config {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--v2-text-text-base);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
.session-service-config-arrow {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-visible) .session-service-config-arrow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
.session-service-dot {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-faint);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
border-radius: 50%;
|
||||
background: var(--v2-icon-icon-faint);
|
||||
}
|
||||
.session-service-dot[data-status="connected"],
|
||||
.session-service-dot[data-status="active"] {
|
||||
color: var(--icon-success-base);
|
||||
background: var(--icon-success-base);
|
||||
}
|
||||
.session-service-dot[data-status="failed"] {
|
||||
color: var(--icon-critical-base);
|
||||
background: var(--icon-critical-base);
|
||||
}
|
||||
.session-service-dot[data-status="needs_auth"] {
|
||||
color: var(--icon-warning-base);
|
||||
background: var(--icon-warning-base);
|
||||
}
|
||||
|
||||
[data-component="switch"].session-mcp-row [data-slot="switch-label"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
@@ -260,7 +171,7 @@
|
||||
}
|
||||
.session-service-status {
|
||||
flex-shrink: 0;
|
||||
font-size: inherit;
|
||||
font-size: 11px;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
@@ -269,8 +180,7 @@
|
||||
}
|
||||
.session-summary-move {
|
||||
position: relative;
|
||||
/* The first 6px sit behind the card, leaving 4px of visible space above the row. */
|
||||
padding: 10px 2px 2px;
|
||||
padding-top: 6px;
|
||||
margin-top: -6px;
|
||||
border-radius: 0 0 6px 6px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
@@ -281,7 +191,7 @@
|
||||
.session-summary-dismiss {
|
||||
position: absolute;
|
||||
inset-inline-end: 8px;
|
||||
bottom: 8px;
|
||||
bottom: 6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
@@ -298,21 +208,14 @@
|
||||
|
||||
@media (max-width: 767px) {
|
||||
[data-component="session-summary-panel"] .session-summary-row,
|
||||
.session-service-menu .session-service-title,
|
||||
.session-service-menu .session-service-row,
|
||||
.session-service-menu .session-service-config,
|
||||
.session-service-menu .session-mcp-row {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.session-summary-popover,
|
||||
.session-service-menu {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.session-service-config .session-service-config-arrow {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount, scrollAdjustment: 0 })
|
||||
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
|
||||
const rows = input.projection.rows
|
||||
const rowByKey = input.projection.rowByKey
|
||||
const rowKeys = createMemo(() => rows().map(TimelineRow.key), undefined, {
|
||||
@@ -154,10 +154,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
})
|
||||
const measuredElements = new WeakSet<Element>()
|
||||
let touchStart: number | undefined
|
||||
let touchTarget: EventTarget | null = null
|
||||
let touchNested = false
|
||||
let touchScrolling = false
|
||||
let touchAdjustment = 0
|
||||
let pointerHeld = false
|
||||
let maxScroll = 0
|
||||
let virtualContent: HTMLDivElement | undefined
|
||||
@@ -181,23 +177,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
observeElementOffset: (instance, callback) => {
|
||||
reportOffset = (offset, scrolling) => {
|
||||
if (!active()) return
|
||||
// Rows and the sizer use the opposite translation while native touch
|
||||
// scrolling keeps its own offset. Range selection uses the logical offset.
|
||||
batch(() => {
|
||||
const logicalOffset = offset + rendering.scrollAdjustment
|
||||
callback(rendering.scrollAdjustment ? Math.max(0, logicalOffset) : offset, scrolling)
|
||||
// Reconcile both start boundaries in one native write. Gradually
|
||||
// clamping row translations lets the compositor paint between
|
||||
// corrections and makes the content oscillate at the top.
|
||||
const root = listRoot()
|
||||
if (
|
||||
rendering.scrollAdjustment !== 0 &&
|
||||
root &&
|
||||
(logicalOffset <= 0 || offset <= 0 || (touchStart !== undefined && offset <= root.clientHeight))
|
||||
)
|
||||
flushTouchAdjustment()
|
||||
if (!scrolling && touchStart === undefined) finishTouchScroll()
|
||||
})
|
||||
callback(offset, scrolling)
|
||||
settleColdBottom()
|
||||
}
|
||||
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
|
||||
@@ -232,7 +212,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
if (!active()) return
|
||||
if (batchingColdSizes && input.pinned()) return
|
||||
setRendering("scrollAdjustment", 0)
|
||||
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
|
||||
elementScroll(offset, options, instance)
|
||||
},
|
||||
@@ -284,15 +263,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
batch(() => {
|
||||
sizes.forEach(([index, value]) => {
|
||||
const row = rows()[index]
|
||||
if (!row || TimelineRow.key(row) !== value.key) return
|
||||
resizeItem(index, value.size)
|
||||
// TanStack recalculates its range after each resize. Advance the
|
||||
// logical fold before deciding whether the next row needs anchoring.
|
||||
if (!touchAdjustment) return
|
||||
setRendering("scrollAdjustment", (value) => value + touchAdjustment)
|
||||
touchAdjustment = 0
|
||||
const root = listRoot()
|
||||
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
|
||||
if (row && TimelineRow.key(row) === value.key) resizeItem(index, value.size)
|
||||
})
|
||||
})
|
||||
batchingColdSizes = false
|
||||
@@ -307,40 +278,13 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
})
|
||||
}
|
||||
onCleanup(() => pendingSizes.clear())
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta, instance) => {
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
||||
// Prepended rows can resize more than once as deferred content mounts. Keep
|
||||
// compensating while they remain entirely above the visible content fold.
|
||||
if (addedKeys.has(String(item.key)))
|
||||
return item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
|
||||
const first = instance.range?.startIndex
|
||||
const adjust = addedKeys.has(String(item.key))
|
||||
? item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
|
||||
: first !== undefined && item.index < first
|
||||
if (!touchScrolling || input.pinned()) return adjust
|
||||
// iOS defers native scroll writes until momentum ends. Keep the same visual
|
||||
// anchor now, rather than moving rows now and snapping the viewport back later.
|
||||
if (adjust) touchAdjustment += delta
|
||||
return false
|
||||
}
|
||||
|
||||
function finishTouchScroll() {
|
||||
touchScrolling = false
|
||||
flushTouchAdjustment()
|
||||
}
|
||||
|
||||
function prepareNavigation() {
|
||||
if (touchStart === undefined) touchScrolling = false
|
||||
flushTouchAdjustment()
|
||||
}
|
||||
|
||||
function flushTouchAdjustment() {
|
||||
const adjustment = rendering.scrollAdjustment
|
||||
const root = listRoot()
|
||||
if (!adjustment || !root) return
|
||||
// Transfer the translation into the native offset in the same paint.
|
||||
batch(() => {
|
||||
setRendering("scrollAdjustment", 0)
|
||||
if (virtualContent) virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
elementScroll(Math.max(0, root.scrollTop + adjustment), {}, virtualizer)
|
||||
})
|
||||
return first !== undefined && item.index < first
|
||||
}
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
@@ -370,12 +314,10 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
: -1
|
||||
const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id)
|
||||
if (index === undefined) return
|
||||
prepareNavigation()
|
||||
virtualizer.scrollToIndex(index, { align: "center" })
|
||||
})
|
||||
input.setScrollToEnd?.(() => {
|
||||
if (!active() || !listRoot()?.isConnected) return
|
||||
prepareNavigation()
|
||||
input.onPin()
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
@@ -480,53 +422,19 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
clearTouchTarget()
|
||||
input.onUserScroll(event.target)
|
||||
touchScrolling = true
|
||||
touchStart = event.touches[0]?.clientY
|
||||
const root = listRoot()
|
||||
const nested = event.target instanceof Element ? event.target.closest<HTMLElement>("[data-scrollable]") : null
|
||||
touchNested = !!nested && nested !== root && nested.scrollHeight > nested.clientHeight
|
||||
// Native touch events keep their original target, even when streaming or
|
||||
// virtualization detaches it. Listen there instead of relying on bubbling.
|
||||
touchTarget = event.target
|
||||
touchTarget?.addEventListener("touchmove", handleListTouchMove, { passive: true })
|
||||
touchTarget?.addEventListener("touchend", handleListTouchEnd, { passive: true })
|
||||
touchTarget?.addEventListener("touchcancel", handleListTouchEnd, { passive: true })
|
||||
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
|
||||
}
|
||||
|
||||
const handleListTouchMove = (event: Event) => {
|
||||
if (!(event instanceof TouchEvent)) return
|
||||
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const current = event.touches[0]?.clientY
|
||||
if (current === undefined || touchStart === undefined) return
|
||||
const previous = touchStart
|
||||
touchStart = current
|
||||
// A retained target can outlive its whole session view. Only the active
|
||||
// timeline may change the shared follow state; release still cleans up below.
|
||||
if (!active()) return
|
||||
// Dragging the content downward reveals earlier messages.
|
||||
if (current <= previous) return
|
||||
// A nested scrollport owns the intent. If it chains into the timeline at a
|
||||
// boundary, the resulting native timeline scroll below will unpin instead.
|
||||
if (touchNested) return
|
||||
if (current <= touchStart) return
|
||||
touchStart = current
|
||||
input.onUnpin()
|
||||
}
|
||||
|
||||
const handleListTouchEnd = () => {
|
||||
clearTouchTarget()
|
||||
touchStart = undefined
|
||||
if (!virtualizer.isScrolling) finishTouchScroll()
|
||||
}
|
||||
|
||||
function clearTouchTarget() {
|
||||
touchTarget?.removeEventListener("touchmove", handleListTouchMove)
|
||||
touchTarget?.removeEventListener("touchend", handleListTouchEnd)
|
||||
touchTarget?.removeEventListener("touchcancel", handleListTouchEnd)
|
||||
touchTarget = null
|
||||
}
|
||||
onCleanup(clearTouchTarget)
|
||||
|
||||
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
|
||||
// so a held pointer is what separates those from the virtualizer's own measurement adjustments.
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
@@ -567,7 +475,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const atEnd = maxScroll - scrollTop <= endEpsilon
|
||||
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
|
||||
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
|
||||
else if ((pointerHeld || touchScrolling) && scrollTop < previousTop - endEpsilon) input.onUnpin()
|
||||
else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin()
|
||||
settleColdBottom()
|
||||
input.onScheduleScrollState(root)
|
||||
input.onHistoryScroll()
|
||||
@@ -596,7 +504,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
data-timeline-key={rowProps.rowKey}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${item().start - topOffset() - rendering.scrollAdjustment}px`,
|
||||
top: `${item().start - topOffset()}px`,
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item().size}px`,
|
||||
@@ -674,10 +582,9 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<ScrollView
|
||||
data-slot="session-timeline-scroll"
|
||||
viewportRef={bindListRoot}
|
||||
onBeforeScroll={prepareNavigation}
|
||||
verticalScrollAdjustment={rendering.scrollAdjustment}
|
||||
onWheel={handleListWheel}
|
||||
onTouchStart={handleListTouchStart}
|
||||
onTouchMove={handleListTouchMove}
|
||||
onPointerDown={handleListPointerDown}
|
||||
onKeyDown={handleListKeyDown}
|
||||
onScroll={handleListScroll}
|
||||
@@ -695,7 +602,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (active()) input.setContentRef(element)
|
||||
}}
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize() - rendering.scrollAdjustment}px`,
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
visibility: coldBottomMount ? "hidden" : undefined,
|
||||
@@ -705,7 +612,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<div
|
||||
data-timeline-row="bottom-spacer"
|
||||
class="h-16 absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64 - rendering.scrollAdjustment}px)` }}
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
|
||||
>
|
||||
{props.bottomSpacer}
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,6 @@ import { discoverPluginTargets, localSource } from "./discovery"
|
||||
import { createPluginSources } from "./source"
|
||||
import { isMissingPath } from "../util/config-directories"
|
||||
import { createMarkdownRenderer } from "./markdown"
|
||||
import { useLog, type LogTags } from "../context/log"
|
||||
|
||||
export interface PackageSource {
|
||||
readonly prepare: (spec: string, install?: boolean) => Promise<Host.Target>
|
||||
@@ -82,13 +81,11 @@ type Registration = {
|
||||
|
||||
// One entry of the desired plugin generation produced by the resolve phase.
|
||||
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
|
||||
type Trace = <T>(stage: string, tags: LogTags, task: () => Promise<T>) => Promise<T>
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
const log = useLog({ component: "plugin" })
|
||||
const config = useConfig()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const client = useClient()
|
||||
@@ -111,34 +108,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
// One save can emit several watch events. Remember setup failures so those
|
||||
// events do not repeatedly tear down and restore the last good generation.
|
||||
const setupFailures = new Map<string, { version: string; options: Registration["options"]; error: string }>()
|
||||
let operationID = 0
|
||||
const trace: Trace = (stage, tags, task) => {
|
||||
const id = ++operationID
|
||||
const started = Date.now()
|
||||
log.debug("plugin operation started", { id, stage, ...tags })
|
||||
const stalled = setTimeout(
|
||||
() => log.warn("plugin operation stalled", { id, stage, elapsedMs: Date.now() - started, ...tags }),
|
||||
5_000,
|
||||
)
|
||||
return task()
|
||||
.then(
|
||||
(value) => {
|
||||
log.debug("plugin operation completed", { id, stage, durationMs: Date.now() - started, ...tags })
|
||||
return value
|
||||
},
|
||||
(error) => {
|
||||
log.warn("plugin operation failed", {
|
||||
id,
|
||||
stage,
|
||||
durationMs: Date.now() - started,
|
||||
error: errorMessage(error),
|
||||
...tags,
|
||||
})
|
||||
throw error
|
||||
},
|
||||
)
|
||||
.finally(() => clearTimeout(stalled))
|
||||
}
|
||||
const markdown = createMarkdownRenderer(() =>
|
||||
Object.values(store.registrations).flatMap((registration) => (registration.active ? [registration.markdown] : [])),
|
||||
)
|
||||
@@ -180,9 +149,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
active: () => Boolean(store.registrations[id]?.active),
|
||||
},
|
||||
})
|
||||
const cleanup = await trace("setup", { plugin: id, target: item.target }, () =>
|
||||
setup(item.plugin, context, owned),
|
||||
).catch((error) => {
|
||||
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
|
||||
clearContributions(id)
|
||||
if (item.target)
|
||||
setupFailures.set(item.target, {
|
||||
@@ -214,9 +181,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
setStore("registrations", id, "active", false)
|
||||
setStore("registrations", id, "cleanups", [])
|
||||
})
|
||||
await trace("cleanup", { plugin: id, target: item.target, cleanups: cleanups.length }, () =>
|
||||
disposeAll(cleanups),
|
||||
).finally(() =>
|
||||
await disposeAll(cleanups).finally(() =>
|
||||
batch(() => {
|
||||
if (store.registrations[id]) {
|
||||
clearContributions(id)
|
||||
@@ -278,20 +243,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
// Package resolution failures would otherwise retry a full npm install on
|
||||
// every watch event; remember them until the configuration changes.
|
||||
const npmFailures = new Map<string, string>()
|
||||
let reconciliationID = 0
|
||||
const reconcile = async () => {
|
||||
const id = ++reconciliationID
|
||||
const started = Date.now()
|
||||
log.info("plugin reconciliation started", { id })
|
||||
await trace("watch", { reconciliation: id, directories: props.directories }, () =>
|
||||
Promise.all(props.directories.map(watcher.wait)).then(() => undefined),
|
||||
)
|
||||
await Promise.all(props.directories.map(watcher.wait))
|
||||
const entries = [
|
||||
...(
|
||||
await trace("discover", { reconciliation: id, directories: props.directories }, () =>
|
||||
discoverPluginTargets(props.directories),
|
||||
)
|
||||
).map((entry) => ({
|
||||
...(await discoverPluginTargets(props.directories)).map((entry) => ({
|
||||
entry,
|
||||
install: true,
|
||||
optional: true,
|
||||
@@ -340,20 +295,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
? { status: "failed" as const, error: memo }
|
||||
: await resolvePlugin(
|
||||
target,
|
||||
local,
|
||||
options,
|
||||
previous,
|
||||
props.packages,
|
||||
source.install,
|
||||
sources.read,
|
||||
trace,
|
||||
id,
|
||||
).catch((error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
}))
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sources.read).catch(
|
||||
(error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
}),
|
||||
)
|
||||
if (resolved.status === "unsupported") {
|
||||
if (source.optional) continue
|
||||
failures.push({ target, status: "unsupported" })
|
||||
@@ -479,7 +426,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
action: { label: "Open plugins", run: () => host.keymap.dispatch("plugins.list") },
|
||||
})
|
||||
setStore("states", reconcileStore(states))
|
||||
log.info("plugin reconciliation completed", { id, durationMs: Date.now() - started, plugins: desired.size })
|
||||
}
|
||||
const slotItems = new WeakMap<SlotRender, Claim<SlotRender>>()
|
||||
// The mounted slot tree: path -> live <Slot> instance count. Reference
|
||||
@@ -652,26 +598,17 @@ async function resolvePlugin(
|
||||
packages: PackageSource,
|
||||
install: boolean,
|
||||
readSource: ReturnType<typeof createPluginSources>["read"],
|
||||
trace: Trace,
|
||||
reconciliation: number,
|
||||
) {
|
||||
// Package entrypoints never change within a session, so a loaded previous
|
||||
// version needs no re-resolution (which could otherwise hit npm).
|
||||
if (!local && previous && sameOptions(previous.options, options))
|
||||
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
|
||||
const target = local
|
||||
? { directory: fileURLToPath(local) }
|
||||
: await trace("prepare", { reconciliation, target: spec, install }, () => packages.prepare(spec, install))
|
||||
const target = local ? { directory: fileURLToPath(local) } : await packages.prepare(spec, install)
|
||||
const entrypoint = Host.resolve(target).tui
|
||||
if (!entrypoint) return { status: "unsupported" as const }
|
||||
// Content remains stable across the several mtimes one save may expose to
|
||||
// filesystem watchers, while the generation keeps reverted modules fresh.
|
||||
let source = local
|
||||
? await trace("read", { reconciliation, target: spec, entrypoint }, () => readSource(entrypoint))
|
||||
: {
|
||||
version: entrypoint,
|
||||
module: await trace("load", { reconciliation, target: spec, entrypoint }, () => Host.load(entrypoint)),
|
||||
}
|
||||
let source = local ? await readSource(entrypoint) : { version: entrypoint, module: await Host.load(entrypoint) }
|
||||
while (true) {
|
||||
const version = source.version
|
||||
if (previous && previous.version === version && sameOptions(previous.options, options))
|
||||
|
||||
@@ -20,10 +20,6 @@ export interface ScrollViewProps extends ComponentProps<"div"> {
|
||||
thumbContainer?: HTMLElement
|
||||
/** Element whose hover reveals the thumb. Defaults to the ScrollView root when unset. */
|
||||
thumbHoverTarget?: HTMLElement
|
||||
/** Reconcile native scroll geometry before keyboard or scrollbar navigation reads it. */
|
||||
onBeforeScroll?: () => void
|
||||
/** Offset/extent correction for a virtualized vertical scrollbar. */
|
||||
verticalScrollAdjustment?: number
|
||||
}
|
||||
|
||||
export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => {
|
||||
@@ -128,8 +124,6 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
"thumbVisibility",
|
||||
"thumbContainer",
|
||||
"thumbHoverTarget",
|
||||
"onBeforeScroll",
|
||||
"verticalScrollAdjustment",
|
||||
"style",
|
||||
],
|
||||
[
|
||||
@@ -195,19 +189,17 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
const minThumbSize = 32
|
||||
|
||||
if (vertical()) {
|
||||
const adjustment = local.verticalScrollAdjustment ?? 0
|
||||
const scrollHeight = viewportRef.scrollHeight + adjustment
|
||||
const trackSize = Math.max(0, (thumbMount()?.clientHeight || viewportRef.clientHeight) - trackPadding * 2)
|
||||
const size = trackSize
|
||||
? Math.min(trackSize, Math.max((viewportRef.clientHeight / scrollHeight) * trackSize, minThumbSize))
|
||||
? Math.min(trackSize, Math.max((viewportRef.clientHeight / viewportRef.scrollHeight) * trackSize, minThumbSize))
|
||||
: 0
|
||||
const maxScroll = scrollHeight - viewportRef.clientHeight
|
||||
const maxScroll = viewportRef.scrollHeight - viewportRef.clientHeight
|
||||
const maxStart = trackSize - size
|
||||
setState("showVerticalThumb", maxScroll > 0)
|
||||
setState("verticalThumbSize", size)
|
||||
setState(
|
||||
"verticalThumbStart",
|
||||
trackPadding + (maxScroll > 0 ? ((viewportRef.scrollTop + adjustment) / maxScroll) * maxStart : 0),
|
||||
trackPadding + (maxScroll > 0 ? (viewportRef.scrollTop / maxScroll) * maxStart : 0),
|
||||
)
|
||||
} else {
|
||||
setState("showVerticalThumb", false)
|
||||
@@ -271,17 +263,9 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
})
|
||||
})
|
||||
|
||||
const prepareScroll = () => {
|
||||
if (local.onBeforeScroll) {
|
||||
local.onBeforeScroll()
|
||||
updateThumb()
|
||||
}
|
||||
}
|
||||
|
||||
const onThumbPointerDown = (axis: "vertical" | "horizontal", e: PointerEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
prepareScroll()
|
||||
setState("dragging", axis)
|
||||
const thumb = axis === "vertical" ? verticalThumbRef : horizontalThumbRef
|
||||
const grabOffset =
|
||||
@@ -293,7 +277,6 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
thumb.setPointerCapture(e.pointerId)
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
prepareScroll()
|
||||
const vertical = axis === "vertical"
|
||||
const rtl = !vertical && getComputedStyle(viewportRef).direction === "rtl"
|
||||
const offset = scrollOffsetFromThumbPointer({
|
||||
@@ -372,23 +355,10 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
return
|
||||
}
|
||||
const next = scrollKey(e)
|
||||
// Modified navigation (for example Ctrl+Home) stays native, but must read
|
||||
// the same reconciled geometry as the keys handled by this component.
|
||||
const intent =
|
||||
next ??
|
||||
scrollKey({
|
||||
key: e.key,
|
||||
shiftKey: e.key === " " && e.shiftKey,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
})
|
||||
if (!intent) return
|
||||
if (!isScrollKeyTarget(e.target, intent)) return
|
||||
if (scrollKeyOwner(viewportRef, e.target, intent) !== viewportRef) return
|
||||
|
||||
prepareScroll()
|
||||
if (!next) return
|
||||
if (!isScrollKeyTarget(e.target, next)) return
|
||||
if (scrollKeyOwner(viewportRef, e.target, next) !== viewportRef) return
|
||||
|
||||
const scrollAmount = viewportRef.clientHeight * 0.8
|
||||
const lineAmount = 40
|
||||
|
||||
|
||||
@@ -10,10 +10,8 @@ const names = [
|
||||
"check",
|
||||
"chevron-down",
|
||||
"close",
|
||||
"code-slash",
|
||||
"edit",
|
||||
"folder",
|
||||
"graduation-cap",
|
||||
"help",
|
||||
"magnifying-glass",
|
||||
"menu",
|
||||
|
||||
@@ -164,14 +164,6 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M14.5 9.8333V13.5H1.5V2.5H7.1667M9.5 2.5V7.5H14.5V2.5H9.5Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
|
||||
},
|
||||
"graduation-cap": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M12.3327 7.50065V11.0007L7.99935 13.6673L3.66602 11.0007V7.50065M14.3327 9.83398V6.16732M7.99935 2.33398L1.66602 6.00065L7.99935 10.0007L14.3327 6.00065L7.99935 2.33398Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
},
|
||||
"code-slash": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0812 2.10803L6.55974 14.0809L5.92016 13.8928L9.44161 1.91992L10.0812 2.10803ZM4.13793 4.97275L1.44666 8.00045L4.13793 11.0281L3.63966 11.471L0.554688 8.00045L3.63966 4.52984L4.13793 4.97275ZM12.3617 4.52984L15.4467 8.00045L12.3617 11.471L11.8634 11.0281L14.5547 8.00045L11.8634 4.97275L12.3617 4.52984Z" fill="currentColor"/>`,
|
||||
},
|
||||
trash: {
|
||||
viewBox: "0 0 20 20",
|
||||
body: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -44,7 +44,7 @@ function knownThemes() {
|
||||
}
|
||||
|
||||
const names: Record<string, string> = {
|
||||
"oc-2": "OpenCode",
|
||||
"oc-2": "OC-2",
|
||||
amoled: "AMOLED",
|
||||
aura: "Aura",
|
||||
ayu: "Ayu",
|
||||
@@ -69,6 +69,7 @@ const names: Record<string, string> = {
|
||||
nord: "Nord",
|
||||
"one-dark": "One Dark",
|
||||
onedarkpro: "One Dark Pro",
|
||||
opencode: "OpenCode",
|
||||
orng: "Orng",
|
||||
"osaka-jade": "Osaka Jade",
|
||||
palenight: "Palenight",
|
||||
|
||||
@@ -24,6 +24,7 @@ import nightowlThemeJson from "./themes/nightowl.json"
|
||||
import nordThemeJson from "./themes/nord.json"
|
||||
import oneDarkThemeJson from "./themes/one-dark.json"
|
||||
import oneDarkProThemeJson from "./themes/onedarkpro.json"
|
||||
import opencodeThemeJson from "./themes/opencode.json"
|
||||
import orngThemeJson from "./themes/orng.json"
|
||||
import osakaJadeThemeJson from "./themes/osaka-jade.json"
|
||||
import palenightThemeJson from "./themes/palenight.json"
|
||||
@@ -61,6 +62,7 @@ export const nightowlTheme = nightowlThemeJson as DesktopTheme
|
||||
export const nordTheme = nordThemeJson as DesktopTheme
|
||||
export const oneDarkTheme = oneDarkThemeJson as DesktopTheme
|
||||
export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme
|
||||
export const opencodeTheme = opencodeThemeJson as DesktopTheme
|
||||
export const orngTheme = orngThemeJson as DesktopTheme
|
||||
export const osakaJadeTheme = osakaJadeThemeJson as DesktopTheme
|
||||
export const palenightTheme = palenightThemeJson as DesktopTheme
|
||||
@@ -99,6 +101,7 @@ export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
|
||||
nord: nordTheme,
|
||||
"one-dark": oneDarkTheme,
|
||||
onedarkpro: oneDarkProTheme,
|
||||
opencode: opencodeTheme,
|
||||
orng: orngTheme,
|
||||
"osaka-jade": osakaJadeTheme,
|
||||
palenight: palenightTheme,
|
||||
|
||||
@@ -63,6 +63,7 @@ export {
|
||||
nordTheme,
|
||||
oneDarkTheme,
|
||||
oneDarkProTheme,
|
||||
opencodeTheme,
|
||||
orngTheme,
|
||||
osakaJadeTheme,
|
||||
palenightTheme,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/desktop-theme.json",
|
||||
"name": "OpenCode",
|
||||
"name": "OC-2",
|
||||
"id": "oc-2",
|
||||
"light": {
|
||||
"palette": {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/desktop-theme.json",
|
||||
"name": "OpenCode",
|
||||
"id": "opencode",
|
||||
"light": {
|
||||
"palette": {
|
||||
"neutral": "#ffffff",
|
||||
"ink": "#1a1a1a",
|
||||
"primary": "#3b7dd8",
|
||||
"accent": "#d68c27",
|
||||
"success": "#3d9a57",
|
||||
"warning": "#d68c27",
|
||||
"error": "#d1383d",
|
||||
"info": "#318795",
|
||||
"diffAdd": "#4db380",
|
||||
"diffDelete": "#f52a65"
|
||||
},
|
||||
"overrides": {
|
||||
"text-weak": "#8a8a8a",
|
||||
"syntax-comment": "#8a8a8a",
|
||||
"syntax-keyword": "#d68c27",
|
||||
"syntax-string": "#3d9a57",
|
||||
"syntax-primitive": "#3b7dd8",
|
||||
"syntax-variable": "#d1383d",
|
||||
"syntax-property": "#318795",
|
||||
"syntax-type": "#b0851f",
|
||||
"syntax-constant": "#d68c27",
|
||||
"syntax-operator": "#318795",
|
||||
"syntax-punctuation": "#1a1a1a",
|
||||
"syntax-object": "#d1383d",
|
||||
"markdown-heading": "#d68c27",
|
||||
"markdown-text": "#1a1a1a",
|
||||
"markdown-link": "#3b7dd8",
|
||||
"markdown-link-text": "#318795",
|
||||
"markdown-code": "#3d9a57",
|
||||
"markdown-block-quote": "#b0851f",
|
||||
"markdown-emph": "#b0851f",
|
||||
"markdown-strong": "#d68c27",
|
||||
"markdown-horizontal-rule": "#8a8a8a",
|
||||
"markdown-list-item": "#3b7dd8",
|
||||
"markdown-list-enumeration": "#318795",
|
||||
"markdown-image": "#3b7dd8",
|
||||
"markdown-image-text": "#318795",
|
||||
"markdown-code-block": "#1a1a1a"
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"palette": {
|
||||
"neutral": "#0a0a0a",
|
||||
"ink": "#eeeeee",
|
||||
"primary": "#fab283",
|
||||
"accent": "#9d7cd8",
|
||||
"success": "#7fd88f",
|
||||
"warning": "#f5a742",
|
||||
"error": "#e06c75",
|
||||
"info": "#56b6c2",
|
||||
"diffAdd": "#b8db87",
|
||||
"diffDelete": "#e26a75"
|
||||
},
|
||||
"overrides": {
|
||||
"text-weak": "#808080",
|
||||
"syntax-comment": "#808080",
|
||||
"syntax-keyword": "#9d7cd8",
|
||||
"syntax-string": "#7fd88f",
|
||||
"syntax-primitive": "#fab283",
|
||||
"syntax-variable": "#e06c75",
|
||||
"syntax-property": "#56b6c2",
|
||||
"syntax-type": "#e5c07b",
|
||||
"syntax-constant": "#f5a742",
|
||||
"syntax-operator": "#56b6c2",
|
||||
"syntax-punctuation": "#eeeeee",
|
||||
"syntax-object": "#e06c75",
|
||||
"markdown-heading": "#9d7cd8",
|
||||
"markdown-text": "#eeeeee",
|
||||
"markdown-link": "#fab283",
|
||||
"markdown-link-text": "#56b6c2",
|
||||
"markdown-code": "#7fd88f",
|
||||
"markdown-block-quote": "#e5c07b",
|
||||
"markdown-emph": "#e5c07b",
|
||||
"markdown-strong": "#f5a742",
|
||||
"markdown-horizontal-rule": "#808080",
|
||||
"markdown-list-item": "#fab283",
|
||||
"markdown-list-enumeration": "#56b6c2",
|
||||
"markdown-image": "#fab283",
|
||||
"markdown-image-text": "#56b6c2",
|
||||
"markdown-code-block": "#eeeeee"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
|
||||
index e470032a9572b3ced764ca02238a8c6be435a9d4..4c25e03b5db221a78793083d97970b2d90e28efa 100644
|
||||
index e470032a9572b3ced764ca02238a8c6be435a9d4..84a7bfca638f70e32a9567bc732a84b83578af4e 100644
|
||||
--- a/dist/cjs/index.cjs
|
||||
+++ b/dist/cjs/index.cjs
|
||||
@@ -289,7 +289,7 @@ class Virtualizer {
|
||||
@@ -82,50 +82,7 @@ index e470032a9572b3ced764ca02238a8c6be435a9d4..4c25e03b5db221a78793083d97970b2d
|
||||
followOnAppend,
|
||||
anchorDelta
|
||||
];
|
||||
@@ -440,7 +450,17 @@ class Virtualizer {
|
||||
);
|
||||
if ("addEventListener" in this.scrollElement) {
|
||||
const scrollEl = this.scrollElement;
|
||||
- const onTouchStart = () => {
|
||||
+ let touchTarget = null;
|
||||
+ const clearTouchTarget = () => {
|
||||
+ touchTarget?.removeEventListener("touchend", onTouchEnd);
|
||||
+ touchTarget?.removeEventListener("touchcancel", onTouchEnd);
|
||||
+ touchTarget = null;
|
||||
+ };
|
||||
+ const onTouchStart = (event) => {
|
||||
+ clearTouchTarget();
|
||||
+ touchTarget = event.target ?? scrollEl;
|
||||
+ touchTarget.addEventListener("touchend", onTouchEnd, addEventListenerOptions);
|
||||
+ touchTarget.addEventListener("touchcancel", onTouchEnd, addEventListenerOptions);
|
||||
this._iosTouching = true;
|
||||
this._iosJustTouchEnded = false;
|
||||
if (this._iosTouchEndTimerId !== null && this.targetWindow != null) {
|
||||
@@ -449,6 +469,7 @@ class Virtualizer {
|
||||
}
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
+ clearTouchTarget();
|
||||
this._iosTouching = false;
|
||||
if (!isIOSWebKit() || this.targetWindow == null) {
|
||||
return;
|
||||
@@ -465,14 +486,9 @@ class Virtualizer {
|
||||
onTouchStart,
|
||||
addEventListenerOptions
|
||||
);
|
||||
- scrollEl.addEventListener(
|
||||
- "touchend",
|
||||
- onTouchEnd,
|
||||
- addEventListenerOptions
|
||||
- );
|
||||
this.unsubs.push(() => {
|
||||
scrollEl.removeEventListener("touchstart", onTouchStart);
|
||||
- scrollEl.removeEventListener("touchend", onTouchEnd);
|
||||
+ clearTouchTarget();
|
||||
if (this._iosTouchEndTimerId !== null && this.targetWindow != null) {
|
||||
this.targetWindow.clearTimeout(this._iosTouchEndTimerId);
|
||||
this._iosTouchEndTimerId = null;
|
||||
@@ -725,17 +741,20 @@ class Virtualizer {
|
||||
@@ -725,17 +735,20 @@ class Virtualizer {
|
||||
this.getMeasurements(),
|
||||
this.getSize(),
|
||||
this.getScrollOffset(),
|
||||
@@ -149,7 +106,7 @@ index e470032a9572b3ced764ca02238a8c6be435a9d4..4c25e03b5db221a78793083d97970b2d
|
||||
lanes,
|
||||
// Pass the typed array so binary search + forward-walk can read
|
||||
// start/end directly from Float64Array, skipping the Proxy traps.
|
||||
@@ -1095,8 +1114,10 @@ class Virtualizer {
|
||||
@@ -1095,8 +1108,10 @@ class Virtualizer {
|
||||
const snapshot = [];
|
||||
if (this.itemSizeCache.size === 0) return snapshot;
|
||||
const m = this.getMeasurements();
|
||||
@@ -189,7 +146,7 @@ index 6b43c0aea7ed9eeef75cbfb1351fcbd243913bdd..7be2680967934ddfbc4583a210a3d11e
|
||||
getVirtualIndexes: {
|
||||
(): number[];
|
||||
diff --git a/dist/esm/index.js b/dist/esm/index.js
|
||||
index 2495b26cf2c3589213546b3958eaadf2eb6b751d..1398bc7dd33690a8fe3280343b6aabdeaea6435a 100644
|
||||
index 2495b26cf2c3589213546b3958eaadf2eb6b751d..accd38a89bee766fd568aa8cec9bb90c6789277a 100644
|
||||
--- a/dist/esm/index.js
|
||||
+++ b/dist/esm/index.js
|
||||
@@ -287,7 +287,7 @@ class Virtualizer {
|
||||
@@ -272,50 +229,7 @@ index 2495b26cf2c3589213546b3958eaadf2eb6b751d..1398bc7dd33690a8fe3280343b6aabde
|
||||
followOnAppend,
|
||||
anchorDelta
|
||||
];
|
||||
@@ -438,7 +448,17 @@ class Virtualizer {
|
||||
);
|
||||
if ("addEventListener" in this.scrollElement) {
|
||||
const scrollEl = this.scrollElement;
|
||||
- const onTouchStart = () => {
|
||||
+ let touchTarget = null;
|
||||
+ const clearTouchTarget = () => {
|
||||
+ touchTarget?.removeEventListener("touchend", onTouchEnd);
|
||||
+ touchTarget?.removeEventListener("touchcancel", onTouchEnd);
|
||||
+ touchTarget = null;
|
||||
+ };
|
||||
+ const onTouchStart = (event) => {
|
||||
+ clearTouchTarget();
|
||||
+ touchTarget = event.target ?? scrollEl;
|
||||
+ touchTarget.addEventListener("touchend", onTouchEnd, addEventListenerOptions);
|
||||
+ touchTarget.addEventListener("touchcancel", onTouchEnd, addEventListenerOptions);
|
||||
this._iosTouching = true;
|
||||
this._iosJustTouchEnded = false;
|
||||
if (this._iosTouchEndTimerId !== null && this.targetWindow != null) {
|
||||
@@ -447,6 +467,7 @@ class Virtualizer {
|
||||
}
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
+ clearTouchTarget();
|
||||
this._iosTouching = false;
|
||||
if (!isIOSWebKit() || this.targetWindow == null) {
|
||||
return;
|
||||
@@ -463,14 +484,9 @@ class Virtualizer {
|
||||
onTouchStart,
|
||||
addEventListenerOptions
|
||||
);
|
||||
- scrollEl.addEventListener(
|
||||
- "touchend",
|
||||
- onTouchEnd,
|
||||
- addEventListenerOptions
|
||||
- );
|
||||
this.unsubs.push(() => {
|
||||
scrollEl.removeEventListener("touchstart", onTouchStart);
|
||||
- scrollEl.removeEventListener("touchend", onTouchEnd);
|
||||
+ clearTouchTarget();
|
||||
if (this._iosTouchEndTimerId !== null && this.targetWindow != null) {
|
||||
this.targetWindow.clearTimeout(this._iosTouchEndTimerId);
|
||||
this._iosTouchEndTimerId = null;
|
||||
@@ -723,17 +739,20 @@ class Virtualizer {
|
||||
@@ -723,17 +733,20 @@ class Virtualizer {
|
||||
this.getMeasurements(),
|
||||
this.getSize(),
|
||||
this.getScrollOffset(),
|
||||
@@ -339,7 +253,7 @@ index 2495b26cf2c3589213546b3958eaadf2eb6b751d..1398bc7dd33690a8fe3280343b6aabde
|
||||
lanes,
|
||||
// Pass the typed array so binary search + forward-walk can read
|
||||
// start/end directly from Float64Array, skipping the Proxy traps.
|
||||
@@ -1093,8 +1112,10 @@ class Virtualizer {
|
||||
@@ -1093,8 +1106,10 @@ class Virtualizer {
|
||||
const snapshot = [];
|
||||
if (this.itemSizeCache.size === 0) return snapshot;
|
||||
const m = this.getMeasurements();
|
||||
@@ -353,7 +267,7 @@ index 2495b26cf2c3589213546b3958eaadf2eb6b751d..1398bc7dd33690a8fe3280343b6aabde
|
||||
index: item.index,
|
||||
key: item.key,
|
||||
diff --git a/src/index.ts b/src/index.ts
|
||||
index dc6f1010c4d4758de9c46fb8d69209e582e47171..9299287d72ce79d5a597cdc3440339ee148e6385 100644
|
||||
index dc6f1010c4d4758de9c46fb8d69209e582e47171..2578338abb5d9237624dd6d96bb50546c6dc3e68 100644
|
||||
--- a/src/index.ts
|
||||
+++ b/src/index.ts
|
||||
@@ -567,7 +567,7 @@ export class Virtualizer<
|
||||
@@ -466,51 +380,7 @@ index dc6f1010c4d4758de9c46fb8d69209e582e47171..9299287d72ce79d5a597cdc3440339ee
|
||||
followOnAppend,
|
||||
anchorDelta,
|
||||
]
|
||||
@@ -895,7 +906,18 @@ export class Virtualizer<
|
||||
// and _flushIosDeferredIfReady so we only burn the path on iOS.
|
||||
if ('addEventListener' in this.scrollElement) {
|
||||
const scrollEl = this.scrollElement as unknown as EventTarget
|
||||
- const onTouchStart = () => {
|
||||
+ let touchTarget: EventTarget | null = null
|
||||
+ const clearTouchTarget = () => {
|
||||
+ touchTarget?.removeEventListener('touchend', onTouchEnd)
|
||||
+ touchTarget?.removeEventListener('touchcancel', onTouchEnd)
|
||||
+ touchTarget = null
|
||||
+ }
|
||||
+ const onTouchStart = (event: Event) => {
|
||||
+ clearTouchTarget()
|
||||
+ // The original target still receives release after its DOM removal.
|
||||
+ touchTarget = event.target ?? scrollEl
|
||||
+ touchTarget.addEventListener('touchend', onTouchEnd, addEventListenerOptions)
|
||||
+ touchTarget.addEventListener('touchcancel', onTouchEnd, addEventListenerOptions)
|
||||
this._iosTouching = true
|
||||
this._iosJustTouchEnded = false
|
||||
if (this._iosTouchEndTimerId !== null && this.targetWindow != null) {
|
||||
@@ -904,6 +926,7 @@ export class Virtualizer<
|
||||
}
|
||||
}
|
||||
const onTouchEnd = () => {
|
||||
+ clearTouchTarget()
|
||||
this._iosTouching = false
|
||||
if (!isIOSWebKit() || this.targetWindow == null) {
|
||||
// Non-iOS: nothing more to track. Just clear the touching flag.
|
||||
@@ -924,14 +947,9 @@ export class Virtualizer<
|
||||
onTouchStart,
|
||||
addEventListenerOptions,
|
||||
)
|
||||
- scrollEl.addEventListener(
|
||||
- 'touchend',
|
||||
- onTouchEnd,
|
||||
- addEventListenerOptions,
|
||||
- )
|
||||
this.unsubs.push(() => {
|
||||
scrollEl.removeEventListener('touchstart', onTouchStart)
|
||||
- scrollEl.removeEventListener('touchend', onTouchEnd)
|
||||
+ clearTouchTarget()
|
||||
if (this._iosTouchEndTimerId !== null && this.targetWindow != null) {
|
||||
this.targetWindow.clearTimeout(this._iosTouchEndTimerId)
|
||||
this._iosTouchEndTimerId = null
|
||||
@@ -1410,16 +1428,25 @@ export class Virtualizer<
|
||||
@@ -1410,16 +1421,25 @@ export class Virtualizer<
|
||||
this.getSize(),
|
||||
this.getScrollOffset(),
|
||||
this.options.lanes,
|
||||
@@ -538,7 +408,7 @@ index dc6f1010c4d4758de9c46fb8d69209e582e47171..9299287d72ce79d5a597cdc3440339ee
|
||||
lanes,
|
||||
// Pass the typed array so binary search + forward-walk can read
|
||||
// start/end directly from Float64Array, skipping the Proxy traps.
|
||||
@@ -1937,12 +1964,13 @@ export class Virtualizer<
|
||||
@@ -1937,12 +1957,13 @@ export class Virtualizer<
|
||||
takeSnapshot = (): Array<VirtualItem> => {
|
||||
const snapshot: Array<VirtualItem> = []
|
||||
if (this.itemSizeCache.size === 0) return snapshot
|
||||
|
||||
Reference in New Issue
Block a user